@jossuealcala/madre 0.7.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
|
@@ -0,0 +1,4593 @@
|
|
|
1
|
+
// SALAS on the Mac: every room this Mac knows, open or asleep, in one place (docs/MACROVERSO.md,
|
|
2
|
+
// phase 1). The room that serves this page answers for all of them (src/salas.mjs): it lists
|
|
3
|
+
// them, opens one that is asleep, asks one to close, and stops every agent in every room. A room
|
|
4
|
+
// opens at its own address; holding ⌘ opens it beside this one.
|
|
5
|
+
//
|
|
6
|
+
// The phone has its own SALAS (salas.js), served by the bridge; the two read the same rooms.
|
|
7
|
+
|
|
8
|
+
import { setLanguage, t } from './i18n.js';
|
|
9
|
+
import { brandOf, monogram } from './brands.js';
|
|
10
|
+
import { startReviewAsk } from './review-ask.js';
|
|
11
|
+
import { startTour } from './tour.js';
|
|
12
|
+
import { dropStickyHover } from './touch.js';
|
|
13
|
+
|
|
14
|
+
// Where nothing can hover (a tablet), a tapped key does not stay lit (public/touch.js).
|
|
15
|
+
dropStickyHover();
|
|
16
|
+
import { creep, finishLoader, makeLoader, setLoader, showCurtain } from './loader.js';
|
|
17
|
+
|
|
18
|
+
const $ = (id) => document.getElementById(id);
|
|
19
|
+
const root = document.documentElement;
|
|
20
|
+
const ui = {
|
|
21
|
+
title: $('sm-title'), stop: $('sm-stop'), macro: $('sm-macro'), foot: $('sm-foot'), colophon: $('sm-colophon'), gear: $('sm-gear'),
|
|
22
|
+
bar: { backRoom: $('sm-back-room'), crumb: $('sm-crumb'), live: $('sm-live'), open: $('sm-tb-open'), agents: $('sm-tb-agents'), waiting: $('sm-tb-waiting'), update: $('sm-tb-update'), links: $('sm-tb-links'), linksCount: $('sm-tb-links-count'), reload: $('sm-reload') },
|
|
23
|
+
add: $('sm-add'), search: $('sm-search'), filter: $('sm-filter'), projects: $('sm-projects'), none: $('sm-none'),
|
|
24
|
+
bishop: {
|
|
25
|
+
box: $('sm-bishop'), mark: $('sm-bishop-mark'), state: $('sm-bishop-state'), go: $('sm-bishop-go'), tab: $('sm-bishop-tab'), about: $('sm-bishop-about'),
|
|
26
|
+
asks: $('sm-bishop-asks'), error: $('sm-bishop-error'),
|
|
27
|
+
},
|
|
28
|
+
work: { box: $('sm-work'), title: $('sm-work-title'), by: $('sm-work-by'), again: $('sm-work-again'), list: $('sm-work-list'), note: $('sm-work-note') },
|
|
29
|
+
waiting: { box: $('sm-waiting'), title: $('sm-waiting-title'), count: $('sm-waiting-count'), body: $('sm-waiting-body') },
|
|
30
|
+
map: $('sm-map'), linksTileTitle: $('sm-links-tile-title'), linksSum: $('sm-links-sum'), linksAll: $('sm-links-all'), link: $('sm-link'),
|
|
31
|
+
linksSheet: $('sm-links-sheet'), linksTitle: $('sm-links-title'), linksNote: $('sm-links-note'), links: $('sm-links'), linksClose: $('sm-links-close'), linkMore: $('sm-link-more'),
|
|
32
|
+
memory: { title: $('sm-memory-title'), total: $('sm-memory-total'), body: $('sm-memory-body') },
|
|
33
|
+
decisions: { title: $('sm-decisions-title'), list: $('sm-decisions-list') },
|
|
34
|
+
attention: { title: $('sm-attention-title'), count: $('sm-attention-count'), list: $('sm-attention-list') },
|
|
35
|
+
};
|
|
36
|
+
let language = 'es';
|
|
37
|
+
let timer = 0;
|
|
38
|
+
let arming = 0;
|
|
39
|
+
let data = null;
|
|
40
|
+
let macro = null; // the macroverse, while it is open
|
|
41
|
+
let welcomed = false; // the first-run wizard was offered this visit
|
|
42
|
+
let agentsSeen = null; // what /api/settings said of the agents, last time it was asked
|
|
43
|
+
let localSeen = null; // what /api/salas/local-ai said: the mode and which projects use the local AI
|
|
44
|
+
let versionSeen = null; // what /api/version said of MADRE, last time it was asked
|
|
45
|
+
let work = null; // MADRE's summary of the last work (/api/salas/work)
|
|
46
|
+
let decisions = null; // the recent decisions of the open rooms (/api/salas/decisions)
|
|
47
|
+
let filter = 'all'; // which projects show: all, open or asleep
|
|
48
|
+
let query = ''; // what the search box holds
|
|
49
|
+
// What each card is in the middle of, kept across the refreshes that redraw it.
|
|
50
|
+
const busy = new Map(); // room id → 'starting' | 'closing'
|
|
51
|
+
const asking = new Set(); // room ids whose card asks before closing
|
|
52
|
+
const forgetting = new Set(); // room ids whose card asks before leaving MADRE
|
|
53
|
+
const failed = new Map(); // room id → what went wrong, said on its card
|
|
54
|
+
// One is one: «1 conversaciones» read wrong on every new project's card.
|
|
55
|
+
const conversationsCount = (n) => (n === 1 ? t('1 conversation') : t('{n} conversations', { n }));
|
|
56
|
+
const memoriesCount = (n) => (n === 1 ? t('1 memory') : t('{n} memories', { n }));
|
|
57
|
+
const revoking = new Set(); // link ids whose row asks before revoking
|
|
58
|
+
|
|
59
|
+
// The room's looks, from this browser: the same keys the room's own page keeps (app.js, LOOKS),
|
|
60
|
+
// so a choice made here is the room's too when the room is served at this address.
|
|
61
|
+
const ACCENTS = ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon', 'pacific', 'love', 'sierra', 'pitahaya', 'papaya', 'agave', 'dinamo', 'marina', 'pulso', 'leal', 'dulce'];
|
|
62
|
+
const ACCENT_WORDS = { madre: () => t('Madre green'), mountain: () => t('Mountain green'), caribbean: () => t('Caribbean blue'), phantom: () => t('Phantom purple'), malbec: () => t('Malbec red'), dijon: () => t('Dijon yellow'), pacific: () => t('Pacific blue'), love: () => t('Love pink'), sierra: () => t('Sierra blue'), pitahaya: () => 'Pitahaya', papaya: () => 'Papaya', agave: () => 'Agave', dinamo: () => t('Dynamo black'), marina: () => t('Marina blue'), pulso: () => t('Pulse green'), leal: () => t('Loyal orange'), dulce: () => t('Sweet yellow') };
|
|
63
|
+
// The wallpapers are the room's (app.js, LOOKS.wallpaper; styles.css, --wp-*): the same images, the
|
|
64
|
+
// same veil, the same «mesh» and blur the room's PERSONALIZE sets, read from the same keys.
|
|
65
|
+
const WALLPAPERS = ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania'];
|
|
66
|
+
const WALLPAPER_WORDS = { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania' };
|
|
67
|
+
// The conversation's own looks (app.js, LOOKS.bubbles and LOOKS.text): the lobby has no
|
|
68
|
+
// conversation, so here they are only chosen, for the room served at this address.
|
|
69
|
+
const BUBBLES = ['comfortable', 'large', 'compact', 'fitted'];
|
|
70
|
+
const TEXTS = ['comfortable', 'larger', 'smaller', 'visual'];
|
|
71
|
+
// The photographs' two dials, as the room keeps them (app.js, TUNES): the first value is no key.
|
|
72
|
+
const TUNES = {
|
|
73
|
+
veil: { key: 'pulse.wallpaper.veil', min: 0, max: 140, step: 5, value: 100, shown: (v) => `${v}%`, apply: (v) => root.style.setProperty('--veil-k', String(v / 100)) },
|
|
74
|
+
blur: { key: 'pulse.wallpaper.blur', min: 0, max: 24, step: 1, value: 0, shown: (v) => `${v}px`, apply: (v) => root.style.setProperty('--wp-blur', `${v}px`) },
|
|
75
|
+
};
|
|
76
|
+
// Home's own choices (2026-10-11): how its project cards read, how large its text is and in which
|
|
77
|
+
// order the projects come. Kept in this browser for Home alone: a project keeps its conversation's
|
|
78
|
+
// (bubbles and text) in its own Settings. The first value of each is Home as it always was.
|
|
79
|
+
const HOME_LOOKS = {
|
|
80
|
+
cards: { key: 'pulse.home.cards', values: ['full', 'compact'], words: { full: () => t('With their last conversation'), compact: () => t('Name and state only') } },
|
|
81
|
+
text: { key: 'pulse.home.text', values: ['comfortable', 'larger', 'smaller'], words: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller') } },
|
|
82
|
+
order: { key: 'pulse.home.order', values: ['attention', 'name', 'recent', 'mine'], words: { attention: () => t('Attention'), name: () => t('Name'), recent: () => t('Recent'), mine: () => t('Your order') } },
|
|
83
|
+
};
|
|
84
|
+
const LOOK_KEYS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', ...Object.values(HOME_LOOKS).map((one) => one.key)];
|
|
85
|
+
function readLooks() {
|
|
86
|
+
let theme = 'auto';
|
|
87
|
+
let accent = null;
|
|
88
|
+
let wallpaper = null;
|
|
89
|
+
let style = null;
|
|
90
|
+
let veil = null;
|
|
91
|
+
let blur = null;
|
|
92
|
+
let bubbles = null;
|
|
93
|
+
let text = null;
|
|
94
|
+
try {
|
|
95
|
+
theme = localStorage.getItem('pulse.theme') ?? 'auto'; accent = localStorage.getItem('pulse.accent'); wallpaper = localStorage.getItem('pulse.wallpaper');
|
|
96
|
+
style = localStorage.getItem('pulse.wallpaper.style'); veil = localStorage.getItem('pulse.wallpaper.veil'); blur = localStorage.getItem('pulse.wallpaper.blur');
|
|
97
|
+
bubbles = localStorage.getItem('pulse.bubbles'); text = localStorage.getItem('pulse.text');
|
|
98
|
+
} catch { /* no storage */ }
|
|
99
|
+
const number = (raw, low, high, fallback) => { const value = raw === null ? Number.NaN : Number(raw); return Number.isFinite(value) && value >= low && value <= high ? value : fallback; };
|
|
100
|
+
return {
|
|
101
|
+
theme: ['auto', 'light', 'dark'].includes(theme) ? theme : 'auto',
|
|
102
|
+
accent: ACCENTS.includes(accent) ? accent : 'madre',
|
|
103
|
+
wallpaper: WALLPAPERS.includes(wallpaper) ? wallpaper : 'default',
|
|
104
|
+
style: style === 'mesh' ? 'mesh' : 'veil',
|
|
105
|
+
veil: number(veil, 0, 140, 100),
|
|
106
|
+
blur: number(blur, 0, 24, 0),
|
|
107
|
+
bubbles: BUBBLES.includes(bubbles) ? bubbles : BUBBLES[0],
|
|
108
|
+
text: TEXTS.includes(text) ? text : TEXTS[0],
|
|
109
|
+
home: Object.fromEntries(Object.entries(HOME_LOOKS).map(([name, spec]) => {
|
|
110
|
+
let value = null;
|
|
111
|
+
try { value = localStorage.getItem(spec.key); } catch { /* no storage */ }
|
|
112
|
+
return [name, spec.values.includes(value) ? value : spec.values[0]];
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function applyLooks() {
|
|
117
|
+
const { theme, accent } = readLooks();
|
|
118
|
+
const light = window.matchMedia?.('(prefers-color-scheme: light)').matches;
|
|
119
|
+
if (theme === 'light' || theme === 'dark') root.dataset.theme = theme; else delete root.dataset.theme;
|
|
120
|
+
root.dataset.scheme = theme === 'auto' ? (light ? 'light' : 'dark') : theme;
|
|
121
|
+
if (accent !== 'madre') root.dataset.accent = accent; else delete root.dataset.accent;
|
|
122
|
+
// The wallpaper, its style and its two dials, as the room applies them (app.js, applyLook, TUNES).
|
|
123
|
+
const looks = readLooks();
|
|
124
|
+
if (looks.wallpaper !== 'default') root.dataset.wallpaper = looks.wallpaper; else delete root.dataset.wallpaper;
|
|
125
|
+
if (looks.style === 'mesh') root.dataset.wpStyle = 'mesh'; else delete root.dataset.wpStyle;
|
|
126
|
+
root.style.setProperty('--veil-k', String(looks.veil / 100));
|
|
127
|
+
root.style.setProperty('--wp-blur', `${looks.blur}px`);
|
|
128
|
+
if (looks.bubbles !== BUBBLES[0]) root.dataset.bubbles = looks.bubbles; else delete root.dataset.bubbles;
|
|
129
|
+
if (looks.text !== TEXTS[0]) root.dataset.text = looks.text; else delete root.dataset.text;
|
|
130
|
+
// Home's own: its cards and its text (salas-mac.css, data-home-*); the order is draw()'s.
|
|
131
|
+
if (looks.home.cards !== 'full') root.dataset.homeCards = looks.home.cards; else delete root.dataset.homeCards;
|
|
132
|
+
if (looks.home.text !== 'comfortable') root.dataset.homeText = looks.home.text; else delete root.dataset.homeText;
|
|
133
|
+
}
|
|
134
|
+
applyLooks();
|
|
135
|
+
function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
|
|
136
|
+
// The first value of each look is the room as it always was, kept as no choice at all (app.js, saveLook).
|
|
137
|
+
const keep = (key, value, first) => { if (value === first) localStorage.removeItem(key); else localStorage.setItem(key, value); };
|
|
138
|
+
try {
|
|
139
|
+
if (theme) localStorage.setItem('pulse.theme', theme);
|
|
140
|
+
if (accent) keep('pulse.accent', accent, 'madre');
|
|
141
|
+
if (wallpaper) { keep('pulse.wallpaper', wallpaper, 'default'); saveOwnWallpaper(wallpaper); }
|
|
142
|
+
if (style) keep('pulse.wallpaper.style', style, 'veil');
|
|
143
|
+
if (bubbles) keep('pulse.bubbles', bubbles, BUBBLES[0]);
|
|
144
|
+
if (text) keep('pulse.text', text, TEXTS[0]);
|
|
145
|
+
} catch { /* the choice lasts as long as the page */ }
|
|
146
|
+
applyLooks();
|
|
147
|
+
settingsSheet?.paintLooks();
|
|
148
|
+
pushLooks();
|
|
149
|
+
}
|
|
150
|
+
function saveHome(name, value) {
|
|
151
|
+
const spec = HOME_LOOKS[name];
|
|
152
|
+
try { if (value === spec.values[0]) localStorage.removeItem(spec.key); else localStorage.setItem(spec.key, value); } catch { /* the choice lasts as long as the page */ }
|
|
153
|
+
applyLooks();
|
|
154
|
+
if (name === 'order' && typeof draw === 'function') draw();
|
|
155
|
+
if (name === 'order' && typeof paintOrderKey === 'function') paintOrderKey();
|
|
156
|
+
settingsSheet?.paintLooks();
|
|
157
|
+
}
|
|
158
|
+
// A dial is saved when it is let go and shown while it moves; the sheet is not painted again under
|
|
159
|
+
// the hand (app.js, saveTune).
|
|
160
|
+
function saveTune(name, value) {
|
|
161
|
+
try { if (value === TUNES[name].value) localStorage.removeItem(TUNES[name].key); else localStorage.setItem(TUNES[name].key, String(value)); } catch { /* the choice lasts as long as the page */ }
|
|
162
|
+
applyLooks(); pushLooks();
|
|
163
|
+
}
|
|
164
|
+
window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', applyLooks);
|
|
165
|
+
// The looks are the person's, not Home's (src/looks.mjs, 2026-10-12): every project is another
|
|
166
|
+
// origin, so what this browser kept here no project saw. ~/.pulse/looks.json is read when Home
|
|
167
|
+
// opens and whenever it comes back into view, and written when a choice changes. The twin of
|
|
168
|
+
// app.js's pullLooks; Home's own card choices (HOME_LOOKS) stay here.
|
|
169
|
+
// The wallpaper is each page's own (2026-10-13): Home keeps the one chosen in Home, each project
|
|
170
|
+
// the one chosen in it (src/looks.mjs, `rooms`). The rest of the looks are shared.
|
|
171
|
+
const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
|
|
172
|
+
// Before a page had its own, every page wore the shared one: that is where a page starts.
|
|
173
|
+
const wallpaperOf = (answer) => answer.own?.wallpaper ?? answer.looks?.['pulse.wallpaper'] ?? null;
|
|
174
|
+
function saveOwnWallpaper(value) {
|
|
175
|
+
void fetch('/api/looks/room', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ wallpaper: value }) }).catch(() => null);
|
|
176
|
+
}
|
|
177
|
+
let looksPush = 0;
|
|
178
|
+
function pushLooks() {
|
|
179
|
+
clearTimeout(looksPush);
|
|
180
|
+
looksPush = setTimeout(() => {
|
|
181
|
+
const patch = {};
|
|
182
|
+
try { for (const key of SHARED_LOOKS) patch[key] = localStorage.getItem(key); } catch { return; }
|
|
183
|
+
void fetch('/api/looks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => null);
|
|
184
|
+
}, 250);
|
|
185
|
+
}
|
|
186
|
+
async function pullLooks() {
|
|
187
|
+
let answer = null;
|
|
188
|
+
try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
|
|
189
|
+
if (!answer?.looks) return;
|
|
190
|
+
try {
|
|
191
|
+
// Nothing kept yet: the first page that has a choice of its own hands it to the others.
|
|
192
|
+
if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
|
|
193
|
+
let changed = false;
|
|
194
|
+
const wall = wallpaperOf(answer);
|
|
195
|
+
for (const [key, value] of [...SHARED_LOOKS.map((one) => [one, answer.looks[one] ?? null]), ['pulse.wallpaper', wall === 'default' ? null : wall]]) {
|
|
196
|
+
if (localStorage.getItem(key) === value) continue;
|
|
197
|
+
if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
|
|
198
|
+
changed = true;
|
|
199
|
+
}
|
|
200
|
+
if (changed) { applyLooks(); settingsSheet?.paintLooks(); }
|
|
201
|
+
} catch { /* no storage: Home keeps the look it has */ }
|
|
202
|
+
}
|
|
203
|
+
void pullLooks();
|
|
204
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void pullLooks(); });
|
|
205
|
+
window.addEventListener('storage', (event) => { if (LOOK_KEYS.includes(event.key)) { applyLooks(); settingsSheet?.paintLooks(); } });
|
|
206
|
+
|
|
207
|
+
const el = (tag, className, text) => {
|
|
208
|
+
const node = document.createElement(tag);
|
|
209
|
+
if (className) node.className = className;
|
|
210
|
+
if (text !== undefined && text !== null) node.textContent = text;
|
|
211
|
+
return node;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// The same small words as the phone's SALAS (salas.js).
|
|
215
|
+
const chatName = (title) => (title === 'First conversation' ? t('First conversation') : title === 'Untitled' ? t('Untitled') : title === 'Coordinator' ? t('Coordinator') : title);
|
|
216
|
+
function ago(iso) {
|
|
217
|
+
const at = Date.parse(iso ?? '');
|
|
218
|
+
if (!Number.isFinite(at)) return '';
|
|
219
|
+
const seconds = (at - Date.now()) / 1000;
|
|
220
|
+
const away = Math.abs(seconds);
|
|
221
|
+
const words = new Intl.RelativeTimeFormat(language, { numeric: 'auto', style: 'short' });
|
|
222
|
+
if (away < 60) return t('now');
|
|
223
|
+
if (away < 3600) return words.format(Math.round(seconds / 60), 'minute');
|
|
224
|
+
if (away < 86400) return words.format(Math.round(seconds / 3600), 'hour');
|
|
225
|
+
if (away < 604800) return words.format(Math.round(seconds / 86400), 'day');
|
|
226
|
+
return new Date(at).toLocaleDateString(language, { day: 'numeric', month: 'short' });
|
|
227
|
+
}
|
|
228
|
+
// The modes as the room names them (app.js, MODES): asked when shown, so the language is the page's.
|
|
229
|
+
const modeName = (n) => [t('INCOGNITO'), t('CHAT ONLY'), t('CREATE'), t('EDIT'), t('FULL ACCESS')][n] ?? '';
|
|
230
|
+
function askLine(ask) {
|
|
231
|
+
if (ask.kind === 'mode') return t('@{agent} asks for #{n} {label}', { agent: ask.agent, n: ask.mode, label: modeName(ask.mode) });
|
|
232
|
+
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 });
|
|
233
|
+
if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: unlock it with your key', { n: ask.mode, agent: ask.agent });
|
|
234
|
+
if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for your press: {reason}', { reason: ask.text }) : t('The Coordinator waits for your press');
|
|
235
|
+
return ask.text ? t('@{agent} asks you: {question}', { agent: ask.agent, question: ask.text }) : t('@{agent} asks you to choose', { agent: ask.agent });
|
|
236
|
+
}
|
|
237
|
+
// The folder, as a person writes it: the home folder is ~.
|
|
238
|
+
const homeWord = (path) => String(path ?? '').replace(/^\/(Users|home)\/[^/]+/, '~');
|
|
239
|
+
// Where a room is reached from this page: this one is here, the rest at their own port.
|
|
240
|
+
const addressOf = (room) => (room.here ? '/' : `http://127.0.0.1:${room.open.port}/`);
|
|
241
|
+
|
|
242
|
+
// The memory kinds, in the colours NOSTROMO gives them (styles.css, --mem-*).
|
|
243
|
+
const KINDS = ['decision', 'fact', 'preference', 'question', 'aberration'];
|
|
244
|
+
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
|
|
245
|
+
// A kind as a sentence says it: «Decisión», not «DECISIÓN».
|
|
246
|
+
const kindWord = (kind) => { const word = String(KIND_WORDS[kind] ?? kind).toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1); };
|
|
247
|
+
|
|
248
|
+
function memoryStrip(census) {
|
|
249
|
+
const strip = el('div', 'sm-strip');
|
|
250
|
+
const total = census?.memories ?? 0;
|
|
251
|
+
strip.setAttribute('role', 'img');
|
|
252
|
+
if (!total) { strip.classList.add('none'); strip.setAttribute('aria-label', t('No memories yet')); return strip; }
|
|
253
|
+
const said = [];
|
|
254
|
+
for (const kind of KINDS) {
|
|
255
|
+
const count = census.kinds?.[kind] ?? 0;
|
|
256
|
+
if (!count) continue;
|
|
257
|
+
const part = el('i', `k-${kind}`);
|
|
258
|
+
part.style.flexGrow = String(count);
|
|
259
|
+
strip.append(part);
|
|
260
|
+
said.push(`${count} ${KIND_WORDS[kind].toLowerCase()}`);
|
|
261
|
+
}
|
|
262
|
+
strip.setAttribute('aria-label', said.join(', '));
|
|
263
|
+
strip.title = said.join(' · ');
|
|
264
|
+
return strip;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stateOf(room) {
|
|
268
|
+
const glance = room.glance ?? {};
|
|
269
|
+
if (!room.open) return { key: 'asleep', words: room.lastOpened ? t('Asleep · last open {when}', { when: ago(room.lastOpened) }) : t('Asleep') };
|
|
270
|
+
if (glance.outdated) return { key: 'outdated', words: t('Update MADRE in this project to see it here.') };
|
|
271
|
+
if (glance.unreachable) return { key: 'quiet', words: t('Not answering right now') };
|
|
272
|
+
if (glance.delayed) return { key: 'quiet', words: t('Responding slowly · last known state') };
|
|
273
|
+
if (glance.waitingCount) return { key: 'waiting', words: t('Waiting for you · {n}', { n: glance.waitingCount }) };
|
|
274
|
+
if (glance.working?.length) return { key: 'working', words: glance.working.length === 1 ? t('@{agent} is working', { agent: glance.working[0] }) : t('{n} agents working', { n: glance.working.length }) };
|
|
275
|
+
return { key: 'calm', words: t('Calm') };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function button(className, words, onClick) {
|
|
279
|
+
const node = el('button', className, words);
|
|
280
|
+
node.type = 'button';
|
|
281
|
+
node.addEventListener('click', onClick);
|
|
282
|
+
return node;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function card(room) {
|
|
286
|
+
const glance = room.glance ?? {};
|
|
287
|
+
const state = stateOf(room);
|
|
288
|
+
const node = el('article', `sm-tile sm-card is-${state.key}${room.here ? ' here' : ''}`);
|
|
289
|
+
node.dataset.room = room.id;
|
|
290
|
+
// Its own piece of the wallpaper (salas-mac.css): a place on the image, steady for the project.
|
|
291
|
+
let seed = 0;
|
|
292
|
+
for (const char of room.id) seed = (seed * 31 + char.charCodeAt(0)) >>> 0;
|
|
293
|
+
node.style.setProperty('--wx', `${seed % 101}%`);
|
|
294
|
+
node.style.setProperty('--wy', `${(seed >>> 8) % 101}%`);
|
|
295
|
+
// A project with a wallpaper of its own («only in this project») shows that one, whole.
|
|
296
|
+
// The wallpaper its own room wears (each page keeps its own), seen whole; none when it has none.
|
|
297
|
+
if (room.wallpaper && room.wallpaper !== 'default') { node.dataset.wp = room.wallpaper; node.style.setProperty('--wx', '50%'); node.style.setProperty('--wy', '50%'); }
|
|
298
|
+
else node.dataset.wp = 'none';
|
|
299
|
+
// To the bar: dragged there, or pinned from its right-click.
|
|
300
|
+
node.draggable = true;
|
|
301
|
+
node.addEventListener('dragstart', (event) => pinsDragOut(event, room));
|
|
302
|
+
node.addEventListener('dragend', () => pinsDropEnd());
|
|
303
|
+
node.addEventListener('contextmenu', (event) => pinsMenu(event, room));
|
|
304
|
+
|
|
305
|
+
const head = el('header', 'sm-card-head');
|
|
306
|
+
head.append(el('h3', null, room.name));
|
|
307
|
+
if (room.here) head.append(el('span', 'sm-chip here', t('You are here')));
|
|
308
|
+
else if (room.open) head.append(el('span', 'sm-chip port', `:${room.open.port}`));
|
|
309
|
+
// What the right-click offers, for whoever does not right-click: a key of its own.
|
|
310
|
+
const more = button('sm-card-more', null, (event) => {
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
const box = more.getBoundingClientRect();
|
|
313
|
+
pinsMenu(event, room, { x: box.right - 214, y: box.bottom + 6 });
|
|
314
|
+
});
|
|
315
|
+
more.innerHTML = MORE_ICON; // trusted: our own SVG string
|
|
316
|
+
more.title = t('Options for {name}', { name: room.name });
|
|
317
|
+
more.setAttribute('aria-label', more.title);
|
|
318
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
319
|
+
head.append(more);
|
|
320
|
+
node.append(head);
|
|
321
|
+
if (room.root) node.append(el('p', 'sm-path', homeWord(room.root)));
|
|
322
|
+
|
|
323
|
+
const line = el('p', 'sm-state');
|
|
324
|
+
line.append(el('i', 'dot'), document.createTextNode(state.words));
|
|
325
|
+
node.append(line);
|
|
326
|
+
|
|
327
|
+
if (glance.waiting?.length) {
|
|
328
|
+
const box = el('div', 'sm-wait');
|
|
329
|
+
box.append(el('span', null, askLine(glance.waiting[0])));
|
|
330
|
+
node.append(box);
|
|
331
|
+
}
|
|
332
|
+
if (glance.chat && !glance.outdated) {
|
|
333
|
+
const chat = el('div', 'sm-chat');
|
|
334
|
+
chat.append(el('b', null, chatName(glance.chat.title)));
|
|
335
|
+
if (glance.chat.preview) chat.append(el('span', null, glance.chat.preview));
|
|
336
|
+
node.append(chat);
|
|
337
|
+
}
|
|
338
|
+
if (room.open && glance.census) {
|
|
339
|
+
node.append(memoryStrip(glance.census));
|
|
340
|
+
const counts = [conversationsCount(glance.chats ?? 0), memoriesCount(glance.census.memories)];
|
|
341
|
+
if (glance.census.memories) counts.push(t('{n} travel', { n: glance.census.travelling }));
|
|
342
|
+
node.append(el('p', 'sm-counts', counts.join(' · ')));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const actions = el('div', 'sm-actions');
|
|
346
|
+
const doing = busy.get(room.id);
|
|
347
|
+
if (failed.has(room.id)) node.append(el('p', 'sm-error', failed.get(room.id)));
|
|
348
|
+
if (doing) actions.append(waitFor(room.id, { starting: t('Waking…'), closing: t('Closing…'), forgetting: t('Removing…') }[doing] ?? ''));
|
|
349
|
+
else if (forgetting.has(room.id)) {
|
|
350
|
+
// Taking it out of MADRE says what goes and what stays before it happens.
|
|
351
|
+
const links = (data.enlaces ?? []).filter((one) => one.rooms?.a === room.id || one.rooms?.b === room.id).length;
|
|
352
|
+
const why = [
|
|
353
|
+
room.open ? t('It closes and leaves Home.') : t('It leaves Home.'),
|
|
354
|
+
t('Its folder stays on this Mac, and its memory in MADRE.'),
|
|
355
|
+
...(links ? [links === 1 ? t('Its connection ends.') : t('Its {n} connections end.', { n: links })] : []),
|
|
356
|
+
].join(' ');
|
|
357
|
+
actions.append(el('span', 'sm-ask', why), button('sm-btn danger small', t('Remove'), () => void forgetRoom(room)), button('sm-btn ghost small', t('Cancel'), () => { forgetting.delete(room.id); draw(); }));
|
|
358
|
+
} else if (asking.has(room.id)) {
|
|
359
|
+
const why = room.here ? t('This page closes with it.') : glance.working?.length ? t('What is running stops.') : t('Nothing is running.');
|
|
360
|
+
actions.append(el('span', 'sm-ask', why), button('sm-btn danger small', t('Close it'), () => closeRoom(room)), button('sm-btn ghost small', t('Cancel'), () => { asking.delete(room.id); draw(); }));
|
|
361
|
+
} else if (room.open) {
|
|
362
|
+
const go = el('a', 'sm-btn primary small', room.here ? t('Back to the project') : t('Open'));
|
|
363
|
+
go.href = addressOf(room);
|
|
364
|
+
actions.append(go);
|
|
365
|
+
if (!glance.outdated) actions.append(button('sm-btn ghost small', t('Close'), () => { failed.delete(room.id); asking.add(room.id); draw(); }));
|
|
366
|
+
} else if (room.root) {
|
|
367
|
+
actions.append(button('sm-btn primary small', t('Open'), () => startRoom(room, { enter: true })));
|
|
368
|
+
}
|
|
369
|
+
node.append(actions);
|
|
370
|
+
return node;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Open first, by what needs the person: waiting, then working, then the rest, newest first.
|
|
374
|
+
const ORDER = { waiting: 0, working: 1, calm: 2, quiet: 3, outdated: 4 };
|
|
375
|
+
// The projects in the order the lobby shows them: the open ones by what needs the person, then
|
|
376
|
+
// the asleep ones, the last opened first. BISHOP's own room is not a project.
|
|
377
|
+
const PROJECT_ORDER_KEY = 'pulse.home.projects';
|
|
378
|
+
function projectOrder() {
|
|
379
|
+
try { const list = JSON.parse(localStorage.getItem(PROJECT_ORDER_KEY) ?? '[]'); return Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []; } catch { return []; }
|
|
380
|
+
}
|
|
381
|
+
// A project moved by hand: the whole order as it is on screen now, with that one moved, becomes
|
|
382
|
+
// «Yours» — so moving one never shuffles the others.
|
|
383
|
+
function moveProject(id, where) {
|
|
384
|
+
const list = projectsInOrder().all.map((room) => room.id);
|
|
385
|
+
const at = list.indexOf(id);
|
|
386
|
+
if (at < 0) return;
|
|
387
|
+
list.splice(at, 1);
|
|
388
|
+
const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
|
|
389
|
+
list.splice(to, 0, id);
|
|
390
|
+
try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(list)); } catch { /* the order lasts as long as the page */ }
|
|
391
|
+
saveHome('order', 'mine');
|
|
392
|
+
}
|
|
393
|
+
function projectsInOrder() {
|
|
394
|
+
const projects = data.rooms.filter((room) => room.id !== data.salas);
|
|
395
|
+
const open = projects.filter((room) => room.open)
|
|
396
|
+
.sort((a, b) => (ORDER[stateOf(a).key] - ORDER[stateOf(b).key]) || (Date.parse(b.glance?.chat?.at ?? 0) - Date.parse(a.glance?.chat?.at ?? 0)));
|
|
397
|
+
const asleep = projects.filter((room) => !room.open).sort((a, b) => Date.parse(b.lastOpened ?? b.added ?? 0) - Date.parse(a.lastOpened ?? a.added ?? 0));
|
|
398
|
+
// The order Home's Appearance chose: what needs the person first (as above), by name, or by the
|
|
399
|
+
// last thing that happened in each, open or asleep.
|
|
400
|
+
const order = readLooks().home.order;
|
|
401
|
+
const touched = (room) => Math.max(Date.parse(room.glance?.chat?.at ?? 0) || 0, Date.parse(room.lastOpened ?? room.added ?? 0) || 0);
|
|
402
|
+
// «Yours» (2026-10-13): the order the person made with right-click › Move; a project they never
|
|
403
|
+
// placed comes after, in the order of what needs them.
|
|
404
|
+
const mine = order === 'mine' ? projectOrder() : [];
|
|
405
|
+
const placed = (room) => { const at = mine.indexOf(room.id); return at < 0 ? Number.MAX_SAFE_INTEGER : at; };
|
|
406
|
+
const all = order === 'name' ? [...projects].sort((a, b) => a.name.localeCompare(b.name, language))
|
|
407
|
+
: order === 'recent' ? [...projects].sort((a, b) => touched(b) - touched(a))
|
|
408
|
+
: order === 'mine' ? [...open, ...asleep].map((room, index) => [room, index]).sort(([a, i], [b, j]) => placed(a) - placed(b) || i - j).map(([room]) => room)
|
|
409
|
+
: [...open, ...asleep];
|
|
410
|
+
return { all, open, asleep };
|
|
411
|
+
}
|
|
412
|
+
function draw() {
|
|
413
|
+
if (!data) return;
|
|
414
|
+
const { all, open } = projectsInOrder();
|
|
415
|
+
drawBishop(data.rooms.find((room) => room.id === data.salas) ?? null);
|
|
416
|
+
drawWaiting();
|
|
417
|
+
drawProjects(all);
|
|
418
|
+
if (!all.length && !welcomed) { welcomed = true; void homeTour.loaded.then((answer) => { if (!answer?.owed) openWizard(); }); }
|
|
419
|
+
welcomeHome();
|
|
420
|
+
const working = open.some((room) => room.glance?.working?.length);
|
|
421
|
+
ui.stop.hidden = !working;
|
|
422
|
+
drawLinks(all);
|
|
423
|
+
drawMemory();
|
|
424
|
+
drawAttention();
|
|
425
|
+
drawTopbar();
|
|
426
|
+
// The macroverse wants a large screen: on a small one SALAS is the whole of it.
|
|
427
|
+
ui.macro.hidden = !data.rooms.length || window.innerWidth < 900;
|
|
428
|
+
macro?.update(data.rooms, data.enlaces ?? []);
|
|
429
|
+
settingsSheet?.sync();
|
|
430
|
+
fitTiles();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// The projects, as the search box and the filter leave them. Nothing is hidden by the filter
|
|
434
|
+
// without saying so: an empty result says what was asked.
|
|
435
|
+
function shows(room) {
|
|
436
|
+
if (filter === 'open' && !room.open) return false;
|
|
437
|
+
if (filter === 'asleep' && room.open) return false;
|
|
438
|
+
if (!query) return true;
|
|
439
|
+
const wanted = query.toLocaleLowerCase(language);
|
|
440
|
+
return room.name.toLocaleLowerCase(language).includes(wanted) || homeWord(room.root).toLocaleLowerCase(language).includes(wanted);
|
|
441
|
+
}
|
|
442
|
+
function drawProjects(all = data ? projectsInOrder().all : []) {
|
|
443
|
+
if (!data) return;
|
|
444
|
+
const shown = all.filter(shows);
|
|
445
|
+
ui.projects.replaceChildren(...shown.map(card));
|
|
446
|
+
ui.projects.hidden = !shown.length;
|
|
447
|
+
ui.none.hidden = Boolean(shown.length);
|
|
448
|
+
ui.none.replaceChildren();
|
|
449
|
+
fitDrawer(all);
|
|
450
|
+
if (!all.length) ui.none.append(el('span', null, t('No projects yet. Add the first one.')), button('sm-btn primary small', t('Add a project'), openAddSheet));
|
|
451
|
+
else if (!shown.length) ui.none.append(el('span', null, query ? t('No project matches «{query}».', { query }) : filter === 'open' ? t('No project is open.') : t('No project is asleep.')));
|
|
452
|
+
}
|
|
453
|
+
// THE DRAWER (2026-10-13): the projects live in a tile of their own, moved like any other, three
|
|
454
|
+
// rows at a time. What does not fit is counted on a key under them — «+5», how many are open and
|
|
455
|
+
// how many asleep — and the key opens the drawer whole, or closes it back to three rows.
|
|
456
|
+
const drawerMore = $('sm-drawer-more');
|
|
457
|
+
let drawerOpen = false;
|
|
458
|
+
const DRAWER_ROWS = 3;
|
|
459
|
+
function fitDrawer(all = data ? projectsInOrder().all : []) {
|
|
460
|
+
const count = $('sm-drawer-count');
|
|
461
|
+
if (count) count.textContent = String(all.length);
|
|
462
|
+
const cards = [...ui.projects.querySelectorAll('.sm-card[data-room]')];
|
|
463
|
+
for (const one of cards) one.hidden = false;
|
|
464
|
+
if (!cards.length) { drawerMore.hidden = true; return; }
|
|
465
|
+
// How many fit in a row is the grid's to say, at this width: 4, 3 or 2.
|
|
466
|
+
const first = cards[0].getBoundingClientRect();
|
|
467
|
+
const perRow = Math.max(1, Math.round((ui.projects.getBoundingClientRect().width + 16) / ((first.width || 1) + 16)));
|
|
468
|
+
const room = perRow * DRAWER_ROWS;
|
|
469
|
+
const out = cards.slice(room);
|
|
470
|
+
if (!out.length) { drawerMore.hidden = true; return; }
|
|
471
|
+
if (!drawerOpen) for (const one of out) one.hidden = true;
|
|
472
|
+
const rooms = out.map((one) => data.rooms.find((room) => room.id === one.dataset.room)).filter(Boolean);
|
|
473
|
+
const open = rooms.filter((room) => room.open).length;
|
|
474
|
+
const asleep = rooms.length - open;
|
|
475
|
+
drawerMore.hidden = false;
|
|
476
|
+
drawerMore.replaceChildren();
|
|
477
|
+
if (drawerOpen) drawerMore.append(el('span', null, t('Show fewer')));
|
|
478
|
+
else {
|
|
479
|
+
drawerMore.append(el('b', null, `+${out.length}`));
|
|
480
|
+
const parts = [open ? t('{n} open', { n: open }) : null, asleep ? t('{n} asleep', { n: asleep }) : null].filter(Boolean);
|
|
481
|
+
drawerMore.append(el('span', null, parts.join(' · ')));
|
|
482
|
+
}
|
|
483
|
+
drawerMore.setAttribute('aria-expanded', String(drawerOpen));
|
|
484
|
+
drawerMore.title = drawerOpen ? t('Show three rows') : t('{n} more projects: {parts}', { n: out.length, parts: [open ? t('{n} open', { n: open }) : null, asleep ? t('{n} asleep', { n: asleep }) : null].filter(Boolean).join(', ') });
|
|
485
|
+
}
|
|
486
|
+
drawerMore?.addEventListener('click', () => { drawerOpen = !drawerOpen; fitDrawer(); fitTiles(); });
|
|
487
|
+
if (typeof ResizeObserver === 'function') {
|
|
488
|
+
let wide = 0;
|
|
489
|
+
new ResizeObserver(() => { const now = Math.round(ui.projects.getBoundingClientRect().width); if (now && now !== wide) { wide = now; fitDrawer(); } }).observe(ui.projects);
|
|
490
|
+
}
|
|
491
|
+
ui.search.addEventListener('input', () => { query = ui.search.value.trim(); drawProjects(); });
|
|
492
|
+
for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
|
|
493
|
+
choice.addEventListener('click', () => {
|
|
494
|
+
filter = choice.dataset.filter;
|
|
495
|
+
for (const one of ui.filter.querySelectorAll('[data-filter]')) one.setAttribute('aria-checked', String(one === choice));
|
|
496
|
+
drawProjects();
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ORDER, where the projects are (2026-10-13): the same four orders as Home's Appearance, one press
|
|
501
|
+
// away from the list they arrange. «Yours» is the order made by dragging a card or by its
|
|
502
|
+
// right-click › Move; the menu says so, so the way to make it is never a secret.
|
|
503
|
+
const SORT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4.5v11M3.5 13 6 15.5 8.5 13"/><path d="M11 6h5.5M11 10h4M11 14h2.5"/></svg>';
|
|
504
|
+
const orderKey = button('sm-btn sm-order', null, () => {
|
|
505
|
+
const box = orderKey.getBoundingClientRect();
|
|
506
|
+
const now = readLooks().home.order;
|
|
507
|
+
homeMenu({ x: box.left, y: box.bottom + 6 }, [
|
|
508
|
+
{ heading: t('Order the projects') },
|
|
509
|
+
...HOME_LOOKS.order.values.map((value) => ({ words: HOME_LOOKS.order.words[value](), icon: SORT_ICON, checked: now === value, act: () => saveHome('order', value) })),
|
|
510
|
+
'sep',
|
|
511
|
+
{ heading: t('Drag a card, or right-click it › Move, to make your own.') },
|
|
512
|
+
], t('Order the projects'));
|
|
513
|
+
});
|
|
514
|
+
ui.filter.after(orderKey);
|
|
515
|
+
function paintOrderKey() {
|
|
516
|
+
const now = readLooks().home.order;
|
|
517
|
+
orderKey.innerHTML = SORT_ICON; // trusted: our own SVG string
|
|
518
|
+
orderKey.append(el('span', null, HOME_LOOKS.order.words[now]()));
|
|
519
|
+
orderKey.title = t('Order the projects');
|
|
520
|
+
orderKey.setAttribute('aria-label', `${t('Order the projects')}: ${HOME_LOOKS.order.words[now]()}`);
|
|
521
|
+
orderKey.setAttribute('aria-haspopup', 'menu');
|
|
522
|
+
}
|
|
523
|
+
paintOrderKey();
|
|
524
|
+
|
|
525
|
+
// WAITING FOR YOU: every question an agent asks the person, from every open room, counted in one
|
|
526
|
+
// number; the first ones said, each leading to its room.
|
|
527
|
+
function drawWaiting() {
|
|
528
|
+
const rooms = data.rooms.filter((room) => room.open && room.glance?.waitingCount);
|
|
529
|
+
const total = rooms.reduce((sum, room) => sum + room.glance.waitingCount, 0);
|
|
530
|
+
ui.waiting.count.textContent = String(total);
|
|
531
|
+
ui.waiting.box.classList.toggle('has', Boolean(total));
|
|
532
|
+
ui.waiting.body.replaceChildren();
|
|
533
|
+
if (!total) { ui.waiting.body.append(el('p', 'sm-hint', t('Nobody needs you. When an agent asks for you, it shows here.'))); return; }
|
|
534
|
+
for (const room of rooms.slice(0, 2)) {
|
|
535
|
+
const row = el('a', 'sm-waiting-row');
|
|
536
|
+
row.href = addressOf(room);
|
|
537
|
+
const first = room.glance.waiting?.[0];
|
|
538
|
+
row.append(el('b', null, roomLabel(room)), el('span', null, first ? askLine(first) : t('Waiting for you · {n}', { n: room.glance.waitingCount })));
|
|
539
|
+
ui.waiting.body.append(row);
|
|
540
|
+
}
|
|
541
|
+
if (rooms.length > 2) ui.waiting.body.append(el('p', 'sm-hint', t('And {n} more projects.', { n: rooms.length - 2 })));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// MEMORY: what the open rooms hold, added up, by kind. A room asleep is not asked, so it is not
|
|
545
|
+
// counted, and the tile says so.
|
|
546
|
+
function drawMemory() {
|
|
547
|
+
const sum = { memories: 0, travelling: 0, kinds: {} };
|
|
548
|
+
const counted = data.rooms.filter((room) => room.open && room.glance?.census);
|
|
549
|
+
for (const room of counted) {
|
|
550
|
+
const census = room.glance.census;
|
|
551
|
+
sum.memories += census.memories ?? 0;
|
|
552
|
+
sum.travelling += census.travelling ?? 0;
|
|
553
|
+
for (const kind of KINDS) sum.kinds[kind] = (sum.kinds[kind] ?? 0) + (census.kinds?.[kind] ?? 0);
|
|
554
|
+
}
|
|
555
|
+
ui.memory.total.textContent = t('{n} memories', { n: sum.memories });
|
|
556
|
+
ui.memory.total.hidden = !counted.length;
|
|
557
|
+
ui.memory.body.replaceChildren();
|
|
558
|
+
if (!counted.length) { ui.memory.body.append(el('p', 'sm-hint', t('Open a project to count its memory.'))); return; }
|
|
559
|
+
ui.memory.body.append(memoryStrip(sum));
|
|
560
|
+
const legend = el('div', 'sm-legend');
|
|
561
|
+
for (const kind of KINDS) {
|
|
562
|
+
if (!sum.kinds[kind]) continue;
|
|
563
|
+
const item = el('span', `k-${kind}`, `${kindWord(kind)} · ${sum.kinds[kind]}`);
|
|
564
|
+
legend.append(item);
|
|
565
|
+
}
|
|
566
|
+
ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} travel', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// AGENTS: the ones on this computer, with the session each has. Connected once, every project
|
|
570
|
+
// uses them; the settings sheet installs or signs in the one that needs it, and «Needs you» says
|
|
571
|
+
// which one does.
|
|
572
|
+
function agentState(agent, answer) {
|
|
573
|
+
const session = answer.sessions?.[agent.id]?.state;
|
|
574
|
+
const busyNow = answer.loggingIn === agent.id;
|
|
575
|
+
const ok = agent.ready && session !== 'signed-out';
|
|
576
|
+
return { ok, busyNow, key: ok ? 'ok' : busyNow ? 'wait' : agent.detected ? 'bad' : 'wait', words: ok ? t('Connected') : busyNow ? t('Signing in…') : agent.detected ? t('Not signed in') : t('Not installed') };
|
|
577
|
+
}
|
|
578
|
+
async function loadAgents() {
|
|
579
|
+
const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
580
|
+
if (!answer?.agents) return;
|
|
581
|
+
agentsSeen = answer;
|
|
582
|
+
drawAttention();
|
|
583
|
+
drawTopbar();
|
|
584
|
+
settingsSheet?.sync();
|
|
585
|
+
}
|
|
586
|
+
// MADRE itself: the version this room runs and the newest one it knows of.
|
|
587
|
+
// Asked again every hour (the server asks npm at most once a day); the first time a newer MADRE
|
|
588
|
+
// shows up while Home is open, it says so once, and the pill stays in the bar.
|
|
589
|
+
let versionAnnounced = null;
|
|
590
|
+
async function loadVersion() {
|
|
591
|
+
const answer = await fetch('/api/version', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
592
|
+
if (!answer) return;
|
|
593
|
+
versionSeen = answer;
|
|
594
|
+
if (answer.available && answer.latest && versionAnnounced !== answer.latest) {
|
|
595
|
+
versionAnnounced = answer.latest;
|
|
596
|
+
toast(t('MADRE {version} is out. Update it from the bar.', { version: answer.latest }), 'look');
|
|
597
|
+
}
|
|
598
|
+
drawAttention();
|
|
599
|
+
drawTopbar();
|
|
600
|
+
settingsSheet?.sync();
|
|
601
|
+
}
|
|
602
|
+
setInterval(() => { if (!document.hidden) void loadVersion(); }, 60 * 60 * 1000);
|
|
603
|
+
|
|
604
|
+
// The open projects that run an older MADRE than Home: Home was updated, they were not restarted.
|
|
605
|
+
const versionParts = (value) => String(value ?? '').split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
606
|
+
function olderVersion(a, b) {
|
|
607
|
+
const [x, y] = [versionParts(a), versionParts(b)];
|
|
608
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
function behindRooms() {
|
|
612
|
+
if (!data?.version) return [];
|
|
613
|
+
return data.rooms.filter((room) => room.open?.version && !room.here && room.id !== data.salas && olderVersion(room.open.version, data.version) && !busy.has(room.id));
|
|
614
|
+
}
|
|
615
|
+
// A project restarted with the MADRE Home runs: closed, then opened again where it was.
|
|
616
|
+
async function restartRoom(room) {
|
|
617
|
+
await closeRoom(room);
|
|
618
|
+
if (data?.rooms.find((one) => one.id === room.id)?.open) return;
|
|
619
|
+
await startRoom(room);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Every project still on the older MADRE, one after the other (1.0.1). Only the quiet ones: a
|
|
623
|
+
// project with an agent working or a question waiting for the person is left as it is, and said.
|
|
624
|
+
const roomQuiet = (room) => !room.glance?.working?.length && !room.glance?.waitingCount;
|
|
625
|
+
let restartingAll = false;
|
|
626
|
+
async function restartAll() {
|
|
627
|
+
if (restartingAll) return;
|
|
628
|
+
const behind = behindRooms();
|
|
629
|
+
const quiet = behind.filter(roomQuiet);
|
|
630
|
+
const left = behind.filter((room) => !roomQuiet(room));
|
|
631
|
+
if (!quiet.length) { toast(t('Every project on the older MADRE is busy now: restart them when they finish.'), 'look'); return; }
|
|
632
|
+
restartingAll = true;
|
|
633
|
+
try {
|
|
634
|
+
for (const [index, room] of quiet.entries()) {
|
|
635
|
+
toast(t('Restarting {room} · {n} of {total}', { room: roomLabel(room), n: index + 1, total: quiet.length }));
|
|
636
|
+
await restartRoom(room);
|
|
637
|
+
}
|
|
638
|
+
} finally {
|
|
639
|
+
restartingAll = false;
|
|
640
|
+
}
|
|
641
|
+
toast(left.length
|
|
642
|
+
? t('{n} restarted. Left as they are, because they are busy: {rooms}.', { n: quiet.length, rooms: left.map(roomLabel).join(', ') })
|
|
643
|
+
: t('{n} restarted with MADRE {version}.', { n: quiet.length, version: data?.version ?? '' }), left.length ? 'look' : 'done');
|
|
644
|
+
settingsSheet?.sync({ detail: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// NEEDS YOU: what the person would want to know before anything else, from what the rooms, the
|
|
648
|
+
// agents and MADRE itself say: a room on an older MADRE or not answering, an agent without a
|
|
649
|
+
// session, a link about to expire, a newer MADRE. Nothing listed is a guess.
|
|
650
|
+
function attentionItems() {
|
|
651
|
+
const items = [];
|
|
652
|
+
for (const room of data?.rooms ?? []) {
|
|
653
|
+
if (!room.open) continue;
|
|
654
|
+
const name = roomLabel(room);
|
|
655
|
+
if (room.glance?.outdated) items.push({ tone: 'wait', words: t('{room} runs an older MADRE.', { room: name }), hint: t('Update it to see it here and to connect it.'), href: addressOf(room), go: t('Open') });
|
|
656
|
+
else if (room.glance?.unreachable) items.push({ tone: 'bad', words: t('{room} is not answering.', { room: name }), hint: t('It may be busy. If it stays like this, close it and open it again.') });
|
|
657
|
+
}
|
|
658
|
+
for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
|
|
659
|
+
const state = agentState(agent, agentsSeen);
|
|
660
|
+
if (!state.ok) items.push({ tone: state.key, words: `${agent.label ?? agent.id} · ${state.words}`, hint: t('Connected once, every project uses it.'), act: () => openSettings('agents'), go: t('Connect') });
|
|
661
|
+
}
|
|
662
|
+
const soon = Date.now() + 3 * 86400000;
|
|
663
|
+
for (const enlace of data?.enlaces ?? []) {
|
|
664
|
+
if (!enlace.expires || Date.parse(enlace.expires) > soon) continue;
|
|
665
|
+
const { source, reader } = endsOf(enlace);
|
|
666
|
+
items.push({ tone: 'wait', words: t('The connection {reader} ← {source} ends {when}.', { reader: roomName(reader), source: roomName(source), when: ago(enlace.expires) }), hint: t('Sign it again if it should go on.'), act: () => ui.linksSheet.showModal(), go: t('See the connections') });
|
|
667
|
+
}
|
|
668
|
+
if (versionSeen?.available && versionSeen.latest) items.push({ tone: 'ok', words: t('MADRE {version} is out.', { version: versionSeen.latest }), hint: t('You run {version}. Home restarts with it from here.', { version: versionSeen.current }), act: () => openSettings('release'), go: t('Update') });
|
|
669
|
+
// After Home updated, the projects that stayed open still run the version they started with.
|
|
670
|
+
const behind = behindRooms();
|
|
671
|
+
if (behind.length > 1) items.push({ tone: 'wait', words: t('{n} projects run an older MADRE.', { n: behind.length }), hint: t('Restart them all to bring them to {latest}. A project that is working or waiting for you is left for later.', { latest: data.version }), act: () => void restartAll(), go: t('Restart all') });
|
|
672
|
+
else for (const room of behind) items.push({ tone: 'wait', words: t('{room} runs MADRE {version}.', { room: roomLabel(room), version: room.open.version }), hint: t('Restart it to bring it to {latest}: what is running stops.', { latest: data.version }), act: () => void restartRoom(room), go: t('Restart') });
|
|
673
|
+
return items;
|
|
674
|
+
}
|
|
675
|
+
function drawAttention() {
|
|
676
|
+
const items = attentionItems();
|
|
677
|
+
ui.attention.count.textContent = String(items.length);
|
|
678
|
+
ui.attention.count.hidden = !items.length;
|
|
679
|
+
ui.attention.list.replaceChildren();
|
|
680
|
+
if (!items.length) {
|
|
681
|
+
const calm = el('li', 'sm-attention-item is-calm');
|
|
682
|
+
const words = el('div', 'sm-attention-words');
|
|
683
|
+
words.append(el('b', null, t('All in order.')), el('span', null, t('No project, agent or connection needs you.')));
|
|
684
|
+
calm.append(el('i', 'dot'), words);
|
|
685
|
+
ui.attention.list.append(calm);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
for (const item of items.slice(0, 3)) {
|
|
689
|
+
const row = el('li', `sm-attention-item is-${item.tone}`);
|
|
690
|
+
const words = el('div', 'sm-attention-words');
|
|
691
|
+
words.append(el('b', null, item.words), el('span', null, item.hint));
|
|
692
|
+
row.append(el('i', 'dot'), words);
|
|
693
|
+
if (item.href) {
|
|
694
|
+
const go = el('a', 'sm-btn small', item.go);
|
|
695
|
+
go.href = item.href;
|
|
696
|
+
if (item.external) { go.target = '_blank'; go.rel = 'noopener'; }
|
|
697
|
+
row.append(go);
|
|
698
|
+
} else if (item.act) row.append(button('sm-btn small', item.go, item.act));
|
|
699
|
+
ui.attention.list.append(row);
|
|
700
|
+
}
|
|
701
|
+
if (items.length > 3) ui.attention.list.append(el('li', 'sm-hint', t('And {n} more.', { n: items.length - 3 })));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// RECENT DECISIONS: what the open rooms decided last, in their own words, as each archive keeps
|
|
705
|
+
// them (the notes that travel into turns). Read through the lobby's route, which asks the rooms
|
|
706
|
+
// at most every few minutes.
|
|
707
|
+
async function loadDecisions() {
|
|
708
|
+
const answer = await fetch('/api/salas/decisions', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
709
|
+
if (!answer) return;
|
|
710
|
+
decisions = answer.decisions ?? [];
|
|
711
|
+
drawDecisions();
|
|
712
|
+
}
|
|
713
|
+
function drawDecisions() {
|
|
714
|
+
const list = ui.decisions.list;
|
|
715
|
+
list.replaceChildren();
|
|
716
|
+
if (!decisions) { list.append(el('li', 'sm-hint', t('Reading the open projects…'))); return; }
|
|
717
|
+
if (!decisions.length) { list.append(el('li', 'sm-hint', t('No decisions yet in the open projects. When an archive keeps one, it shows here.'))); return; }
|
|
718
|
+
for (const one of decisions.slice(0, 4)) {
|
|
719
|
+
const item = el('li', 'sm-decision');
|
|
720
|
+
const head = el('div', 'sm-decision-head');
|
|
721
|
+
head.append(roomTag(one.room, one.name), el('time', null, ago(one.at)));
|
|
722
|
+
item.append(head, el('p', null, one.text));
|
|
723
|
+
list.append(item);
|
|
724
|
+
}
|
|
725
|
+
if (typeof fitTiles === 'function') fitTiles();
|
|
726
|
+
}
|
|
727
|
+
// A project's name as a small tag: a way into it when it is open.
|
|
728
|
+
function roomTag(id, name) {
|
|
729
|
+
const room = data?.rooms.find((one) => one.id === id) ?? null;
|
|
730
|
+
// The Coordinator's own project is named as the Coordinator, in the reader's language.
|
|
731
|
+
const tag = el(room?.open ? 'a' : 'span', 'sm-chip port sm-tag-room', id === data?.salas ? t('Coordinator') : name);
|
|
732
|
+
if (room?.open) tag.href = addressOf(room);
|
|
733
|
+
return tag;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// THE LAST WORK: MADRE's summary of the newest conversations of every project, written with its
|
|
737
|
+
// local model in this Mac (src/lobby-work.mjs) and checked against them. Without the model, or
|
|
738
|
+
// while it writes, the conversations show as they are.
|
|
739
|
+
let workTimer = 0;
|
|
740
|
+
async function loadWork({ again = false } = {}) {
|
|
741
|
+
clearTimeout(workTimer);
|
|
742
|
+
if (again) { ui.work.again.disabled = true; ui.work.box.classList.add('is-working'); }
|
|
743
|
+
const answer = await fetch('/api/salas/work', again ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } : { cache: 'no-store' })
|
|
744
|
+
.then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
745
|
+
if (answer) { work = answer; drawWork(); }
|
|
746
|
+
// While MADRE writes, the tile asks again soon; otherwise every two minutes.
|
|
747
|
+
workTimer = setTimeout(() => { if (!document.hidden) void loadWork(); }, answer?.state === 'working' ? 3000 : 120000);
|
|
748
|
+
}
|
|
749
|
+
function drawWork() {
|
|
750
|
+
const w = ui.work;
|
|
751
|
+
w.list.replaceChildren();
|
|
752
|
+
const state = work?.state ?? 'loading';
|
|
753
|
+
w.box.classList.toggle('is-working', state === 'working');
|
|
754
|
+
w.again.hidden = !work || state === 'empty' || state === 'unavailable';
|
|
755
|
+
w.again.disabled = state === 'working';
|
|
756
|
+
if (!work) { w.by.textContent = ''; w.note.textContent = t('Reading your conversations…'); return; }
|
|
757
|
+
if (state === 'empty') { w.by.textContent = ''; w.note.textContent = t('No conversations in the last two weeks. What you work on in any project shows here, summarised by MADRE.'); return; }
|
|
758
|
+
const bullets = work.bullets ?? [];
|
|
759
|
+
if (bullets.length) {
|
|
760
|
+
// Each line's project is the conversation's it summarises, put by the lobby (src/lobby-work.mjs).
|
|
761
|
+
for (const one of bullets) {
|
|
762
|
+
const item = el('li');
|
|
763
|
+
item.append(roomTag(one.room, one.name), el('span', null, one.text));
|
|
764
|
+
w.list.append(item);
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
for (const one of (work.sources ?? []).slice(0, 5)) {
|
|
768
|
+
const item = el('li', 'raw');
|
|
769
|
+
const words = el('span');
|
|
770
|
+
words.append(el('b', null, chatName(one.title)));
|
|
771
|
+
if (one.last?.text) words.append(document.createTextNode(` — ${one.last.text}`));
|
|
772
|
+
item.append(roomTag(one.room, one.name), words, el('time', null, ago(one.at)));
|
|
773
|
+
w.list.append(item);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
w.by.textContent = bullets.length && work.at ? `@madre · ${work.model ?? ''} · ${ago(new Date(work.at).toISOString())}` : '';
|
|
777
|
+
w.note.textContent = state === 'working' ? t('MADRE is summarising your latest conversations…')
|
|
778
|
+
: 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.'))
|
|
779
|
+
: state === 'failed' ? t('MADRE could not summarise them this time: these are your latest conversations, as they are.')
|
|
780
|
+
: work.stale ? t('There is newer work than this summary: it is written again in a few minutes, or now if you ask.')
|
|
781
|
+
: t('Written by MADRE on this Mac from your latest conversations.');
|
|
782
|
+
if (typeof fitTiles === 'function') fitTiles();
|
|
783
|
+
}
|
|
784
|
+
ui.work.again.addEventListener('click', () => void loadWork({ again: true }));
|
|
785
|
+
|
|
786
|
+
// LINKS (docs/MACROVERSO.md, phase 3c): the contracts between rooms, read from /api/salas, each
|
|
787
|
+
// one revocable here, and the sheet that signs a new one. A contract says which room reads which,
|
|
788
|
+
// what kinds cross and until when; what crosses is counted, never shown.
|
|
789
|
+
const roomLabel = (room) => (room.id === data?.salas ? t('Coordinator') : room.name);
|
|
790
|
+
const roomName = (id) => { const room = data?.rooms.find((one) => one.id === id); return room ? roomLabel(room) : id === data?.salas ? t('Coordinator') : id; };
|
|
791
|
+
const kindsWords = (kinds) => (kinds ?? []).map((kind) => (KIND_WORDS[kind] ?? kind).toLowerCase()).join(', ');
|
|
792
|
+
// Who reads and who serves, from the contract's own words: `a→b` means the notes travel from a to b.
|
|
793
|
+
function endsOf(enlace) {
|
|
794
|
+
const { a, b } = enlace.rooms;
|
|
795
|
+
return enlace.direction === 'b→a' ? { source: b, reader: a } : { source: a, reader: b };
|
|
796
|
+
}
|
|
797
|
+
function linkRow(enlace) {
|
|
798
|
+
const { source, reader } = endsOf(enlace);
|
|
799
|
+
const node = el('article', `sm-link${revoking.has(enlace.id) ? ' revoking' : ''}`);
|
|
800
|
+
node.dataset.link = enlace.id;
|
|
801
|
+
node.append(el('span', 'sm-link-kind', source === data.salas || reader === data.salas ? t('With the Coordinator') : t('Between projects')));
|
|
802
|
+
const who = el('p', 'who');
|
|
803
|
+
who.append(enlace.direction === 'both' ? sayTinted('{a} and {b} read each other', { a: enlace.rooms.a, b: enlace.rooms.b }) : sayTinted('{reader} reads from {source}', { reader, source }));
|
|
804
|
+
node.append(who);
|
|
805
|
+
const what = [kindsWords(enlace.kinds), enlace.expires ? t('until {date}', { date: new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'short' }) }) : t('no expiry')];
|
|
806
|
+
if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
|
|
807
|
+
// D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
|
|
808
|
+
if (enlace.signed?.by === 'bishop') what.push(t('signed by the Coordinator'));
|
|
809
|
+
node.append(el('p', 'what', what.join(' · ')));
|
|
810
|
+
const traffic = enlace.traffic ?? {};
|
|
811
|
+
// S-021: the last reading says who read, as the room that asked said it.
|
|
812
|
+
const last = traffic.lastAt ? (traffic.lastAgent ? t('last by @{agent} of {room}, {when}', { agent: traffic.lastAgent, room: roomName(traffic.lastRoom), when: ago(traffic.lastAt) }) : t('last {when}', { when: ago(traffic.lastAt) })) : t('none yet');
|
|
813
|
+
node.append(el('p', 'traffic', [t('{n} reads in 24 h', { n: traffic.reads24h ?? 0 }), last].join(' · ')));
|
|
814
|
+
// Fase 3e: recall on every turn, ticked or cleared here by the human.
|
|
815
|
+
const recall = el('label', 'row sm-recall');
|
|
816
|
+
const recallBox = el('input'); recallBox.type = 'checkbox'; recallBox.checked = Boolean(enlace.recall);
|
|
817
|
+
recallBox.addEventListener('change', async () => {
|
|
818
|
+
recallBox.disabled = true;
|
|
819
|
+
const answer = await fetch('/api/salas/enlaces/recall', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: enlace.id, recall: recallBox.checked }) })
|
|
820
|
+
.then((response) => response.ok).catch(() => false);
|
|
821
|
+
if (!answer) recallBox.checked = !recallBox.checked;
|
|
822
|
+
recallBox.disabled = false;
|
|
823
|
+
await refresh();
|
|
824
|
+
});
|
|
825
|
+
recall.append(recallBox, document.createTextNode(t('Always include (up to 3 notes)')));
|
|
826
|
+
node.append(recall);
|
|
827
|
+
const actions = el('div', 'sm-actions');
|
|
828
|
+
if (revoking.has(enlace.id)) actions.append(el('span', 'sm-ask', t('Nothing crosses from then on.')), button('sm-btn danger', t('Revoke it'), () => revokeLink(enlace.id)), button('sm-btn ghost', t('Cancel'), () => { revoking.delete(enlace.id); draw(); }));
|
|
829
|
+
else actions.append(button('sm-btn ghost', t('Revoke'), () => { revoking.add(enlace.id); draw(); }));
|
|
830
|
+
node.append(actions);
|
|
831
|
+
return node;
|
|
832
|
+
}
|
|
833
|
+
function drawLinks(projects) {
|
|
834
|
+
const enlaces = [...(data.enlaces ?? [])].sort((a, b) => {
|
|
835
|
+
const direct = (one) => Number(one.rooms.a !== data.salas && one.rooms.b !== data.salas);
|
|
836
|
+
return direct(b) - direct(a) || roomName(endsOf(a).reader).localeCompare(roomName(endsOf(b).reader)) || roomName(endsOf(a).source).localeCompare(roomName(endsOf(b).source));
|
|
837
|
+
});
|
|
838
|
+
const enough = data.rooms.length >= 2;
|
|
839
|
+
ui.link.hidden = !enough;
|
|
840
|
+
ui.linkMore.hidden = !enough;
|
|
841
|
+
ui.linksAll.hidden = !enlaces.length;
|
|
842
|
+
const reads = enlaces.reduce((sum, enlace) => sum + (enlace.traffic?.reads24h ?? 0), 0);
|
|
843
|
+
ui.linksSum.textContent = enlaces.length
|
|
844
|
+
? [enlaces.length === 1 ? t('1 in force') : t('{n} in force', { n: enlaces.length }), t('{n} reads in 24 h', { n: reads })].join(' · ')
|
|
845
|
+
: enough ? t('No connections yet.') : t('Add a project to connect it.');
|
|
846
|
+
ui.linksNote.textContent = t('A connection lets one project read the distilled notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes that travel into turns cross.');
|
|
847
|
+
ui.links.replaceChildren(...enlaces.map(linkRow));
|
|
848
|
+
if (!enlaces.length && ui.linksSheet.open) ui.linksSheet.close();
|
|
849
|
+
drawMap(projects, enlaces);
|
|
850
|
+
}
|
|
851
|
+
// A sentence whose project names wear their tone (the map's): `vars` names room ids.
|
|
852
|
+
function sayTinted(key, vars) {
|
|
853
|
+
const out = document.createDocumentFragment();
|
|
854
|
+
const template = t(key);
|
|
855
|
+
let at = 0;
|
|
856
|
+
for (const match of template.matchAll(/\{(\w+)\}/g)) {
|
|
857
|
+
out.append(template.slice(at, match.index));
|
|
858
|
+
const id = vars[match[1]];
|
|
859
|
+
if (id === undefined) out.append(match[0]);
|
|
860
|
+
else {
|
|
861
|
+
const name = el('b', `sm-tinted${id === data.salas ? ' hub' : ''}`, roomName(id));
|
|
862
|
+
if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
|
|
863
|
+
out.append(name);
|
|
864
|
+
}
|
|
865
|
+
at = match.index + match[0].length;
|
|
866
|
+
}
|
|
867
|
+
out.append(template.slice(at));
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
async function revokeLink(id) {
|
|
871
|
+
revoking.delete(id);
|
|
872
|
+
await fetch('/api/salas/enlaces/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).catch(() => null);
|
|
873
|
+
await refresh();
|
|
874
|
+
}
|
|
875
|
+
ui.linksAll.addEventListener('click', () => { if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
|
|
876
|
+
// The tile's map is the small one: a press opens the map of connections in its own window.
|
|
877
|
+
ui.map.addEventListener('click', (event) => { if (event.target.closest('svg') && (data?.enlaces ?? []).length) openGraph(); });
|
|
878
|
+
// Closed, the sheet has no size to draw at: the wide map is drawn each time it opens.
|
|
879
|
+
|
|
880
|
+
ui.linksClose.addEventListener('click', () => ui.linksSheet.close());
|
|
881
|
+
ui.linkMore.addEventListener('click', () => openLinkSheet());
|
|
882
|
+
|
|
883
|
+
// The map of the links (redrawn 2026-10-13): the Coordinator in the middle and, round it, the
|
|
884
|
+
// projects a contract touches, each in its own tone of the accent the person chose, so a project
|
|
885
|
+
// is the same colour on the map, on its wires and in the words under it. The tile draws only the
|
|
886
|
+
// connected ones, large enough to read; the sheet («See the connections») draws them all, wide.
|
|
887
|
+
// Nothing here is drawn that the contracts do not say.
|
|
888
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
889
|
+
const svgNode = (tag, attributes = {}) => {
|
|
890
|
+
const node = document.createElementNS(SVG, tag);
|
|
891
|
+
for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value));
|
|
892
|
+
return node;
|
|
893
|
+
};
|
|
894
|
+
// A project's tone: the accent turned round the colour wheel, keeping its own lightness and
|
|
895
|
+
// strength. The turns are shared out evenly among the projects a connection touches (in a steady
|
|
896
|
+
// order, by id), so no two of them look alike; the first wears the accent itself. Where the
|
|
897
|
+
// browser cannot turn a colour (no relative colour syntax), every project wears the accent.
|
|
898
|
+
const TURNS = typeof CSS !== 'undefined' && CSS.supports?.('color', 'oklch(from red l c h)');
|
|
899
|
+
function toneOf(id) {
|
|
900
|
+
const linked = [...new Set((data?.enlaces ?? []).flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]))].filter((one) => one !== data?.salas).sort();
|
|
901
|
+
const index = linked.indexOf(id);
|
|
902
|
+
if (!TURNS || index <= 0) return 'var(--s-acc)';
|
|
903
|
+
const step = 360 / Math.max(5, linked.length);
|
|
904
|
+
return `oklch(from var(--s-acc) l c calc(h + ${(index * step).toFixed(1)}))`;
|
|
905
|
+
}
|
|
906
|
+
// One project at a time (2026-10-13): under the pointer, or chosen in the window, a project
|
|
907
|
+
// lights its own connections and the projects at their other end; the rest steps back.
|
|
908
|
+
function focusable(svg, { pick = null, picked = null } = {}) {
|
|
909
|
+
const light = (id) => {
|
|
910
|
+
svg.classList.toggle('focusing', Boolean(id));
|
|
911
|
+
const near = new Set(id ? [id] : []);
|
|
912
|
+
for (const wire of svg.querySelectorAll('.wire')) {
|
|
913
|
+
const on = Boolean(id) && (wire.dataset.a === id || wire.dataset.b === id);
|
|
914
|
+
wire.classList.toggle('lit', Boolean(on || (picked?.link && wire.dataset.link === picked.link)));
|
|
915
|
+
if (on) { near.add(wire.dataset.a); near.add(wire.dataset.b); }
|
|
916
|
+
}
|
|
917
|
+
for (const dot of svg.querySelectorAll('.note, .flow')) dot.classList.toggle('lit', Boolean(id) && (dot.dataset.a === id || dot.dataset.b === id));
|
|
918
|
+
for (const node of svg.querySelectorAll('.node, .bishop')) node.classList.toggle('lit', near.has(node.dataset.id));
|
|
919
|
+
};
|
|
920
|
+
const resting = () => {
|
|
921
|
+
if (picked?.room) return light(picked.room);
|
|
922
|
+
if (picked?.link) {
|
|
923
|
+
const wire = svg.querySelector(`.wire[data-link="${CSS.escape(picked.link)}"]`);
|
|
924
|
+
svg.classList.add('focusing');
|
|
925
|
+
wire?.classList.add('lit');
|
|
926
|
+
for (const node of svg.querySelectorAll('.node, .bishop')) node.classList.toggle('lit', Boolean(wire) && (node.dataset.id === wire.dataset.a || node.dataset.id === wire.dataset.b));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
light(null);
|
|
930
|
+
};
|
|
931
|
+
for (const node of svg.querySelectorAll('.node, .bishop')) {
|
|
932
|
+
node.addEventListener('pointerenter', () => light(node.dataset.id));
|
|
933
|
+
node.addEventListener('pointerleave', resting);
|
|
934
|
+
if (pick) {
|
|
935
|
+
node.classList.add('pickable');
|
|
936
|
+
node.addEventListener('click', (event) => { event.stopPropagation(); pick({ room: node.dataset.id }); });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (pick) {
|
|
940
|
+
for (const wire of svg.querySelectorAll('.wire')) {
|
|
941
|
+
wire.classList.add('pickable');
|
|
942
|
+
// A line is thin; the hand gets a wider, invisible one along it.
|
|
943
|
+
const hit = svgNode('path', { class: 'hit', d: wire.getAttribute('d') });
|
|
944
|
+
hit.addEventListener('click', (event) => { event.stopPropagation(); pick({ link: wire.dataset.link }); });
|
|
945
|
+
hit.addEventListener('pointerenter', () => wire.classList.add('hover'));
|
|
946
|
+
hit.addEventListener('pointerleave', () => wire.classList.remove('hover'));
|
|
947
|
+
wire.after(hit);
|
|
948
|
+
}
|
|
949
|
+
svg.addEventListener('click', () => pick(null));
|
|
950
|
+
}
|
|
951
|
+
resting();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// THE MAP OF CONNECTIONS, in a window of its own: the same drawing, wide, every project round the
|
|
955
|
+
// Coordinator, and beside it what was chosen — a project (whom it reads, who reads it) or a line
|
|
956
|
+
// (what crosses, until when, how often it was read). Opened from the tile's map.
|
|
957
|
+
let graphWindow = null;
|
|
958
|
+
function openGraph() {
|
|
959
|
+
if (graphWindow?.open) return;
|
|
960
|
+
graphWindow?.remove();
|
|
961
|
+
graphWindow = el('dialog', 'sm-sheet sm-graph');
|
|
962
|
+
const body = el('div', 'sm-sheet-body');
|
|
963
|
+
const head = el('header', 'sm-sheet-head');
|
|
964
|
+
const words = el('div', 'sm-graph-title');
|
|
965
|
+
words.append(el('h3', null, t('Map of connections')), el('small', null, t('Who looks up whom. Touch a project or a line.')));
|
|
966
|
+
const keys = el('span', 'surface-modes');
|
|
967
|
+
const close = button('surface-mode close x-close x-only', null, () => graphWindow.close());
|
|
968
|
+
close.setAttribute('aria-label', t('Close'));
|
|
969
|
+
close.innerHTML = '<span class="x-icon"><svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>'; // trusted
|
|
970
|
+
keys.append(close);
|
|
971
|
+
head.append(words, keys);
|
|
972
|
+
const stage = el('div', 'sm-graph-stage');
|
|
973
|
+
const canvas = el('div', 'sm-map sm-map-wide sm-graph-canvas');
|
|
974
|
+
const side = el('aside', 'sm-graph-side');
|
|
975
|
+
stage.append(canvas, side);
|
|
976
|
+
body.append(head, stage);
|
|
977
|
+
graphWindow.append(body);
|
|
978
|
+
document.body.append(graphWindow);
|
|
979
|
+
let picked = null;
|
|
980
|
+
const paint = () => {
|
|
981
|
+
if (!graphWindow?.isConnected || !mapDrawn) return;
|
|
982
|
+
paintMap(canvas, mapDrawn.projects, mapDrawn.enlaces, { wide: true, pick: (next) => { picked = next; paint(); }, picked });
|
|
983
|
+
paintSide(side, picked, () => { graphWindow.close(); if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
|
|
984
|
+
};
|
|
985
|
+
const watch = typeof ResizeObserver === 'function' ? new ResizeObserver(() => paint()) : null;
|
|
986
|
+
graphWindow.addEventListener('close', () => { watch?.disconnect(); graphWindow?.remove(); graphWindow = null; });
|
|
987
|
+
graphWindow.repaint = paint;
|
|
988
|
+
graphWindow.showModal();
|
|
989
|
+
watch?.observe(canvas);
|
|
990
|
+
requestAnimationFrame(paint);
|
|
991
|
+
}
|
|
992
|
+
// What the side of the window says about the chosen thing, only what the contracts and the lobby say.
|
|
993
|
+
function paintSide(side, picked, toList) {
|
|
994
|
+
side.replaceChildren();
|
|
995
|
+
const enlaces = mapDrawn?.enlaces ?? [];
|
|
996
|
+
const reads = enlaces.reduce((sum, enlace) => sum + (enlace.traffic?.reads24h ?? 0), 0);
|
|
997
|
+
const listKey = button('sm-btn ghost small', t('See the connections'), toList);
|
|
998
|
+
if (!picked) {
|
|
999
|
+
side.append(el('b', 'sm-graph-big', String(enlaces.length)), el('span', 'sm-graph-sub', enlaces.length === 1 ? t('connection in force') : t('connections in force')));
|
|
1000
|
+
side.append(el('b', 'sm-graph-big', String(reads)), el('span', 'sm-graph-sub', t('reads in the last 24 h')));
|
|
1001
|
+
side.append(el('p', 'sm-hint', t('Each line goes from the project whose notes are read to the one that reads them, in the colour of where they come from.')));
|
|
1002
|
+
const quiet = (() => { try { return JSON.parse(side.previousElementSibling?.dataset.quiet ?? '[]'); } catch { return []; } })();
|
|
1003
|
+
if (quiet.length) {
|
|
1004
|
+
const box = el('div', 'sm-graph-list');
|
|
1005
|
+
box.append(el('span', 'sm-graph-label', t('Not connected')));
|
|
1006
|
+
box.append(el('span', 'sm-graph-quiet', quiet.map((id) => roomName(id)).join(' · ')));
|
|
1007
|
+
side.append(box);
|
|
1008
|
+
}
|
|
1009
|
+
side.append(listKey);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (picked.room) {
|
|
1013
|
+
const id = picked.room;
|
|
1014
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
1015
|
+
const name = el('b', `sm-tinted sm-graph-name${id === data.salas ? ' hub' : ''}`, roomName(id));
|
|
1016
|
+
if (id !== data.salas) name.style.setProperty('--pc', toneOf(id));
|
|
1017
|
+
side.append(name);
|
|
1018
|
+
if (room && id !== data.salas) side.append(el('span', 'sm-graph-sub', stateOf(room).words));
|
|
1019
|
+
const readsFrom = enlaces.filter((enlace) => endsOf(enlace).reader === id || (enlace.direction === 'both' && (enlace.rooms.a === id || enlace.rooms.b === id))).map((enlace) => { const { source, reader } = endsOf(enlace); return source === id ? reader : source; });
|
|
1020
|
+
const readBy = enlaces.filter((enlace) => endsOf(enlace).source === id || (enlace.direction === 'both' && (enlace.rooms.a === id || enlace.rooms.b === id))).map((enlace) => { const { source, reader } = endsOf(enlace); return reader === id ? source : reader; });
|
|
1021
|
+
const list = (title, ids) => {
|
|
1022
|
+
const box = el('div', 'sm-graph-list');
|
|
1023
|
+
box.append(el('span', 'sm-graph-label', title));
|
|
1024
|
+
if (!ids.length) box.append(el('span', 'sm-graph-sub', t('nobody')));
|
|
1025
|
+
for (const one of [...new Set(ids)]) { const who = el('b', `sm-tinted${one === data.salas ? ' hub' : ''}`, roomName(one)); if (one !== data.salas) who.style.setProperty('--pc', toneOf(one)); box.append(who); }
|
|
1026
|
+
side.append(box);
|
|
1027
|
+
};
|
|
1028
|
+
list(t('Looks up'), readsFrom);
|
|
1029
|
+
list(t('Looked up by'), readBy);
|
|
1030
|
+
if (room?.open && id !== data.salas) side.append(button('sm-btn primary small', t('Open the project'), () => leaveTo(addressOf(room))));
|
|
1031
|
+
side.append(listKey);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const enlace = enlaces.find((one) => one.id === picked.link);
|
|
1035
|
+
if (!enlace) { side.append(el('p', 'sm-hint', t('That connection is no longer in force.')), listKey); return; }
|
|
1036
|
+
const { source, reader } = endsOf(enlace);
|
|
1037
|
+
const who = el('p', 'sm-graph-who');
|
|
1038
|
+
who.append(enlace.direction === 'both' ? sayTinted('{a} and {b} read each other', { a: enlace.rooms.a, b: enlace.rooms.b }) : sayTinted('{reader} reads from {source}', { reader, source }));
|
|
1039
|
+
side.append(who);
|
|
1040
|
+
const fact = (label, value) => { const row = el('div', 'sm-graph-fact'); row.append(el('span', 'sm-graph-label', label), el('span', null, value)); side.append(row); };
|
|
1041
|
+
fact(t('What crosses'), kindsWords(enlace.kinds) || '—');
|
|
1042
|
+
fact(t('Until'), enlace.expires ? new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'long' }) : t('no expiry'));
|
|
1043
|
+
fact(t('Reads in 24 h'), String(enlace.traffic?.reads24h ?? 0));
|
|
1044
|
+
if (enlace.orders?.allowed) fact(t('Orders'), t('up to #{n}', { n: enlace.orders.maxMode }));
|
|
1045
|
+
if (enlace.signed?.by === 'bishop') fact(t('Signed by'), t('Coordinator'));
|
|
1046
|
+
side.append(listKey);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
let mapDrawn = null;
|
|
1050
|
+
function drawMap(projects, enlaces) {
|
|
1051
|
+
mapDrawn = { projects, enlaces };
|
|
1052
|
+
paintMap(ui.map, projects, enlaces, { wide: false });
|
|
1053
|
+
graphWindow?.repaint?.();
|
|
1054
|
+
}
|
|
1055
|
+
function paintMap(host, projects, enlaces, { wide, pick = null, picked = null }) {
|
|
1056
|
+
host.replaceChildren();
|
|
1057
|
+
if (!projects.length) { host.append(el('p', 'sm-hint', t('Your projects show here, round the Coordinator, with the connections between them.'))); return; }
|
|
1058
|
+
const box = host.getBoundingClientRect();
|
|
1059
|
+
const W = Math.max(240, Math.round(box.width || 320)), H = Math.max(170, Math.round(box.height || 220));
|
|
1060
|
+
const cx = W / 2, cy = H / 2;
|
|
1061
|
+
const linked = new Set(enlaces.flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]));
|
|
1062
|
+
const connected = projects.filter((room) => linked.has(room.id));
|
|
1063
|
+
// Only who is connected goes round the Coordinator, evenly; the rest are named apart (the tile's
|
|
1064
|
+
// last line, the window's side), so the drawing is never weighed down on one side.
|
|
1065
|
+
const ring = (connected.length ? connected : projects).slice(0, wide ? 18 : 10);
|
|
1066
|
+
const quiet = projects.length - ring.length;
|
|
1067
|
+
const name = (room) => (room.name.length > 16 ? `${room.name.slice(0, 15)}…` : room.name);
|
|
1068
|
+
// The ring leaves room for the longest name on either side, so nothing is cut at the edge.
|
|
1069
|
+
const longest = Math.max(40, ...ring.map((room) => name(room).length * (wide ? 7.4 : 7)));
|
|
1070
|
+
let rx = Math.max(60, W / 2 - longest - 18), ry = Math.max(44, H / 2 - (wide ? 34 : 26));
|
|
1071
|
+
// Wide, the ring is a circle: an abstract map, not a shape stretched to its frame.
|
|
1072
|
+
if (wide) rx = ry = Math.max(60, Math.min(rx, ry));
|
|
1073
|
+
const hubR = wide ? 21 : 17;
|
|
1074
|
+
const at = new Map([[data.salas, { x: cx, y: cy, r: hubR, pad: linked.has(data.salas) ? 6 : 0 }]]);
|
|
1075
|
+
ring.forEach((room, index) => {
|
|
1076
|
+
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / ring.length + (ring.length === 2 ? Math.PI / 2 : 0);
|
|
1077
|
+
const memories = room.glance?.census?.memories ?? 0;
|
|
1078
|
+
const r = linked.has(room.id) ? (wide ? 8 : 7) + Math.min(3, Math.sqrt(memories) / 4) : 4.5;
|
|
1079
|
+
at.set(room.id, { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry, r, cos: Math.cos(angle), sin: Math.sin(angle) });
|
|
1080
|
+
});
|
|
1081
|
+
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img' });
|
|
1082
|
+
svg.setAttribute('aria-label', ui.linksSum.textContent);
|
|
1083
|
+
const defs = svgNode('defs');
|
|
1084
|
+
svg.append(defs);
|
|
1085
|
+
// STARS (2026-10-13): each project is a point of light in its tone, its glow a radial gradient
|
|
1086
|
+
// that fades to nothing; the notes on their way are sparks. The theme decides how bright
|
|
1087
|
+
// (salas-mac.css): a lit sky in the dark, a quieter one in the light, where the text must read.
|
|
1088
|
+
const glowId = `sm-glow-${wide ? 'w' : 't'}`;
|
|
1089
|
+
const glow = svgNode('filter', { id: glowId, x: '-200%', y: '-200%', width: '500%', height: '500%' });
|
|
1090
|
+
glow.append(svgNode('feGaussianBlur', { stdDeviation: wide ? 2.2 : 1.8, result: 'soft' }));
|
|
1091
|
+
const merge = svgNode('feMerge');
|
|
1092
|
+
merge.append(svgNode('feMergeNode', { in: 'soft' }), svgNode('feMergeNode', { in: 'SourceGraphic' }));
|
|
1093
|
+
glow.append(merge);
|
|
1094
|
+
defs.append(glow);
|
|
1095
|
+
const halos = new Map();
|
|
1096
|
+
const haloFor = (id, colour) => {
|
|
1097
|
+
if (halos.has(id)) return halos.get(id);
|
|
1098
|
+
const ref = `sm-halo-${wide ? 'w' : 't'}-${halos.size}`;
|
|
1099
|
+
const gradient = svgNode('radialGradient', { id: ref });
|
|
1100
|
+
for (const [offset, opacity] of [['0%', 0.95], ['38%', 0.42], ['100%', 0]]) {
|
|
1101
|
+
const stop = svgNode('stop', { offset, 'stop-opacity': opacity });
|
|
1102
|
+
stop.style.stopColor = colour;
|
|
1103
|
+
gradient.append(stop);
|
|
1104
|
+
}
|
|
1105
|
+
defs.append(gradient);
|
|
1106
|
+
halos.set(id, `url(#${ref})`);
|
|
1107
|
+
return halos.get(id);
|
|
1108
|
+
};
|
|
1109
|
+
const edges = (from, to, gapFrom = 4, gapTo = 5) => {
|
|
1110
|
+
const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy) || 1;
|
|
1111
|
+
const ux = dx / length, uy = dy / length;
|
|
1112
|
+
const reach = (spot, gap) => spot.r + (spot.pad ?? 0) + gap;
|
|
1113
|
+
return { start: { x: from.x + ux * reach(from, gapFrom), y: from.y + uy * reach(from, gapFrom) }, end: { x: to.x - ux * reach(to, gapTo), y: to.y - uy * reach(to, gapTo) } };
|
|
1114
|
+
};
|
|
1115
|
+
// A hairline from the middle to each project on the ring that no contract touches: it is there,
|
|
1116
|
+
// and nothing crosses.
|
|
1117
|
+
const centre = at.get(data.salas);
|
|
1118
|
+
for (const room of ring) {
|
|
1119
|
+
if (linked.has(room.id)) continue;
|
|
1120
|
+
const { start, end } = edges(centre, at.get(room.id), 3, 3);
|
|
1121
|
+
svg.append(svgNode('line', { class: 'spoke', x1: start.x.toFixed(1), y1: start.y.toFixed(1), x2: end.x.toFixed(1), y2: end.y.toFixed(1) }));
|
|
1122
|
+
}
|
|
1123
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
1124
|
+
enlaces.forEach((enlace, index) => {
|
|
1125
|
+
const { source, reader } = endsOf(enlace);
|
|
1126
|
+
const from = at.get(source), to = at.get(reader);
|
|
1127
|
+
if (!from || !to) return;
|
|
1128
|
+
const { start, end } = edges(from, to);
|
|
1129
|
+
// To or from the Coordinator the wire is straight; between two projects it bows away from the
|
|
1130
|
+
// middle, so it never runs through the Coordinator.
|
|
1131
|
+
let d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} L${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
|
|
1132
|
+
if (source !== data.salas && reader !== data.salas) {
|
|
1133
|
+
const mx = (start.x + end.x) / 2, my = (start.y + end.y) / 2;
|
|
1134
|
+
const ox = mx - cx, oy = my - cy, out = Math.hypot(ox, oy) || 1;
|
|
1135
|
+
const bow = Math.min(46, Math.hypot(end.x - start.x, end.y - start.y) * 0.28);
|
|
1136
|
+
d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${(mx + (ox / out) * bow).toFixed(1)} ${(my + (oy / out) * bow).toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
|
|
1137
|
+
}
|
|
1138
|
+
// The wire wears the tone of the project the notes come from.
|
|
1139
|
+
const tone = source === data.salas ? 'var(--text-3)' : toneOf(source);
|
|
1140
|
+
const id = `sm-wire-${wide ? 'w' : 't'}-${index}`;
|
|
1141
|
+
const wire = svgNode('path', { id, class: 'wire', d });
|
|
1142
|
+
wire.style.stroke = tone;
|
|
1143
|
+
wire.dataset.a = source;
|
|
1144
|
+
wire.dataset.b = reader;
|
|
1145
|
+
wire.dataset.link = enlace.id;
|
|
1146
|
+
const title = svgNode('title');
|
|
1147
|
+
title.textContent = enlace.direction === 'both' ? t('{a} and {b} look each other up', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) }) : reader === data.salas ? t('The Coordinator looks up {source}', { source: roomName(source) }) : t('{reader} looks up {source}', { reader: roomName(reader), source: roomName(source) });
|
|
1148
|
+
wire.append(title);
|
|
1149
|
+
svg.append(wire);
|
|
1150
|
+
// ELECTRIC FLOW (2026-10-13): no arrowheads; the current runs along the line the way the notes
|
|
1151
|
+
// travel, from the project read to the one reading, and both ways when they read each other.
|
|
1152
|
+
// A still line under it says the connection is there even when nothing moves.
|
|
1153
|
+
const ways = enlace.direction === 'both' ? [['out', tone], ['back', reader === data.salas ? 'var(--text-3)' : toneOf(reader)]] : [['out', tone]];
|
|
1154
|
+
for (const [way, colour] of ways) {
|
|
1155
|
+
const flow = svgNode('path', { class: `flow ${way}`, d, filter: `url(#${glowId})` });
|
|
1156
|
+
flow.style.stroke = colour;
|
|
1157
|
+
flow.style.setProperty('--flow-delay', `${(index * 0.37) % 1.4}s`);
|
|
1158
|
+
flow.dataset.a = source;
|
|
1159
|
+
flow.dataset.b = reader;
|
|
1160
|
+
if (still) flow.classList.add('still');
|
|
1161
|
+
svg.append(flow);
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
for (const room of ring) {
|
|
1165
|
+
const spot = at.get(room.id);
|
|
1166
|
+
const on = linked.has(room.id);
|
|
1167
|
+
const group = svgNode('g', { class: `node${on ? ' linked' : ''}${room.open ? '' : ' asleep'}${room.glance?.waitingCount ? ' waiting' : ''}` });
|
|
1168
|
+
group.style.setProperty('--pc', toneOf(room.id));
|
|
1169
|
+
group.dataset.id = room.id;
|
|
1170
|
+
if (on && room.glance?.waitingCount) group.append(svgNode('circle', { class: 'wait', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r + 5).toFixed(1) }));
|
|
1171
|
+
// The star: a halo that breathes, a core of its tone and a white heart. Each one twinkles at its
|
|
1172
|
+
// own pace, so the sky never pulses as one.
|
|
1173
|
+
const star = svgNode('g', { class: 'star' });
|
|
1174
|
+
star.style.setProperty('--tw', `${(2.6 + ((room.id.length * 37) % 19) / 10).toFixed(1)}s`);
|
|
1175
|
+
star.style.setProperty('--tw-delay', `${(((room.id.charCodeAt(0) ?? 0) * 13) % 23) / 10}s`);
|
|
1176
|
+
star.append(
|
|
1177
|
+
svgNode('circle', { class: 'halo', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r * (on ? 2.9 : 2.2)).toFixed(1), fill: haloFor(room.id, toneOf(room.id)) }),
|
|
1178
|
+
svgNode('circle', { class: 'body', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: (spot.r * (on ? 0.82 : 0.9)).toFixed(1) }),
|
|
1179
|
+
svgNode('circle', { class: 'heart', cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: Math.max(0.9, spot.r * 0.22).toFixed(1) }),
|
|
1180
|
+
);
|
|
1181
|
+
group.append(star);
|
|
1182
|
+
const title = svgNode('title');
|
|
1183
|
+
title.textContent = `${room.name} · ${stateOf(room).words}`;
|
|
1184
|
+
group.append(title);
|
|
1185
|
+
// The name sits outside the ring, on the side its project is on: to the right on the right,
|
|
1186
|
+
// to the left on the left, above or below at the top and the bottom.
|
|
1187
|
+
const side = Math.abs(spot.cos) < 0.32 ? 'middle' : spot.cos > 0 ? 'start' : 'end';
|
|
1188
|
+
const gap = spot.r + 7;
|
|
1189
|
+
const x = side === 'middle' ? spot.x : spot.x + Math.sign(spot.cos) * gap;
|
|
1190
|
+
const y = side === 'middle' ? (spot.sin < 0 ? spot.y - gap - 2 : spot.y + gap + 10) : spot.y + 4;
|
|
1191
|
+
const label = svgNode('text', { class: 'label', x: x.toFixed(1), y: y.toFixed(1), 'text-anchor': side });
|
|
1192
|
+
label.textContent = name(room);
|
|
1193
|
+
group.append(label);
|
|
1194
|
+
svg.append(group);
|
|
1195
|
+
}
|
|
1196
|
+
// The Coordinator, in its own colours and mark (brands.js).
|
|
1197
|
+
const brand = brandOf('bishop');
|
|
1198
|
+
const hub = svgNode('g', { class: `bishop${linked.has(data.salas) ? ' linked' : ''}` });
|
|
1199
|
+
hub.dataset.id = data.salas;
|
|
1200
|
+
// The Coordinator is the brightest star, in its own colour, its mark at the heart.
|
|
1201
|
+
hub.append(svgNode('circle', { class: 'corona', cx, cy, r: hubR * 2.4, fill: haloFor(data.salas, brand.solid ?? brand.color) }));
|
|
1202
|
+
if (linked.has(data.salas)) hub.append(svgNode('circle', { class: 'halo', cx, cy, r: hubR + 6 }));
|
|
1203
|
+
hub.append(svgNode('circle', { class: 'core', cx, cy, r: hubR, fill: brand.solid ?? brand.color }));
|
|
1204
|
+
const size = Math.round(hubR * 1.24);
|
|
1205
|
+
const face = svgNode('g', { transform: `translate(${cx - size / 2} ${cy - size / 2})`, color: brand.ink });
|
|
1206
|
+
face.innerHTML = brand.mark(); // trusted: our own SVG string from brands.js
|
|
1207
|
+
face.firstElementChild?.setAttribute('width', String(size));
|
|
1208
|
+
face.firstElementChild?.setAttribute('height', String(size));
|
|
1209
|
+
hub.append(face);
|
|
1210
|
+
const hubTitle = svgNode('title');
|
|
1211
|
+
hubTitle.textContent = t('Coordinator');
|
|
1212
|
+
hub.append(hubTitle);
|
|
1213
|
+
svg.append(hub);
|
|
1214
|
+
host.append(svg);
|
|
1215
|
+
focusable(svg, { pick, picked });
|
|
1216
|
+
host.dataset.quiet = JSON.stringify(projects.filter((room) => !ring.includes(room)).map((room) => room.id));
|
|
1217
|
+
if (!wide && quiet > 0 && connected.length) host.append(el('span', 'sm-map-quiet', quiet === 1 ? t('1 more project, not connected') : t('{n} more projects, not connected', { n: quiet })));
|
|
1218
|
+
}
|
|
1219
|
+
// The tile and the sheet change size with the window: the map is drawn again at the new size.
|
|
1220
|
+
if (typeof ResizeObserver === 'function') {
|
|
1221
|
+
for (const [host, wide] of [[ui.map, false]]) {
|
|
1222
|
+
let drawnAt = '';
|
|
1223
|
+
new ResizeObserver(() => {
|
|
1224
|
+
const box = host.getBoundingClientRect();
|
|
1225
|
+
const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
|
|
1226
|
+
if (!box.width || size === drawnAt || !mapDrawn) return;
|
|
1227
|
+
drawnAt = size;
|
|
1228
|
+
paintMap(host, mapDrawn.projects, mapDrawn.enlaces, { wide });
|
|
1229
|
+
}).observe(host);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// HOME, ARRANGED BY ITS PERSON (2026-10-13). Every tile of Home — the Coordinator, who waits,
|
|
1234
|
+
// the connections, the decisions, what needs you, the memory, and the widgets added to it — sits
|
|
1235
|
+
// where the person put it, at the size they chose. Right-click a tile: move it, resize it, take it
|
|
1236
|
+
// off Home, or add another. What is kept is this browser's (Home's own layout, like its cards).
|
|
1237
|
+
// A widget exists only where something real feeds it (src/server.mjs, homeWidget): the machine,
|
|
1238
|
+
// the projects' servers sentinels and datasets, the Coordinator's own dataset, and Ollama.
|
|
1239
|
+
const LAYOUT_KEY = 'pulse.home.layout';
|
|
1240
|
+
// One board (2026-10-13): every tile of Home in one order, the projects' drawer among them.
|
|
1241
|
+
const BOARD = 'sm-top';
|
|
1242
|
+
const SIZES = [[4, () => t('Small')], [6, () => t('Medium')], [8, () => t('Large')], [12, () => t('Full width')]];
|
|
1243
|
+
// THE GRID (2026-10-13): 12 columns across and rows of 96 px down, 16 px between them. Every tile
|
|
1244
|
+
// takes whole columns and whole rows, so every edge falls on the same lines and tiles fit side by
|
|
1245
|
+
// side exactly: a 5-row tile beside a 2-row and a 3-row one ends where they end. A tile's height is
|
|
1246
|
+
// its default, «Automatic» (the rows its content needs), or the rows the person chose; what does
|
|
1247
|
+
// not fit in a chosen height scrolls inside the tile.
|
|
1248
|
+
const HEIGHTS = [[0, () => t('Automatic')], [2, () => t('2 rows')], [3, () => t('3 rows')], [4, () => t('4 rows')], [5, () => t('5 rows')]];
|
|
1249
|
+
const HOME_TILES = {
|
|
1250
|
+
bishop: { id: 'sm-bishop', name: () => t('Coordinator'), about: () => t('Your coordinator, its state and its recent summary.'), size: 8, rows: 5 },
|
|
1251
|
+
waiting: { id: 'sm-waiting', name: () => t('Waiting for you'), about: () => t('The questions and permissions an agent is waiting on.'), size: 4, rows: 2 },
|
|
1252
|
+
links: { id: 'sm-links-tile', name: () => t('Connections'), about: () => t('The map of who looks up whom.'), size: 4, rows: 3 },
|
|
1253
|
+
projects: { id: 'sm-drawer', name: () => t('Projects'), about: () => t('Every project, open or asleep, three rows at a time.'), size: 12, sizes: [12], kept: true },
|
|
1254
|
+
decisions: { id: 'sm-decisions', name: () => t('Recent decisions'), about: () => t('What your projects decided lately.'), size: 4, rows: 3 },
|
|
1255
|
+
attention: { id: 'sm-attention', name: () => t('Needs you'), about: () => t('What Home noticed that needs a hand.'), size: 4, rows: 3 },
|
|
1256
|
+
memory: { id: 'sm-memory', name: () => t('Memory'), about: () => t('What your projects remember, by kind.'), size: 4, rows: 3 },
|
|
1257
|
+
};
|
|
1258
|
+
const WIDGETS = {
|
|
1259
|
+
'w-system': { kind: 'system', name: () => t('System resources'), about: () => t('How busy this Mac\'s processor is, and how much memory is free.'), size: 4, rows: 3 },
|
|
1260
|
+
'w-servers': { kind: 'servers', name: () => t('Servers'), about: () => t('The servers running in your open projects, and whether they answer.'), size: 4, rows: 3, needs: () => t('Turn on Preview in a project\'s Modules to see its servers.') },
|
|
1261
|
+
'w-memory': { kind: 'memory', name: () => t('Memory quality'), about: () => t('How grown each open project\'s memory is, and its weakest side.'), size: 4, rows: 3, needs: () => t('Open a project to read its memory.') },
|
|
1262
|
+
'w-coordinator': { kind: 'coordinator', name: () => t('Coordinator training'), about: () => t('How far the Coordinator\'s own record is from a model of its own.'), size: 4, rows: 3 },
|
|
1263
|
+
// Only for MADRE's author, in a copy run from source with the author's token (src/server.mjs, homeWidget):
|
|
1264
|
+
// anywhere else the server has no such widget and Home never offers it.
|
|
1265
|
+
'w-usage': { kind: 'usage', owner: true, name: () => t('MADRE usage'), about: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'), size: 8, rows: 3 },
|
|
1266
|
+
'w-models': { kind: 'models', name: () => t('Local models'), about: () => t('Ollama, its models, and how close each project is to its own.'), size: 4, rows: 3 },
|
|
1267
|
+
};
|
|
1268
|
+
const DEFAULT_LAYOUT = { order: ['bishop', 'waiting', 'links', 'projects', 'decisions', 'attention', 'memory'], size: {}, rows: {} };
|
|
1269
|
+
// What each tile is, drawn in the accent the person chose (the same line as the room's AJUSTES icons).
|
|
1270
|
+
const TILE_ICONS = {
|
|
1271
|
+
'bishop': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="2.6"/><circle cx="10" cy="3.6" r="1.4"/><circle cx="4.4" cy="14" r="1.4"/><circle cx="15.6" cy="14" r="1.4"/><path d="M10 5v2.4M8 11.6 5.6 13.2M12 11.6l2.4 1.6"/></svg>',
|
|
1272
|
+
'waiting': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5h8M6 16.5h8M6.8 3.5c0 3.2 3.2 4 3.2 6.5S6.8 13.3 6.8 16.5M13.2 3.5c0 3.2-3.2 4-3.2 6.5s3.2 3.3 3.2 6.5"/></svg>',
|
|
1273
|
+
'projects': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="12.5" rx="2.4"/><path d="M3 9.2h14M8 6.6h4"/></svg>',
|
|
1274
|
+
'links': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="13.5" r="2.2"/><circle cx="15" cy="6.5" r="2.2"/><path d="M7 12.2 13 7.8"/></svg>',
|
|
1275
|
+
'decisions': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="13" height="13" rx="3"/><path d="m7 10.2 2.1 2.1L13.2 8"/></svg>',
|
|
1276
|
+
'attention': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="10" cy="10" r="6.5"/><path d="M10 6.6v4.2"/><path d="M10 13.4h.01" stroke-width="2.2"/></svg>',
|
|
1277
|
+
'memory': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="10" cy="5.6" rx="5.5" ry="2.1"/><path d="M4.5 5.6v4.4c0 1.2 2.5 2.1 5.5 2.1s5.5-.9 5.5-2.1V5.6M4.5 10v4.4c0 1.2 2.5 2.1 5.5 2.1s5.5-.9 5.5-2.1V10"/></svg>',
|
|
1278
|
+
'w-system': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="9" height="9" rx="1.8"/><path d="M8 3v2.5M12 3v2.5M8 14.5V17M12 14.5V17M3 8h2.5M3 12h2.5M14.5 8H17M14.5 12H17"/></svg>',
|
|
1279
|
+
'w-servers': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.8" width="13" height="5" rx="1.6"/><rect x="3.5" y="11.2" width="13" height="5" rx="1.6"/><path d="M6.4 6.3h.01M6.4 13.7h.01" stroke-width="2.2"/><path d="M10 6.3h3.4M10 13.7h3.4"/></svg>',
|
|
1280
|
+
'w-memory': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.8 13.5a6.2 6.2 0 1 1 12.4 0"/><path d="m10 13.5 3-4"/><path d="M10 13.5h.01" stroke-width="2.4"/></svg>',
|
|
1281
|
+
'w-coordinator': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 16.5h13"/><path d="M5 13.5l3.2-3.2 2.6 2.4 4.2-5.2"/><path d="M12.4 7.5H15v2.6"/></svg>',
|
|
1282
|
+
'w-models': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3.2 16 6.6v6.8L10 16.8 4 13.4V6.6z"/><path d="M4 6.6 10 10l6-3.4M10 10v6.8"/></svg>',
|
|
1283
|
+
'w-usage': '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 16.5h13"/><path d="M5.5 13.5v-3M9 13.5V7M12.5 13.5v-4.5M16 13.5V4.5"/></svg>',
|
|
1284
|
+
};
|
|
1285
|
+
const tileIcon = (id) => { const box = el('span', 'sm-tile-icon'); box.innerHTML = TILE_ICONS[id] ?? ''; return box; }; // trusted: our own SVG strings
|
|
1286
|
+
const known = (id) => Object.hasOwn(HOME_TILES, id) || Object.hasOwn(WIDGETS, id);
|
|
1287
|
+
function readLayout() {
|
|
1288
|
+
let raw = null;
|
|
1289
|
+
try { raw = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? 'null'); } catch { raw = null; }
|
|
1290
|
+
if (!raw || typeof raw !== 'object') return structuredClone(DEFAULT_LAYOUT);
|
|
1291
|
+
const seen = new Set();
|
|
1292
|
+
const clean = (list) => (Array.isArray(list) ? list : []).filter((id) => typeof id === 'string' && known(id) && !seen.has(id) && seen.add(id));
|
|
1293
|
+
// An arrangement kept before the board was one (top · projects · low) reads as one order.
|
|
1294
|
+
const order = clean(Array.isArray(raw.order) ? raw.order : [...(raw.top ?? []), 'projects', ...(raw.low ?? [])]);
|
|
1295
|
+
// The drawer is never taken off: a list that lost it gets it back where the projects were.
|
|
1296
|
+
if (!order.includes('projects')) order.splice(Math.min(order.length, 3), 0, 'projects');
|
|
1297
|
+
const size = Object.fromEntries(Object.entries(raw.size ?? {}).filter(([id, span]) => known(id) && SIZES.some(([one]) => one === span)));
|
|
1298
|
+
const rows = Object.fromEntries(Object.entries(raw.rows ?? {}).filter(([id, count]) => known(id) && HEIGHTS.some(([one]) => one === count)));
|
|
1299
|
+
return { order, size, rows };
|
|
1300
|
+
}
|
|
1301
|
+
let layout = readLayout();
|
|
1302
|
+
function saveLayout() {
|
|
1303
|
+
try {
|
|
1304
|
+
if (JSON.stringify(layout) === JSON.stringify(DEFAULT_LAYOUT)) localStorage.removeItem(LAYOUT_KEY);
|
|
1305
|
+
else localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
|
1306
|
+
} catch { /* the arrangement lasts as long as the page */ }
|
|
1307
|
+
applyLayout();
|
|
1308
|
+
}
|
|
1309
|
+
const placed = (id) => layout.order.includes(id);
|
|
1310
|
+
const spanOf = (id) => layout.size[id] ?? (HOME_TILES[id] ?? WIDGETS[id]).size;
|
|
1311
|
+
const widgetNodes = new Map();
|
|
1312
|
+
function tileNode(id) {
|
|
1313
|
+
if (HOME_TILES[id]) return $(HOME_TILES[id].id);
|
|
1314
|
+
if (!widgetNodes.has(id)) widgetNodes.set(id, makeWidget(id));
|
|
1315
|
+
return widgetNodes.get(id);
|
|
1316
|
+
}
|
|
1317
|
+
function applyLayout() {
|
|
1318
|
+
const board = $(BOARD);
|
|
1319
|
+
for (const id of layout.order) {
|
|
1320
|
+
const node = tileNode(id);
|
|
1321
|
+
if (!node) continue;
|
|
1322
|
+
node.classList.remove('sm-off', 'c-3', 'c-4', 'c-5', 'c-6', 'c-8', 'c-12', 'r-1', 'r-2', 'r-3');
|
|
1323
|
+
node.classList.add(`c-${spanOf(id)}`);
|
|
1324
|
+
node.dataset.tile = id;
|
|
1325
|
+
board.append(node);
|
|
1326
|
+
tileFits.observe(node);
|
|
1327
|
+
}
|
|
1328
|
+
for (const id of Object.keys(HOME_TILES)) if (!placed(id)) $(HOME_TILES[id].id)?.classList.add('sm-off');
|
|
1329
|
+
for (const [id, node] of widgetNodes) if (!placed(id)) { tileFits.unobserve(node); node.remove(); node.stop?.(); widgetNodes.delete(id); }
|
|
1330
|
+
for (const [id, node] of widgetNodes) if (placed(id)) node.start?.();
|
|
1331
|
+
fitTiles();
|
|
1332
|
+
}
|
|
1333
|
+
// Each tile is as tall as what it holds (2026-10-13): the board's rows are 4 px and a tile spans as
|
|
1334
|
+
// many as its content needs, plus the gap under it. A tile made wider or narrower grows or shrinks
|
|
1335
|
+
// with its content instead of stretching to a height chosen for another size.
|
|
1336
|
+
const ROW_UNIT = 96;
|
|
1337
|
+
const ROW_GAP = 16;
|
|
1338
|
+
const rowsOf = (id) => layout.rows?.[id] ?? (HOME_TILES[id] ?? WIDGETS[id])?.rows ?? 0;
|
|
1339
|
+
// On a middle-width screen the tiles go in pairs, half and half: a pair ends level only if both
|
|
1340
|
+
// are as tall, so there a short tile takes at least the three rows the others take.
|
|
1341
|
+
const PAIRED = window.matchMedia?.('(max-width: 1180px) and (min-width: 761px)');
|
|
1342
|
+
PAIRED?.addEventListener?.('change', () => fitTiles());
|
|
1343
|
+
function fitTile(node) {
|
|
1344
|
+
const own = rowsOf(node.dataset.tile);
|
|
1345
|
+
const chosen = own && PAIRED?.matches && spanOf(node.dataset.tile) < 12 ? Math.max(3, own) : own;
|
|
1346
|
+
node.classList.toggle('sm-fixed', chosen > 0);
|
|
1347
|
+
// How many rows it has, for the tile's own CSS: a short tile shows what matters and leaves the rest.
|
|
1348
|
+
node.dataset.rows = chosen ? String(chosen) : 'auto';
|
|
1349
|
+
if (chosen) { node.style.gridRow = `auto / span ${chosen}`; return; }
|
|
1350
|
+
// Automatic: the rows its content needs, read at its natural height and rounded up to a whole row.
|
|
1351
|
+
node.style.alignSelf = 'start';
|
|
1352
|
+
const height = node.getBoundingClientRect().height;
|
|
1353
|
+
node.style.alignSelf = '';
|
|
1354
|
+
if (height) node.style.gridRow = `auto / span ${Math.max(1, Math.ceil((height + ROW_GAP) / (ROW_UNIT + ROW_GAP)))}`;
|
|
1355
|
+
}
|
|
1356
|
+
function fitTiles() { for (const node of $(BOARD)?.querySelectorAll(':scope > [data-tile]') ?? []) fitTile(node); }
|
|
1357
|
+
const tileFits = typeof ResizeObserver === 'function' ? new ResizeObserver((entries) => { for (const entry of entries) fitTile(entry.target); }) : { observe() {}, unobserve() {} };
|
|
1358
|
+
function moveTile(id, where) {
|
|
1359
|
+
const list = layout.order;
|
|
1360
|
+
const at = list.indexOf(id);
|
|
1361
|
+
if (at < 0) return;
|
|
1362
|
+
list.splice(at, 1);
|
|
1363
|
+
const to = where === 'first' ? 0 : where === 'last' ? list.length : Math.max(0, Math.min(list.length, at + where));
|
|
1364
|
+
list.splice(to, 0, id);
|
|
1365
|
+
saveLayout();
|
|
1366
|
+
}
|
|
1367
|
+
function removeTile(id) {
|
|
1368
|
+
if ((HOME_TILES[id] ?? WIDGETS[id])?.kept) return;
|
|
1369
|
+
layout.order = layout.order.filter((one) => one !== id);
|
|
1370
|
+
delete layout.size[id];
|
|
1371
|
+
saveLayout();
|
|
1372
|
+
}
|
|
1373
|
+
function addTile(id) {
|
|
1374
|
+
if (placed(id)) return;
|
|
1375
|
+
layout.order.push(id);
|
|
1376
|
+
saveLayout();
|
|
1377
|
+
requestAnimationFrame(() => tileNode(id)?.scrollIntoView({ block: 'center', behavior: motionStill() ? 'auto' : 'smooth' }));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// The menu a right-click opens, shared by the tiles and the space between them: the same card and
|
|
1381
|
+
// keys as a project's menu (pinsMenu), closed the same way.
|
|
1382
|
+
const MOVE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3.5v13M5.5 8 10 3.5 14.5 8M5.5 12 10 16.5 14.5 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1383
|
+
const BEFORE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4.5 7 10l5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1384
|
+
const AFTER_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7.5 4.5 13 10l-5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1385
|
+
// A height is drawn as the rows it takes, out of five; «Automatic» as rows that follow the content.
|
|
1386
|
+
const heightIcon = (count) => `<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="5.2" y="2.8" width="9.6" height="14.4" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".45"/>${count ? `<rect x="7" y="${(15.4 - 10.8 * count / 5).toFixed(2)}" width="6" height="${(10.8 * count / 5).toFixed(2)}" rx="1.2" fill="currentColor"/>` : '<path d="M7.2 8.6 10 6l2.8 2.6M7.2 11.4 10 14l2.8-2.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'}</svg>`;
|
|
1387
|
+
// A size is drawn as the share of the row it takes: a third, a half, two thirds, all of it.
|
|
1388
|
+
const sizeIcon = (span) => `<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.8" y="5.2" width="14.4" height="9.6" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".45"/><rect x="4.6" y="7" width="${(10.8 * span / 12).toFixed(2)}" height="6" rx="1.2" fill="currentColor"/></svg>`;
|
|
1389
|
+
const ADD_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v11M4.5 10h11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
|
|
1390
|
+
const RESET_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.2 8.6A6 6 0 1 1 4.8 13"/><path d="M3.8 4.6v4.2H8"/></svg>';
|
|
1391
|
+
const HIDE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 10h9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
|
1392
|
+
function homeMenu(point, entries, label) {
|
|
1393
|
+
pinsCloseMenu();
|
|
1394
|
+
const menu = el('div', 'bar-card sm-menu');
|
|
1395
|
+
menu.setAttribute('role', 'menu');
|
|
1396
|
+
if (label) menu.setAttribute('aria-label', label);
|
|
1397
|
+
for (const entry of entries) {
|
|
1398
|
+
if (entry === 'sep') { menu.append(el('i', 'sm-menu-sep')); continue; }
|
|
1399
|
+
if (entry.heading) { const head = el('span', 'sm-menu-head'); if (entry.tile) head.append(tileIcon(entry.tile)); head.append(el('span', null, entry.heading)); menu.append(head); continue; }
|
|
1400
|
+
const one = button(`sm-menu-item${entry.danger ? ' danger' : ''}${entry.checked ? ' checked' : ''}`, null, () => { pinsCloseMenu(); entry.act(); });
|
|
1401
|
+
one.setAttribute('role', entry.checked !== undefined ? 'menuitemradio' : 'menuitem');
|
|
1402
|
+
if (entry.checked !== undefined) one.setAttribute('aria-checked', String(Boolean(entry.checked)));
|
|
1403
|
+
if (entry.disabled) one.disabled = true;
|
|
1404
|
+
one.innerHTML = entry.icon ?? ''; // trusted: our own SVG strings
|
|
1405
|
+
one.append(el('span', null, entry.words));
|
|
1406
|
+
menu.append(one);
|
|
1407
|
+
}
|
|
1408
|
+
menu.addEventListener('keydown', (key) => {
|
|
1409
|
+
const items = [...menu.querySelectorAll('.sm-menu-item:not(:disabled)')];
|
|
1410
|
+
const at = items.indexOf(document.activeElement);
|
|
1411
|
+
if (key.key === 'ArrowDown') { key.preventDefault(); items[(at + 1) % items.length].focus(); }
|
|
1412
|
+
if (key.key === 'ArrowUp') { key.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
|
|
1413
|
+
});
|
|
1414
|
+
document.body.append(menu);
|
|
1415
|
+
pins.menu = menu;
|
|
1416
|
+
const width = menu.offsetWidth;
|
|
1417
|
+
const height = menu.offsetHeight;
|
|
1418
|
+
menu.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - width - 8, point.x)))}px`;
|
|
1419
|
+
menu.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - height - 8, point.y)))}px`;
|
|
1420
|
+
menu.style.right = 'auto';
|
|
1421
|
+
menu.querySelector('.sm-menu-item:not(:disabled)')?.focus({ preventScroll: true });
|
|
1422
|
+
}
|
|
1423
|
+
const customized = () => JSON.stringify(layout) !== JSON.stringify(DEFAULT_LAYOUT);
|
|
1424
|
+
const addEntries = () => [{ words: t('Add to Home…'), icon: ADD_ICON, act: () => openHomeAdd() }, ...(customized() ? [{ words: t('Back to the default layout'), icon: RESET_ICON, act: () => { layout = structuredClone(DEFAULT_LAYOUT); saveLayout(); } }] : [])];
|
|
1425
|
+
function tileMenu(event, id) {
|
|
1426
|
+
event.preventDefault();
|
|
1427
|
+
const list = layout.order;
|
|
1428
|
+
const at = list.indexOf(id);
|
|
1429
|
+
const spec = HOME_TILES[id] ?? WIDGETS[id];
|
|
1430
|
+
const sizes = SIZES.filter(([span]) => !spec.sizes || spec.sizes.includes(span));
|
|
1431
|
+
homeMenu({ x: event.clientX, y: event.clientY }, [
|
|
1432
|
+
{ heading: spec.name(), tile: id },
|
|
1433
|
+
{ words: t('Move before'), icon: BEFORE_ICON, disabled: at <= 0, act: () => moveTile(id, -1) },
|
|
1434
|
+
{ words: t('Move after'), icon: AFTER_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 1) },
|
|
1435
|
+
{ words: t('Move to the start'), icon: MOVE_ICON, disabled: at <= 0, act: () => moveTile(id, 'first') },
|
|
1436
|
+
{ words: t('Move to the end'), icon: MOVE_ICON, disabled: at >= list.length - 1, act: () => moveTile(id, 'last') },
|
|
1437
|
+
...(sizes.length > 1 ? ['sep', { heading: t('Width') }, ...sizes.map(([span, words]) => ({ words: words(), icon: sizeIcon(span), checked: spanOf(id) === span, act: () => { if (span === spec.size) delete layout.size[id]; else layout.size[id] = span; saveLayout(); } }))] : []),
|
|
1438
|
+
...(spec.kept ? [] : ['sep', { heading: t('Height') }, ...HEIGHTS.map(([count, words]) => ({ words: words(), icon: heightIcon(count), checked: rowsOf(id) === count, act: () => { layout.rows ??= {}; if (count === (spec.rows ?? 0)) delete layout.rows[id]; else layout.rows[id] = count; saveLayout(); } }))]),
|
|
1439
|
+
'sep',
|
|
1440
|
+
...(spec.kept ? [] : [{ words: t('Take off Home'), icon: HIDE_ICON, act: () => removeTile(id) }]),
|
|
1441
|
+
...addEntries(),
|
|
1442
|
+
], spec.name());
|
|
1443
|
+
}
|
|
1444
|
+
$(BOARD)?.addEventListener('contextmenu', (event) => {
|
|
1445
|
+
// A project's card keeps its own menu (pinsMenu); the drawer around it has the tile's.
|
|
1446
|
+
if (event.target.closest('input, textarea, select, [contenteditable="true"], .sm-card')) return;
|
|
1447
|
+
const tile = event.target.closest('[data-tile]');
|
|
1448
|
+
if (tile) { tileMenu(event, tile.dataset.tile); return; }
|
|
1449
|
+
event.preventDefault();
|
|
1450
|
+
homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
|
|
1451
|
+
});
|
|
1452
|
+
// DRAG AND DROP (2026-10-13): a tile is taken by its head and set down where it should go — in the
|
|
1453
|
+
// same row of tiles or the other one, above or below the projects. While it is carried the tiles
|
|
1454
|
+
// make room for it; let go, that order is kept. Escape, or a drop outside, puts everything back.
|
|
1455
|
+
const TILE_TYPE = 'application/x-madre-tile';
|
|
1456
|
+
let carried = null;
|
|
1457
|
+
const grabbable = (event) => !event.target.closest('a, button, input, select, textarea, label, svg, .sm-map, .sm-card, .sm-tools, [contenteditable="true"]') && event.target.closest('.sm-tile-head, .sm-bishop-head, .sm-bishop-words');
|
|
1458
|
+
{
|
|
1459
|
+
const host = $(BOARD);
|
|
1460
|
+
host?.addEventListener('pointerdown', (event) => {
|
|
1461
|
+
const tile = event.target.closest('[data-tile]');
|
|
1462
|
+
if (tile && event.button === 0 && grabbable(event)) tile.draggable = true;
|
|
1463
|
+
});
|
|
1464
|
+
host?.addEventListener('dragstart', (event) => {
|
|
1465
|
+
const tile = event.target.closest?.('[data-tile]');
|
|
1466
|
+
if (!tile || !tile.draggable) return;
|
|
1467
|
+
carried = tile;
|
|
1468
|
+
event.dataTransfer.setData(TILE_TYPE, tile.dataset.tile);
|
|
1469
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
1470
|
+
pinsCloseMenu();
|
|
1471
|
+
requestAnimationFrame(() => tile.classList.add('sm-carried'));
|
|
1472
|
+
});
|
|
1473
|
+
host?.addEventListener('dragover', (event) => {
|
|
1474
|
+
if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
|
|
1475
|
+
event.preventDefault();
|
|
1476
|
+
event.dataTransfer.dropEffect = 'move';
|
|
1477
|
+
const over = event.target.closest('[data-tile]');
|
|
1478
|
+
if (over && !host.contains(over)) return;
|
|
1479
|
+
if (!over) { if (!host.contains(carried)) host.append(carried); return; }
|
|
1480
|
+
if (over === carried) return;
|
|
1481
|
+
const box = over.getBoundingClientRect();
|
|
1482
|
+
const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
|
|
1483
|
+
const target = after ? over.nextSibling : over;
|
|
1484
|
+
if (target !== carried && carried.nextSibling !== target) host.insertBefore(carried, target);
|
|
1485
|
+
});
|
|
1486
|
+
host?.addEventListener('drop', (event) => {
|
|
1487
|
+
if (!carried || !event.dataTransfer.types.includes(TILE_TYPE)) return;
|
|
1488
|
+
event.preventDefault();
|
|
1489
|
+
layout.order = [...host.children].filter((node) => node.dataset.tile && known(node.dataset.tile) && !node.classList.contains('sm-off')).map((node) => node.dataset.tile);
|
|
1490
|
+
carried.classList.remove('sm-carried');
|
|
1491
|
+
carried.draggable = false;
|
|
1492
|
+
carried = null;
|
|
1493
|
+
saveLayout();
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
document.addEventListener('dragend', (event) => {
|
|
1497
|
+
if (!carried || event.target !== carried) return;
|
|
1498
|
+
carried.classList.remove('sm-carried');
|
|
1499
|
+
carried.draggable = false;
|
|
1500
|
+
carried = null;
|
|
1501
|
+
applyLayout();
|
|
1502
|
+
});
|
|
1503
|
+
document.addEventListener('pointerup', () => { for (const tile of document.querySelectorAll('[data-tile][draggable="true"]')) if (tile !== carried) tile.draggable = false; });
|
|
1504
|
+
|
|
1505
|
+
// A project's card, dragged among the others (it was already dragged to the bar to pin it): the
|
|
1506
|
+
// cards make room as it passes, and where it is let go becomes «Yours». A search or a filter shows
|
|
1507
|
+
// only some of them: the ones out of sight keep their places.
|
|
1508
|
+
let projectCarried = null;
|
|
1509
|
+
ui.projects.addEventListener('dragstart', (event) => { projectCarried = event.target.closest?.('.sm-card[data-room]') ?? null; });
|
|
1510
|
+
ui.projects.addEventListener('dragover', (event) => {
|
|
1511
|
+
if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
|
|
1512
|
+
const over = event.target.closest('.sm-card[data-room]');
|
|
1513
|
+
event.preventDefault();
|
|
1514
|
+
event.dataTransfer.dropEffect = 'move';
|
|
1515
|
+
if (!over || over === projectCarried) return;
|
|
1516
|
+
projectCarried.classList.add('sm-carried');
|
|
1517
|
+
const box = over.getBoundingClientRect();
|
|
1518
|
+
const after = event.clientY > box.bottom - box.height / 3 || (event.clientY > box.top + box.height / 3 && event.clientX > box.left + box.width / 2);
|
|
1519
|
+
const target = after ? over.nextSibling : over;
|
|
1520
|
+
if (target !== projectCarried && projectCarried.nextSibling !== target) ui.projects.insertBefore(projectCarried, target);
|
|
1521
|
+
});
|
|
1522
|
+
ui.projects.addEventListener('drop', (event) => {
|
|
1523
|
+
if (!projectCarried || !event.dataTransfer.types.includes(PIN_TYPE)) return;
|
|
1524
|
+
event.preventDefault();
|
|
1525
|
+
const shown = [...ui.projects.querySelectorAll('.sm-card[data-room]')].map((node) => node.dataset.room);
|
|
1526
|
+
const all = projectsInOrder().all.map((room) => room.id);
|
|
1527
|
+
const slots = all.map((id) => shown.includes(id));
|
|
1528
|
+
let next = 0;
|
|
1529
|
+
const order = all.map((id, index) => (slots[index] ? shown[next++] : id));
|
|
1530
|
+
try { localStorage.setItem(PROJECT_ORDER_KEY, JSON.stringify(order)); } catch { /* the order lasts as long as the page */ }
|
|
1531
|
+
projectCarried.classList.remove('sm-carried');
|
|
1532
|
+
projectCarried = null;
|
|
1533
|
+
saveHome('order', 'mine');
|
|
1534
|
+
});
|
|
1535
|
+
document.addEventListener('dragend', () => {
|
|
1536
|
+
if (!projectCarried) return;
|
|
1537
|
+
projectCarried.classList.remove('sm-carried');
|
|
1538
|
+
projectCarried = null;
|
|
1539
|
+
draw();
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// THE BAR'S KEYS MOVE UNDER THE POINTER (2026-10-13), as the room's do (styles.css, «Each drawing
|
|
1543
|
+
// moves in its own way»): the two halves of Connections close on each other (CSS), and the planets
|
|
1544
|
+
// of the Project map go round their orbit while the pointer stays.
|
|
1545
|
+
{
|
|
1546
|
+
const key = $('sm-macro');
|
|
1547
|
+
const orbits = [...(key?.querySelectorAll('animateMotion') ?? [])];
|
|
1548
|
+
const still = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
1549
|
+
key?.addEventListener('pointerenter', () => { if (!still()) for (const one of orbits) one.beginElement?.(); });
|
|
1550
|
+
key?.addEventListener('pointerleave', () => { for (const one of orbits) one.endElement?.(); });
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// CARRIED TO THE EDGE (2026-10-13): while a tile or a project is carried, the page scrolls when the
|
|
1554
|
+
// pointer nears its top or its bottom — faster the closer it gets — so a tile goes anywhere on the
|
|
1555
|
+
// board without being let go. The browser's own scrolling while dragging is too slow to rely on.
|
|
1556
|
+
{
|
|
1557
|
+
const EDGE = 96;
|
|
1558
|
+
const FASTEST = 22;
|
|
1559
|
+
let pointerY = null;
|
|
1560
|
+
let frame = 0;
|
|
1561
|
+
const carrying = () => Boolean(carried || projectCarried);
|
|
1562
|
+
const step = () => {
|
|
1563
|
+
frame = 0;
|
|
1564
|
+
if (!carrying() || pointerY === null) return;
|
|
1565
|
+
const room = window.innerHeight;
|
|
1566
|
+
const near = pointerY < EDGE ? -(EDGE - pointerY) / EDGE : pointerY > room - EDGE ? (pointerY - (room - EDGE)) / EDGE : 0;
|
|
1567
|
+
if (near) window.scrollBy(0, Math.round(Math.sign(near) * Math.max(2, Math.abs(near) ** 1.6 * FASTEST)));
|
|
1568
|
+
frame = requestAnimationFrame(step);
|
|
1569
|
+
};
|
|
1570
|
+
document.addEventListener('dragover', (event) => {
|
|
1571
|
+
if (!carrying()) return;
|
|
1572
|
+
pointerY = event.clientY;
|
|
1573
|
+
if (!frame) frame = requestAnimationFrame(step);
|
|
1574
|
+
});
|
|
1575
|
+
const stop = () => { pointerY = null; cancelAnimationFrame(frame); frame = 0; };
|
|
1576
|
+
document.addEventListener('drop', stop);
|
|
1577
|
+
document.addEventListener('dragend', stop);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// The space around the projects, right-clicked, offers the same.
|
|
1581
|
+
document.querySelector('.sm-main')?.addEventListener('contextmenu', (event) => {
|
|
1582
|
+
if (event.defaultPrevented || event.target.closest('.sm-tile, .sm-card, input, textarea, select, a, button, dialog')) return;
|
|
1583
|
+
event.preventDefault();
|
|
1584
|
+
homeMenu({ x: event.clientX, y: event.clientY }, addEntries(), t('Home'));
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// «Add to Home»: the tiles taken off and the widgets not yet on it, each said in a line, with what
|
|
1588
|
+
// it would need when nothing feeds it yet.
|
|
1589
|
+
let homeAddSheet = null;
|
|
1590
|
+
async function openHomeAdd() {
|
|
1591
|
+
homeAddSheet?.remove();
|
|
1592
|
+
homeAddSheet = el('dialog', 'sm-sheet sm-add-sheet');
|
|
1593
|
+
const body = el('div', 'sm-sheet-body');
|
|
1594
|
+
const head = el('header', 'sm-sheet-head');
|
|
1595
|
+
head.append(el('h3', null, t('Add to Home')));
|
|
1596
|
+
// The same ✕ as every sheet of Home (the connections, the settings).
|
|
1597
|
+
const keys = el('span', 'surface-modes');
|
|
1598
|
+
const close = button('surface-mode close x-close x-only', null, () => homeAddSheet.close());
|
|
1599
|
+
close.setAttribute('aria-label', t('Close'));
|
|
1600
|
+
close.innerHTML = '<span class="x-icon"><svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>'; // trusted
|
|
1601
|
+
keys.append(close);
|
|
1602
|
+
head.append(keys);
|
|
1603
|
+
body.append(head, el('p', 'hint', t('Tiles you took off and widgets you can put on Home. Right-click any tile to move it, resize it or take it off.')));
|
|
1604
|
+
const list = el('div', 'sm-add-list');
|
|
1605
|
+
body.append(list);
|
|
1606
|
+
homeAddSheet.append(body);
|
|
1607
|
+
homeAddSheet.addEventListener('close', () => { homeAddSheet?.remove(); homeAddSheet = null; });
|
|
1608
|
+
document.body.append(homeAddSheet);
|
|
1609
|
+
homeAddSheet.showModal();
|
|
1610
|
+
void homeTour.hint('home-add', list, { inside: true, title: t('Add to Home'), text: t('The tiles you took off, and the widgets. A widget is offered only when something on this Mac feeds it; right-click any tile to move it, resize it or take it off.') });
|
|
1611
|
+
// The author's widget is asked about before the list is drawn: where it cannot be read, it is not listed at all.
|
|
1612
|
+
const ownerOk = new Set();
|
|
1613
|
+
for (const [id, spec] of Object.entries(WIDGETS).filter(([id, spec]) => spec.owner && !placed(id))) {
|
|
1614
|
+
if (await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => response.ok).catch(() => false)) ownerOk.add(id);
|
|
1615
|
+
}
|
|
1616
|
+
if (!homeAddSheet) return;
|
|
1617
|
+
const entries = [
|
|
1618
|
+
...Object.entries(HOME_TILES).filter(([id]) => !placed(id)).map(([id, spec]) => ({ id, spec, ready: true })),
|
|
1619
|
+
...Object.entries(WIDGETS).filter(([id, spec]) => !placed(id) && (!spec.owner || ownerOk.has(id))).map(([id, spec]) => ({ id, spec, ready: null })),
|
|
1620
|
+
];
|
|
1621
|
+
if (!entries.length) { list.append(el('p', 'sm-hint', t('Everything is already on Home.'))); return; }
|
|
1622
|
+
for (const entry of entries) {
|
|
1623
|
+
const row = el('div', 'sm-add-row');
|
|
1624
|
+
row.append(tileIcon(entry.id));
|
|
1625
|
+
const words = el('div', 'sm-add-words');
|
|
1626
|
+
words.append(el('b', null, entry.spec.name()), el('small', null, entry.spec.about()));
|
|
1627
|
+
const said = el('small', 'sm-add-need');
|
|
1628
|
+
words.append(said);
|
|
1629
|
+
const add = button('sm-btn primary small', t('Add'), () => { addTile(entry.id); homeAddSheet?.close(); });
|
|
1630
|
+
row.append(words, add);
|
|
1631
|
+
list.append(row);
|
|
1632
|
+
// A widget is offered only when something feeds it: asked of the server, which says so.
|
|
1633
|
+
if (entry.ready === null) {
|
|
1634
|
+
add.disabled = true;
|
|
1635
|
+
said.textContent = t('Checking…');
|
|
1636
|
+
const reading = await fetch(`/api/salas/widget?kind=${entry.spec.kind}`, { cache: 'no-store' }).then((response) => (response.ok ? response.json() : { failed: response.status })).catch(() => ({ failed: true }));
|
|
1637
|
+
if (!row.isConnected) return;
|
|
1638
|
+
const ok = Boolean(reading?.available);
|
|
1639
|
+
add.disabled = !ok;
|
|
1640
|
+
// A page served by a project started before widgets existed cannot read them: said as that,
|
|
1641
|
+
// not as a Mac with nothing to show.
|
|
1642
|
+
said.textContent = ok ? '' : reading.failed === 404 ? t('This page is served by a project started before widgets existed: open Home from its own address, or restart that project.') : reading.failed ? t('It could not be read just now.') : (entry.spec.needs?.() ?? t('Nothing feeds it on this Mac yet.'));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// ---- The widgets. Each is a tile that reads its own route every 20 s while Home is in view.
|
|
1648
|
+
const pct = (value) => (value === null || value === undefined ? '—' : `${Math.round(value * 100)}%`);
|
|
1649
|
+
const gb = (bytes) => `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
1650
|
+
function meter(value, tone = null) {
|
|
1651
|
+
const bar = el('span', `sm-meter${tone ? ` ${tone}` : ''}`);
|
|
1652
|
+
const fill = el('i');
|
|
1653
|
+
fill.style.width = `${Math.round(Math.max(0, Math.min(1, value ?? 0)) * 100)}%`;
|
|
1654
|
+
bar.append(fill);
|
|
1655
|
+
return bar;
|
|
1656
|
+
}
|
|
1657
|
+
const toneFor = (share) => (share >= 0.85 ? 'hot' : share >= 0.6 ? 'warm' : null);
|
|
1658
|
+
const OS_WORDS = { darwin: 'macOS', linux: 'Linux', win32: 'Windows', other: () => t('other') };
|
|
1659
|
+
const WIDGET_PAINT = {
|
|
1660
|
+
usage(body, reading) {
|
|
1661
|
+
if (!reading.available) { body.append(el('p', 'sm-hint', reading.status ? t('madre.run answered {status}.', { status: reading.status }) : t('madre.run could not be reached.'))); return; }
|
|
1662
|
+
const grid = el('div', 'sm-w-grid sm-usage-figures');
|
|
1663
|
+
for (const [label, n, line] of [[t('Today'), reading.active.day, t('copies on')], [t('7 days'), reading.active.week, t('active copies')], [t('30 days'), reading.active.month, t('active copies')], [t('Ever'), reading.active.ever, t('copies seen')]]) {
|
|
1664
|
+
const cell = el('div', 'sm-w-metric');
|
|
1665
|
+
cell.append(el('span', 'sm-w-label', label), el('b', 'sm-w-big', Number(n).toLocaleString()), el('small', null, line));
|
|
1666
|
+
grid.append(cell);
|
|
1667
|
+
}
|
|
1668
|
+
body.append(grid);
|
|
1669
|
+
// The last 30 days: copies on each day, and the part of them seen for the first time.
|
|
1670
|
+
const top = Math.max(1, ...reading.days.map((one) => one.active));
|
|
1671
|
+
const days = el('div', 'sm-usage-days');
|
|
1672
|
+
days.setAttribute('role', 'img');
|
|
1673
|
+
days.setAttribute('aria-label', t('Copies on each day, last 30 days'));
|
|
1674
|
+
for (const one of reading.days) {
|
|
1675
|
+
const bar = el('i');
|
|
1676
|
+
bar.style.setProperty('--h', String(one.active / top));
|
|
1677
|
+
bar.style.setProperty('--new', String(one.active ? one.new / one.active : 0));
|
|
1678
|
+
bar.title = t('{day}: {n} on · {fresh} new', { day: one.day, n: one.active, fresh: one.new });
|
|
1679
|
+
days.append(bar);
|
|
1680
|
+
}
|
|
1681
|
+
body.append(days);
|
|
1682
|
+
const share = (list, word = (name) => name) => list.slice(0, 4).map((one) => `${typeof word(one.name) === 'function' ? word(one.name)() : word(one.name)} ${one.n}`).join(' · ') || '—';
|
|
1683
|
+
const back = (part) => (part.cohort ? `${Math.round((part.back / part.cohort) * 100)}%` : '—');
|
|
1684
|
+
const lines = el('div', 'sm-usage-lines');
|
|
1685
|
+
for (const [label, said] of [
|
|
1686
|
+
[t('Versions'), share(reading.versions)],
|
|
1687
|
+
[t('Systems'), share(reading.os, (name) => OS_WORDS[name] ?? name)],
|
|
1688
|
+
[t('Came back'), t('after 1 day {d1} · 7 days {d7} · 30 days {d30}', { d1: back(reading.retention.d1), d7: back(reading.retention.d7), d30: back(reading.retention.d30) })],
|
|
1689
|
+
]) {
|
|
1690
|
+
const row = el('div', 'sm-w-line');
|
|
1691
|
+
row.append(el('b', null, label), el('small', null, said));
|
|
1692
|
+
lines.append(row);
|
|
1693
|
+
}
|
|
1694
|
+
body.append(lines);
|
|
1695
|
+
},
|
|
1696
|
+
system(body, reading) {
|
|
1697
|
+
const cpu = el('div', 'sm-w-metric');
|
|
1698
|
+
cpu.append(el('span', 'sm-w-label', t('Processor')), el('b', 'sm-w-big', pct(reading.cpu.busy)), meter(reading.cpu.busy, toneFor(reading.cpu.busy ?? 0)), el('small', null, t('{n} cores · load {load}', { n: reading.cpu.cores, load: reading.cpu.load[0] })));
|
|
1699
|
+
const used = reading.memory.total ? 1 - reading.memory.free / reading.memory.total : null;
|
|
1700
|
+
const mem = el('div', 'sm-w-metric');
|
|
1701
|
+
mem.append(el('span', 'sm-w-label', t('Memory')), el('b', 'sm-w-big', t('{free} free', { free: gb(reading.memory.free) })), meter(used, toneFor(used ?? 0)), el('small', null, t('of {total}. The system lends what is free to its cache and takes it back when needed.', { total: gb(reading.memory.total) })));
|
|
1702
|
+
const grid = el('div', 'sm-w-grid');
|
|
1703
|
+
grid.append(cpu, mem);
|
|
1704
|
+
body.append(grid);
|
|
1705
|
+
},
|
|
1706
|
+
servers(body, reading) {
|
|
1707
|
+
const running = reading.projects.flatMap((one) => one.ports.map((port) => ({ ...port, project: one.name })));
|
|
1708
|
+
if (!running.length) body.append(el('p', 'sm-hint', reading.projects.some((one) => one.preview) ? t('No server is running in your open projects.') : t('Turn on Preview in a project\'s Modules to see its servers.')));
|
|
1709
|
+
const grid = el('div', 'sm-w-grid');
|
|
1710
|
+
for (const one of running) {
|
|
1711
|
+
const healthy = one.status !== null && one.status < 500;
|
|
1712
|
+
const row = el('div', `sm-w-row${healthy ? ' ok' : ' bad'}`);
|
|
1713
|
+
row.append(el('i', 'sm-w-dot'), el('b', null, `:${one.port}`), el('span', null, one.project), el('small', null, one.status ? t('answers {status}', { status: one.status }) : t('no answer')));
|
|
1714
|
+
grid.append(row);
|
|
1715
|
+
}
|
|
1716
|
+
if (running.length) body.append(grid);
|
|
1717
|
+
const off = reading.projects.filter((one) => !one.preview && !one.unreachable).length;
|
|
1718
|
+
if (off && running.length) body.append(el('small', 'sm-w-foot', off === 1 ? t('1 project has Preview off.') : t('{n} projects have Preview off.', { n: off })));
|
|
1719
|
+
},
|
|
1720
|
+
memory(body, reading) {
|
|
1721
|
+
const read = reading.projects.filter((one) => !one.unreachable);
|
|
1722
|
+
if (!read.length) { body.append(el('p', 'sm-hint', t('Open a project to read its memory.'))); return; }
|
|
1723
|
+
const grid = el('div', 'sm-w-grid');
|
|
1724
|
+
body.append(grid);
|
|
1725
|
+
for (const one of read) {
|
|
1726
|
+
const row = el('div', 'sm-w-project');
|
|
1727
|
+
const top = el('div', 'sm-w-line');
|
|
1728
|
+
top.append(el('b', null, one.name), el('span', 'sm-w-stage', one.stage ?? '—'), el('small', null, pct(one.score)));
|
|
1729
|
+
row.append(top, meter(one.score));
|
|
1730
|
+
const notes = [one.weakest?.label ? t('weakest: {what}', { what: one.weakest.label.toLowerCase() }) : null, one.aberrations ? t('{n} odd memories', { n: one.aberrations }) : null].filter(Boolean);
|
|
1731
|
+
if (notes.length) row.append(el('small', null, notes.join(' · ')));
|
|
1732
|
+
grid.append(row);
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
coordinator(body, reading) {
|
|
1736
|
+
const share = reading.target ? reading.pairs / reading.target : 0;
|
|
1737
|
+
const top = el('div', 'sm-w-line');
|
|
1738
|
+
top.append(el('b', 'sm-w-big', `${reading.pairs}`), el('span', null, t('of {n} exchanges to train on', { n: reading.target })));
|
|
1739
|
+
body.append(top, meter(share));
|
|
1740
|
+
body.append(el('p', 'sm-w-says', [reading.stage, reading.says].filter(Boolean).join(' · ')));
|
|
1741
|
+
const facts = [t('{n} memories', { n: reading.notes ?? 0 }), reading.trained ? t('model {name}', { name: reading.trained }) : t('no model of its own yet')];
|
|
1742
|
+
body.append(el('small', 'sm-w-foot', facts.join(' · ')));
|
|
1743
|
+
},
|
|
1744
|
+
models(body, reading) {
|
|
1745
|
+
const head = el('div', `sm-w-row${reading.ollama.running ? ' ok' : ' bad'}`);
|
|
1746
|
+
head.append(el('i', 'sm-w-dot'), el('b', null, 'Ollama'), el('span', null, reading.ollama.running ? t('{n} models on this Mac', { n: reading.ollama.models.length }) : t('not running')));
|
|
1747
|
+
// Which projects use it is set in Home's Settings › Local AI.
|
|
1748
|
+
const tune = button('sm-w-link', t('Choose projects'), (event) => { event.stopPropagation(); openSettings('local'); });
|
|
1749
|
+
head.append(tune);
|
|
1750
|
+
body.append(head);
|
|
1751
|
+
if (reading.ollama.models.length) body.append(el('small', 'sm-w-foot mono', reading.ollama.models.slice(0, 4).join(' · ') + (reading.ollama.models.length > 4 ? ' …' : '')));
|
|
1752
|
+
const grid = el('div', 'sm-w-grid');
|
|
1753
|
+
body.append(grid);
|
|
1754
|
+
for (const one of reading.projects.filter((project) => !project.unreachable)) {
|
|
1755
|
+
const row = el('div', 'sm-w-project');
|
|
1756
|
+
const top = el('div', 'sm-w-line');
|
|
1757
|
+
const ready = one.pairs >= one.target;
|
|
1758
|
+
const count = el('small', null, one.trained ? t('trained · {name}', { name: one.trained }) : ready ? t('ready to train') : `${one.pairs}/${one.target}`);
|
|
1759
|
+
count.title = t('{n} of {target} examples this project needs to train a model of its own', { n: one.pairs, target: one.target });
|
|
1760
|
+
top.append(el('b', null, one.name));
|
|
1761
|
+
if (one.localAi === false) { row.classList.add('sm-w-off'); top.append(el('span', 'sm-w-tag', t('without local AI'))); }
|
|
1762
|
+
top.append(count);
|
|
1763
|
+
row.append(top, meter(one.trained ? 1 : one.target ? one.pairs / one.target : 0));
|
|
1764
|
+
grid.append(row);
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
};
|
|
1768
|
+
function makeWidget(id) {
|
|
1769
|
+
const spec = WIDGETS[id];
|
|
1770
|
+
if (!spec) return null;
|
|
1771
|
+
const node = el('section', `sm-tile sm-widget w-${spec.kind}`);
|
|
1772
|
+
const head = el('header', 'sm-tile-head');
|
|
1773
|
+
head.append(el('h2', null, spec.name()));
|
|
1774
|
+
const body = el('div', 'sm-w-body');
|
|
1775
|
+
body.append(el('p', 'sm-hint', t('Reading…')));
|
|
1776
|
+
node.append(head, body);
|
|
1777
|
+
let timer = 0;
|
|
1778
|
+
const read = async () => {
|
|
1779
|
+
if (document.hidden || !node.isConnected) return;
|
|
1780
|
+
const reading = await fetch(`/api/salas/widget?kind=${spec.kind}`, { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
1781
|
+
if (!node.isConnected) return;
|
|
1782
|
+
body.replaceChildren();
|
|
1783
|
+
// The author's widget is not there at all for a copy that cannot read it (no token, not from source).
|
|
1784
|
+
if (spec.owner) node.hidden = !reading;
|
|
1785
|
+
if (!reading) { body.append(el('p', 'sm-hint', t('It could not be read just now.'))); return; }
|
|
1786
|
+
WIDGET_PAINT[spec.kind](body, reading);
|
|
1787
|
+
fitTile(node);
|
|
1788
|
+
// The first time it shows, what it is (homeTour is set up below this, so it is read late).
|
|
1789
|
+
if (!node.dataset.guided) { node.dataset.guided = '1'; setTimeout(() => { try { guideWidget(node, spec.kind); } catch { /* the tour is not up yet */ } }, 1500); }
|
|
1790
|
+
};
|
|
1791
|
+
node.start = () => { if (timer) return; void read(); timer = setInterval(() => void read(), 20000); };
|
|
1792
|
+
node.stop = () => { clearInterval(timer); timer = 0; };
|
|
1793
|
+
return node;
|
|
1794
|
+
}
|
|
1795
|
+
applyLayout();
|
|
1796
|
+
|
|
1797
|
+
// THE WELCOME OF 1.0 (public/tour.js): the bonjour the first time, the chapters after it, the pill
|
|
1798
|
+
// that waits in the bar, `?` for the gestures, and the hint on Connections. In Home, «Take me
|
|
1799
|
+
// there» opens what lives here; for what lives inside a project it opens the one open most
|
|
1800
|
+
// recently, if any is. A new person is handed to the wizard (agents, projects, open) first.
|
|
1801
|
+
const agentsReady = () => (agentsSeen?.agents ?? []).some((agent) => agent.id !== 'madre' && agentState(agent, agentsSeen).ok);
|
|
1802
|
+
const intoAProject = () => {
|
|
1803
|
+
const room = data ? projectsInOrder().open[0] : null;
|
|
1804
|
+
return room ? () => leaveTo(addressOf(room)) : undefined;
|
|
1805
|
+
};
|
|
1806
|
+
const homeTour = startTour({
|
|
1807
|
+
page: 'home',
|
|
1808
|
+
pillSlot: $('sm-tour-pill'),
|
|
1809
|
+
hidden: () => (data?.phonePreview ? [] : ['phone']),
|
|
1810
|
+
ready: () => Boolean(data) && !wizard && !sheet,
|
|
1811
|
+
welcome: { needed: () => !projectsInOrder().all.length || !agentsReady(), open: (then) => { welcomed = true; openWizard({ then }); } },
|
|
1812
|
+
onProjects: () => void refresh(),
|
|
1813
|
+
go: {
|
|
1814
|
+
get project() { return intoAProject(); },
|
|
1815
|
+
get talk() { return intoAProject(); },
|
|
1816
|
+
get modes() { return intoAProject(); },
|
|
1817
|
+
get modules() { return intoAProject(); },
|
|
1818
|
+
get phone() { return intoAProject(); },
|
|
1819
|
+
coordinator: () => openBishop(),
|
|
1820
|
+
links: () => openLinkSheet(),
|
|
1821
|
+
get maps() { return ui.macro.hidden ? undefined : () => ui.macro.click(); },
|
|
1822
|
+
settings: () => openSettings(),
|
|
1823
|
+
local: () => openSettings('local'),
|
|
1824
|
+
gestures: () => homeTour.openGestures(),
|
|
1825
|
+
},
|
|
1826
|
+
});
|
|
1827
|
+
// What each key of Home's bar opens, said the first time it opens (public/tour.js, firstOpen).
|
|
1828
|
+
const topSheet = () => [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
|
|
1829
|
+
homeTour.firstOpen('home-links', ui.bar.links, {
|
|
1830
|
+
opened: topSheet,
|
|
1831
|
+
inside: (box) => box.querySelector('.sm-link-list, .sm-sheet-body'),
|
|
1832
|
+
title: () => t('Connections'),
|
|
1833
|
+
text: () => t('Which projects can look up what another learned, and the contract each one signed. Nothing is shared without your signature, and you can cancel any connection here.'),
|
|
1834
|
+
});
|
|
1835
|
+
homeTour.firstOpen('home-map', ui.macro, {
|
|
1836
|
+
title: () => t('Project map'),
|
|
1837
|
+
text: () => t('Your projects as a map. Drag one onto another to connect them, click one to go to it, and press Esc to come back.'),
|
|
1838
|
+
after: 900,
|
|
1839
|
+
});
|
|
1840
|
+
homeTour.firstOpen('home-settings', ui.gear, {
|
|
1841
|
+
opened: () => document.querySelector('dialog.sm-settings[open]'),
|
|
1842
|
+
inside: (box) => box.querySelector('.sm-settings-window'),
|
|
1843
|
+
title: () => t('Home settings'),
|
|
1844
|
+
text: () => t('How Home looks and what its bar holds, your agents, the Coordinator, which projects use the local AI, your projects and the version. ⌘ , opens it from anywhere.'),
|
|
1845
|
+
});
|
|
1846
|
+
// What each widget shows, the first time it is on Home: its own line, and how to change it.
|
|
1847
|
+
const WIDGET_GUIDE = {
|
|
1848
|
+
system: () => t('How busy this Mac\'s processor is and how much memory is free. It updates on its own.'),
|
|
1849
|
+
servers: () => t('The servers running in your open projects and whether they answer. A project shows here when its Preview is on.'),
|
|
1850
|
+
memory: () => t('How grown each open project\'s memory is, and its weakest side: the one thing that would improve it most.'),
|
|
1851
|
+
coordinator: () => t('How far the Coordinator\'s own record is from training a model of its own.'),
|
|
1852
|
+
models: () => t('Ollama and its models, and how many examples each project has toward a model of its own (8/300 is 8 of 300). «Choose projects» sets which ones use the local AI.'),
|
|
1853
|
+
usage: () => t('How many copies of MADRE are on, which versions and whether people come back. Only you see this.'),
|
|
1854
|
+
};
|
|
1855
|
+
function guideWidget(node, kind) {
|
|
1856
|
+
const said = WIDGET_GUIDE[kind];
|
|
1857
|
+
if (!said) return;
|
|
1858
|
+
void homeTour.hint(`w-${kind}`, node.querySelector('.sm-tile-head') ?? node, { title: WIDGETS[`w-${kind}`]?.name() ?? '', text: `${said()} ${t('Right-click it to change its size or take it off.')}` });
|
|
1859
|
+
}
|
|
1860
|
+
// Once Home has drawn: the bonjour if it is owed; if it was given, the hint on Connections — what
|
|
1861
|
+
// it is, and, for whoever came from 0.7.2, what the word meant before.
|
|
1862
|
+
let welcomeAsked = false;
|
|
1863
|
+
function welcomeHome() {
|
|
1864
|
+
if (welcomeAsked) return;
|
|
1865
|
+
welcomeAsked = true;
|
|
1866
|
+
void homeTour.loaded.then(async (answer) => {
|
|
1867
|
+
if (answer?.owed) { await homeTour.maybeBonjour(); return; }
|
|
1868
|
+
if (!answer?.state?.bonjourAt) return;
|
|
1869
|
+
setTimeout(() => {
|
|
1870
|
+
const tile = $('sm-links-tile');
|
|
1871
|
+
if (!tile || tile.hidden || document.querySelector('dialog[open]')) return;
|
|
1872
|
+
void homeTour.hint('links', tile.querySelector('.sm-tile-head') ?? tile, {
|
|
1873
|
+
title: t('Connections'),
|
|
1874
|
+
text: homeTour.path() === 'back'
|
|
1875
|
+
? t('A connection lets one project look up what another learned, by a contract you sign. In 0.7.2 «Connections» was signing your agents in: that is now in Settings › Agents.')
|
|
1876
|
+
: t('A connection lets one project look up what another learned, by a contract you sign. Nothing is written across.'),
|
|
1877
|
+
});
|
|
1878
|
+
}, 2200);
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// LINKING TWO ROOMS, step by step. A link lets one room read the notes of another; nothing is
|
|
1883
|
+
// written across. The assistant asks one thing at a time, in the room's own material — the step,
|
|
1884
|
+
// the question and what it decides; the choices on a card, each saying what it does, the safe one
|
|
1885
|
+
// marked; and, under every step, the link as it stands so far, in words: who serves, what crosses,
|
|
1886
|
+
// who reads, until when, how the notes arrive, whether orders go. Safe from the start: a week,
|
|
1887
|
+
// decisions and facts, notes read only when asked, no orders. Orders appear only with BISHOP on
|
|
1888
|
+
// one side, off, and ask for the room's designation typed, as arming #3 does. It warns when the
|
|
1889
|
+
// room that serves is asleep or older, and says the whole contract before it is signed.
|
|
1890
|
+
const LINK_KINDS = ['decision', 'fact', 'preference', 'question'];
|
|
1891
|
+
const LINK_KIND_NOTES = { decision: () => t('What was agreed: the choices the project made.'), fact: () => t('What is known: data and findings the project settled.'), preference: () => t('How you like to work: conventions and tastes.'), question: () => t('What is open: questions still waiting for an answer.') };
|
|
1892
|
+
const LINK_DAYS = [[1, () => t('A day')], [7, () => t('A week')], [30, () => t('30 days')], [0, () => t('No expiry')]];
|
|
1893
|
+
let sheet = null;
|
|
1894
|
+
// A sentence whose project names stand out (2026-10-11): the slots named in `bold` (all of them,
|
|
1895
|
+
// unless said) are written in bold, the rest as the catalogue says it.
|
|
1896
|
+
function sayBold(key, vars, bold = Object.keys(vars)) {
|
|
1897
|
+
const out = document.createDocumentFragment();
|
|
1898
|
+
const template = t(key);
|
|
1899
|
+
let at = 0;
|
|
1900
|
+
for (const match of template.matchAll(/\{(\w+)\}/g)) {
|
|
1901
|
+
out.append(template.slice(at, match.index));
|
|
1902
|
+
const value = Object.hasOwn(vars, match[1]) ? String(vars[match[1]]) : match[0];
|
|
1903
|
+
out.append(bold.includes(match[1]) ? el('b', null, value) : value);
|
|
1904
|
+
at = match.index + match[0].length;
|
|
1905
|
+
}
|
|
1906
|
+
out.append(template.slice(at));
|
|
1907
|
+
return out;
|
|
1908
|
+
}
|
|
1909
|
+
const ALLOWED_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10.4l3.2 3.1L15 6.6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1910
|
+
const NOT_ALLOWED_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6.5 6.5l7 7M13.5 6.5l-7 7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
1911
|
+
const BOTH_WAYS_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7.2h11.2L12.4 4.4M16 12.8H4.8l2.8 2.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1912
|
+
function openLinkSheet({ reader = null, source = null } = {}) {
|
|
1913
|
+
if (!data || sheet) return;
|
|
1914
|
+
const rooms = [...data.rooms].sort((x, y) => Number(Boolean(y.open)) - Number(Boolean(x.open)) || roomLabel(x).localeCompare(roomLabel(y)));
|
|
1915
|
+
if (rooms.length < 2) return;
|
|
1916
|
+
// Where it starts, unless it was asked for a pair: BISHOP reading, from a room that can hand
|
|
1917
|
+
// notes over now (open and up to date) when there is one.
|
|
1918
|
+
const ready = (room) => room.open && !room.glance?.outdated && !room.glance?.unreachable;
|
|
1919
|
+
// Two projects already connected are not connected again (2026-10-11): whichever way the one in
|
|
1920
|
+
// force goes, the wizard says how they are connected and where to change it, and signs nothing.
|
|
1921
|
+
const linkedPair = (x, y) => (x && y ? (data.enlaces ?? []).find((one) => [one.rooms.a, one.rooms.b].sort().join('\n') === [x, y].sort().join('\n')) ?? null : null);
|
|
1922
|
+
const pick = {
|
|
1923
|
+
reader: reader ?? (source !== data.salas && rooms.some((room) => room.id === data.salas) ? data.salas : rooms.find((room) => room.id !== source)?.id),
|
|
1924
|
+
source: null, both: false, kinds: new Set(['decision', 'fact']), days: 7, recall: false,
|
|
1925
|
+
orders: false, maxMode: 1, perHour: 6, designation: '',
|
|
1926
|
+
};
|
|
1927
|
+
const free = (room) => room.id !== pick.reader && room.id !== data.salas && !linkedPair(room.id, pick.reader);
|
|
1928
|
+
pick.source = source ?? (rooms.find((room) => free(room) && ready(room)) ?? rooms.find(free) ?? rooms.find((room) => room.id !== pick.reader && room.id !== data.salas))?.id;
|
|
1929
|
+
// The Coordinator never shares its memory (src/enlaces.mjs: it gathers every project and is
|
|
1930
|
+
// nobody's source), so the wizard never offers what signing would refuse: asked for with the
|
|
1931
|
+
// Coordinator as the one that shares, the two change places; it never reads both ways.
|
|
1932
|
+
const coordinator = (id) => Boolean(data.salas) && id === data.salas;
|
|
1933
|
+
if (coordinator(pick.source)) [pick.reader, pick.source] = [pick.source, pick.reader];
|
|
1934
|
+
const settle = () => { if (coordinator(pick.source) && !coordinator(pick.reader)) [pick.reader, pick.source] = [pick.source, pick.reader]; if (coordinator(pick.source) || coordinator(pick.reader)) pick.both = false; };
|
|
1935
|
+
const roomOf = (id) => rooms.find((room) => room.id === id) ?? null;
|
|
1936
|
+
const nameOf = (id) => { const room = roomOf(id); return room ? roomLabel(room) : ''; };
|
|
1937
|
+
// Orders only with BISHOP on one side; they would go into the other.
|
|
1938
|
+
const ordersInto = () => (data.salas && [pick.reader, pick.source].includes(data.salas) ? [pick.reader, pick.source].find((id) => id !== data.salas) ?? null : null);
|
|
1939
|
+
const stepsNow = () => ['who', 'what', 'time', ...(ordersInto() ? ['orders'] : []), 'review'];
|
|
1940
|
+
const STEP_WORDS = { who: () => t('Projects'), what: () => t('What it looks up'), time: () => t('How long'), orders: () => t('Orders'), review: () => t('Sign') };
|
|
1941
|
+
// The kinds as a sentence says them: «decisiones y hechos».
|
|
1942
|
+
const KIND_PLURALS = { decision: () => t('decisions'), fact: () => t('facts'), preference: () => t('preferences'), question: () => t('questions') };
|
|
1943
|
+
const kindsSaid = () => new Intl.ListFormat(language, { type: 'conjunction' }).format(LINK_KINDS.filter((kind) => pick.kinds.has(kind)).map((kind) => KIND_PLURALS[kind]()));
|
|
1944
|
+
const until = () => new Date(Date.now() + pick.days * 86400000).toLocaleDateString(language, { day: 'numeric', month: 'long' });
|
|
1945
|
+
let at = 0;
|
|
1946
|
+
sheet = el('dialog', 'sm-sheet sm-wide sm-link-wizard');
|
|
1947
|
+
const inner = el('div', 'sm-sheet-body');
|
|
1948
|
+
sheet.append(inner);
|
|
1949
|
+
document.body.append(sheet);
|
|
1950
|
+
|
|
1951
|
+
// ---- The pieces every step is made of.
|
|
1952
|
+
const card = () => el('div', 'lw-card');
|
|
1953
|
+
// A row: what it is and what it does on the left, its control on the right.
|
|
1954
|
+
const row = (title, note, control, { recommended = false, dot = null } = {}) => {
|
|
1955
|
+
const line = el('div', 'lw-row');
|
|
1956
|
+
const words = el('div', 'lw-words');
|
|
1957
|
+
const head = el('div', 'lw-title');
|
|
1958
|
+
if (dot) head.append(el('i', `lw-dot k-${dot}`));
|
|
1959
|
+
// Words, or a sentence with its names in bold (sayBold).
|
|
1960
|
+
const name = el('b');
|
|
1961
|
+
name.append(title);
|
|
1962
|
+
head.append(name);
|
|
1963
|
+
if (recommended) head.append(el('span', 'lw-tag', t('Recommended')));
|
|
1964
|
+
words.append(head);
|
|
1965
|
+
if (note) { const said = el('span', 'lw-note'); said.append(note); words.append(said); }
|
|
1966
|
+
line.append(words);
|
|
1967
|
+
if (control) line.append(control);
|
|
1968
|
+
return line;
|
|
1969
|
+
};
|
|
1970
|
+
const toggle = (on, change, label) => {
|
|
1971
|
+
const wrap = el('label', 'settings-switch');
|
|
1972
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = on; box.setAttribute('aria-label', label);
|
|
1973
|
+
box.addEventListener('change', () => change(box.checked));
|
|
1974
|
+
wrap.append(box, el('i'));
|
|
1975
|
+
return wrap;
|
|
1976
|
+
};
|
|
1977
|
+
const rail = (options, current, choose, label) => {
|
|
1978
|
+
const seg = el('div', 'sx-seg');
|
|
1979
|
+
seg.setAttribute('role', 'radiogroup');
|
|
1980
|
+
seg.setAttribute('aria-label', label);
|
|
1981
|
+
for (const [value, words] of options) {
|
|
1982
|
+
const one = button(null, words, () => { choose(value); render(); });
|
|
1983
|
+
one.setAttribute('role', 'radio');
|
|
1984
|
+
one.setAttribute('aria-checked', String(value === current));
|
|
1985
|
+
seg.append(one);
|
|
1986
|
+
}
|
|
1987
|
+
return seg;
|
|
1988
|
+
};
|
|
1989
|
+
const roomSelect = (chosen, change, label, { shares = false, other = null } = {}) => {
|
|
1990
|
+
const select = el('select', 'lw-select');
|
|
1991
|
+
select.setAttribute('aria-label', label);
|
|
1992
|
+
for (const room of rooms) {
|
|
1993
|
+
if (shares && coordinator(room.id)) continue;
|
|
1994
|
+
const linked = room.id !== other && linkedPair(room.id, other);
|
|
1995
|
+
const option = el('option', null, linked ? t('{project} · already connected', { project: roomLabel(room) }) : roomLabel(room)); option.value = room.id; if (room.id === chosen) option.selected = true; select.append(option);
|
|
1996
|
+
}
|
|
1997
|
+
select.addEventListener('change', () => change(select.value));
|
|
1998
|
+
return select;
|
|
1999
|
+
};
|
|
2000
|
+
// What the connection allows and what it does not, line by line (2026-10-11): a tick for what
|
|
2001
|
+
// it lets happen, a cross for what it never does, each with its reason, so nobody has to
|
|
2002
|
+
// guess why a choice is missing.
|
|
2003
|
+
const allows = (lines) => {
|
|
2004
|
+
const box = el('div', 'lw-allows');
|
|
2005
|
+
const list = el('ul');
|
|
2006
|
+
for (const [yes, words] of lines) {
|
|
2007
|
+
const line = el('li', yes ? 'yes' : 'no');
|
|
2008
|
+
const sign = el('i');
|
|
2009
|
+
sign.innerHTML = yes ? ALLOWED_GLYPH : NOT_ALLOWED_GLYPH; // trusted: our own SVG strings
|
|
2010
|
+
const said = el('span');
|
|
2011
|
+
said.append(words);
|
|
2012
|
+
line.append(sign, said);
|
|
2013
|
+
list.append(line);
|
|
2014
|
+
}
|
|
2015
|
+
box.append(el('span', 'lw-allows-head', t('What this connection allows')), list);
|
|
2016
|
+
return box;
|
|
2017
|
+
};
|
|
2018
|
+
const alreadyLinked = (enlace) => {
|
|
2019
|
+
const ends = endsOf(enlace);
|
|
2020
|
+
const box = el('div', 'lw-allows lw-already');
|
|
2021
|
+
const said = el('p', 'lw-already-says');
|
|
2022
|
+
said.append(enlace.direction === 'both'
|
|
2023
|
+
? sayBold('{a} and {b} already look each other up.', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) })
|
|
2024
|
+
: coordinator(ends.reader) ? sayBold('The Coordinator already looks up the notes of {source}.', { source: roomName(ends.source) })
|
|
2025
|
+
: sayBold('{reader} already looks up the notes of {source}.', { reader: roomName(ends.reader), source: roomName(ends.source) }));
|
|
2026
|
+
const kinds = new Intl.ListFormat(language, { type: 'conjunction' }).format(LINK_KINDS.filter((kind) => (enlace.kinds ?? []).includes(kind)).map((kind) => KIND_PLURALS[kind]()));
|
|
2027
|
+
const what = [kinds.charAt(0).toUpperCase() + kinds.slice(1), enlace.expires ? t('until {date}', { date: new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'short' }) }) : t('no expiry')];
|
|
2028
|
+
if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
|
|
2029
|
+
const see = button('sm-btn small', t('See the connections'), () => { sheet.close(); if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
|
|
2030
|
+
box.append(el('span', 'lw-allows-head', t('They are already connected')), said, el('p', 'lw-note', what.join(' · ')), el('p', 'lw-note', t('A pair is connected once. To change what it looks up, how long or the orders, revoke it in Connections and make a new one.')), see);
|
|
2031
|
+
return box;
|
|
2032
|
+
};
|
|
2033
|
+
const caution = (words) => { const line = el('div', 'lw-caution'); const said = el('span'); said.append(words); line.append(el('i'), said); return line; };
|
|
2034
|
+
// What the person should know about the rooms chosen.
|
|
2035
|
+
function cautions() {
|
|
2036
|
+
const said = [];
|
|
2037
|
+
for (const id of pick.both ? [pick.source, pick.reader] : [pick.source]) {
|
|
2038
|
+
const room = roomOf(id);
|
|
2039
|
+
if (!room) continue;
|
|
2040
|
+
if (!room.open) said.push(sayBold('{room} is asleep: while it sleeps, it hands nothing over.', { room: roomLabel(room) }));
|
|
2041
|
+
else if (room.glance?.outdated) said.push(sayBold('{room} runs an older MADRE: it may hand nothing over until you update it.', { room: roomLabel(room) }));
|
|
2042
|
+
}
|
|
2043
|
+
return said;
|
|
2044
|
+
}
|
|
2045
|
+
// The scene over every step, in the welcome's own style (wizardScene): the room that serves and
|
|
2046
|
+
// the one that reads, arriving and floating; BISHOP with its ring; between them the wire, its dashes
|
|
2047
|
+
// running the way the notes travel (both ways when both read), and the notes themselves crossing it,
|
|
2048
|
+
// one dot per kind that crosses, in its kind's colour. Under the scene, in a strip of their own so
|
|
2049
|
+
// they never cover it, the link's terms. It is made once and kept across the steps; a choice
|
|
2050
|
+
// redraws what it says, not the scene.
|
|
2051
|
+
const stage = el('div', 'sm-scene scene-link');
|
|
2052
|
+
const field = el('div', 'lw-field');
|
|
2053
|
+
stage.append(field);
|
|
2054
|
+
const shown = { head: null, page: null };
|
|
2055
|
+
stage.setAttribute('aria-hidden', 'true');
|
|
2056
|
+
const ends = { source: null, reader: null };
|
|
2057
|
+
function endNode(role) {
|
|
2058
|
+
const node = el('div', `sm-scene-node lw-end ${role}`);
|
|
2059
|
+
node.style.left = role === 'source' ? '20%' : '80%';
|
|
2060
|
+
node.style.top = '42%';
|
|
2061
|
+
node.style.setProperty('--d', role === 'source' ? '0ms' : '160ms');
|
|
2062
|
+
field.append(node);
|
|
2063
|
+
return node;
|
|
2064
|
+
}
|
|
2065
|
+
function paintEnd(node, id, role) {
|
|
2066
|
+
if (node.dataset.room !== id) {
|
|
2067
|
+
node.dataset.room = id;
|
|
2068
|
+
node.replaceChildren();
|
|
2069
|
+
const body = id === data.salas ? mark('bishop', 46) : (() => { const disc = el('span', 'lw-room-mark'); disc.innerHTML = FOLDER_GLYPH; return disc; })(); // trusted: drawn here
|
|
2070
|
+
node.classList.toggle('is-bishop', id === data.salas);
|
|
2071
|
+
node.append(body, el('b', null, nameOf(id)), el('small'));
|
|
2072
|
+
}
|
|
2073
|
+
node.lastChild.textContent = role;
|
|
2074
|
+
}
|
|
2075
|
+
function paintStage() {
|
|
2076
|
+
ends.source ??= endNode('source');
|
|
2077
|
+
ends.reader ??= endNode('reader');
|
|
2078
|
+
paintEnd(ends.source, pick.source, pick.both ? t('Shares and looks up') : t('Shares'));
|
|
2079
|
+
paintEnd(ends.reader, pick.reader, pick.both ? t('Shares and looks up') : coordinator(pick.reader) && pick.orders ? t('Looks up and gives orders') : t('Looks up'));
|
|
2080
|
+
stage.querySelector('.lw-wires')?.remove();
|
|
2081
|
+
stage.querySelector('.lw-terms')?.remove();
|
|
2082
|
+
const box = field.getBoundingClientRect();
|
|
2083
|
+
if (box.width) {
|
|
2084
|
+
// In the scene's own pixels, so the dots stay round and the wire meets the bodies.
|
|
2085
|
+
const W = box.width, H = box.height, y = H * 0.42, x0 = W * 0.2 + 34, x1 = W * 0.8 - 34;
|
|
2086
|
+
const wires = svgNode('svg', { class: 'lw-wires', viewBox: `0 0 ${W} ${H}` });
|
|
2087
|
+
const there = `M${x0} ${y} C ${x0 + (x1 - x0) * 0.33} ${y - 18}, ${x0 + (x1 - x0) * 0.66} ${y + 18}, ${x1} ${y}`;
|
|
2088
|
+
const back = `M${x1} ${y + 7} C ${x0 + (x1 - x0) * 0.66} ${y + 26}, ${x0 + (x1 - x0) * 0.33} ${y - 10}, ${x0} ${y + 7}`;
|
|
2089
|
+
const paths = [['lw-there', there], ...(pick.both ? [['lw-back', back]] : [])];
|
|
2090
|
+
paths.forEach(([id, d]) => wires.append(svgNode('path', { id, class: 'sm-wire on', d })));
|
|
2091
|
+
// The notes crossing: one dot per kind, one after another, along each way (still, for whoever
|
|
2092
|
+
// asked for less motion: then the wire alone says it).
|
|
2093
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
2094
|
+
const kinds = still ? [] : LINK_KINDS.filter((kind) => pick.kinds.has(kind));
|
|
2095
|
+
paths.forEach(([id], way) => kinds.forEach((kind, index) => {
|
|
2096
|
+
const dot = svgNode('circle', { r: 4.2, class: `lw-crossing k-${kind}`, opacity: 0 });
|
|
2097
|
+
const begin = `${(index * 2.6) / Math.max(1, kinds.length) + way * 1.3}s`;
|
|
2098
|
+
const move = svgNode('animateMotion', { dur: '2.6s', repeatCount: 'indefinite', begin, calcMode: 'spline', keyTimes: '0;1', keySplines: '.45 0 .55 1' });
|
|
2099
|
+
const along = svgNode('mpath');
|
|
2100
|
+
along.setAttribute('href', `#${id}`);
|
|
2101
|
+
move.append(along);
|
|
2102
|
+
dot.append(move, svgNode('animate', { attributeName: 'opacity', values: '0;1;1;0', keyTimes: '0;.12;.88;1', dur: '2.6s', repeatCount: 'indefinite', begin }));
|
|
2103
|
+
wires.append(dot);
|
|
2104
|
+
}));
|
|
2105
|
+
field.prepend(wires);
|
|
2106
|
+
}
|
|
2107
|
+
const terms = el('p', 'lw-terms');
|
|
2108
|
+
const said = [
|
|
2109
|
+
pick.kinds.size ? kindsSaid() : t('nothing yet'),
|
|
2110
|
+
pick.days ? t('Ends {date}', { date: until() }) : t('No expiry'),
|
|
2111
|
+
pick.recall ? t('On every turn') : t('Only when asked'),
|
|
2112
|
+
...(ordersInto() ? [pick.orders ? t('Orders up to #{n}', { n: pick.maxMode }) : t('No orders')] : []),
|
|
2113
|
+
];
|
|
2114
|
+
for (const words of said) terms.append(el('span', null, words));
|
|
2115
|
+
stage.append(terms);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function render({ back = false } = {}) {
|
|
2119
|
+
settle();
|
|
2120
|
+
const steps = stepsNow();
|
|
2121
|
+
at = Math.min(at, steps.length - 1);
|
|
2122
|
+
const kind = steps[at];
|
|
2123
|
+
const head = el('header', 'sm-sheet-head');
|
|
2124
|
+
const dots = el('ol', 'sm-steps');
|
|
2125
|
+
steps.forEach((name, index) => dots.append(el('li', index === at ? 'on' : index < at ? 'done' : null, STEP_WORDS[name]())));
|
|
2126
|
+
head.append(dots, closeKey(() => sheet.close()));
|
|
2127
|
+
const page = el('div', `sm-step lw-step${back ? ' back' : ''}`);
|
|
2128
|
+
// The swap, on the first step: it turns the link round, and the scene shows it.
|
|
2129
|
+
const turn = button('sm-btn small', t('Swap'), () => { [pick.reader, pick.source] = [pick.source, pick.reader]; render(); });
|
|
2130
|
+
const intro = (title, lead) => {
|
|
2131
|
+
const box = el('div', 'lw-intro');
|
|
2132
|
+
box.append(el('span', 'lw-eyebrow', t('Step {n} of {total}', { n: at + 1, total: steps.length })), el('h3', null, title));
|
|
2133
|
+
if (lead) { const said = el('p', 'lw-lead'); said.append(lead); box.append(said); }
|
|
2134
|
+
page.append(box);
|
|
2135
|
+
};
|
|
2136
|
+
const error = el('p', 'error');
|
|
2137
|
+
const actions = el('div', 'actions');
|
|
2138
|
+
const next = (check) => button('sm-btn primary', t('Continue'), () => { const wrong = check?.(); if (wrong) { error.textContent = wrong; return; } at += 1; render(); });
|
|
2139
|
+
if (at > 0) actions.append(button('sm-btn', t('Back'), () => { at -= 1; render({ back: true }); }));
|
|
2140
|
+
|
|
2141
|
+
if (kind === 'who') {
|
|
2142
|
+
// With the Coordinator at one end the step speaks of it: what it reads, the orders it may
|
|
2143
|
+
// give, and why it is never read (src/enlaces.mjs). Between two projects, who reads whom.
|
|
2144
|
+
const withCoordinator = coordinator(pick.reader);
|
|
2145
|
+
const source = nameOf(pick.source), reader = nameOf(pick.reader);
|
|
2146
|
+
intro(t('Which projects do you want to connect?'), withCoordinator
|
|
2147
|
+
? t('The Coordinator looks up what a project has already learned: its decisions, facts and preferences. If you allow it, it can also ask that project for tasks.')
|
|
2148
|
+
: t('Connecting them lets one project look up what the other has already learned: its decisions, facts and preferences. It only reads; nothing in the other project ever changes.'));
|
|
2149
|
+
const box = card();
|
|
2150
|
+
if (pick.both) {
|
|
2151
|
+
// Both ways, neither only serves nor only reads: the two projects side by side, with no
|
|
2152
|
+
// direction to choose and nothing to swap.
|
|
2153
|
+
const pair = el('div', 'lw-pair');
|
|
2154
|
+
const both = el('span', 'lw-both');
|
|
2155
|
+
both.innerHTML = BOTH_WAYS_ICON; // trusted: our own SVG string
|
|
2156
|
+
pair.append(roomSelect(pick.source, (id) => { pick.source = id; render(); }, t('One project'), { shares: true, other: pick.reader }), both, roomSelect(pick.reader, (id) => { pick.reader = id; render(); }, t('The other project'), { shares: true, other: pick.source }));
|
|
2157
|
+
const line = row(t('The two projects'), t('Each one looks up the notes of the other. Nothing is written into either.'), null);
|
|
2158
|
+
line.classList.add('lw-stack');
|
|
2159
|
+
line.append(pair);
|
|
2160
|
+
box.append(line);
|
|
2161
|
+
} else {
|
|
2162
|
+
// Nothing to swap with the Coordinator: it is always the one that looks up, so its project
|
|
2163
|
+
// is chosen alone, as wide as the one under it.
|
|
2164
|
+
const sharing = roomSelect(pick.source, (id) => { pick.source = id; render(); }, t('Shares its memory'), { shares: true, other: pick.reader });
|
|
2165
|
+
const serves = withCoordinator ? sharing : el('div', 'lw-control lw-pick');
|
|
2166
|
+
if (!withCoordinator) serves.append(sharing, turn);
|
|
2167
|
+
box.append(row(t('Shares its memory'), withCoordinator ? t('The Coordinator reads its notes.') : t('Its notes can be read. Nothing is written into it.'), serves));
|
|
2168
|
+
box.append(row(t('Looks it up'), t('Its agents can look the notes up when they need them.'), roomSelect(pick.reader, (id) => { pick.reader = id; render(); }, t('Looks it up'), { other: pick.source })));
|
|
2169
|
+
}
|
|
2170
|
+
if (!withCoordinator) box.append(row(t('Both ways'), t('Each one looks up the other. Leave it off if only one needs it.'), toggle(pick.both, (on) => { pick.both = on; render(); }, t('Both ways'))));
|
|
2171
|
+
const looks = (who, whose) => sayBold('{reader} looks up the notes of {source}.', { reader: who, source: whose });
|
|
2172
|
+
const existing = pick.reader !== pick.source ? linkedPair(pick.source, pick.reader) : null;
|
|
2173
|
+
if (existing) box.append(alreadyLinked(existing));
|
|
2174
|
+
else box.append(allows(withCoordinator
|
|
2175
|
+
? [
|
|
2176
|
+
[true, sayBold('The Coordinator looks up the notes of {room}.', { room: source })],
|
|
2177
|
+
[true, sayBold('It can ask {room} for tasks, up to #2, if you allow it in the Orders step.', { room: source })],
|
|
2178
|
+
[false, sayBold('{room} does not look up the Coordinator: the Coordinator keeps what it read in all your projects, and {room} would see it without your signature.', { room: source })],
|
|
2179
|
+
]
|
|
2180
|
+
: pick.both
|
|
2181
|
+
? [[true, looks(reader, source)], [true, looks(source, reader)], [false, t('Neither one writes in the other: nothing in either project changes.')]]
|
|
2182
|
+
: [[true, looks(reader, source)], [false, sayBold('{source} does not look up {reader}. Turn on Both ways if it needs to.', { source, reader })], [false, t('Neither one writes in the other: nothing in either project changes.')]]));
|
|
2183
|
+
for (const said of cautions()) box.append(caution(said));
|
|
2184
|
+
page.append(box);
|
|
2185
|
+
const onward = next(() => (pick.reader === pick.source ? t('Choose two different projects.') : coordinator(pick.source) ? t('The Coordinator never shares its memory: it is always the one that looks up.') : existing ? t('These two are already connected. To change it, revoke it in Connections and make a new one.') : null));
|
|
2186
|
+
// Already connected: nothing to go on to; the box above says why and where to change it.
|
|
2187
|
+
onward.disabled = Boolean(existing);
|
|
2188
|
+
actions.append(onward);
|
|
2189
|
+
} else if (kind === 'what') {
|
|
2190
|
+
intro(t('What can it look up?'), t('Turn on only what it needs. Decisions and facts are usually enough.'));
|
|
2191
|
+
const box = card();
|
|
2192
|
+
box.classList.add('lw-grid');
|
|
2193
|
+
for (const kindOf of LINK_KINDS) {
|
|
2194
|
+
box.append(row(kindWord(kindOf), LINK_KIND_NOTES[kindOf](), toggle(pick.kinds.has(kindOf), (on) => { if (on) pick.kinds.add(kindOf); else pick.kinds.delete(kindOf); error.textContent = ''; render(); }, kindWord(kindOf)), { recommended: kindOf === 'decision' || kindOf === 'fact', dot: kindOf }));
|
|
2195
|
+
}
|
|
2196
|
+
page.append(box, el('p', 'lw-foot', t('Only the notes already in use are shared, cleaned of private data first. Never your files, your whole conversations or the notes you set aside.')));
|
|
2197
|
+
actions.append(next(() => (pick.kinds.size ? null : t('Choose at least one kind.'))));
|
|
2198
|
+
} else if (kind === 'time') {
|
|
2199
|
+
intro(t('For how long?'), t('The connection ends on its own on the day you choose. Until then, its agents look the notes up only when they need them, unless you turn on the option below.'));
|
|
2200
|
+
const box = card();
|
|
2201
|
+
const ends = el('div', 'lw-control');
|
|
2202
|
+
ends.append(rail(LINK_DAYS.map(([value, words]) => [value, words()]), pick.days, (value) => { pick.days = value; }, t('It ends')));
|
|
2203
|
+
box.append(row(t('It ends'), t('A week is enough to try it; signing it again takes a moment.'), ends, { recommended: pick.days === 7 }));
|
|
2204
|
+
box.append(row(t('Always include'), t('With every question it gets up to 3 related notes without looking for them; each time counts toward the hourly limit. Off, its agents look them up only when they need to.'), toggle(pick.recall, (on) => { pick.recall = on; render(); }, t('Always include'))));
|
|
2205
|
+
page.append(box);
|
|
2206
|
+
actions.append(next());
|
|
2207
|
+
} else if (kind === 'orders') {
|
|
2208
|
+
const name = nameOf(ordersInto());
|
|
2209
|
+
intro(t('Orders from the coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. Edit (#3) and Full access (#4) never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
|
|
2210
|
+
const box = card();
|
|
2211
|
+
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 }));
|
|
2212
|
+
if (pick.orders) {
|
|
2213
|
+
const modes = el('div', 'lw-control');
|
|
2214
|
+
modes.append(rail([1, 2].map((n) => [n, `#${n} ${modeName(n)}`]), pick.maxMode, (value) => { pick.maxMode = value; }, t('Up to which mode')));
|
|
2215
|
+
box.append(row(t('Up to which mode'), null, modes));
|
|
2216
|
+
const hourly = el('div', 'lw-control');
|
|
2217
|
+
hourly.append(rail([3, 6, 12].map((n) => [n, String(n)]), pick.perHour, (value) => { pick.perHour = value; }, t('How many an hour')));
|
|
2218
|
+
box.append(row(t('How many an hour'), null, hourly));
|
|
2219
|
+
const typed = el('input', 'lw-input'); typed.type = 'text'; typed.autocomplete = 'off'; typed.spellcheck = false; typed.value = pick.designation;
|
|
2220
|
+
typed.setAttribute('aria-label', t('Type the name of {room} to allow orders into it', { room: name }));
|
|
2221
|
+
typed.addEventListener('input', () => { pick.designation = typed.value; error.textContent = ''; });
|
|
2222
|
+
const said = row(sayBold('Type the name of {room} to allow orders into it', { room: name }), null, null);
|
|
2223
|
+
said.classList.add('lw-stack');
|
|
2224
|
+
said.append(typed);
|
|
2225
|
+
box.append(said);
|
|
2226
|
+
}
|
|
2227
|
+
page.append(box);
|
|
2228
|
+
actions.append(next(() => (pick.orders && !pick.designation.trim() ? t('Type the project name to allow orders, or turn them off.') : null)));
|
|
2229
|
+
} else {
|
|
2230
|
+
intro(t('Check it and sign'), t('This is the whole connection. Nothing changes until you press Sign.'));
|
|
2231
|
+
const list = el('ul', 'lw-card lw-review');
|
|
2232
|
+
const said = (words) => { const item = el('li'); item.append(words); list.append(item); };
|
|
2233
|
+
said(pick.both ? sayBold('{a} and {b} will look up each other\'s {kinds}.', { a: nameOf(pick.reader), b: nameOf(pick.source), kinds: kindsSaid() }, ['a', 'b']) : sayBold('{reader} will look up the {kinds} of {source}.', { reader: nameOf(pick.reader), source: nameOf(pick.source), kinds: kindsSaid() }, ['reader', 'source']));
|
|
2234
|
+
said(pick.days ? t('Until {date}.', { date: until() }) : t('With no expiry: it lasts until you revoke it.'));
|
|
2235
|
+
said(pick.recall ? t('With every question, up to 3 of these notes arrive without being looked for.') : t('Its agents look them up only when they need them.'));
|
|
2236
|
+
if (ordersInto()) said(pick.orders ? sayBold('The Coordinator may send orders into {room}, up to #{n}, {k} an hour.', { room: nameOf(ordersInto()), n: pick.maxMode, k: pick.perHour }, ['room']) : t('No orders: the Coordinator only looks up.'));
|
|
2237
|
+
said(t('Nothing is written across, and you can revoke it at any moment from Connections.'));
|
|
2238
|
+
page.append(list);
|
|
2239
|
+
const warn = cautions();
|
|
2240
|
+
if (warn.length) { const box = card(); for (const words of warn) box.append(caution(words)); page.append(box); }
|
|
2241
|
+
const sign = button('sm-btn primary', t('Sign'), async () => {
|
|
2242
|
+
error.textContent = '';
|
|
2243
|
+
const body = {
|
|
2244
|
+
a: pick.source, b: pick.reader, direction: pick.both ? 'both' : 'a→b', kinds: [...pick.kinds],
|
|
2245
|
+
expires: pick.days ? new Date(Date.now() + pick.days * 86400000).toISOString() : null,
|
|
2246
|
+
orders: pick.orders && ordersInto() ? { allowed: true, maxMode: pick.maxMode, perHour: pick.perHour } : { allowed: false, maxMode: 0, perHour: pick.perHour },
|
|
2247
|
+
designation: pick.orders && ordersInto() ? pick.designation.trim() : undefined,
|
|
2248
|
+
recall: pick.recall,
|
|
2249
|
+
};
|
|
2250
|
+
sign.disabled = true;
|
|
2251
|
+
sign.textContent = t('Signing…');
|
|
2252
|
+
const answer = await fetch('/api/salas/enlaces', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2253
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
2254
|
+
sign.disabled = false;
|
|
2255
|
+
sign.textContent = t('Sign');
|
|
2256
|
+
if (!answer.ok) { error.textContent = answer.body.error ?? t('The connection could not be signed.'); return; }
|
|
2257
|
+
sheet.close();
|
|
2258
|
+
await refresh();
|
|
2259
|
+
});
|
|
2260
|
+
actions.append(sign);
|
|
2261
|
+
}
|
|
2262
|
+
page.append(error, actions);
|
|
2263
|
+
// The scene stays where it is (taken out and put back, its animations would start over).
|
|
2264
|
+
if (!stage.isConnected) inner.replaceChildren(head, stage, page);
|
|
2265
|
+
else { shown.head.replaceWith(head); shown.page.replaceWith(page); }
|
|
2266
|
+
shown.head = head; shown.page = page;
|
|
2267
|
+
paintStage();
|
|
2268
|
+
}
|
|
2269
|
+
sheet.addEventListener('close', () => { sheet.remove(); sheet = null; });
|
|
2270
|
+
render();
|
|
2271
|
+
sheet.showModal();
|
|
2272
|
+
paintStage();
|
|
2273
|
+
}
|
|
2274
|
+
ui.link.addEventListener('click', () => openLinkSheet());
|
|
2275
|
+
|
|
2276
|
+
let refreshPending = null;
|
|
2277
|
+
async function refresh() {
|
|
2278
|
+
if (refreshPending) return refreshPending;
|
|
2279
|
+
refreshPending = fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
2280
|
+
const answer = await refreshPending;
|
|
2281
|
+
refreshPending = null;
|
|
2282
|
+
if (!answer) { ui.foot.textContent = t('This project did not answer. It may have closed.'); ui.bar.live.classList.add('is-off'); ui.bar.live.lastChild.textContent = t('Not answering right now'); return; }
|
|
2283
|
+
if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
|
|
2284
|
+
data = answer;
|
|
2285
|
+
// A project that came up after Home stopped waiting is open: its card no longer says it did not.
|
|
2286
|
+
for (const room of answer.rooms ?? []) if (room.open) failed.delete(room.id);
|
|
2287
|
+
ui.foot.textContent = t('Every project runs on this Mac, on its own, with its own memory.');
|
|
2288
|
+
// The room's corner (app.js, #colophon): the version this Home runs, and NOTICE's own line.
|
|
2289
|
+
if (answer.version) {
|
|
2290
|
+
ui.colophon.replaceChildren(el('b', null, `MADRE ${answer.version}`), document.createTextNode('\u00a0·\u00a0© 2026 Jossue Alcalá'));
|
|
2291
|
+
ui.colophon.hidden = false;
|
|
2292
|
+
}
|
|
2293
|
+
draw();
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
async function startRoom(room, { enter = false } = {}) {
|
|
2297
|
+
busy.set(room.id, 'starting');
|
|
2298
|
+
failed.delete(room.id);
|
|
2299
|
+
draw();
|
|
2300
|
+
const answer = await fetch('/api/salas/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: room.id }) })
|
|
2301
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
2302
|
+
if (answer.ok) await waitDone(room.id); else waitDrop(room.id);
|
|
2303
|
+
busy.delete(room.id);
|
|
2304
|
+
if (!answer.ok) failed.set(room.id, answer.body.error ?? t('The project did not open.'));
|
|
2305
|
+
// Opened from its card, the person goes in: that is what «Open» means.
|
|
2306
|
+
else if (enter && answer.body.port) { leaveTo(`http://127.0.0.1:${answer.body.port}/`); return answer; }
|
|
2307
|
+
await refresh();
|
|
2308
|
+
return answer;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// A card's wait (2026-10-10): MADRE's loader in a line (loader.js), kept across the redraws of the
|
|
2312
|
+
// card so it never starts over, creeping while the project wakes, closes or leaves, and filled when
|
|
2313
|
+
// it is done.
|
|
2314
|
+
const waits = new Map(); // room id → its loader while the card waits
|
|
2315
|
+
function waitFor(id, label) {
|
|
2316
|
+
let loader = waits.get(id);
|
|
2317
|
+
if (!loader) {
|
|
2318
|
+
loader = makeLoader({ small: true, progress: 0.04 });
|
|
2319
|
+
loader.stop = creep(loader, { to: 0.9, rate: 0.03, every: 200 });
|
|
2320
|
+
waits.set(id, loader);
|
|
2321
|
+
}
|
|
2322
|
+
setLoader(loader, 0, label);
|
|
2323
|
+
return loader;
|
|
2324
|
+
}
|
|
2325
|
+
// It did not happen: the loader goes without filling, and the card says why.
|
|
2326
|
+
function waitDrop(id) {
|
|
2327
|
+
waits.get(id)?.stop?.();
|
|
2328
|
+
waits.delete(id);
|
|
2329
|
+
}
|
|
2330
|
+
function waitDone(id) {
|
|
2331
|
+
const loader = waits.get(id);
|
|
2332
|
+
if (!loader) return Promise.resolve();
|
|
2333
|
+
waits.delete(id);
|
|
2334
|
+
loader.stop?.();
|
|
2335
|
+
return finishLoader(loader, { hold: 220 });
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// Out of MADRE (2026-10-10): Home forgets it (src/salas.mjs, forgetProject); its folder and its
|
|
2339
|
+
// memory stay where they are, and adding the folder again brings them back.
|
|
2340
|
+
async function forgetRoom(room) {
|
|
2341
|
+
forgetting.delete(room.id);
|
|
2342
|
+
busy.set(room.id, 'forgetting');
|
|
2343
|
+
failed.delete(room.id);
|
|
2344
|
+
draw();
|
|
2345
|
+
const answer = await post('/api/salas/forget', { room: room.id });
|
|
2346
|
+
if (answer.ok) await waitDone(room.id); else waitDrop(room.id);
|
|
2347
|
+
busy.delete(room.id);
|
|
2348
|
+
if (!answer.ok) { failed.set(room.id, answer.body.error ?? t('It could not be taken out of MADRE.')); draw(); return; }
|
|
2349
|
+
if (pinsStored()?.includes(room.id)) pinRemove(room.id);
|
|
2350
|
+
toast(t('{name} left MADRE. Its folder is still on this Mac.', { name: answer.body.name ?? room.name }));
|
|
2351
|
+
await refresh();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
async function closeRoom(room) {
|
|
2355
|
+
asking.delete(room.id);
|
|
2356
|
+
busy.set(room.id, 'closing');
|
|
2357
|
+
failed.delete(room.id);
|
|
2358
|
+
draw();
|
|
2359
|
+
const answer = await fetch('/api/salas/close', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: room.id }) })
|
|
2360
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
2361
|
+
if (answer.body.here) {
|
|
2362
|
+
// This page goes with the room that serves it: it says so, and stops asking.
|
|
2363
|
+
clearInterval(timer);
|
|
2364
|
+
document.body.classList.add('sm-gone');
|
|
2365
|
+
ui.none.hidden = false;
|
|
2366
|
+
ui.none.replaceChildren(el('span', null, t('This project closed. Open MADRE again to come back to Home.')));
|
|
2367
|
+
ui.stop.hidden = true;
|
|
2368
|
+
ui.projects.hidden = true;
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
if (!answer.ok) { waitDrop(room.id); busy.delete(room.id); failed.set(room.id, answer.body.error ?? t('That project did not answer.')); draw(); return; }
|
|
2372
|
+
// It closes in a moment; the card stays «closing» until the list says it is asleep.
|
|
2373
|
+
for (let wait = 0; wait < 20; wait += 1) {
|
|
2374
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
2375
|
+
await refresh();
|
|
2376
|
+
if (!data?.rooms.find((one) => one.id === room.id)?.open) break;
|
|
2377
|
+
}
|
|
2378
|
+
await waitDone(room.id);
|
|
2379
|
+
busy.delete(room.id);
|
|
2380
|
+
draw();
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// THE MACROVERSE (macroverso.js): the same rooms as universes, entered from here.
|
|
2384
|
+
function macroWords() {
|
|
2385
|
+
return {
|
|
2386
|
+
title: t('Project map'),
|
|
2387
|
+
back: t('Back to Home'),
|
|
2388
|
+
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'),
|
|
2389
|
+
bishop: t('Coordinator'),
|
|
2390
|
+
bishopAbout: t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
|
|
2391
|
+
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.')),
|
|
2392
|
+
notLinked: t('Not connected yet: touch one to connect it'),
|
|
2393
|
+
talk: t('Talk to the Coordinator'),
|
|
2394
|
+
asleep: t('Asleep'),
|
|
2395
|
+
recenter: t('RECENTER'),
|
|
2396
|
+
// The legend under its name, in the Memory map's words.
|
|
2397
|
+
legend: t('Memory kinds'),
|
|
2398
|
+
kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('OUT OF CIRCULATION') },
|
|
2399
|
+
veins: (n) => t('{n} connections', { n }),
|
|
2400
|
+
links: t('Connections'),
|
|
2401
|
+
link: t('Connect…'),
|
|
2402
|
+
// One link as seen from one of its rooms.
|
|
2403
|
+
linkRow: (enlace, roomId) => {
|
|
2404
|
+
const other = roomName(enlace.rooms.a === roomId ? enlace.rooms.b : enlace.rooms.a);
|
|
2405
|
+
const kinds = kindsWords(enlace.kinds);
|
|
2406
|
+
if (enlace.direction === 'both') return t('both ways with {room}: {kinds}', { room: other, kinds });
|
|
2407
|
+
return endsOf(enlace).reader === roomId ? t('reads {kinds} from {room}', { kinds, room: other }) : t('{room} reads its {kinds}', { room: other, kinds });
|
|
2408
|
+
},
|
|
2409
|
+
sub: (rooms) => {
|
|
2410
|
+
const open = rooms.filter((room) => room.open);
|
|
2411
|
+
const memories = open.reduce((sum, room) => sum + (room.glance?.census?.memories ?? 0), 0);
|
|
2412
|
+
const waiting = open.filter((room) => room.glance?.waitingCount).length;
|
|
2413
|
+
return [data?.mac ?? null, t('{n} projects', { n: rooms.length }), waiting ? t('{n} waiting for you', { n: waiting }) : null, t('{n} memories', { n: memories })].filter(Boolean).join(' · ');
|
|
2414
|
+
},
|
|
2415
|
+
state: (room) => stateOf(room).words,
|
|
2416
|
+
path: homeWord,
|
|
2417
|
+
ask: askLine,
|
|
2418
|
+
kind: (kind) => KIND_WORDS[kind] ?? kind,
|
|
2419
|
+
counts: (room) => (room.open ? [conversationsCount(room.glance?.chats ?? 0), memoriesCount(room.glance?.census?.memories ?? 0)].join(' · ') : room.lastOpened ? t('Asleep · last open {when}', { when: ago(room.lastOpened) }) : t('Asleep')),
|
|
2420
|
+
enter: t('Enter the project'),
|
|
2421
|
+
backToRoom: t('Back to the project'),
|
|
2422
|
+
wake: t('Wake'),
|
|
2423
|
+
waking: t('Waking…'),
|
|
2424
|
+
all: t('All the projects'),
|
|
2425
|
+
close: t('Close'),
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2428
|
+
// The macroverse is night in either theme, so it wears the accent as the night does: the hex the
|
|
2429
|
+
// stylesheet gives that accent before the light scheme recolours it (S-014), read from the rule
|
|
2430
|
+
// itself rather than from the page, which may be light.
|
|
2431
|
+
function darkAccent() {
|
|
2432
|
+
const wanted = `[data-accent="${root.dataset.accent || 'madre'}"]`;
|
|
2433
|
+
try {
|
|
2434
|
+
for (const sheet of document.styleSheets) {
|
|
2435
|
+
let rules;
|
|
2436
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
2437
|
+
for (const rule of rules) {
|
|
2438
|
+
if (rule.selectorText !== wanted) continue;
|
|
2439
|
+
const hex = rule.style.getPropertyValue('--acc').trim();
|
|
2440
|
+
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
} catch { /* no sheets to read: the house accent */ }
|
|
2444
|
+
return '#9bff66';
|
|
2445
|
+
}
|
|
2446
|
+
// And the ink that accent wears, read the same way.
|
|
2447
|
+
function darkAccentInk() {
|
|
2448
|
+
const wanted = `[data-accent="${root.dataset.accent || 'madre'}"]`;
|
|
2449
|
+
try {
|
|
2450
|
+
for (const sheet of document.styleSheets) {
|
|
2451
|
+
let rules;
|
|
2452
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
2453
|
+
for (const rule of rules) {
|
|
2454
|
+
if (rule.selectorText !== wanted) continue;
|
|
2455
|
+
const hex = rule.style.getPropertyValue('--acc-ink').trim();
|
|
2456
|
+
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
} catch { /* no sheets to read: the house ink */ }
|
|
2460
|
+
return '#0a1405';
|
|
2461
|
+
}
|
|
2462
|
+
ui.macro.addEventListener('click', async () => {
|
|
2463
|
+
if (macro || !data) return;
|
|
2464
|
+
const { openMacroverso } = await import('./macroverso.js');
|
|
2465
|
+
document.body.classList.add('sm-in-macro');
|
|
2466
|
+
macro = openMacroverso({
|
|
2467
|
+
host: document.body,
|
|
2468
|
+
rooms: data.rooms,
|
|
2469
|
+
here: data.here,
|
|
2470
|
+
enlaces: data.enlaces ?? [],
|
|
2471
|
+
accent: darkAccent(),
|
|
2472
|
+
accentInk: darkAccentInk(),
|
|
2473
|
+
// BISHOP at the centre (D-S12): the macroverse draws its body itself (macroverso.js, bishopOrb).
|
|
2474
|
+
bishop: data.salas ? { id: data.salas } : null,
|
|
2475
|
+
words: macroWords(),
|
|
2476
|
+
actions: {
|
|
2477
|
+
wake: async (room) => { await startRoom(room); },
|
|
2478
|
+
// Dragged from one universe onto another: the notes of the first would travel to the second.
|
|
2479
|
+
link: (source, reader) => openLinkSheet({ source: source?.id ?? null, reader: reader?.id ?? null }),
|
|
2480
|
+
closed: () => { macro = null; document.body.classList.remove('sm-in-macro'); ui.macro.focus(); },
|
|
2481
|
+
},
|
|
2482
|
+
});
|
|
2483
|
+
});
|
|
2484
|
+
window.addEventListener('resize', () => { if (data) ui.macro.hidden = !data.rooms.length || window.innerWidth < 900; });
|
|
2485
|
+
|
|
2486
|
+
ui.stop.addEventListener('click', async () => {
|
|
2487
|
+
if (!arming) {
|
|
2488
|
+
ui.stop.classList.add('armed');
|
|
2489
|
+
ui.stop.querySelector('span').textContent = t('Touch again to stop all');
|
|
2490
|
+
arming = setTimeout(() => { arming = 0; ui.stop.classList.remove('armed'); ui.stop.querySelector('span').textContent = t('Stop all'); }, 3000);
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
clearTimeout(arming);
|
|
2494
|
+
arming = 0;
|
|
2495
|
+
ui.stop.classList.remove('armed');
|
|
2496
|
+
ui.stop.disabled = true;
|
|
2497
|
+
await fetch('/api/salas/stop-all', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
|
|
2498
|
+
ui.stop.disabled = false;
|
|
2499
|
+
ui.stop.querySelector('span').textContent = t('Stop all');
|
|
2500
|
+
await refresh();
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
// ---- THE BAR, as a room's: the logo and where you are (and, served by a project's room, the way
|
|
2504
|
+
// back to it); this lobby answering; the rooms on the bar, each with how it is and a way into it; the
|
|
2505
|
+
// agents with their sessions; who waits for you; a newer MADRE; what is running and the key that
|
|
2506
|
+
// stops it; the links in force; the macroverse; reload and settings apart, as in the room.
|
|
2507
|
+
function drawTopbar() {
|
|
2508
|
+
if (!data) return;
|
|
2509
|
+
const b = ui.bar;
|
|
2510
|
+
// Where you are, and the way back to the room that serves this page, when it is a project's.
|
|
2511
|
+
const here = data.rooms.find((room) => room.id === data.here) ?? null;
|
|
2512
|
+
b.backRoom.hidden = !here || here.id === data.salas;
|
|
2513
|
+
if (here && here.id !== data.salas) { b.backRoom.href = '/'; b.backRoom.lastChild.textContent = here.name; b.backRoom.title = t('Back to the project'); }
|
|
2514
|
+
b.live.classList.remove('is-off');
|
|
2515
|
+
b.live.lastChild.textContent = data.mac ?? '';
|
|
2516
|
+
// The rooms on the bar: the open projects, or the ones the person pinned, in their order.
|
|
2517
|
+
drawPins();
|
|
2518
|
+
// The agents of this computer, each with its session.
|
|
2519
|
+
b.agents.replaceChildren();
|
|
2520
|
+
for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
|
|
2521
|
+
const state = agentState(agent, agentsSeen);
|
|
2522
|
+
const key = button(`sm-agent-key ${state.key}`, null, () => openSettings('agents'));
|
|
2523
|
+
key.append(mark(agent.id, 24));
|
|
2524
|
+
key.title = `${agent.label ?? agent.id} · ${state.words}`;
|
|
2525
|
+
key.setAttribute('aria-label', key.title);
|
|
2526
|
+
b.agents.append(key);
|
|
2527
|
+
}
|
|
2528
|
+
b.agents.hidden = !b.agents.childElementCount;
|
|
2529
|
+
// Who waits for you: how many, and a way into the first room that asks.
|
|
2530
|
+
const asking = data.rooms.filter((room) => room.open && room.glance?.waitingCount);
|
|
2531
|
+
const waiting = asking.reduce((sum, room) => sum + room.glance.waitingCount, 0);
|
|
2532
|
+
b.waiting.hidden = !waiting;
|
|
2533
|
+
if (waiting) { b.waiting.href = addressOf(asking[0]); b.waiting.lastChild.textContent = t('{n} waiting for you', { n: waiting }); b.waiting.title = asking.map((room) => roomLabel(room)).join(' · '); }
|
|
2534
|
+
// A newer MADRE.
|
|
2535
|
+
b.update.hidden = !(versionSeen?.available && versionSeen.latest);
|
|
2536
|
+
if (!b.update.hidden) { b.update.lastChild.textContent = t('{version} AVAILABLE', { version: versionSeen.latest }); b.update.title = t('MADRE {version} is out.', { version: versionSeen.latest }); }
|
|
2537
|
+
// The links in force.
|
|
2538
|
+
const links = (data.enlaces ?? []).length;
|
|
2539
|
+
b.linksCount.hidden = !links;
|
|
2540
|
+
b.linksCount.textContent = String(links);
|
|
2541
|
+
}
|
|
2542
|
+
// ---- THE BAR'S ROOMS, the way you want them (2026-10-10), as the room's bar is arranged (app.js,
|
|
2543
|
+
// «The bar, the way you want it»). Until the person arranges it, the bar holds the projects that
|
|
2544
|
+
// are open, the ones that need the person first. A project is pinned from its card (right-click
|
|
2545
|
+
// it, or drag the card to the bar); right-click a room on the bar and the bar opens for editing:
|
|
2546
|
+
// the rooms tremble, each wears a «−», they can be carried to another place, and a card under the
|
|
2547
|
+
// bar holds the projects that are not on it yet. What is on the bar, and in which order, is this
|
|
2548
|
+
// browser's own (localStorage), as the room keeps its bar.
|
|
2549
|
+
const PIN_KEY = 'pulse.lobbyBar';
|
|
2550
|
+
const PIN_TYPE = 'application/x-madre-room';
|
|
2551
|
+
const PIN_MINUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
2552
|
+
const PIN_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7.4 3.4h5.2l-.9 4.4 2.6 2.6v1.2H5.7v-1.2l2.6-2.6zM10 11.6v5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
2553
|
+
const UNPIN_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7.4 3.4h5.2l-.9 4.4 2.6 2.6v1.2H5.7v-1.2l2.6-2.6zM10 11.6v5M3.6 3.6l12.8 12.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
2554
|
+
const ARRANGE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><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"/></svg>';
|
|
2555
|
+
const FORGET_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><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"/><path d="M7.6 11h4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
|
|
2556
|
+
const MORE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="5" cy="10" r="1.6" fill="currentColor"/><circle cx="10" cy="10" r="1.6" fill="currentColor"/><circle cx="15" cy="10" r="1.6" fill="currentColor"/></svg>';
|
|
2557
|
+
const pins = {
|
|
2558
|
+
saved: null, // what this page decided, for when storage will not keep it
|
|
2559
|
+
chips: new Map(), // room id → its key on the bar
|
|
2560
|
+
badges: new Map(), // room id → its «−»
|
|
2561
|
+
layer: null, // where the «−» live, over the rooms
|
|
2562
|
+
more: null, // «+n», when the rooms do not all fit
|
|
2563
|
+
spilled: [], // the rooms behind «+n»
|
|
2564
|
+
moreCard: null,
|
|
2565
|
+
card: null, // the card under the bar while it is being arranged
|
|
2566
|
+
cardSaid: '',
|
|
2567
|
+
menu: null, // a card's right-click menu
|
|
2568
|
+
editing: false,
|
|
2569
|
+
frame: 0,
|
|
2570
|
+
drag: null, // a room being carried along the bar
|
|
2571
|
+
dropping: null, // a project's card being dragged towards the bar
|
|
2572
|
+
slot: null, // where it would land
|
|
2573
|
+
slotAt: null,
|
|
2574
|
+
};
|
|
2575
|
+
const motionStill = () => Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
|
|
2576
|
+
const motionMs = (ms) => ms * (Number.parseFloat(getComputedStyle(root).getPropertyValue('--motion-scale')) || 1);
|
|
2577
|
+
|
|
2578
|
+
function pinsStored() {
|
|
2579
|
+
if (pins.saved) return [...pins.saved];
|
|
2580
|
+
try {
|
|
2581
|
+
const raw = JSON.parse(localStorage.getItem(PIN_KEY) ?? 'null');
|
|
2582
|
+
if (Array.isArray(raw) && raw.every((id) => typeof id === 'string')) return [...new Set(raw)];
|
|
2583
|
+
} catch { /* no storage, or not a list: the bar as it comes */ }
|
|
2584
|
+
return null;
|
|
2585
|
+
}
|
|
2586
|
+
// The rooms on the bar, in their order: the person's, or the open projects until they arrange it.
|
|
2587
|
+
// A project that left the lobby is skipped; BISHOP's own room is never a project.
|
|
2588
|
+
function pinsShown() {
|
|
2589
|
+
if (!data) return [];
|
|
2590
|
+
const stored = pinsStored();
|
|
2591
|
+
if (!stored) return projectsInOrder().open.map((room) => room.id);
|
|
2592
|
+
const known = new Set(data.rooms.filter((room) => room.id !== data.salas).map((room) => room.id));
|
|
2593
|
+
return stored.filter((id) => known.has(id));
|
|
2594
|
+
}
|
|
2595
|
+
function pinsWrite(list) {
|
|
2596
|
+
pins.saved = [...list];
|
|
2597
|
+
try { localStorage.setItem(PIN_KEY, JSON.stringify(pins.saved)); } catch { /* this page only */ }
|
|
2598
|
+
drawTopbar();
|
|
2599
|
+
settingsSheet?.sync({ detail: true });
|
|
2600
|
+
}
|
|
2601
|
+
function pinAdd(id, at = null) {
|
|
2602
|
+
const list = pinsShown().filter((one) => one !== id);
|
|
2603
|
+
list.splice(at === null ? list.length : Math.max(0, Math.min(at, list.length)), 0, id);
|
|
2604
|
+
pinsWrite(list);
|
|
2605
|
+
// It arrives where it was put, growing into its place rather than appearing in it.
|
|
2606
|
+
const chip = pins.chips.get(id);
|
|
2607
|
+
if (chip && typeof chip.animate === 'function' && !motionStill()) chip.animate([{ opacity: 0, scale: '.6' }, { opacity: 1, scale: '1' }], { duration: motionMs(240), easing: 'cubic-bezier(.2, .7, .2, 1)' });
|
|
2608
|
+
}
|
|
2609
|
+
function pinRemove(id) { pinsWrite(pinsShown().filter((one) => one !== id)); }
|
|
2610
|
+
function pinsReset() {
|
|
2611
|
+
pins.saved = null;
|
|
2612
|
+
try { localStorage.removeItem(PIN_KEY); } catch { /* this page only */ }
|
|
2613
|
+
drawTopbar();
|
|
2614
|
+
settingsSheet?.sync({ detail: true });
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// From six rooms on, each is a square with its two letters (brands.js, monogram).
|
|
2618
|
+
const PINS_SQUARE = 6;
|
|
2619
|
+
|
|
2620
|
+
// A room's key on the bar: its lamp and its name, or, from six rooms on, a square with its two
|
|
2621
|
+
// letters and the lamp at its corner (its name is then its title). Open, it is a link to its room;
|
|
2622
|
+
// asleep, pressing it wakes it and goes in, as «Open» on its card does.
|
|
2623
|
+
function pinChip(id) {
|
|
2624
|
+
let chip = pins.chips.get(id);
|
|
2625
|
+
if (chip) return chip;
|
|
2626
|
+
chip = el('a', 'sm-tb-room');
|
|
2627
|
+
chip.dataset.barItem = id;
|
|
2628
|
+
chip.draggable = false;
|
|
2629
|
+
chip.append(el('i'), el('span'), el('b', 'sm-tb-mono'));
|
|
2630
|
+
chip.addEventListener('click', (event) => {
|
|
2631
|
+
if (pins.editing) return;
|
|
2632
|
+
const room = data?.rooms.find((one) => one.id === id);
|
|
2633
|
+
if (!room || room.open) return;
|
|
2634
|
+
event.preventDefault();
|
|
2635
|
+
if (!busy.has(room.id)) void startRoom(room, { enter: true });
|
|
2636
|
+
});
|
|
2637
|
+
chip.addEventListener('keydown', (event) => { if (!chip.hasAttribute('href') && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); chip.click(); } });
|
|
2638
|
+
chip.addEventListener('contextmenu', (event) => { event.preventDefault(); event.pinHandled = true; pinsEdit(!pins.editing); });
|
|
2639
|
+
chip.addEventListener('pointerdown', (event) => { if (pins.editing) pinsDragStart(event, id); });
|
|
2640
|
+
pins.chips.set(id, chip);
|
|
2641
|
+
return chip;
|
|
2642
|
+
}
|
|
2643
|
+
// Puts the keys where the order says. Only what is out of place moves, so a key that stays is
|
|
2644
|
+
// never taken off the page (and keeps its focus, and its tremble).
|
|
2645
|
+
function pinsPlace(ids) {
|
|
2646
|
+
const box = ui.bar.open;
|
|
2647
|
+
const want = ids.map((id) => pins.chips.get(id)).filter(Boolean);
|
|
2648
|
+
if (pins.dropping && pins.slot) want.splice(Math.min(pins.slotAt ?? want.length, want.length), 0, pins.slot);
|
|
2649
|
+
want.push(pins.more);
|
|
2650
|
+
let next = pins.layer.parentNode === box ? pins.layer : null;
|
|
2651
|
+
for (let index = want.length - 1; index >= 0; index -= 1) {
|
|
2652
|
+
const node = want[index];
|
|
2653
|
+
if (node.parentNode !== box || node.nextElementSibling !== next) box.insertBefore(node, next);
|
|
2654
|
+
next = node;
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
function drawPins() {
|
|
2658
|
+
const box = ui.bar.open;
|
|
2659
|
+
const ids = pinsShown();
|
|
2660
|
+
for (const id of ids) {
|
|
2661
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
2662
|
+
const state = stateOf(room);
|
|
2663
|
+
const chip = pinChip(id);
|
|
2664
|
+
const lifted = pins.drag?.moved && pins.drag.id === id;
|
|
2665
|
+
chip.className = `sm-tb-room is-${busy.get(id) === 'starting' ? 'working' : state.key}${lifted ? ' bar-lifted' : ''}${pins.dropping === id ? ' is-moving' : ''}`;
|
|
2666
|
+
chip.querySelector('span').textContent = room.name;
|
|
2667
|
+
chip.lastChild.textContent = monogram(room.name);
|
|
2668
|
+
chip.title = `${room.name} · ${busy.get(id) === 'starting' ? t('Waking…') : state.words}`;
|
|
2669
|
+
chip.setAttribute('aria-label', room.name);
|
|
2670
|
+
if (room.open) { chip.href = addressOf(room); chip.removeAttribute('role'); chip.removeAttribute('tabindex'); }
|
|
2671
|
+
else { chip.removeAttribute('href'); chip.setAttribute('role', 'button'); chip.tabIndex = 0; }
|
|
2672
|
+
}
|
|
2673
|
+
for (const [id, chip] of pins.chips) if (!ids.includes(id)) { chip.remove(); pins.chips.delete(id); }
|
|
2674
|
+
pinsPlace(pins.drag?.order ?? (pins.dropping ? ids.filter((id) => id !== pins.dropping) : ids));
|
|
2675
|
+
box.classList.toggle('is-square', ids.length + (pins.dropping && !ids.includes(pins.dropping) ? 1 : 0) >= PINS_SQUARE);
|
|
2676
|
+
box.hidden = !ids.length && !pins.dropping && !pins.editing;
|
|
2677
|
+
pinsFit();
|
|
2678
|
+
if (pins.editing) { pinsBadges(); pinsDrawCard(); }
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// When the rooms do not all fit, the last ones go behind «+n», which opens a small card with them.
|
|
2682
|
+
// While the bar is arranged every room shows.
|
|
2683
|
+
function pinsFit() {
|
|
2684
|
+
const box = ui.bar.open;
|
|
2685
|
+
for (const chip of pins.chips.values()) chip.classList.remove('is-spilled');
|
|
2686
|
+
pins.spilled = [];
|
|
2687
|
+
pins.more.hidden = true;
|
|
2688
|
+
if (pins.editing || pins.dropping || box.hidden || !box.clientWidth) { pinsCloseMore(); return; }
|
|
2689
|
+
const spills = () => box.scrollWidth > box.clientWidth + 1;
|
|
2690
|
+
if (!spills()) { pinsCloseMore(); return; }
|
|
2691
|
+
pins.more.hidden = false;
|
|
2692
|
+
const ids = pinsShown();
|
|
2693
|
+
while (ids.length && spills()) {
|
|
2694
|
+
const id = ids.pop();
|
|
2695
|
+
pins.chips.get(id)?.classList.add('is-spilled');
|
|
2696
|
+
pins.spilled.unshift(id);
|
|
2697
|
+
pins.more.textContent = `+${pins.spilled.length}`;
|
|
2698
|
+
}
|
|
2699
|
+
pins.more.title = t('{n} more', { n: pins.spilled.length });
|
|
2700
|
+
if (pins.moreCard && !pins.moreCard.hidden) pinsDrawMore();
|
|
2701
|
+
}
|
|
2702
|
+
function pinTile(room, onPress) {
|
|
2703
|
+
const tile = button('bar-tile', null, onPress);
|
|
2704
|
+
tile.dataset.barItem = room.id;
|
|
2705
|
+
const glyph = el('span', `bar-tile-glyph sm-pin-glyph is-${stateOf(room).key}`);
|
|
2706
|
+
glyph.innerHTML = FOLDER_GLYPH; // trusted: our own SVG string
|
|
2707
|
+
glyph.append(el('i'));
|
|
2708
|
+
tile.append(glyph, el('span', 'bar-tile-name', room.name));
|
|
2709
|
+
return tile;
|
|
2710
|
+
}
|
|
2711
|
+
function pinsDrawMore() {
|
|
2712
|
+
const card = pins.moreCard ??= (() => { const node = el('div', 'bar-card bar-more-card'); node.setAttribute('role', 'menu'); node.hidden = true; document.body.append(node); return node; })();
|
|
2713
|
+
const grid = el('div', 'bar-grid');
|
|
2714
|
+
for (const id of pins.spilled) {
|
|
2715
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
2716
|
+
if (!room) continue;
|
|
2717
|
+
const tile = pinTile(room, () => { pinsCloseMore(); pins.chips.get(id)?.click(); });
|
|
2718
|
+
tile.setAttribute('role', 'menuitem');
|
|
2719
|
+
grid.append(tile);
|
|
2720
|
+
}
|
|
2721
|
+
card.replaceChildren(grid);
|
|
2722
|
+
card.hidden = false;
|
|
2723
|
+
const key = pins.more.getBoundingClientRect();
|
|
2724
|
+
const width = card.offsetWidth || 240;
|
|
2725
|
+
card.style.top = `${Math.round(key.bottom + 10)}px`;
|
|
2726
|
+
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, key.left)))}px`;
|
|
2727
|
+
card.style.right = 'auto';
|
|
2728
|
+
pins.more.setAttribute('aria-expanded', 'true');
|
|
2729
|
+
}
|
|
2730
|
+
function pinsCloseMore() {
|
|
2731
|
+
if (pins.moreCard) pins.moreCard.hidden = true;
|
|
2732
|
+
pins.more?.setAttribute('aria-expanded', 'false');
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// The «−» of every room on the bar. They live in a layer of their own over the rooms (a link
|
|
2736
|
+
// cannot hold a button) and follow their room every frame: its place, its tremble, its carry.
|
|
2737
|
+
function pinsBadges() {
|
|
2738
|
+
const ids = pinsShown();
|
|
2739
|
+
for (const [id, badge] of pins.badges) if (!ids.includes(id)) { badge.remove(); pins.badges.delete(id); }
|
|
2740
|
+
ids.forEach((id, index) => {
|
|
2741
|
+
const delay = `${-((index * 97) % 260)}ms`;
|
|
2742
|
+
pins.chips.get(id)?.style.setProperty('--jiggle-delay', delay);
|
|
2743
|
+
let badge = pins.badges.get(id);
|
|
2744
|
+
if (!badge) {
|
|
2745
|
+
const name = data.rooms.find((one) => one.id === id)?.name ?? id;
|
|
2746
|
+
badge = button('bar-badge', null, (event) => { event.stopPropagation(); pinRemove(id); });
|
|
2747
|
+
badge.innerHTML = PIN_MINUS; // trusted: our own SVG string
|
|
2748
|
+
badge.title = t('Remove {name} from the bar', { name });
|
|
2749
|
+
badge.setAttribute('aria-label', badge.title);
|
|
2750
|
+
pins.badges.set(id, badge);
|
|
2751
|
+
pins.layer.append(badge);
|
|
2752
|
+
}
|
|
2753
|
+
badge.style.setProperty('--jiggle-delay', delay);
|
|
2754
|
+
});
|
|
2755
|
+
pinsFollow();
|
|
2756
|
+
}
|
|
2757
|
+
function pinsFollow() {
|
|
2758
|
+
for (const [id, badge] of pins.badges) {
|
|
2759
|
+
const chip = pins.chips.get(id);
|
|
2760
|
+
badge.hidden = !chip?.offsetWidth;
|
|
2761
|
+
if (badge.hidden) continue;
|
|
2762
|
+
badge.style.left = `${chip.offsetLeft - 5}px`;
|
|
2763
|
+
badge.style.top = `${chip.offsetTop - 5}px`;
|
|
2764
|
+
badge.style.transformOrigin = `${chip.offsetWidth / 2 + 5}px ${chip.offsetHeight / 2 + 5}px`;
|
|
2765
|
+
badge.style.translate = chip.style.translate || '';
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
function pinsTick() {
|
|
2769
|
+
pins.frame = 0;
|
|
2770
|
+
if (!pins.editing) return;
|
|
2771
|
+
pinsFollow();
|
|
2772
|
+
pinsPlaceCard();
|
|
2773
|
+
pins.frame = requestAnimationFrame(pinsTick);
|
|
2774
|
+
}
|
|
2775
|
+
// The card under the bar: the projects that are not on it, a key to finish, and how to come back.
|
|
2776
|
+
function pinsDrawCard() {
|
|
2777
|
+
if (!pins.card) {
|
|
2778
|
+
pins.card = el('section', 'bar-card sm-pins-card');
|
|
2779
|
+
pins.card.setAttribute('role', 'dialog');
|
|
2780
|
+
pins.card.setAttribute('aria-label', t('Add to the bar'));
|
|
2781
|
+
document.body.append(pins.card);
|
|
2782
|
+
}
|
|
2783
|
+
const ids = pinsShown();
|
|
2784
|
+
const offered = projectsInOrder().all.filter((room) => !ids.includes(room.id));
|
|
2785
|
+
// Drawn again only when what it offers changed, so a tile under the pointer stays put.
|
|
2786
|
+
const said = offered.map((room) => `${room.id}:${stateOf(room).key}`).join() + language;
|
|
2787
|
+
if (said === pins.cardSaid && !pins.card.hidden) { pinsPlaceCard(); return; }
|
|
2788
|
+
pins.cardSaid = said;
|
|
2789
|
+
const head = el('header', 'bar-card-head');
|
|
2790
|
+
const done = sxButton(t('Done'), () => pinsEdit(false), { tone: 'primary' });
|
|
2791
|
+
done.classList.add('bar-done');
|
|
2792
|
+
head.append(el('h3', null, t('Add to the bar')), done);
|
|
2793
|
+
const grid = el('div', 'bar-grid');
|
|
2794
|
+
for (const room of offered) {
|
|
2795
|
+
const tile = pinTile(room, () => pinAdd(room.id));
|
|
2796
|
+
tile.title = t('Add {name}', { name: room.name });
|
|
2797
|
+
tile.setAttribute('aria-label', tile.title);
|
|
2798
|
+
grid.append(tile);
|
|
2799
|
+
}
|
|
2800
|
+
pins.card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.')), el('p', 'bar-card-hint', t('Drag a project to move it. A project comes to the bar from its card: right-click it, or drag it here.')));
|
|
2801
|
+
pins.card.hidden = false;
|
|
2802
|
+
pinsPlaceCard();
|
|
2803
|
+
}
|
|
2804
|
+
function pinsPlaceCard() {
|
|
2805
|
+
const card = pins.card;
|
|
2806
|
+
if (!card || card.hidden) return;
|
|
2807
|
+
const bar = ui.bar.open.closest('.sm-topbar').getBoundingClientRect();
|
|
2808
|
+
const from = ui.bar.open.getBoundingClientRect();
|
|
2809
|
+
const width = card.offsetWidth || 360;
|
|
2810
|
+
card.style.top = `${Math.round(bar.bottom + 10)}px`;
|
|
2811
|
+
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, from.left - 6)))}px`;
|
|
2812
|
+
card.style.right = 'auto';
|
|
2813
|
+
}
|
|
2814
|
+
function pinsEdit(on) {
|
|
2815
|
+
if (on === pins.editing) return;
|
|
2816
|
+
pins.editing = on;
|
|
2817
|
+
pinsCloseMenu();
|
|
2818
|
+
ui.bar.open.classList.toggle('is-editing', on);
|
|
2819
|
+
if (on) {
|
|
2820
|
+
pins.cardSaid = '';
|
|
2821
|
+
drawPins();
|
|
2822
|
+
pins.card?.querySelector('.bar-tile, .bar-done')?.focus({ preventScroll: true });
|
|
2823
|
+
if (!pins.frame) pins.frame = requestAnimationFrame(pinsTick);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
cancelAnimationFrame(pins.frame);
|
|
2827
|
+
pins.frame = 0;
|
|
2828
|
+
pinsDragEnd({ save: true });
|
|
2829
|
+
for (const badge of pins.badges.values()) badge.remove();
|
|
2830
|
+
pins.badges.clear();
|
|
2831
|
+
if (pins.card) { pins.card.hidden = true; pins.card.replaceChildren(); }
|
|
2832
|
+
drawPins();
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// Carrying a room to another place: it follows the pointer, the others make room as it passes
|
|
2836
|
+
// their middle, and it settles where it is let go. The order is saved then.
|
|
2837
|
+
function pinsDragStart(event, id) {
|
|
2838
|
+
if (event.button !== 0 || pins.drag) return;
|
|
2839
|
+
const chip = pins.chips.get(id);
|
|
2840
|
+
if (!chip || event.target.closest?.('.bar-badge')) return;
|
|
2841
|
+
const drag = { id, node: chip, x0: event.clientX, base: 0, moved: false, order: pinsShown() };
|
|
2842
|
+
drag.move = (moving) => pinsDragMove(moving);
|
|
2843
|
+
drag.up = () => pinsDragEnd({ save: true });
|
|
2844
|
+
pins.drag = drag;
|
|
2845
|
+
window.addEventListener('pointermove', drag.move);
|
|
2846
|
+
window.addEventListener('pointerup', drag.up);
|
|
2847
|
+
window.addEventListener('pointercancel', drag.up);
|
|
2848
|
+
}
|
|
2849
|
+
function pinsDragMove(event) {
|
|
2850
|
+
const drag = pins.drag;
|
|
2851
|
+
if (!drag) return;
|
|
2852
|
+
const dx = event.clientX - drag.x0;
|
|
2853
|
+
if (!drag.moved) {
|
|
2854
|
+
if (Math.abs(dx) < 5) return;
|
|
2855
|
+
drag.moved = true;
|
|
2856
|
+
drag.base = drag.node.offsetLeft;
|
|
2857
|
+
drag.node.classList.add('bar-lifted');
|
|
2858
|
+
}
|
|
2859
|
+
const centre = drag.base + dx + drag.node.offsetWidth / 2;
|
|
2860
|
+
const others = drag.order.filter((one) => one !== drag.id).map((one) => [one, pins.chips.get(one)]).filter(([, node]) => node);
|
|
2861
|
+
const past = others.find(([, node]) => centre < node.offsetLeft + node.offsetWidth / 2)?.[0] ?? null;
|
|
2862
|
+
const order = drag.order.filter((one) => one !== drag.id);
|
|
2863
|
+
order.splice(past ? order.indexOf(past) : order.length, 0, drag.id);
|
|
2864
|
+
if (order.join() !== drag.order.join()) {
|
|
2865
|
+
const before = new Map(others.map(([one, node]) => [one, node.offsetLeft]));
|
|
2866
|
+
drag.order = order;
|
|
2867
|
+
pinsPlace(order);
|
|
2868
|
+
// The rooms that moved slide from where they were, instead of jumping.
|
|
2869
|
+
for (const [one, node] of others) {
|
|
2870
|
+
const shift = before.get(one) - node.offsetLeft;
|
|
2871
|
+
if (shift && typeof node.animate === 'function' && !motionStill()) node.animate([{ translate: `${shift}px 0` }, { translate: '0 0' }], { duration: motionMs(180), easing: 'cubic-bezier(.2, .7, .2, 1)' });
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
drag.node.style.translate = `${drag.base + dx - drag.node.offsetLeft}px 0`;
|
|
2875
|
+
}
|
|
2876
|
+
function pinsDragEnd({ save = false } = {}) {
|
|
2877
|
+
const drag = pins.drag;
|
|
2878
|
+
if (!drag) return;
|
|
2879
|
+
pins.drag = null;
|
|
2880
|
+
window.removeEventListener('pointermove', drag.move);
|
|
2881
|
+
window.removeEventListener('pointerup', drag.up);
|
|
2882
|
+
window.removeEventListener('pointercancel', drag.up);
|
|
2883
|
+
if (!drag.moved) return;
|
|
2884
|
+
const from = drag.node.style.translate;
|
|
2885
|
+
drag.node.style.translate = '';
|
|
2886
|
+
drag.node.classList.remove('bar-lifted');
|
|
2887
|
+
if (from && typeof drag.node.animate === 'function' && !motionStill()) drag.node.animate([{ translate: from }, { translate: '0 0' }], { duration: motionMs(160), easing: 'cubic-bezier(.2, .7, .2, 1)' });
|
|
2888
|
+
if (save) pinsWrite(drag.order);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// A project's card, right-clicked: pin it to the bar (or take it off), or arrange the bar.
|
|
2892
|
+
function pinsCloseMenu() { pins.menu?.remove(); pins.menu = null; }
|
|
2893
|
+
function pinsMenu(event, room, at = null) {
|
|
2894
|
+
event.preventDefault();
|
|
2895
|
+
pinsCloseMenu();
|
|
2896
|
+
const menu = el('div', 'bar-card sm-menu');
|
|
2897
|
+
menu.setAttribute('role', 'menu');
|
|
2898
|
+
menu.setAttribute('aria-label', room.name);
|
|
2899
|
+
const item = (words, icon, act, { danger = false } = {}) => {
|
|
2900
|
+
const one = button(`sm-menu-item${danger ? ' danger' : ''}`, null, () => { pinsCloseMenu(); act(); });
|
|
2901
|
+
one.setAttribute('role', 'menuitem');
|
|
2902
|
+
one.innerHTML = icon; // trusted: our own SVG strings
|
|
2903
|
+
one.append(el('span', null, words));
|
|
2904
|
+
menu.append(one);
|
|
2905
|
+
};
|
|
2906
|
+
const on = pinsShown().includes(room.id);
|
|
2907
|
+
item(on ? t('Remove from the bar') : t('Pin to the bar'), on ? UNPIN_ICON : PIN_ICON, () => (on ? pinRemove(room.id) : pinAdd(room.id)));
|
|
2908
|
+
item(t('Arrange the bar'), ARRANGE_ICON, () => pinsEdit(true));
|
|
2909
|
+
menu.append(el('i', 'sm-menu-sep'));
|
|
2910
|
+
// Where it sits among the projects: moved by hand, the order becomes «Yours».
|
|
2911
|
+
const order = projectsInOrder().all.map((one) => one.id);
|
|
2912
|
+
const place = order.indexOf(room.id);
|
|
2913
|
+
if (place > 0) item(t('Move before'), BEFORE_ICON, () => moveProject(room.id, -1));
|
|
2914
|
+
if (place >= 0 && place < order.length - 1) item(t('Move after'), AFTER_ICON, () => moveProject(room.id, 1));
|
|
2915
|
+
if (place > 0) item(t('Move to the start'), BEFORE_ICON, () => moveProject(room.id, 'first'));
|
|
2916
|
+
item(t('Add to Home…'), ADD_ICON, () => openHomeAdd());
|
|
2917
|
+
menu.append(el('i', 'sm-menu-sep'));
|
|
2918
|
+
item(t('Remove from MADRE…'), FORGET_ICON, () => { forgetting.add(room.id); asking.delete(room.id); failed.delete(room.id); draw(); }, { danger: true });
|
|
2919
|
+
menu.addEventListener('keydown', (key) => {
|
|
2920
|
+
const items = [...menu.querySelectorAll('.sm-menu-item')];
|
|
2921
|
+
const at = items.indexOf(document.activeElement);
|
|
2922
|
+
if (key.key === 'ArrowDown') { key.preventDefault(); items[(at + 1) % items.length].focus(); }
|
|
2923
|
+
if (key.key === 'ArrowUp') { key.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
|
|
2924
|
+
});
|
|
2925
|
+
document.body.append(menu);
|
|
2926
|
+
pins.menu = menu;
|
|
2927
|
+
const width = menu.offsetWidth;
|
|
2928
|
+
const height = menu.offsetHeight;
|
|
2929
|
+
const point = at ?? { x: event.clientX, y: event.clientY };
|
|
2930
|
+
menu.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - width - 8, point.x)))}px`;
|
|
2931
|
+
menu.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - height - 8, point.y)))}px`;
|
|
2932
|
+
menu.style.right = 'auto';
|
|
2933
|
+
menu.querySelector('.sm-menu-item')?.focus({ preventScroll: true });
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// A project's card, dragged to the bar: the bar shows where it would land, following the pointer,
|
|
2937
|
+
// and it is pinned there when it is let go. A room already on the bar is moved instead.
|
|
2938
|
+
function pinsDragOut(event, room) {
|
|
2939
|
+
if (!event.dataTransfer) return;
|
|
2940
|
+
event.dataTransfer.setData(PIN_TYPE, room.id);
|
|
2941
|
+
event.dataTransfer.setData('text/plain', room.name);
|
|
2942
|
+
event.dataTransfer.effectAllowed = 'copyMove';
|
|
2943
|
+
pinsCloseMenu();
|
|
2944
|
+
pins.dropping = room.id;
|
|
2945
|
+
pins.slotAt = null;
|
|
2946
|
+
pins.slot = el('span', 'sm-tb-room sm-tb-slot');
|
|
2947
|
+
pins.slot.append(el('i'), el('span', null, room.name), el('b', 'sm-tb-mono', monogram(room.name)));
|
|
2948
|
+
document.body.classList.add('sm-pin-dragging');
|
|
2949
|
+
// The source card stays as it is while it is carried: the lobby is not drawn again under it.
|
|
2950
|
+
requestAnimationFrame(() => drawPins());
|
|
2951
|
+
}
|
|
2952
|
+
function pinsDropEnd() {
|
|
2953
|
+
if (!pins.dropping) return;
|
|
2954
|
+
pins.dropping = null;
|
|
2955
|
+
pins.slot?.remove();
|
|
2956
|
+
pins.slot = null;
|
|
2957
|
+
pins.slotAt = null;
|
|
2958
|
+
document.body.classList.remove('sm-pin-dragging');
|
|
2959
|
+
drawPins();
|
|
2960
|
+
}
|
|
2961
|
+
function pinsIndexAt(x) {
|
|
2962
|
+
const ids = pinsShown().filter((id) => id !== pins.dropping);
|
|
2963
|
+
const index = ids.findIndex((id) => { const box = pins.chips.get(id)?.getBoundingClientRect(); return box && x < box.left + box.width / 2; });
|
|
2964
|
+
return index < 0 ? ids.length : index;
|
|
2965
|
+
}
|
|
2966
|
+
{
|
|
2967
|
+
const box = ui.bar.open;
|
|
2968
|
+
pins.layer = el('div', 'bar-badges');
|
|
2969
|
+
box.append(pins.layer);
|
|
2970
|
+
pins.more = button('sm-tb-room is-more', '', (event) => { event.stopPropagation(); if (pins.moreCard && !pins.moreCard.hidden) pinsCloseMore(); else pinsDrawMore(); });
|
|
2971
|
+
pins.more.hidden = true;
|
|
2972
|
+
pins.more.setAttribute('aria-haspopup', 'menu');
|
|
2973
|
+
box.append(pins.more);
|
|
2974
|
+
const bar = box.closest('.sm-topbar');
|
|
2975
|
+
// While the bar is arranged a room is carried, not followed; a right-click between the rooms
|
|
2976
|
+
// puts the bar back, as a second right-click on a room does.
|
|
2977
|
+
box.addEventListener('click', (event) => {
|
|
2978
|
+
if (!pins.editing || event.target.closest?.('.bar-badge') || !event.target.closest?.('[data-bar-item]')) return;
|
|
2979
|
+
event.preventDefault();
|
|
2980
|
+
event.stopPropagation();
|
|
2981
|
+
}, true);
|
|
2982
|
+
box.addEventListener('contextmenu', (event) => { if (pins.editing && !event.pinHandled) { event.preventDefault(); pinsEdit(false); } });
|
|
2983
|
+
// A press anywhere but the bar and its card puts the bar back; Escape too.
|
|
2984
|
+
document.addEventListener('pointerdown', (event) => {
|
|
2985
|
+
if (pins.menu && !pins.menu.contains(event.target)) pinsCloseMenu();
|
|
2986
|
+
if (pins.moreCard && !pins.moreCard.hidden && !pins.moreCard.contains(event.target) && !pins.more.contains(event.target)) pinsCloseMore();
|
|
2987
|
+
if (pins.editing && !box.contains(event.target) && !pins.card?.contains(event.target)) pinsEdit(false);
|
|
2988
|
+
}, true);
|
|
2989
|
+
document.addEventListener('keydown', (event) => {
|
|
2990
|
+
if (event.key !== 'Escape') return;
|
|
2991
|
+
if (pins.menu) { pinsCloseMenu(); return; }
|
|
2992
|
+
if (pins.moreCard && !pins.moreCard.hidden) { pinsCloseMore(); return; }
|
|
2993
|
+
if (pins.editing) { event.preventDefault(); pinsEdit(false); }
|
|
2994
|
+
});
|
|
2995
|
+
window.addEventListener('resize', () => { pinsCloseMenu(); if (data) drawPins(); });
|
|
2996
|
+
window.addEventListener('scroll', () => pinsCloseMenu(), { passive: true });
|
|
2997
|
+
// The drop: anywhere on the bar, at the place nearest the pointer.
|
|
2998
|
+
bar.addEventListener('dragover', (event) => {
|
|
2999
|
+
if (!pins.dropping || !event.dataTransfer?.types.includes(PIN_TYPE)) return;
|
|
3000
|
+
event.preventDefault();
|
|
3001
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
3002
|
+
const at = pinsIndexAt(event.clientX);
|
|
3003
|
+
if (at !== pins.slotAt) { pins.slotAt = at; bar.classList.add('is-drop'); pinsPlace(pinsShown().filter((id) => id !== pins.dropping)); }
|
|
3004
|
+
});
|
|
3005
|
+
bar.addEventListener('dragleave', (event) => {
|
|
3006
|
+
if (!pins.dropping || bar.contains(event.relatedTarget)) return;
|
|
3007
|
+
pins.slotAt = null;
|
|
3008
|
+
bar.classList.remove('is-drop');
|
|
3009
|
+
pinsPlace(pinsShown().filter((id) => id !== pins.dropping));
|
|
3010
|
+
});
|
|
3011
|
+
bar.addEventListener('drop', (event) => {
|
|
3012
|
+
const id = event.dataTransfer?.getData(PIN_TYPE);
|
|
3013
|
+
if (!id) return;
|
|
3014
|
+
event.preventDefault();
|
|
3015
|
+
const at = pins.slotAt ?? pinsIndexAt(event.clientX);
|
|
3016
|
+
bar.classList.remove('is-drop');
|
|
3017
|
+
pinsDropEnd();
|
|
3018
|
+
pinAdd(id, at);
|
|
3019
|
+
});
|
|
3020
|
+
// Another tab arranged the bar: this one follows.
|
|
3021
|
+
window.addEventListener('storage', (event) => { if (event.key === PIN_KEY) { pins.saved = null; if (data) drawPins(); } });
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
ui.bar.links.addEventListener('click', () => { if ((data?.enlaces ?? []).length) { if (!ui.linksSheet.open) ui.linksSheet.showModal(); } else openLinkSheet(); });
|
|
3025
|
+
ui.bar.update.addEventListener('click', () => openSettings('release'));
|
|
3026
|
+
// RELOAD, as a room reloads (app.js, reloadWithGrace): the curtain of Home's own ground with MADRE's
|
|
3027
|
+
// loader; the page goes with the share it reached and the new one fills the rest (salas-mac.html).
|
|
3028
|
+
function reloadWithGrace() {
|
|
3029
|
+
if (motionStill() || document.querySelector('.reload-curtain')) { location.reload(); return; }
|
|
3030
|
+
const curtain = el('div', 'reload-curtain');
|
|
3031
|
+
const loader = makeLoader({ progress: 0.04 });
|
|
3032
|
+
curtain.append(loader);
|
|
3033
|
+
showCurtain(curtain);
|
|
3034
|
+
creep(loader, { to: 0.34, rate: 0.09 });
|
|
3035
|
+
setTimeout(() => {
|
|
3036
|
+
try { sessionStorage.setItem('madre.curtain', loader.dataset.p ?? '0.3'); } catch { /* the new page simply arrives */ }
|
|
3037
|
+
location.reload();
|
|
3038
|
+
}, 900);
|
|
3039
|
+
}
|
|
3040
|
+
ui.bar.reload.addEventListener('click', reloadWithGrace);
|
|
3041
|
+
|
|
3042
|
+
// From Home into a project, and back (2026-10-10): the page settles back under a veil of its own
|
|
3043
|
+
// ground and goes (styles.css, madre-leaving); the room lifts the veil as it arrives. Every way in
|
|
3044
|
+
// is a link — a card, a key on the bar, a tag, the Coordinator, the map — so a plain press on any
|
|
3045
|
+
// link to a MADRE page goes through here; ⌘-click, a new tab or a page elsewhere is left alone.
|
|
3046
|
+
function leaveTo(url) {
|
|
3047
|
+
if (motionStill()) { location.href = url; return; }
|
|
3048
|
+
root.classList.add('madre-leaving');
|
|
3049
|
+
setTimeout(() => { location.href = url; }, 200);
|
|
3050
|
+
setTimeout(() => root.classList.remove('madre-leaving'), 2500);
|
|
3051
|
+
}
|
|
3052
|
+
window.addEventListener('pageshow', () => root.classList.remove('madre-leaving'));
|
|
3053
|
+
document.addEventListener('click', (event) => {
|
|
3054
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
3055
|
+
const link = event.target.closest?.('a[href]');
|
|
3056
|
+
if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return;
|
|
3057
|
+
let to = null;
|
|
3058
|
+
try { to = new URL(link.href, location.href); } catch { return; }
|
|
3059
|
+
if (to.protocol !== 'http:' || !/^(127\.0\.0\.1|localhost)$/.test(to.hostname)) return;
|
|
3060
|
+
if (to.origin === location.origin && to.pathname === location.pathname) return;
|
|
3061
|
+
event.preventDefault();
|
|
3062
|
+
leaveTo(to.href);
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
// What just happened, in a line (the room's notice: styles.css, .toast), at the foot of the screen
|
|
3066
|
+
// and over any open sheet, for a few seconds.
|
|
3067
|
+
let toastNode = null;
|
|
3068
|
+
let toastTimer = 0;
|
|
3069
|
+
function toast(words, tone = 'done') {
|
|
3070
|
+
toastNode?.remove();
|
|
3071
|
+
const node = el('div', 'toast sm-toast');
|
|
3072
|
+
node.setAttribute('role', 'status');
|
|
3073
|
+
node.dataset.tone = tone;
|
|
3074
|
+
node.append(el('i', 'toast-dot'), el('span', 'toast-text', words));
|
|
3075
|
+
document.body.append(node);
|
|
3076
|
+
if (typeof node.showPopover === 'function') { node.popover = 'manual'; node.showPopover(); }
|
|
3077
|
+
toastNode = node;
|
|
3078
|
+
clearTimeout(toastTimer);
|
|
3079
|
+
toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, 5000);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
// ---- THE LOBBY (2026-10-10). The first screen of MADRE: every project on this Mac, BISHOP above
|
|
3083
|
+
// them, and the way to add more. A bare `madre` opens here; `madre start` in a folder still opens
|
|
3084
|
+
// that project directly.
|
|
3085
|
+
|
|
3086
|
+
const post = (url, payload) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
|
|
3087
|
+
.then(async (response) => ({ ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }))
|
|
3088
|
+
.catch(() => ({ ok: false, status: 0, body: {} }));
|
|
3089
|
+
function mark(id, size = 40) {
|
|
3090
|
+
const brand = brandOf(id);
|
|
3091
|
+
const node = el('span', 'sm-mark');
|
|
3092
|
+
node.style.setProperty('--b', brand.solid ?? brand.color);
|
|
3093
|
+
node.style.setProperty('--b-ink', brand.ink);
|
|
3094
|
+
node.style.width = node.style.height = `${size}px`;
|
|
3095
|
+
node.innerHTML = brand.mark(); // trusted: our own SVG strings from brands.js
|
|
3096
|
+
return node;
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// BISHOP, at the head of the lobby: the super agent that reads across the projects, signs links
|
|
3100
|
+
// between them and carries orders from one to another. It is talked to in its own room (MADRE's
|
|
3101
|
+
// folder, not a project), here or in a new tab; asleep, it is woken first. What it waits for is
|
|
3102
|
+
// read from that room. Under it, the last work of every project, summarised by MADRE.
|
|
3103
|
+
ui.bishop.mark.replaceWith(mark('bishop', 44));
|
|
3104
|
+
function drawBishop(room) {
|
|
3105
|
+
const b = ui.bishop;
|
|
3106
|
+
const glance = room?.glance ?? {};
|
|
3107
|
+
const state = room?.open ? stateOf(room) : { key: 'asleep', words: t('Asleep') };
|
|
3108
|
+
// Only its state: its size is the person's (applyLayout), and a class written whole took it back.
|
|
3109
|
+
for (const one of [...b.box.classList]) if (one.startsWith('is-')) b.box.classList.remove(one);
|
|
3110
|
+
b.box.classList.add(`is-${state.key}`);
|
|
3111
|
+
b.state.querySelector('span').textContent = state.words;
|
|
3112
|
+
const waking = busy.get(data?.salas) === 'starting';
|
|
3113
|
+
for (const way of [b.go, b.tab]) way.setAttribute('aria-disabled', String(waking));
|
|
3114
|
+
b.go.textContent = waking ? t('Waking…') : t('Talk to the Coordinator');
|
|
3115
|
+
syncBishopLinks();
|
|
3116
|
+
// What it waits for, each one a door into its room.
|
|
3117
|
+
b.asks.replaceChildren();
|
|
3118
|
+
b.asks.hidden = !glance.waiting?.length;
|
|
3119
|
+
if (!b.asks.hidden) {
|
|
3120
|
+
b.asks.append(el('span', 'sm-hint', t('Waiting for you')));
|
|
3121
|
+
for (const ask of glance.waiting.slice(0, 3)) b.asks.append(button('sm-chip ask', askLine(ask), () => openBishop()));
|
|
3122
|
+
}
|
|
3123
|
+
b.error.hidden = !failed.has(data?.salas);
|
|
3124
|
+
b.error.textContent = failed.get(data?.salas) ?? '';
|
|
3125
|
+
}
|
|
3126
|
+
// Both ways into BISHOP's room are links, so ⌘-click and «open in a new tab» do what they always
|
|
3127
|
+
// do. Asleep, it is woken first; a new tab is then opened at the press, while the press is still
|
|
3128
|
+
// the person's, and sent on when the room answers: a tab opened after waiting is blocked.
|
|
3129
|
+
function bishopAddress() {
|
|
3130
|
+
const room = data?.rooms.find((one) => one.id === data.salas);
|
|
3131
|
+
return room?.open ? addressOf(room) : null;
|
|
3132
|
+
}
|
|
3133
|
+
function syncBishopLinks() {
|
|
3134
|
+
const address = bishopAddress() ?? '/salas';
|
|
3135
|
+
ui.bishop.go.href = address;
|
|
3136
|
+
ui.bishop.tab.href = address;
|
|
3137
|
+
}
|
|
3138
|
+
function wakeBishop({ newTab = false } = {}) {
|
|
3139
|
+
const tab = newTab ? window.open('about:blank', '_blank') : null;
|
|
3140
|
+
if (tab) tab.opener = null;
|
|
3141
|
+
void startRoom({ id: data.salas }).then((answer) => {
|
|
3142
|
+
if (!answer.ok || !answer.body.port) { tab?.close(); return; }
|
|
3143
|
+
const address = `http://127.0.0.1:${answer.body.port}/`;
|
|
3144
|
+
if (tab) tab.location.href = address;
|
|
3145
|
+
else leaveTo(address);
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
function openBishop({ newTab = false } = {}) {
|
|
3149
|
+
if (!data) return;
|
|
3150
|
+
const address = bishopAddress();
|
|
3151
|
+
if (!address) { wakeBishop({ newTab }); return; }
|
|
3152
|
+
if (newTab) window.open(address, '_blank', 'noopener');
|
|
3153
|
+
else leaveTo(address);
|
|
3154
|
+
}
|
|
3155
|
+
for (const [way, alwaysNew] of [[ui.bishop.go, false], [ui.bishop.tab, true]]) {
|
|
3156
|
+
way.addEventListener('click', (event) => {
|
|
3157
|
+
if (way.getAttribute('aria-disabled') === 'true' || !data) { event.preventDefault(); return; }
|
|
3158
|
+
// Awake, the link goes where it points.
|
|
3159
|
+
if (bishopAddress()) return;
|
|
3160
|
+
event.preventDefault();
|
|
3161
|
+
wakeBishop({ newTab: alwaysNew || event.metaKey || event.ctrlKey || event.shiftKey });
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// ---- Choosing folders (2026-10-10): tiles, a page at a time. Suggestions first (folders that look
|
|
3166
|
+
// like projects in the places people keep them), or the folders of the home folder, walked one
|
|
3167
|
+
// level at a time; a search narrows either. Several can be chosen; a folder already in Home says
|
|
3168
|
+
// so and is not offered again. The sheet never runs past the screen: the tiles take as many rows
|
|
3169
|
+
// as it holds, and ‹ › turn the page, so nothing scrolls inside it.
|
|
3170
|
+
const SEARCH_GLYPH = '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="9" cy="9" r="5.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M13.2 13.2 17 17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
3171
|
+
const PAGE_BACK = '<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.9" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
3172
|
+
const PAGE_ON = '<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.9" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
3173
|
+
const FOLD_TICK = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 8.4 6.8 11 12 5.4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
3174
|
+
const FOLD_ROWS = 5;
|
|
3175
|
+
|
|
3176
|
+
// The tiles and their pages. How many rows is measured, not guessed: from five down to two, the
|
|
3177
|
+
// most that keep the sheet inside the screen, measured again when the screen changes size. The
|
|
3178
|
+
// grid keeps the height of its rows whatever the page holds, so turning a page or searching never
|
|
3179
|
+
// moves the keys under it; a list that does not change (`snug`) takes only the rows it fills.
|
|
3180
|
+
function foldPages({ snug = false } = {}) {
|
|
3181
|
+
const grid = el('div', 'sm-fold-grid');
|
|
3182
|
+
const pager = el('div', 'sm-pager');
|
|
3183
|
+
const back = button('sm-icon-key sm-page', null, () => turn(-1));
|
|
3184
|
+
const on = button('sm-icon-key sm-page', null, () => turn(1));
|
|
3185
|
+
back.innerHTML = PAGE_BACK; // trusted: our own SVG strings
|
|
3186
|
+
on.innerHTML = PAGE_ON;
|
|
3187
|
+
back.setAttribute('aria-label', t('Previous'));
|
|
3188
|
+
on.setAttribute('aria-label', t('Next'));
|
|
3189
|
+
const where = el('span', 'sm-page-where');
|
|
3190
|
+
pager.append(back, where, on);
|
|
3191
|
+
let items = [];
|
|
3192
|
+
let tile = () => null;
|
|
3193
|
+
let none = null;
|
|
3194
|
+
let page = 0;
|
|
3195
|
+
let rows = 4;
|
|
3196
|
+
let cols = 3;
|
|
3197
|
+
let seen = '';
|
|
3198
|
+
const per = () => rows * cols;
|
|
3199
|
+
const paint = () => {
|
|
3200
|
+
const pages = Math.max(1, Math.ceil(items.length / per()));
|
|
3201
|
+
page = Math.max(0, Math.min(page, pages - 1));
|
|
3202
|
+
grid.style.setProperty('--cols', String(cols));
|
|
3203
|
+
grid.style.setProperty('--rows', String(snug ? Math.max(1, Math.min(rows, Math.ceil(items.length / cols))) : rows));
|
|
3204
|
+
grid.replaceChildren(...items.slice(page * per(), (page + 1) * per()).map((item) => tile(item)));
|
|
3205
|
+
if (!items.length && none) grid.append(el('p', 'sm-fold-none', none()));
|
|
3206
|
+
pager.hidden = pages < 2;
|
|
3207
|
+
where.textContent = t('{n} of {total}', { n: page + 1, total: pages });
|
|
3208
|
+
back.disabled = page === 0;
|
|
3209
|
+
on.disabled = page >= pages - 1;
|
|
3210
|
+
};
|
|
3211
|
+
const turn = (step) => {
|
|
3212
|
+
page += step;
|
|
3213
|
+
paint();
|
|
3214
|
+
// The key pressed stays under the finger; at the last page it is the other one.
|
|
3215
|
+
if (document.activeElement === document.body || document.activeElement?.disabled) (step > 0 ? back : on).focus({ preventScroll: true });
|
|
3216
|
+
};
|
|
3217
|
+
const fit = () => {
|
|
3218
|
+
const layer = grid.closest('dialog');
|
|
3219
|
+
if (!grid.isConnected || !layer?.open || !grid.clientWidth) return;
|
|
3220
|
+
const key = `${grid.clientWidth}x${layer.clientHeight}`;
|
|
3221
|
+
if (key === seen) return;
|
|
3222
|
+
seen = key;
|
|
3223
|
+
cols = grid.clientWidth >= 540 ? 3 : grid.clientWidth >= 340 ? 2 : 1;
|
|
3224
|
+
rows = FOLD_ROWS;
|
|
3225
|
+
paint();
|
|
3226
|
+
while (rows > 2 && layer.scrollHeight > layer.clientHeight + 1) { rows -= 1; paint(); }
|
|
3227
|
+
};
|
|
3228
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => requestAnimationFrame(fit)).observe(grid);
|
|
3229
|
+
const resized = () => { if (!grid.isConnected) { window.removeEventListener('resize', resized); return; } seen = ''; requestAnimationFrame(fit); };
|
|
3230
|
+
window.addEventListener('resize', resized);
|
|
3231
|
+
return {
|
|
3232
|
+
grid,
|
|
3233
|
+
pager,
|
|
3234
|
+
show(list, { draw, empty = null, keepPage = false }) {
|
|
3235
|
+
items = list;
|
|
3236
|
+
tile = draw;
|
|
3237
|
+
none = empty;
|
|
3238
|
+
if (!keepPage) page = 0;
|
|
3239
|
+
paint();
|
|
3240
|
+
},
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// One folder as a tile: its mark (green when it looks like a project), its name and where it is.
|
|
3245
|
+
// Chosen, the tile takes the accent and its mark a tick; a folder already in Home is only shown.
|
|
3246
|
+
function foldTile({ name, path, note = null, project = true, known = false, checked = false, onToggle = null, enter = null }) {
|
|
3247
|
+
const item = el('label', `sm-fold${project ? ' is-project' : ''}${known ? ' known' : ''}${checked ? ' is-on' : ''}`);
|
|
3248
|
+
item.title = homeWord(path);
|
|
3249
|
+
const box = el('input');
|
|
3250
|
+
box.type = 'checkbox';
|
|
3251
|
+
box.checked = checked;
|
|
3252
|
+
box.disabled = known;
|
|
3253
|
+
box.addEventListener('change', () => { item.classList.toggle('is-on', box.checked); onToggle?.(box.checked); });
|
|
3254
|
+
const glyph = el('span', 'sm-fold-glyph');
|
|
3255
|
+
glyph.innerHTML = FOLDER_GLYPH; // trusted: our own SVG strings
|
|
3256
|
+
const tick = el('i', 'sm-fold-tick');
|
|
3257
|
+
tick.innerHTML = FOLD_TICK;
|
|
3258
|
+
glyph.append(tick);
|
|
3259
|
+
const words = el('span', 'sm-fold-words');
|
|
3260
|
+
words.append(el('b', null, name), el('small', null, note ?? homeWord(path)));
|
|
3261
|
+
item.append(box, glyph, words);
|
|
3262
|
+
if (enter) {
|
|
3263
|
+
const into = button('sm-into', null, (event) => { event.preventDefault(); enter(); });
|
|
3264
|
+
into.innerHTML = PAGE_ON;
|
|
3265
|
+
into.setAttribute('aria-label', t('Open {name}', { name }));
|
|
3266
|
+
into.title = t('Open {name}', { name });
|
|
3267
|
+
item.append(into);
|
|
3268
|
+
}
|
|
3269
|
+
return item;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {}) {
|
|
3273
|
+
const chosen = new Map(); // path → name
|
|
3274
|
+
const node = el('div', 'sm-picker');
|
|
3275
|
+
const pages = foldPages();
|
|
3276
|
+
let source = 'found'; // found · browse
|
|
3277
|
+
let found = null; // the suggestions, once read
|
|
3278
|
+
let here = null; // the folder being walked, as /api/salas/folders answered
|
|
3279
|
+
let query = '';
|
|
3280
|
+
let touched = false; // the person chose the list: an empty one is not left on its own
|
|
3281
|
+
const head = el('div', 'sm-picker-head');
|
|
3282
|
+
let seg = el('span');
|
|
3283
|
+
const search = el('label', 'settings-search sm-picker-search');
|
|
3284
|
+
search.innerHTML = SEARCH_GLYPH; // trusted: our own SVG string
|
|
3285
|
+
const input = el('input');
|
|
3286
|
+
input.type = 'search';
|
|
3287
|
+
input.autocomplete = 'off';
|
|
3288
|
+
input.spellcheck = false;
|
|
3289
|
+
input.placeholder = t('Search a folder…');
|
|
3290
|
+
input.setAttribute('aria-label', t('Search a folder…'));
|
|
3291
|
+
input.addEventListener('input', () => { query = input.value.trim().toLocaleLowerCase(language); draw(); });
|
|
3292
|
+
search.append(input);
|
|
3293
|
+
head.append(seg, search);
|
|
3294
|
+
const line = el('div', 'sm-picker-line');
|
|
3295
|
+
const footer = el('div', 'sm-picker-foot');
|
|
3296
|
+
const count = el('span', 'sm-picked');
|
|
3297
|
+
const addButton = withAdd ? button('sm-btn primary', t('Add'), () => void add()) : null;
|
|
3298
|
+
const error = el('p', 'error');
|
|
3299
|
+
footer.append(pages.pager, count);
|
|
3300
|
+
if (addButton) footer.append(addButton);
|
|
3301
|
+
node.append(head, line, pages.grid, footer, error);
|
|
3302
|
+
const sync = () => {
|
|
3303
|
+
count.textContent = chosen.size ? (chosen.size === 1 ? t('1 folder chosen') : t('{n} folders chosen', { n: chosen.size })) : t('Choose one or more folders');
|
|
3304
|
+
if (addButton) {
|
|
3305
|
+
addButton.disabled = !chosen.size;
|
|
3306
|
+
addButton.textContent = chosen.size > 1 ? t('Add {n} projects', { n: chosen.size }) : t('Add project');
|
|
3307
|
+
}
|
|
3308
|
+
onChange?.(chosen.size);
|
|
3309
|
+
};
|
|
3310
|
+
const matches = (one) => !query || one.name.toLocaleLowerCase(language).includes(query) || homeWord(one.path).toLocaleLowerCase(language).includes(query);
|
|
3311
|
+
const tile = (one) => foldTile({
|
|
3312
|
+
...one,
|
|
3313
|
+
note: one.known ? t('In Home') : null,
|
|
3314
|
+
checked: chosen.has(one.path),
|
|
3315
|
+
onToggle: (on) => { if (on) chosen.set(one.path, one.name); else chosen.delete(one.path); sync(); },
|
|
3316
|
+
});
|
|
3317
|
+
function draw({ keepPage = false } = {}) {
|
|
3318
|
+
const fresh = customSeg([['found', t('Found on this Mac')], ['browse', t('Your folders')]], source, (value) => {
|
|
3319
|
+
touched = true;
|
|
3320
|
+
source = value;
|
|
3321
|
+
draw();
|
|
3322
|
+
if (value === 'browse' && !here) void walk();
|
|
3323
|
+
}, 'two');
|
|
3324
|
+
seg.replaceWith(fresh);
|
|
3325
|
+
seg = fresh;
|
|
3326
|
+
const noMatch = () => t('No folder matches «{query}».', { query: input.value.trim() });
|
|
3327
|
+
if (source === 'found') {
|
|
3328
|
+
line.replaceChildren(el('span', 'sm-picker-note', found ? t('Folders that look like projects, in the places people keep them.') : t('Looking…')));
|
|
3329
|
+
pages.show(found ? found.filter(matches) : [], {
|
|
3330
|
+
draw: tile,
|
|
3331
|
+
empty: () => (!found ? t('Looking…') : query ? noMatch() : t('Nothing that looks like a project in the usual places. Look for it in Your folders.')),
|
|
3332
|
+
keepPage,
|
|
3333
|
+
});
|
|
3334
|
+
return;
|
|
3335
|
+
}
|
|
3336
|
+
line.replaceChildren();
|
|
3337
|
+
if (here?.parent) {
|
|
3338
|
+
const up = button('sm-btn ghost small sm-up', null, () => void walk(here.parent));
|
|
3339
|
+
up.innerHTML = PAGE_BACK; // trusted: our own SVG string
|
|
3340
|
+
up.append(el('span', null, t('Up')));
|
|
3341
|
+
line.append(up);
|
|
3342
|
+
}
|
|
3343
|
+
line.append(el('span', 'sm-where', here?.path ? homeWord(here.path) : t('Looking…')));
|
|
3344
|
+
const list = here?.entries
|
|
3345
|
+
? [...(here.path !== here.home ? [{ name: t('This folder'), path: here.path, project: false }] : []), ...here.entries.map((one) => ({ ...one, enter: () => void walk(one.path) }))]
|
|
3346
|
+
: [];
|
|
3347
|
+
pages.show(list.filter(matches), {
|
|
3348
|
+
draw: tile,
|
|
3349
|
+
empty: () => (!here ? t('Looking…') : !here.entries ? (here.error ?? t('That folder is not there.')) : query ? noMatch() : t('No folders inside.')),
|
|
3350
|
+
keepPage,
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
async function loadFound() {
|
|
3354
|
+
const answer = await fetch('/api/salas/suggestions', { cache: 'no-store' }).then((response) => response.json()).catch(() => ({ suggestions: [] }));
|
|
3355
|
+
found = answer.suggestions ?? [];
|
|
3356
|
+
// Nothing found and nothing chosen yet: the folders of the home folder, where it must be.
|
|
3357
|
+
if (!found.length && !touched && source === 'found') { source = 'browse'; if (!here) void walk(); }
|
|
3358
|
+
draw({ keepPage: true });
|
|
3359
|
+
}
|
|
3360
|
+
async function walk(path = null) {
|
|
3361
|
+
const answer = await fetch(`/api/salas/folders${path ? `?path=${encodeURIComponent(path)}` : ''}`, { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
|
|
3362
|
+
here = answer ?? { error: t('That folder is not there.') };
|
|
3363
|
+
if (path !== null) { query = ''; input.value = ''; }
|
|
3364
|
+
if (source === 'browse') draw();
|
|
3365
|
+
}
|
|
3366
|
+
async function add() {
|
|
3367
|
+
error.textContent = '';
|
|
3368
|
+
if (addButton) addButton.disabled = true;
|
|
3369
|
+
const answer = await post('/api/salas/projects', { roots: [...chosen.keys()] });
|
|
3370
|
+
if (addButton) addButton.disabled = false;
|
|
3371
|
+
if (!answer.ok) { error.textContent = answer.body.refused?.[0]?.error ?? answer.body.error ?? t('They could not be added.'); return false; }
|
|
3372
|
+
if (answer.body.refused?.length) error.textContent = answer.body.refused.map((one) => `${homeWord(one.root)}: ${one.error}`).join(' · ');
|
|
3373
|
+
chosen.clear();
|
|
3374
|
+
sync();
|
|
3375
|
+
await refresh();
|
|
3376
|
+
const added = answer.body.added ?? [];
|
|
3377
|
+
if (added.length) toast(added.length === 1 ? t('{name} was added to Home.', { name: added[0].name }) : t('{n} projects were added to Home.', { n: added.length }));
|
|
3378
|
+
onAdded?.(added);
|
|
3379
|
+
void loadFound();
|
|
3380
|
+
if (here) void walk(here.path === here.home ? null : here.path);
|
|
3381
|
+
return true;
|
|
3382
|
+
}
|
|
3383
|
+
draw();
|
|
3384
|
+
void loadFound();
|
|
3385
|
+
sync();
|
|
3386
|
+
return { node, add, count: () => chosen.size };
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// The room's close (styles.css, .surface-modes · .x-close): a sunk track holding one key, the
|
|
3390
|
+
// cross that turns as the pointer comes to it.
|
|
3391
|
+
function closeKey(onClick) {
|
|
3392
|
+
const track = el('span', 'surface-modes');
|
|
3393
|
+
const key = button('surface-mode close x-close x-only', null, onClick);
|
|
3394
|
+
key.setAttribute('aria-label', t('Close'));
|
|
3395
|
+
const icon = el('span', 'x-icon');
|
|
3396
|
+
icon.innerHTML = '<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>';
|
|
3397
|
+
key.append(icon);
|
|
3398
|
+
track.append(key);
|
|
3399
|
+
return track;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
// «Add a project», on its own: the picker in a sheet, under the welcome's scene of the projects.
|
|
3403
|
+
let addSheet = null;
|
|
3404
|
+
function openAddSheet() {
|
|
3405
|
+
if (addSheet) return;
|
|
3406
|
+
addSheet = el('dialog', 'sm-sheet sm-wide sm-add');
|
|
3407
|
+
const inner = el('div', 'sm-sheet-body');
|
|
3408
|
+
const head = el('header', 'sm-sheet-head');
|
|
3409
|
+
head.append(el('h3', null, t('Add a project')), closeKey(() => addSheet.close()));
|
|
3410
|
+
// The welcome's own scene over it: the folders dropping into Home.
|
|
3411
|
+
inner.append(head, wizardScene('projects'), el('p', 'hint', 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.')));
|
|
3412
|
+
inner.append(projectPicker({ onAdded: () => addSheet?.close() }).node);
|
|
3413
|
+
addSheet.append(inner);
|
|
3414
|
+
document.body.append(addSheet);
|
|
3415
|
+
addSheet.addEventListener('close', () => { addSheet.remove(); addSheet = null; });
|
|
3416
|
+
addSheet.showModal();
|
|
3417
|
+
}
|
|
3418
|
+
ui.add.addEventListener('click', openAddSheet);
|
|
3419
|
+
|
|
3420
|
+
// The agents of this computer, with everything a connection needs in one place: which account
|
|
3421
|
+
// each one signs in with, the doors it has, the box where a pasted key goes, the address a
|
|
3422
|
+
// sign-in opened and the code that page gives back. Home used to show a name, a lamp and one
|
|
3423
|
+
// button — the project's panel had the rest, so the same errand was two different screens.
|
|
3424
|
+
// Kept current while it is on screen: polled, and told by the room's events how it is going.
|
|
3425
|
+
// `compact` is the welcome's first step: there, a connected agent is done, and a button to change
|
|
3426
|
+
// the account it signs in with is a question nobody asked on their first minute.
|
|
3427
|
+
// Who makes each agent, said under its name as the project's AJUSTES says it.
|
|
3428
|
+
const VENDOR_WORDS = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'OpenCode' };
|
|
3429
|
+
|
|
3430
|
+
// OpenCode's providers in Home, the same block as the project's AJUSTES › Agents › OpenCode: a line
|
|
3431
|
+
// per provider with a key or an account and its switch for the chat, one row to add another, and
|
|
3432
|
+
// the model it answers with by default. Asked of the server once a minute at most, because asking
|
|
3433
|
+
// runs `opencode models`; a change asks again at once.
|
|
3434
|
+
const openCodeProviders = (() => {
|
|
3435
|
+
let reading = null;
|
|
3436
|
+
let at = 0;
|
|
3437
|
+
let host = null;
|
|
3438
|
+
const fetchNow = async () => {
|
|
3439
|
+
reading = await fetch('/api/agents/opencode/providers', { cache: 'no-store' }).then((response) => response.json().then((json) => ({ ok: response.ok, json }))).catch(() => ({ ok: false, json: {} }));
|
|
3440
|
+
at = Date.now();
|
|
3441
|
+
paintInto();
|
|
3442
|
+
};
|
|
3443
|
+
const sendKey = (path, payload) => fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
3444
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
3445
|
+
const switchFor = (on, change, label) => {
|
|
3446
|
+
const wrap = el('label', 'settings-switch');
|
|
3447
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = on; box.setAttribute('aria-label', label);
|
|
3448
|
+
box.addEventListener('change', () => void change(box.checked, box));
|
|
3449
|
+
wrap.append(box, el('i'));
|
|
3450
|
+
return wrap;
|
|
3451
|
+
};
|
|
3452
|
+
function paintInto() {
|
|
3453
|
+
if (!host?.isConnected) return;
|
|
3454
|
+
host.replaceChildren(el('b', 'sm-oc-title', t('Providers')));
|
|
3455
|
+
if (!reading) { host.append(el('small', 'sm-agent-note', t('Reading OpenCode\'s providers…'))); return; }
|
|
3456
|
+
if (!reading.ok) { host.append(el('small', 'sm-agent-note', reading.json.error ?? t('OpenCode\'s providers could not be read.'))); return; }
|
|
3457
|
+
const providers = reading.json.providers ?? [];
|
|
3458
|
+
const hidden = new Set(providers.filter((one) => one.hidden).map((one) => one.id));
|
|
3459
|
+
for (const provider of providers.filter((one) => one.via)) {
|
|
3460
|
+
const row = el('div', 'sm-oc-row');
|
|
3461
|
+
const words = el('span', 'sm-agent-words');
|
|
3462
|
+
const status = provider.via === 'own' ? t('Included with OpenCode · {n} models', { n: provider.models.length })
|
|
3463
|
+
: provider.via === 'api' ? t('Connected with a key · {n} models', { n: provider.models.length })
|
|
3464
|
+
: provider.via === 'oauth' ? t('Connected with its account · {n} models', { n: provider.models.length })
|
|
3465
|
+
: t('Connected · {n} models', { n: provider.models.length });
|
|
3466
|
+
words.append(el('b', null, provider.label), el('small', 'sm-agent-note', status));
|
|
3467
|
+
const controls = el('span', 'sm-oc-controls');
|
|
3468
|
+
if (provider.via === 'api') {
|
|
3469
|
+
const remove = button('sm-btn ghost small', t('Remove the key'), async () => {
|
|
3470
|
+
if (remove.dataset.sure !== '1') { remove.dataset.sure = '1'; remove.textContent = t('Remove it?'); return; }
|
|
3471
|
+
remove.disabled = true;
|
|
3472
|
+
const answer = await sendKey('/api/agents/opencode/providers/remove', { provider: provider.id });
|
|
3473
|
+
if (!answer.ok) { remove.disabled = false; remove.textContent = answer.body.error ?? t('The key was not removed.'); return; }
|
|
3474
|
+
void fetchNow();
|
|
3475
|
+
});
|
|
3476
|
+
controls.append(remove);
|
|
3477
|
+
}
|
|
3478
|
+
const label = el('span', 'sm-oc-shown', t('In the chat'));
|
|
3479
|
+
controls.append(label, switchFor(!provider.hidden, async (on, box) => {
|
|
3480
|
+
if (on) hidden.delete(provider.id); else hidden.add(provider.id);
|
|
3481
|
+
const answer = await sendKey('/api/settings', { opencode: { hidden: [...hidden] } });
|
|
3482
|
+
if (!answer.ok) { box.checked = !on; if (on) hidden.add(provider.id); else hidden.delete(provider.id); }
|
|
3483
|
+
}, t('Show {provider} in the chat', { provider: provider.label })));
|
|
3484
|
+
row.append(words, controls);
|
|
3485
|
+
host.append(row);
|
|
3486
|
+
}
|
|
3487
|
+
const waiting = providers.filter((one) => !one.via && one.takesKey);
|
|
3488
|
+
if (waiting.length) {
|
|
3489
|
+
const form = el('form', 'sm-agent-key-form sm-oc-add');
|
|
3490
|
+
const which = el('select');
|
|
3491
|
+
which.setAttribute('aria-label', t('Provider'));
|
|
3492
|
+
for (const provider of waiting) { const option = el('option', null, provider.label); option.value = provider.id; which.append(option); }
|
|
3493
|
+
const input = el('input');
|
|
3494
|
+
input.type = 'password'; input.autocomplete = 'off'; input.spellcheck = false;
|
|
3495
|
+
const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
|
|
3496
|
+
link.target = '_blank'; link.rel = 'noopener';
|
|
3497
|
+
const said = el('small', 'sm-agent-line');
|
|
3498
|
+
const sync = () => {
|
|
3499
|
+
const provider = waiting.find((one) => one.id === which.value);
|
|
3500
|
+
input.placeholder = t('{provider} API key', { provider: provider?.label ?? '' });
|
|
3501
|
+
link.hidden = !provider?.keyUrl;
|
|
3502
|
+
if (provider?.keyUrl) link.href = provider.keyUrl;
|
|
3503
|
+
};
|
|
3504
|
+
which.addEventListener('change', sync);
|
|
3505
|
+
sync();
|
|
3506
|
+
const save = el('button', 'sm-btn primary small', t('Add'));
|
|
3507
|
+
save.type = 'submit';
|
|
3508
|
+
form.append(which, input, save, link, said);
|
|
3509
|
+
form.addEventListener('submit', async (event) => {
|
|
3510
|
+
event.preventDefault();
|
|
3511
|
+
if (!input.value.trim()) { input.focus(); return; }
|
|
3512
|
+
save.disabled = true;
|
|
3513
|
+
said.textContent = t('Checking…');
|
|
3514
|
+
const answer = await sendKey('/api/agents/opencode/key', { key: input.value.trim(), provider: which.value });
|
|
3515
|
+
input.value = '';
|
|
3516
|
+
save.disabled = false;
|
|
3517
|
+
if (answer.ok) void fetchNow(); else said.textContent = answer.body.error ?? t('The key was not accepted.');
|
|
3518
|
+
});
|
|
3519
|
+
host.append(el('b', 'sm-oc-title', t('Add a provider')), form);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
return {
|
|
3523
|
+
node() {
|
|
3524
|
+
// The same node across the panel's repaints, so a key being typed is never thrown away.
|
|
3525
|
+
host ??= el('div', 'sm-oc');
|
|
3526
|
+
if (!reading || Date.now() - at > 60000) void fetchNow(); else paintInto();
|
|
3527
|
+
return host;
|
|
3528
|
+
},
|
|
3529
|
+
};
|
|
3530
|
+
})();
|
|
3531
|
+
|
|
3532
|
+
function agentsPanel({ onPaint = null, compact = false } = {}) {
|
|
3533
|
+
const list = el('div', 'sm-agents');
|
|
3534
|
+
const lastLine = new Map(); // agent → what its install or sign-in said last
|
|
3535
|
+
const openMore = new Set(); // agents whose connection detail is unfolded
|
|
3536
|
+
const toggleMore = (id) => { if (openMore.has(id)) openMore.delete(id); else openMore.add(id); };
|
|
3537
|
+
let live = null; // the sign-in running right now, as the server tells it
|
|
3538
|
+
let poll = 0;
|
|
3539
|
+
let stream = null;
|
|
3540
|
+
|
|
3541
|
+
// The second half of a browser sign-in: the address stays put, and the code that page hands
|
|
3542
|
+
// back is typed here. Without it the sign-in could only wait until its clock killed it.
|
|
3543
|
+
const askBlock = (agent) => {
|
|
3544
|
+
if (!live || live.agent !== agent.id) return null;
|
|
3545
|
+
const box = el('div', 'sm-agent-ask');
|
|
3546
|
+
if (live.url) {
|
|
3547
|
+
const open = el('a', 'sm-btn primary small', t('Open the sign-in page'));
|
|
3548
|
+
open.href = live.url;
|
|
3549
|
+
open.target = '_blank';
|
|
3550
|
+
open.rel = 'noopener';
|
|
3551
|
+
box.append(open);
|
|
3552
|
+
}
|
|
3553
|
+
if (live.asksForCode || live.awaitingCode) {
|
|
3554
|
+
const form = el('form', 'sm-agent-code');
|
|
3555
|
+
const input = el('input');
|
|
3556
|
+
input.type = 'text';
|
|
3557
|
+
input.autocomplete = 'off';
|
|
3558
|
+
input.spellcheck = false;
|
|
3559
|
+
input.placeholder = t('Paste the code the page gave you');
|
|
3560
|
+
const send = el('button', 'sm-btn primary small', t('Send the code'));
|
|
3561
|
+
send.type = 'submit';
|
|
3562
|
+
const said = el('small', 'sm-agent-line');
|
|
3563
|
+
form.append(input, send, said);
|
|
3564
|
+
form.addEventListener('submit', async (event) => {
|
|
3565
|
+
event.preventDefault();
|
|
3566
|
+
send.disabled = true;
|
|
3567
|
+
said.textContent = t('Checking…');
|
|
3568
|
+
const answer = await post(`/api/agents/${agent.id}/login/code`, { code: input.value });
|
|
3569
|
+
if (answer.ok) { input.value = ''; said.textContent = t('Sent. The sign-in finishes on its own.'); }
|
|
3570
|
+
else said.textContent = answer.body.error ?? t('The code was not accepted.');
|
|
3571
|
+
send.disabled = false;
|
|
3572
|
+
});
|
|
3573
|
+
box.append(form);
|
|
3574
|
+
}
|
|
3575
|
+
box.append(button('sm-btn ghost small', t('Cancel the sign-in'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/login/cancel`); void paint(); }));
|
|
3576
|
+
return box;
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3579
|
+
// Handing a key to a CLI, with the same promise the project's panel makes: it is written where
|
|
3580
|
+
// that CLI reads it, MADRE keeps no copy, and the CLI itself says whether it took.
|
|
3581
|
+
const keyForm = (agent) => {
|
|
3582
|
+
const plan = agent.key;
|
|
3583
|
+
const form = el('form', 'sm-agent-key-form');
|
|
3584
|
+
let select = null;
|
|
3585
|
+
if (plan.providers) {
|
|
3586
|
+
select = el('select');
|
|
3587
|
+
select.setAttribute('aria-label', t('Provider'));
|
|
3588
|
+
for (const provider of plan.providers) { const option = el('option', null, provider.label); option.value = provider.id; select.append(option); }
|
|
3589
|
+
form.append(select);
|
|
3590
|
+
}
|
|
3591
|
+
const input = el('input');
|
|
3592
|
+
input.type = 'password';
|
|
3593
|
+
input.autocomplete = 'off';
|
|
3594
|
+
input.spellcheck = false;
|
|
3595
|
+
input.placeholder = t(plan.label);
|
|
3596
|
+
const save = el('button', 'sm-btn primary small', t('Save the key'));
|
|
3597
|
+
save.type = 'submit';
|
|
3598
|
+
form.append(input, save);
|
|
3599
|
+
const said = el('small', 'sm-agent-line');
|
|
3600
|
+
const keyUrl = plan.providers ? (plan.providers[0]?.keyUrl ?? null) : plan.keyUrl;
|
|
3601
|
+
if (keyUrl) {
|
|
3602
|
+
const link = el('a', 'sm-agent-key-link', t('Where do I get one?'));
|
|
3603
|
+
link.href = keyUrl;
|
|
3604
|
+
link.target = '_blank';
|
|
3605
|
+
link.rel = 'noopener';
|
|
3606
|
+
form.append(link);
|
|
3607
|
+
select?.addEventListener('change', () => { const next = plan.providers.find((one) => one.id === select.value)?.keyUrl; if (next) link.href = next; });
|
|
3608
|
+
}
|
|
3609
|
+
form.append(said, el('small', 'sm-agent-note', t(plan.note)));
|
|
3610
|
+
form.addEventListener('submit', async (event) => {
|
|
3611
|
+
event.preventDefault();
|
|
3612
|
+
save.disabled = true;
|
|
3613
|
+
said.textContent = t('Checking…');
|
|
3614
|
+
const answer = await post(`/api/agents/${agent.id}/key`, { key: input.value, provider: select?.value ?? null });
|
|
3615
|
+
if (answer.ok) { input.value = ''; openMore.delete(agent.id); void paint(); }
|
|
3616
|
+
else { said.textContent = answer.body.error ?? t('The key was not accepted.'); save.disabled = false; }
|
|
3617
|
+
});
|
|
3618
|
+
return form;
|
|
3619
|
+
};
|
|
3620
|
+
|
|
3621
|
+
const paint = async () => {
|
|
3622
|
+
const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
|
|
3623
|
+
if (!answer?.agents) return;
|
|
3624
|
+
agentsSeen = answer;
|
|
3625
|
+
drawAttention();
|
|
3626
|
+
live = answer.login ?? null;
|
|
3627
|
+
let ready = 0;
|
|
3628
|
+
for (const agent of answer.agents.filter((one) => one.id !== 'madre')) if (agentState(agent, answer).ok) ready += 1;
|
|
3629
|
+
// A field being typed into is never yanked away by the poll: the panel redraws every few
|
|
3630
|
+
// seconds, and a half-pasted key or code would go with it.
|
|
3631
|
+
if (list.contains(document.activeElement) && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) { onPaint?.(ready, answer); return; }
|
|
3632
|
+
list.replaceChildren();
|
|
3633
|
+
for (const agent of answer.agents.filter((one) => one.id !== 'madre')) {
|
|
3634
|
+
const state = agentState(agent, answer);
|
|
3635
|
+
// The project's AJUSTES design (2026-10-13): one card per agent, folded to a line that says
|
|
3636
|
+
// who it is and whether it has a session. One that needs the person opens by itself.
|
|
3637
|
+
const unfolded = openMore.has(agent.id) || !state.ok || state.busyNow || (live && live.agent === agent.id);
|
|
3638
|
+
const item = el('section', `sm-agent${state.ok ? ' ok' : ''}${unfolded ? ' open' : ''}`);
|
|
3639
|
+
item.dataset.agent = agent.id;
|
|
3640
|
+
const head = button('sm-agent-head', null, () => { toggleMore(agent.id); if (!openMore.has(agent.id) && !state.ok) openMore.add(agent.id); void paint(); });
|
|
3641
|
+
head.setAttribute('aria-expanded', String(unfolded));
|
|
3642
|
+
const words = el('span', 'sm-agent-words');
|
|
3643
|
+
words.append(el('b', null, agent.label ?? agent.id), el('small', null, VENDOR_WORDS[agent.id] ?? ''));
|
|
3644
|
+
const chip = el('span', `sm-agent-chip session-lamp ${state.key}`);
|
|
3645
|
+
chip.append(el('span', null, state.words));
|
|
3646
|
+
head.append(mark(agent.id, 34), words, chip, el('span', 'sm-chevron'));
|
|
3647
|
+
item.append(head);
|
|
3648
|
+
const body = el('div', 'sm-agent-body');
|
|
3649
|
+
// Which account it is signed in with, as its own CLI reports it, and which one it asks for.
|
|
3650
|
+
const facts = el('div', 'sm-agent-facts');
|
|
3651
|
+
if (agent.detected && agent.version) facts.append(el('code', 'sm-agent-line', `${agent.version}${agent.path ? ` · ${agent.path}` : ''}`));
|
|
3652
|
+
if (agent.detected && answer.sessions?.[agent.id]?.detail) facts.append(el('small', 'sm-agent-line', answer.sessions[agent.id].detail));
|
|
3653
|
+
if (agent.account) facts.append(el('small', 'sm-agent-note', agent.account));
|
|
3654
|
+
if (lastLine.has(agent.id) && !state.ok) {
|
|
3655
|
+
const said = lastLine.get(agent.id);
|
|
3656
|
+
const line = el('small', 'sm-agent-line', said.line);
|
|
3657
|
+
if (said.url) { const link = el('a', null, t('Open the sign-in page')); link.href = said.url; link.target = '_blank'; link.rel = 'noopener'; line.append(' ', link); }
|
|
3658
|
+
facts.append(line);
|
|
3659
|
+
}
|
|
3660
|
+
if (facts.childNodes.length) body.append(facts);
|
|
3661
|
+
// One button per account the CLI can sign in with: Claude has two, and only the human can
|
|
3662
|
+
// say which one is paying.
|
|
3663
|
+
const doorKeys = () => {
|
|
3664
|
+
const doors = agent.login?.methods?.length ? agent.login.methods : [{ id: null, label: null, note: agent.login?.note }];
|
|
3665
|
+
return doors.map((door) => {
|
|
3666
|
+
const name = door.label ? t(door.label) : state.ok ? t('Sign in again') : t('Sign in');
|
|
3667
|
+
const key = button(`sm-btn ${state.ok ? '' : 'primary'} small`, name, async (event) => {
|
|
3668
|
+
event.currentTarget.disabled = true;
|
|
3669
|
+
await post(`/api/agents/${agent.id}/login`, { method: door.id });
|
|
3670
|
+
void paint();
|
|
3671
|
+
});
|
|
3672
|
+
if (door.note) key.title = t(door.note);
|
|
3673
|
+
return key;
|
|
3674
|
+
});
|
|
3675
|
+
};
|
|
3676
|
+
const acts = el('div', 'sm-agent-acts');
|
|
3677
|
+
if (!state.busyNow) {
|
|
3678
|
+
if (!agent.detected && agent.install) acts.append(button('sm-btn primary small', t('Install'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/install`); void paint(); }));
|
|
3679
|
+
else if (agent.detected && agent.login?.headless) for (const key of doorKeys()) acts.append(key);
|
|
3680
|
+
}
|
|
3681
|
+
if (acts.childNodes.length) body.append(acts);
|
|
3682
|
+
const ask = askBlock(agent);
|
|
3683
|
+
if (ask) body.append(ask);
|
|
3684
|
+
// A key, pasted here; OpenCode's are its providers', in the block below.
|
|
3685
|
+
if (agent.detected && agent.key && agent.id !== 'opencode') body.append(keyForm(agent));
|
|
3686
|
+
// A CLI that signs in from its own prompt gets the command, until it has a session.
|
|
3687
|
+
if (agent.detected && agent.login && !agent.login.headless && !state.ok) body.append(el('code', 'sm-agent-command', agent.login.display));
|
|
3688
|
+
if (agent.id === 'opencode' && agent.detected) body.append(openCodeProviders.node());
|
|
3689
|
+
item.append(body);
|
|
3690
|
+
list.append(item);
|
|
3691
|
+
}
|
|
3692
|
+
onPaint?.(ready, answer);
|
|
3693
|
+
};
|
|
3694
|
+
const liveOn = () => {
|
|
3695
|
+
if (!list.isConnected || poll) return;
|
|
3696
|
+
poll = setInterval(() => { if (!document.hidden) void paint(); }, 2500);
|
|
3697
|
+
try {
|
|
3698
|
+
stream = new EventSource('/api/events');
|
|
3699
|
+
stream.onmessage = (message) => {
|
|
3700
|
+
try {
|
|
3701
|
+
const event = JSON.parse(message.data);
|
|
3702
|
+
if (event.type === 'connection.login.output' || event.type === 'connection.install.output') lastLine.set(event.payload.agent, { line: event.payload.line, url: event.payload.url ?? null });
|
|
3703
|
+
if (/^connection\./.test(event.type)) void paint();
|
|
3704
|
+
} catch { /* not ours */ }
|
|
3705
|
+
};
|
|
3706
|
+
} catch { /* no stream: the poll keeps it current */ }
|
|
3707
|
+
};
|
|
3708
|
+
const stop = () => { clearInterval(poll); poll = 0; stream?.close(); stream = null; };
|
|
3709
|
+
return { node: list, paint, live: liveOn, stop };
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
// ---- The first time: three steps. The agents (connected once, for every project: a CLI's session
|
|
3713
|
+
// is this computer's, not a project's), the projects, and which to open now. Over each step, a
|
|
3714
|
+
// scene of what it is about, moving: the agents come to MADRE and light up as they are connected;
|
|
3715
|
+
// the folders drop into the lobby; the rooms light up one after another as the beat runs through
|
|
3716
|
+
// them. Drawn here, from MADRE's own marks; still for whoever asked for less motion.
|
|
3717
|
+
const SCENE_AGENTS = [['codex', 17, 30], ['claude', 27, 78], ['gemini', 83, 30], ['opencode', 73, 78]];
|
|
3718
|
+
const FOLDER_GLYPH = '<svg viewBox="0 0 32 26" aria-hidden="true"><path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h7.2l3 3.2h12.8A2.5 2.5 0 0 1 30 8.7v12.8a2.5 2.5 0 0 1-2.5 2.5h-23A2.5 2.5 0 0 1 2 21.5z" fill="currentColor"/><path d="M2 10h28" stroke="rgba(255,255,255,.35)" stroke-width="1.4"/></svg>';
|
|
3719
|
+
const BEAT_PATH = 'M0 22H70L78 22L86 6L96 38L104 22H190L198 22L206 6L216 38L224 22H320L328 22L336 6L346 38L354 22H420';
|
|
3720
|
+
function wizardScene(kind) {
|
|
3721
|
+
const scene = el('div', `sm-scene scene-${kind}`);
|
|
3722
|
+
scene.setAttribute('aria-hidden', 'true');
|
|
3723
|
+
if (kind === 'agents') {
|
|
3724
|
+
const wires = svgNode('svg', { class: 'sm-wires', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
|
|
3725
|
+
SCENE_AGENTS.forEach(([id, x, y], index) => {
|
|
3726
|
+
wires.append(svgNode('line', { class: 'sm-wire', 'data-agent': id, x1: 50, y1: 54, x2: x, y2: y, 'vector-effect': 'non-scaling-stroke' }));
|
|
3727
|
+
const node = el('span', 'sm-scene-node agent');
|
|
3728
|
+
node.dataset.agent = id;
|
|
3729
|
+
node.style.left = `${x}%`;
|
|
3730
|
+
node.style.top = `${y}%`;
|
|
3731
|
+
node.style.setProperty('--d', `${index * 160}ms`);
|
|
3732
|
+
node.append(mark(id, 42));
|
|
3733
|
+
scene.append(node);
|
|
3734
|
+
});
|
|
3735
|
+
const core = el('span', 'sm-scene-node core');
|
|
3736
|
+
core.style.left = '50%';
|
|
3737
|
+
core.style.top = '54%';
|
|
3738
|
+
core.append(mark('madre', 60));
|
|
3739
|
+
scene.prepend(wires);
|
|
3740
|
+
scene.append(core);
|
|
3741
|
+
} else if (kind === 'projects') {
|
|
3742
|
+
// Each folder lives in its own slot of the tray and drops into it, one after another.
|
|
3743
|
+
const tray = el('div', 'sm-tray');
|
|
3744
|
+
for (let index = 0; index < 5; index += 1) {
|
|
3745
|
+
const slot = el('i', 'sm-slot');
|
|
3746
|
+
slot.style.setProperty('--i', String(index));
|
|
3747
|
+
const folder = el('span', 'sm-fly');
|
|
3748
|
+
folder.innerHTML = FOLDER_GLYPH; // trusted: drawn here
|
|
3749
|
+
slot.append(folder);
|
|
3750
|
+
tray.append(slot);
|
|
3751
|
+
}
|
|
3752
|
+
scene.append(tray);
|
|
3753
|
+
} else {
|
|
3754
|
+
const rooms = el('div', 'sm-wins');
|
|
3755
|
+
for (let index = 0; index < 3; index += 1) {
|
|
3756
|
+
const win = el('span', 'sm-win');
|
|
3757
|
+
win.style.setProperty('--i', String(index));
|
|
3758
|
+
win.append(el('i', 'sm-win-bar'), el('i', 'sm-win-line'), el('i', 'sm-win-line short'));
|
|
3759
|
+
rooms.append(win);
|
|
3760
|
+
}
|
|
3761
|
+
const beat = svgNode('svg', { class: 'sm-beat', viewBox: '0 0 420 44', preserveAspectRatio: 'none' });
|
|
3762
|
+
beat.append(svgNode('path', { d: BEAT_PATH, pathLength: 100, 'vector-effect': 'non-scaling-stroke' }));
|
|
3763
|
+
scene.append(beat, rooms);
|
|
3764
|
+
}
|
|
3765
|
+
return scene;
|
|
3766
|
+
}
|
|
3767
|
+
let wizard = null;
|
|
3768
|
+
let wizardThen = null; // what comes once the wizard closes: the chapters, after a bonjour
|
|
3769
|
+
function openWizard({ then = null } = {}) {
|
|
3770
|
+
if (wizard) return;
|
|
3771
|
+
wizardThen = then;
|
|
3772
|
+
wizard = el('dialog', 'sm-sheet sm-wide sm-wizard');
|
|
3773
|
+
const inner = el('div', 'sm-sheet-body');
|
|
3774
|
+
wizard.append(inner);
|
|
3775
|
+
document.body.append(wizard);
|
|
3776
|
+
let step = 0;
|
|
3777
|
+
let panel = null;
|
|
3778
|
+
let scene = null;
|
|
3779
|
+
const steps = [t('Agents'), t('Projects'), t('Open')];
|
|
3780
|
+
const KINDS_OF_STEP = ['agents', 'projects', 'open'];
|
|
3781
|
+
const stop = () => { panel?.stop(); panel = null; };
|
|
3782
|
+
const frame = (title, note, body, actions, { back = false } = {}) => {
|
|
3783
|
+
stop();
|
|
3784
|
+
inner.replaceChildren();
|
|
3785
|
+
const head = el('header', 'sm-sheet-head');
|
|
3786
|
+
const dots = el('ol', 'sm-steps');
|
|
3787
|
+
steps.forEach((name, index) => { const item = el('li', index === step ? 'on' : index < step ? 'done' : null, name); dots.append(item); });
|
|
3788
|
+
head.append(dots, closeKey(() => wizard.close()));
|
|
3789
|
+
scene = wizardScene(KINDS_OF_STEP[step]);
|
|
3790
|
+
const page = el('div', `sm-step${back ? ' back' : ''}`);
|
|
3791
|
+
page.append(el('h3', null, title));
|
|
3792
|
+
if (note) page.append(el('p', 'hint', note));
|
|
3793
|
+
page.append(body, actions);
|
|
3794
|
+
inner.append(head, scene, page);
|
|
3795
|
+
};
|
|
3796
|
+
async function agentsStep(options) {
|
|
3797
|
+
const actions = el('div', 'actions');
|
|
3798
|
+
const next = button('sm-btn primary', t('Continue'), () => { step = 1; projectsStep(); });
|
|
3799
|
+
actions.append(next);
|
|
3800
|
+
const fresh = agentsPanel({
|
|
3801
|
+
compact: true,
|
|
3802
|
+
onPaint: (ready, answer) => {
|
|
3803
|
+
next.textContent = ready ? t('Continue') : t('Continue without agents');
|
|
3804
|
+
// The scene lights the agents that are connected, and their wire to MADRE.
|
|
3805
|
+
for (const agent of answer.agents) {
|
|
3806
|
+
const on = agentState(agent, answer).ok;
|
|
3807
|
+
scene?.querySelector(`.sm-scene-node[data-agent="${agent.id}"]`)?.classList.toggle('on', on);
|
|
3808
|
+
scene?.querySelector(`.sm-wire[data-agent="${agent.id}"]`)?.classList.toggle('on', on);
|
|
3809
|
+
}
|
|
3810
|
+
},
|
|
3811
|
+
});
|
|
3812
|
+
frame(t('Connect your agents'), t('Agents are the AI tools that write code, such as Claude Code or Codex. MADRE works with the ones already on this computer: each one is connected once, and every project uses it.'), fresh.node, actions, options);
|
|
3813
|
+
panel = fresh;
|
|
3814
|
+
await fresh.paint();
|
|
3815
|
+
fresh.live();
|
|
3816
|
+
}
|
|
3817
|
+
function projectsStep(options) {
|
|
3818
|
+
// What is chosen here is added on the way to the next step: one key, not two.
|
|
3819
|
+
const actions = el('div', 'actions');
|
|
3820
|
+
const next = button('sm-btn primary', t('Continue'), async () => {
|
|
3821
|
+
if (picker.count()) {
|
|
3822
|
+
next.disabled = true;
|
|
3823
|
+
const added = await picker.add();
|
|
3824
|
+
next.disabled = false;
|
|
3825
|
+
if (!added) return;
|
|
3826
|
+
}
|
|
3827
|
+
step = 2;
|
|
3828
|
+
openStep();
|
|
3829
|
+
});
|
|
3830
|
+
const picker = projectPicker({ withAdd: false, onChange: (n) => { next.textContent = n ? t('Add and continue') : t('Continue'); } });
|
|
3831
|
+
actions.append(button('sm-btn', t('Back'), () => { step = 0; void agentsStep({ back: true }); }), next);
|
|
3832
|
+
frame(t('Add your projects'), t('A project is a folder you work in. Choose yours: adding them does not open them; in the next step you pick which to open.'), picker.node, actions, options);
|
|
3833
|
+
}
|
|
3834
|
+
function openStep() {
|
|
3835
|
+
const projects = (data?.rooms ?? []).filter((room) => room.id !== data?.salas);
|
|
3836
|
+
const chosen = new Set();
|
|
3837
|
+
const pages = foldPages({ snug: true });
|
|
3838
|
+
const list = el('div', 'sm-picker');
|
|
3839
|
+
const foot = el('div', 'sm-picker-foot');
|
|
3840
|
+
foot.append(pages.pager);
|
|
3841
|
+
list.append(pages.grid, foot);
|
|
3842
|
+
pages.show(projects, {
|
|
3843
|
+
draw: (room) => foldTile({
|
|
3844
|
+
name: room.name,
|
|
3845
|
+
path: room.root ?? '',
|
|
3846
|
+
note: room.open ? t('Already open') : null,
|
|
3847
|
+
checked: Boolean(room.open) || chosen.has(room.id),
|
|
3848
|
+
known: Boolean(room.open),
|
|
3849
|
+
onToggle: (on) => { if (on) chosen.add(room.id); else chosen.delete(room.id); },
|
|
3850
|
+
}),
|
|
3851
|
+
empty: () => t('No projects yet. You can add them from Home whenever you like.'),
|
|
3852
|
+
});
|
|
3853
|
+
const actions = el('div', 'actions');
|
|
3854
|
+
const go = button('sm-btn primary', t('Done'), async () => {
|
|
3855
|
+
go.disabled = true;
|
|
3856
|
+
for (const id of chosen) {
|
|
3857
|
+
go.textContent = t('Opening {name}…', { name: projects.find((room) => room.id === id)?.name ?? id });
|
|
3858
|
+
await startRoom({ id });
|
|
3859
|
+
}
|
|
3860
|
+
wizard.close();
|
|
3861
|
+
});
|
|
3862
|
+
actions.append(button('sm-btn', t('Back'), () => { step = 1; projectsStep({ back: true }); }), go);
|
|
3863
|
+
frame(t('Open what you will use'), t('Each open project runs on this Mac on its own. The rest stay in Home, asleep, until you open them.'), list, actions);
|
|
3864
|
+
}
|
|
3865
|
+
wizard.addEventListener('close', () => { stop(); wizard.remove(); wizard = null; void loadAgents(); const after = wizardThen; wizardThen = null; after?.(); });
|
|
3866
|
+
wizard.showModal();
|
|
3867
|
+
void agentsStep();
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
// ---- AJUSTES, as the room's (app.js, SETTINGS_SECTIONS and its sheets; 2026-10-10): a board of
|
|
3871
|
+
// three sections, a card per category that already says how it stands and holds the one switch
|
|
3872
|
+
// people reach for, the category in a sheet over the board, and one search for any setting. The
|
|
3873
|
+
// window is the room's own (styles.css: #settings, .settings-*, .sx-*), not a likeness of it. The
|
|
3874
|
+
// lobby sets what is the lobby's — how it looks, what its bar holds, the agents of this Mac,
|
|
3875
|
+
// BISHOP's ways in, the projects and the version; a room's turns, memory or permissions are set
|
|
3876
|
+
// in that room. Nothing scrolls inside the window: the screen behind it does.
|
|
3877
|
+
const SET_SECTIONS = [
|
|
3878
|
+
['You', 'How Home looks and what its bar holds', ['look', 'bar']],
|
|
3879
|
+
['The agents', 'Who works on this Mac', ['agents', 'bishop', 'local']],
|
|
3880
|
+
['System', 'The projects of this Mac and the version', ['projects', 'release']],
|
|
3881
|
+
];
|
|
3882
|
+
// The room's own icons where the room has the category (app.js, SETTINGS_CATS); the bar, BISHOP
|
|
3883
|
+
// and the projects drawn here in the same hand: 20×20, one round stroke.
|
|
3884
|
+
const SET_CATS = {
|
|
3885
|
+
look: { name: 'Appearance', icon: '<circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 3.5a6.5 6.5 0 0 1 0 13z" fill="currentColor"/>' },
|
|
3886
|
+
bar: { name: 'Top bar', icon: '<rect x="2.6" y="5.4" width="14.8" height="9.2" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6.2 10h2.2M11.6 10h2.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' },
|
|
3887
|
+
agents: { name: 'Agents', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
3888
|
+
bishop: { name: 'Coordinator', icon: '<path d="M2.6 10c1.7-3.1 4.3-4.8 7.4-4.8s5.7 1.7 7.4 4.8c-1.7 3.1-4.3 4.8-7.4 4.8S4.3 13.1 2.6 10z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.3" fill="currentColor"/>' },
|
|
3889
|
+
local: { name: 'Local AI', icon: '<rect x="5" y="5" width="10" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 2.6v2.4M12 2.6v2.4M8 15v2.4M12 15v2.4M2.6 8h2.4M2.6 12h2.4M15 8h2.4M15 12h2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="10" r="1.8" fill="currentColor"/>' },
|
|
3890
|
+
projects: { name: 'Projects', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
|
|
3891
|
+
release: { name: 'Version', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
3892
|
+
};
|
|
3893
|
+
const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'local', 'projects', 'release'];
|
|
3894
|
+
// The words the search knows, each pointing to the category that holds it (app.js, SETTINGS_INDEX).
|
|
3895
|
+
const SET_INDEX = [
|
|
3896
|
+
['look', 'Preview'], ['look', 'Theme'], ['look', 'Accent'], ['look', 'Wallpaper'], ['look', 'Intensity'], ['look', 'Blur'], ['look', 'Project cards'], ['look', 'Text'], ['look', 'Project order'], ['look', 'Language'],
|
|
3897
|
+
['bar', 'Arrange the bar'], ['bar', 'Back to default'],
|
|
3898
|
+
['bishop', 'Talk to the Coordinator'], ['bishop', 'Open the Coordinator in a new tab'],
|
|
3899
|
+
['local', 'Ollama'], ['local', 'Automatic'], ['local', 'One by one'], ['local', 'Manual'],
|
|
3900
|
+
['projects', 'Add a project'], ['projects', 'See the welcome again'], ['projects', 'The tour of MADRE 1.0'],
|
|
3901
|
+
['release', 'Check now'],
|
|
3902
|
+
];
|
|
3903
|
+
const THEME_WORDS = { auto: () => t('Automatic'), light: () => t('Light'), dark: () => t('Dark') };
|
|
3904
|
+
const catName = (id) => t(SET_CATS[id].name);
|
|
3905
|
+
const setIcon = (id) => { const span = el('span', 'settings-icon'); span.innerHTML = `<svg viewBox="0 0 20 20" aria-hidden="true">${SET_CATS[id].icon}</svg>`; span.style.setProperty('--cat', 'var(--phosphor)'); return span; }; // trusted: our own SVG strings
|
|
3906
|
+
const CHEVRON = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 6.2 8 10l3.8-3.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
3907
|
+
const chevron = (className) => { const mark = el('span', `${className} chev-icon`); mark.innerHTML = CHEVRON; return mark; }; // trusted: our own SVG string
|
|
3908
|
+
const fold = (text) => String(text).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
3909
|
+
const bishopRoom = () => data?.rooms.find((room) => room.id === data.salas) ?? null;
|
|
3910
|
+
|
|
3911
|
+
// The pieces every sheet is built from, the room's (app.js, sxBlock, sxRow, sxButton): a block is a
|
|
3912
|
+
// card with a title and a line about it, then its rows; a row says what it is on the left and
|
|
3913
|
+
// holds its control on the right.
|
|
3914
|
+
function sxBlock(host, title, about = null, { aside = null, className = '' } = {}) {
|
|
3915
|
+
const block = el('section', `sx-block${className ? ` ${className}` : ''}`);
|
|
3916
|
+
const head = el('header', 'sx-head');
|
|
3917
|
+
const words = el('div', 'sx-words');
|
|
3918
|
+
words.append(el('h3', null, title));
|
|
3919
|
+
if (about) words.append(el('p', null, about));
|
|
3920
|
+
head.append(words);
|
|
3921
|
+
if (aside) head.append(aside);
|
|
3922
|
+
block.rows = el('div', 'sx-rows');
|
|
3923
|
+
block.append(head, block.rows);
|
|
3924
|
+
host.append(block);
|
|
3925
|
+
return block;
|
|
3926
|
+
}
|
|
3927
|
+
function sxRow(block, label, hint, control = null, { stack = false } = {}) {
|
|
3928
|
+
const row = el('div', `sx-row${stack ? ' stack' : ''}`);
|
|
3929
|
+
const words = el('div', 'sx-words');
|
|
3930
|
+
words.append(el('b', null, label));
|
|
3931
|
+
if (hint) words.append(el('span', 'sx-hint', hint));
|
|
3932
|
+
row.append(words);
|
|
3933
|
+
if (control) { const box = el('div', 'sx-control'); box.append(control); row.append(box); }
|
|
3934
|
+
block.rows.append(row);
|
|
3935
|
+
return row;
|
|
3936
|
+
}
|
|
3937
|
+
const sxButton = (words, click, { tone = null } = {}) => button(`sx-btn${tone ? ` ${tone}` : ''}`, words, click);
|
|
3938
|
+
// A choice of a few, on the rail PERSONALIZE uses (app.js, drawCustom, segmented).
|
|
3939
|
+
function customSeg(options, current, pick, size = '') {
|
|
3940
|
+
const seg = el('div', `custom-seg${size ? ` ${size}` : ''}`);
|
|
3941
|
+
seg.setAttribute('role', 'radiogroup');
|
|
3942
|
+
for (const [value, words] of options) {
|
|
3943
|
+
const option = button('seg-option', words, () => { if (value !== current) pick(value); });
|
|
3944
|
+
option.setAttribute('role', 'radio');
|
|
3945
|
+
option.setAttribute('aria-checked', String(value === current));
|
|
3946
|
+
seg.append(option);
|
|
3947
|
+
}
|
|
3948
|
+
return seg;
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// How each category stands, in a line, from what the lobby already knows; nothing is guessed.
|
|
3952
|
+
function setStatus(id) {
|
|
3953
|
+
const looks = readLooks();
|
|
3954
|
+
if (id === 'look') return { line: `${THEME_WORDS[looks.theme]()} · ${WALLPAPER_WORDS[looks.wallpaper]()} · ${ACCENT_WORDS[looks.accent]()}` };
|
|
3955
|
+
if (id === 'bar') {
|
|
3956
|
+
if (!pinsStored()) return { line: t('The open projects, as they open') };
|
|
3957
|
+
const count = pinsShown().length;
|
|
3958
|
+
return { line: count ? t('{n} projects pinned', { n: count }) : t('Nothing pinned') };
|
|
3959
|
+
}
|
|
3960
|
+
if (id === 'agents') {
|
|
3961
|
+
if (!agentsSeen) return { line: t('Reading…') };
|
|
3962
|
+
const here = agentsSeen.agents.filter((agent) => agent.id !== 'madre' && agent.detected);
|
|
3963
|
+
const off = here.filter((agent) => !agentState(agent, agentsSeen).ok);
|
|
3964
|
+
return { line: t('{n} of {total} signed in', { n: here.length - off.length, total: here.length }), warn: off.length ? t('{agents} without a session', { agents: off.map((agent) => agent.label ?? agent.id).join(', ') }) : null };
|
|
3965
|
+
}
|
|
3966
|
+
if (id === 'bishop') { const room = bishopRoom(); return { line: room ? stateOf(room).words : t('Reading…') }; }
|
|
3967
|
+
if (id === 'local') {
|
|
3968
|
+
if (!localSeen) return { line: localFailed ? t('Home did not answer.') : t('Reading…') };
|
|
3969
|
+
const on = localSeen.projects.filter((one) => one.on).length;
|
|
3970
|
+
return { line: `${LOCAL_MODE_WORDS[localSeen.mode]()} · ${t('{n} of {total} projects', { n: on, total: localSeen.projects.length })}`, warn: !localSeen.master ? t('Local AI is switched off for every project.') : !localSeen.ollama.running ? t('Ollama is not running') : null };
|
|
3971
|
+
}
|
|
3972
|
+
if (id === 'projects') {
|
|
3973
|
+
if (!data) return { line: t('Reading…') };
|
|
3974
|
+
const { all, open } = projectsInOrder();
|
|
3975
|
+
return { line: t('{n} projects · {open} open', { n: all.length, open: open.length }) };
|
|
3976
|
+
}
|
|
3977
|
+
if (id === 'release') {
|
|
3978
|
+
if (!versionSeen) return { line: t('Reading…') };
|
|
3979
|
+
return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is on npm', { version: versionSeen.latest }) : null };
|
|
3980
|
+
}
|
|
3981
|
+
return { line: '' };
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
// The one thing people reach for, on the card itself: changing it opens nothing.
|
|
3985
|
+
function setQuick(id) {
|
|
3986
|
+
const rows = el('div', 'settings-quick');
|
|
3987
|
+
const add = (label, control) => { const row = el('div', 'settings-quick-row'); row.append(el('span', null, label), control); rows.append(row); };
|
|
3988
|
+
if (id === 'look') {
|
|
3989
|
+
const { theme } = readLooks();
|
|
3990
|
+
const seg = el('div', 'settings-seg');
|
|
3991
|
+
for (const [value, word] of [['auto', t('Auto')], ['light', t('Light')], ['dark', t('Dark')]]) {
|
|
3992
|
+
seg.append(button(value === theme ? 'on' : null, word, (event) => { event.stopPropagation(); saveLooks({ theme: value }); }));
|
|
3993
|
+
}
|
|
3994
|
+
add(t('Theme'), seg);
|
|
3995
|
+
}
|
|
3996
|
+
if (id === 'bar') add(t('Arrange the bar'), sxButton(t('Arrange'), (event) => { event.stopPropagation(); settingsSheet?.close(); pinsEdit(true); }));
|
|
3997
|
+
if (id === 'agents' && agentsSeen) {
|
|
3998
|
+
for (const agent of agentsSeen.agents.filter((one) => one.id !== 'madre' && one.detected)) {
|
|
3999
|
+
const state = agentState(agent, agentsSeen);
|
|
4000
|
+
const row = el('div', 'settings-quick-row agent');
|
|
4001
|
+
const who = el('span', 'who', agent.label ?? agent.id);
|
|
4002
|
+
who.style.setProperty('--agent', brandOf(agent.id).color);
|
|
4003
|
+
row.append(who, el('span', `state session-lamp ${state.ok ? 'on ok' : state.key === 'bad' ? 'off bad' : 'wait'}`, state.ok ? t('signed in') : state.key === 'bad' ? t('no session') : t('unknown')));
|
|
4004
|
+
rows.append(row);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
return rows.children.length ? rows : null;
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
// What MADRE looks like with these choices: the room's preview, built as the room builds it
|
|
4011
|
+
// (app.js, drawCustom), wearing the choices, so the bubbles and the text — which the lobby has no
|
|
4012
|
+
// conversation to show — are seen before they are lived with.
|
|
4013
|
+
// The preview is Home itself, small (2026-10-11): its bar with the projects on it, the
|
|
4014
|
+
// Coordinator, and two of your projects as cards, on the wallpaper, theme and accent chosen, with
|
|
4015
|
+
// the cards, the text and the order Home's own choices give them. It wears the room's preview
|
|
4016
|
+
// (styles.css, .look-preview) for the ground, so the wallpaper reads exactly as it will.
|
|
4017
|
+
function lookPreview(looks) {
|
|
4018
|
+
const preview = el('div', 'look-preview sm-home-preview');
|
|
4019
|
+
preview.setAttribute('aria-hidden', 'true');
|
|
4020
|
+
Object.assign(preview.dataset, { accent: looks.accent, wallpaper: looks.wallpaper, wpStyle: looks.style, homeCards: looks.home.cards, homeText: looks.home.text });
|
|
4021
|
+
preview.style.setProperty('--veil-k', String(looks.veil / 100));
|
|
4022
|
+
preview.style.setProperty('--wp-blur', `${looks.blur}px`);
|
|
4023
|
+
preview.dataset.scheme = root.dataset.scheme ?? 'dark';
|
|
4024
|
+
const shown = (data ? projectsInOrder().all : []).slice(0, 2);
|
|
4025
|
+
const names = shown.length ? shown.map((room) => room.name) : ['pulse', 'tienda'];
|
|
4026
|
+
const bar = el('div', 'hp-bar');
|
|
4027
|
+
bar.append(el('i', 'hp-logo'), el('b', null, t('Home')));
|
|
4028
|
+
for (const name of names) { const pin = el('span', 'hp-pin'); pin.append(el('i'), name); bar.append(pin); }
|
|
4029
|
+
const grid = el('div', 'hp-grid');
|
|
4030
|
+
const coord = el('div', 'hp-tile hp-coord');
|
|
4031
|
+
const head = el('div', 'hp-head');
|
|
4032
|
+
head.append(el('span', 'hp-mark'), el('b', null, t('Coordinator')));
|
|
4033
|
+
coord.append(head, el('small', null, t('Calm')), el('span', 'hp-key', t('Talk to the Coordinator')));
|
|
4034
|
+
grid.append(coord);
|
|
4035
|
+
names.forEach((name, index) => {
|
|
4036
|
+
const card = el('div', 'hp-tile hp-card');
|
|
4037
|
+
const state = el('small', 'hp-state');
|
|
4038
|
+
state.append(el('i'), index ? t('Asleep') : t('Calm'));
|
|
4039
|
+
card.append(el('b', null, name), state, el('span', 'hp-chat'), el('span', 'hp-strip'), el('span', `hp-key${index ? '' : ' on'}`, t('Open')));
|
|
4040
|
+
grid.append(card);
|
|
4041
|
+
});
|
|
4042
|
+
preview.append(bar, grid);
|
|
4043
|
+
return preview;
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
// The sheets. Appearance is the room's own sheet of PERSONALIZE inside AJUSTES (app.js,
|
|
4047
|
+
// drawCustom with customWindow.instant): the preview first, then the colours, the wallpaper, the
|
|
4048
|
+
// conversation and the language, each applied as it is touched.
|
|
4049
|
+
function fillLook(host) {
|
|
4050
|
+
host.classList.add('settings-look');
|
|
4051
|
+
const looks = readLooks();
|
|
4052
|
+
// Where the choices are kept, and the way back to MADRE as it comes, on one line above it all.
|
|
4053
|
+
const top = el('div', 'sm-look-top');
|
|
4054
|
+
top.append(el('p', 'sx-intro', t('Kept in this browser. Theme, accent and wallpaper also dress the project served at this address.')));
|
|
4055
|
+
host.append(top);
|
|
4056
|
+
// Two columns when the window is wide (it opens over the whole window): the preview, with how
|
|
4057
|
+
// the conversation reads and the language under it; the colours and the wallpaper beside it. So
|
|
4058
|
+
// the whole of Appearance is seen at once.
|
|
4059
|
+
const frame = el('div', 'sx-look sm-look');
|
|
4060
|
+
const side = el('div', 'sm-look-side');
|
|
4061
|
+
const preview = lookPreview(looks);
|
|
4062
|
+
sxBlock(side, t('Preview'), t('Your choices, on Home itself.'), { className: 'sx-preview' }).rows.append(preview);
|
|
4063
|
+
const page = el('div', 'sx-look-controls');
|
|
4064
|
+
frame.append(side, page);
|
|
4065
|
+
|
|
4066
|
+
const named = el('span', 'custom-current', ACCENT_WORDS[looks.accent]());
|
|
4067
|
+
const colour = sxBlock(page, t('Colour'), null, { aside: named });
|
|
4068
|
+
sxRow(colour, t('Theme'), t('Automatic follows your system.'), customSeg(['auto', 'light', 'dark'].map((value) => [value, THEME_WORDS[value]()]), looks.theme, (value) => saveLooks({ theme: value }), 'three'));
|
|
4069
|
+
const swatches = el('div', 'custom-swatches');
|
|
4070
|
+
swatches.setAttribute('role', 'radiogroup');
|
|
4071
|
+
for (const id of ACCENTS) {
|
|
4072
|
+
const swatch = button('swatch', null, () => saveLooks({ accent: id }));
|
|
4073
|
+
swatch.dataset.swatch = id;
|
|
4074
|
+
swatch.setAttribute('role', 'radio');
|
|
4075
|
+
swatch.setAttribute('aria-checked', String(id === looks.accent));
|
|
4076
|
+
swatch.setAttribute('aria-label', ACCENT_WORDS[id]());
|
|
4077
|
+
// The colour's name sits beside COLOUR: the one picked, or the one under the pointer.
|
|
4078
|
+
const say = () => { named.textContent = ACCENT_WORDS[id](); named.classList.toggle('peek', id !== looks.accent); };
|
|
4079
|
+
const back = () => { named.textContent = ACCENT_WORDS[looks.accent](); named.classList.remove('peek'); };
|
|
4080
|
+
swatch.addEventListener('pointerenter', say);
|
|
4081
|
+
swatch.addEventListener('focus', say);
|
|
4082
|
+
swatch.addEventListener('pointerleave', back);
|
|
4083
|
+
swatch.addEventListener('blur', back);
|
|
4084
|
+
swatches.append(swatch);
|
|
4085
|
+
}
|
|
4086
|
+
sxRow(colour, t('Accent'), t('Each colour in dark (top) and in light (bottom). The permission modes keep their own.'), swatches, { stack: true });
|
|
4087
|
+
|
|
4088
|
+
const photo = looks.wallpaper !== 'default';
|
|
4089
|
+
const papers = el('div', 'custom-papers');
|
|
4090
|
+
papers.setAttribute('role', 'radiogroup');
|
|
4091
|
+
for (const value of WALLPAPERS) {
|
|
4092
|
+
const paper = button('paper', null, () => saveLooks({ wallpaper: value }));
|
|
4093
|
+
paper.dataset.wp = value;
|
|
4094
|
+
paper.setAttribute('role', 'radio');
|
|
4095
|
+
paper.setAttribute('aria-checked', String(value === looks.wallpaper));
|
|
4096
|
+
paper.append(el('span', 'paper-swatch'), el('span', 'paper-name', WALLPAPER_WORDS[value]()));
|
|
4097
|
+
papers.append(paper);
|
|
4098
|
+
}
|
|
4099
|
+
const paperBox = el('div', 'paper-box');
|
|
4100
|
+
paperBox.append(papers);
|
|
4101
|
+
if (photo) {
|
|
4102
|
+
// A photograph: layer or mesh, how strong, how blurred. The dials move the lobby and the
|
|
4103
|
+
// preview while they are dragged, and are kept when they are let go.
|
|
4104
|
+
const tune = el('div', 'paper-tune');
|
|
4105
|
+
tune.append(customSeg([['veil', t('Layer')], ['mesh', t('Mesh')]], looks.style, (value) => saveLooks({ style: value }), 'two'));
|
|
4106
|
+
const dial = (name, label) => {
|
|
4107
|
+
const spec = TUNES[name];
|
|
4108
|
+
const line = el('label', 'tune-row');
|
|
4109
|
+
const value = el('span', 'tune-value', spec.shown(looks[name]));
|
|
4110
|
+
const input = el('input', 'tune-range');
|
|
4111
|
+
input.type = 'range';
|
|
4112
|
+
input.min = String(spec.min); input.max = String(spec.max); input.step = String(spec.step);
|
|
4113
|
+
input.value = String(looks[name]);
|
|
4114
|
+
const fill = (v) => input.style.setProperty('--fill', `${((v - spec.min) / (spec.max - spec.min)) * 100}%`);
|
|
4115
|
+
fill(looks[name]);
|
|
4116
|
+
input.addEventListener('input', () => {
|
|
4117
|
+
const v = Number(input.value);
|
|
4118
|
+
spec.apply(v);
|
|
4119
|
+
if (name === 'veil') preview.style.setProperty('--veil-k', String(v / 100)); else preview.style.setProperty('--wp-blur', `${v}px`);
|
|
4120
|
+
value.textContent = spec.shown(v);
|
|
4121
|
+
fill(v);
|
|
4122
|
+
});
|
|
4123
|
+
input.addEventListener('change', () => saveTune(name, Number(input.value)));
|
|
4124
|
+
line.append(el('span', 'tune-name', label), input, value);
|
|
4125
|
+
return line;
|
|
4126
|
+
};
|
|
4127
|
+
tune.append(dial('veil', t('Intensity')), dial('blur', t('Blur')));
|
|
4128
|
+
paperBox.append(tune);
|
|
4129
|
+
}
|
|
4130
|
+
sxBlock(page, t('Wallpaper'), photo
|
|
4131
|
+
? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
|
|
4132
|
+
: t('Behind Home, and behind the conversation of the project served at this address. Light and dark each have their own.')).rows.append(paperBox);
|
|
4133
|
+
|
|
4134
|
+
// Home's own: its cards, its text, the order of its projects. The conversation's bubbles and text
|
|
4135
|
+
// are each project's, in its own Settings.
|
|
4136
|
+
const home = sxBlock(side, t('Home'), t('Only for Home: each project keeps its conversation as you set it there.'));
|
|
4137
|
+
const homeSeg = (name) => customSeg(HOME_LOOKS[name].values.map((value) => [value, HOME_LOOKS[name].words[value]()]), looks.home[name], (value) => saveHome(name, value), ['', '', 'two', 'three', 'four'][HOME_LOOKS[name].values.length] ?? 'three');
|
|
4138
|
+
sxRow(home, t('Project cards'), null, homeSeg('cards'), { stack: true });
|
|
4139
|
+
sxRow(home, t('Text'), null, homeSeg('text'), { stack: true });
|
|
4140
|
+
sxRow(home, t('Project order'), null, homeSeg('order'), { stack: true });
|
|
4141
|
+
|
|
4142
|
+
const words = sxBlock(side, t('Language'));
|
|
4143
|
+
sxRow(words, t('Interface'), t('The page reloads in it when you choose to.'), customSeg([['es', 'Español'], ['en', 'English']], language, async (code) => {
|
|
4144
|
+
try { localStorage.setItem('pulse.language', code); } catch { /* no storage */ }
|
|
4145
|
+
await post('/api/language', { language: code });
|
|
4146
|
+
reloadWithGrace();
|
|
4147
|
+
}, 'two'));
|
|
4148
|
+
|
|
4149
|
+
const homeDefault = Object.entries(HOME_LOOKS).every(([name, spec]) => looks.home[name] === spec.values[0]);
|
|
4150
|
+
const atDefault = looks.accent === 'madre' && looks.wallpaper === 'default' && looks.style === 'veil' && looks.veil === TUNES.veil.value && looks.blur === TUNES.blur.value && homeDefault;
|
|
4151
|
+
if (!atDefault) {
|
|
4152
|
+
top.append(sxButton(t('Back to default'), () => {
|
|
4153
|
+
for (const name of ['veil', 'blur']) saveTune(name, TUNES[name].value);
|
|
4154
|
+
for (const [name, spec] of Object.entries(HOME_LOOKS)) { try { localStorage.removeItem(spec.key); } catch { /* this page only */ } }
|
|
4155
|
+
saveLooks({ accent: 'madre', wallpaper: 'default', style: 'veil' });
|
|
4156
|
+
draw();
|
|
4157
|
+
}));
|
|
4158
|
+
}
|
|
4159
|
+
host.append(frame);
|
|
4160
|
+
}
|
|
4161
|
+
function fillBar(host) {
|
|
4162
|
+
const block = sxBlock(host, t('Top bar'), t('The projects you keep at hand, in the order you choose. A project comes to the bar from its card: right-click it, or drag it to the bar.'));
|
|
4163
|
+
const ids = pinsShown();
|
|
4164
|
+
if (!ids.length) sxRow(block, t('Nothing pinned'), null);
|
|
4165
|
+
for (const id of ids) {
|
|
4166
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
4167
|
+
sxRow(block, room.name, stateOf(room).words, sxButton(t('Remove'), () => pinRemove(id)));
|
|
4168
|
+
}
|
|
4169
|
+
const acts = el('div', 'sx-actions end');
|
|
4170
|
+
if (pinsStored()) acts.append(sxButton(t('Back to default'), () => pinsReset()));
|
|
4171
|
+
acts.append(sxButton(t('Arrange the bar'), () => { settingsSheet?.close(); pinsEdit(true); }, { tone: 'primary' }));
|
|
4172
|
+
host.append(acts);
|
|
4173
|
+
}
|
|
4174
|
+
function fillAgents(host, keep) {
|
|
4175
|
+
// The project's AJUSTES › Agents, here too: a sentence, then a card per agent.
|
|
4176
|
+
host.append(el('p', 'sx-intro', t('MADRE works with the AI tools already on this computer. Each one is connected once, and every project uses it.')));
|
|
4177
|
+
const panel = agentsPanel({ onPaint: () => settingsSheet?.sync() });
|
|
4178
|
+
panel.node.classList.add('cards');
|
|
4179
|
+
host.append(panel.node);
|
|
4180
|
+
keep(panel.stop);
|
|
4181
|
+
void panel.paint().then(() => panel.live());
|
|
4182
|
+
}
|
|
4183
|
+
function fillBishop(host) {
|
|
4184
|
+
const room = bishopRoom();
|
|
4185
|
+
const block = sxBlock(host, t('Coordinator'), t('Its agent and its mode are chosen in its own project.'));
|
|
4186
|
+
sxRow(block, t('Talk to the Coordinator'), room ? stateOf(room).words : null, sxButton(t('Open'), () => { settingsSheet?.close(); openBishop(); }, { tone: 'primary' }));
|
|
4187
|
+
sxRow(block, t('Open the Coordinator in a new tab'), null, sxButton(t('Open'), () => openBishop({ newTab: true })));
|
|
4188
|
+
}
|
|
4189
|
+
// LOCAL AI (src/local-ai.mjs): whether every project uses Ollama, the ones picked here, or each
|
|
4190
|
+
// one as it decides in its own Settings. The switch of each project is the same one its Settings
|
|
4191
|
+
// show; Ollama itself and what it does are set in a project's Modules › Local AI.
|
|
4192
|
+
const LOCAL_MODE_WORDS = { auto: () => t('Automatic'), pick: () => t('One by one'), manual: () => t('Manual') };
|
|
4193
|
+
const LOCAL_MODE_SAYS = {
|
|
4194
|
+
auto: () => t('Every project uses the local AI, new ones too.'),
|
|
4195
|
+
pick: () => t('Only the projects you switch on below. New projects start without it.'),
|
|
4196
|
+
manual: () => t('Each project decides in its own Settings › Modules › Local AI. New projects start with it.'),
|
|
4197
|
+
};
|
|
4198
|
+
let localFailed = false; // the last read got no answer: the sheet says so instead of «Reading…» forever
|
|
4199
|
+
async function loadLocal() {
|
|
4200
|
+
const answer = await fetch('/api/salas/local-ai', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
4201
|
+
if (answer) localSeen = answer;
|
|
4202
|
+
localFailed = !answer;
|
|
4203
|
+
settingsSheet?.sync({ detail: true });
|
|
4204
|
+
return answer;
|
|
4205
|
+
}
|
|
4206
|
+
async function saveLocal(patch) {
|
|
4207
|
+
const answer = await post('/api/salas/local-ai', patch);
|
|
4208
|
+
if (!answer.ok) { toast(answer.body?.error ?? t('Home did not answer.')); return; }
|
|
4209
|
+
localSeen = answer.body;
|
|
4210
|
+
if (localSeen.later?.length) toast(t('{names} will use it when restarted: it was started before this setting existed.', { names: localSeen.later.join(', ') }));
|
|
4211
|
+
settingsSheet?.sync({ detail: true });
|
|
4212
|
+
}
|
|
4213
|
+
// The rail every settings sheet uses (app.js, sxSeg; styles.css, .sx-seg): the mark moves when chosen.
|
|
4214
|
+
function sxSeg(options, current, pick, { label = null } = {}) {
|
|
4215
|
+
const seg = el('div', 'sx-seg');
|
|
4216
|
+
seg.setAttribute('role', 'radiogroup');
|
|
4217
|
+
if (label) seg.setAttribute('aria-label', label);
|
|
4218
|
+
for (const [value, words] of options) {
|
|
4219
|
+
const option = button(null, words, () => {
|
|
4220
|
+
if (value === current || seg.classList.contains('busy')) return;
|
|
4221
|
+
for (const other of seg.children) other.setAttribute('aria-checked', String(other === option));
|
|
4222
|
+
seg.classList.add('busy');
|
|
4223
|
+
void Promise.resolve(pick(value)).finally(() => seg.classList.remove('busy'));
|
|
4224
|
+
});
|
|
4225
|
+
option.setAttribute('role', 'radio');
|
|
4226
|
+
option.setAttribute('aria-checked', String(value === current));
|
|
4227
|
+
seg.append(option);
|
|
4228
|
+
}
|
|
4229
|
+
return seg;
|
|
4230
|
+
}
|
|
4231
|
+
const localModeSeg = () => sxSeg(Object.keys(LOCAL_MODE_WORDS).map((mode) => [mode, LOCAL_MODE_WORDS[mode]()]), localSeen?.mode, (mode) => saveLocal({ mode }), { label: t('Mode') });
|
|
4232
|
+
// The projects in pages of eight, two to a row where they fit: the sheet never needs a scroll.
|
|
4233
|
+
const LOCAL_PER_PAGE = 8;
|
|
4234
|
+
let localPage = 0;
|
|
4235
|
+
function fillLocal(host) {
|
|
4236
|
+
if (!localSeen && localFailed) {
|
|
4237
|
+
// A Home started before this setting existed has no answer for it: reloading the page reaches the one running now.
|
|
4238
|
+
const block = sxBlock(host, t('Home did not answer.'), t('The project serving this page was started before this setting existed. Restart it, or open Home from another project.'));
|
|
4239
|
+
sxRow(block, t('Try again'), null, sxButton(t('Reload'), () => location.reload(), { tone: 'primary' }));
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
if (!localSeen) { host.append(el('p', 'settings-reading', t('Reading…'))); return; }
|
|
4243
|
+
host.append(el('p', 'sx-intro', t('The local AI is Ollama, on this computer: it searches the memory by meaning, sums it up and answers as @madre, without spending tokens or sending anything out. Here you choose which projects use it.')));
|
|
4244
|
+
const mode = sxBlock(host, t('Which projects'), LOCAL_MODE_SAYS[localSeen.mode]());
|
|
4245
|
+
sxRow(mode, t('Mode'), null, localModeSeg());
|
|
4246
|
+
if (!localSeen.master) sxRow(mode, t('Local AI is switched off for every project.'), t('Turn it on in a project, in Modules › Local AI.'));
|
|
4247
|
+
else if (!localSeen.ollama.running) sxRow(mode, t('Ollama is not running'), t('The projects keep working with their agents until it starts. Start it from a project, in Modules › Local AI.'));
|
|
4248
|
+
const list = sxBlock(host, t('Projects'), localSeen.mode === 'auto' ? t('All of them use it. Choose «One by one» or «Manual» to turn some off.') : t('A switch here is the same one the project shows in its Settings.'));
|
|
4249
|
+
if (!localSeen.projects.length) { sxRow(list, t('No projects yet'), null); return; }
|
|
4250
|
+
const pages = Math.ceil(localSeen.projects.length / LOCAL_PER_PAGE);
|
|
4251
|
+
localPage = Math.max(0, Math.min(localPage, pages - 1));
|
|
4252
|
+
const grid = el('div', 'sm-local-grid');
|
|
4253
|
+
for (const one of localSeen.projects.slice(localPage * LOCAL_PER_PAGE, (localPage + 1) * LOCAL_PER_PAGE)) {
|
|
4254
|
+
const item = el('label', `sm-local-item${one.on ? '' : ' off'}`);
|
|
4255
|
+
const words = el('span', 'sm-local-words');
|
|
4256
|
+
words.append(el('b', null, one.name), el('small', null, one.open ? (one.on ? t('Open · using it') : t('Open · without it')) : t('Closed · applies when it opens')));
|
|
4257
|
+
const wrap = el('span', 'settings-switch');
|
|
4258
|
+
const box = el('input');
|
|
4259
|
+
box.type = 'checkbox';
|
|
4260
|
+
box.checked = Boolean(one.chosen);
|
|
4261
|
+
box.disabled = !one.editable || !localSeen.master;
|
|
4262
|
+
box.setAttribute('aria-label', one.name);
|
|
4263
|
+
box.addEventListener('change', () => { box.disabled = true; void saveLocal({ rooms: { [one.id]: box.checked } }); });
|
|
4264
|
+
wrap.append(box, el('i'));
|
|
4265
|
+
item.append(words, wrap);
|
|
4266
|
+
grid.append(item);
|
|
4267
|
+
}
|
|
4268
|
+
// The last page keeps the height of a full one, so ‹ › stay under the pointer.
|
|
4269
|
+
if (pages > 1) for (let gap = grid.children.length; gap < LOCAL_PER_PAGE; gap += 1) grid.append(el('span', 'sm-local-item sm-local-gap'));
|
|
4270
|
+
list.rows.append(grid);
|
|
4271
|
+
if (pages > 1) {
|
|
4272
|
+
const foot = el('div', 'sm-local-foot');
|
|
4273
|
+
const pager = el('div', 'sm-pager');
|
|
4274
|
+
const turn = (step) => { localPage += step; settingsSheet?.sync({ detail: true }); };
|
|
4275
|
+
const back = button('sm-icon-key sm-page', null, () => turn(-1));
|
|
4276
|
+
const on = button('sm-icon-key sm-page', null, () => turn(1));
|
|
4277
|
+
back.innerHTML = PAGE_BACK; // trusted: our own SVG strings
|
|
4278
|
+
on.innerHTML = PAGE_ON;
|
|
4279
|
+
back.setAttribute('aria-label', t('Previous'));
|
|
4280
|
+
on.setAttribute('aria-label', t('Next'));
|
|
4281
|
+
back.disabled = localPage === 0;
|
|
4282
|
+
on.disabled = localPage >= pages - 1;
|
|
4283
|
+
pager.append(back, el('span', 'sm-page-where', t('{n} of {total}', { n: localPage + 1, total: pages })), on);
|
|
4284
|
+
foot.append(pager, el('small', null, t('{n} projects', { n: localSeen.projects.length })));
|
|
4285
|
+
list.rows.append(foot);
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
function fillProjects(host) {
|
|
4289
|
+
const block = sxBlock(host, t('Projects'), t('A project is a folder: MADRE gives it its own conversations and its own memory. Adding it does not open it; you open it from its card.'));
|
|
4290
|
+
sxRow(block, t('Add a project'), null, sxButton(t('Add'), () => { settingsSheet?.close(); openAddSheet(); }, { tone: 'primary' }));
|
|
4291
|
+
sxRow(block, t('See the welcome again'), null, sxButton(t('Open'), () => { settingsSheet?.close(); welcomed = true; openWizard(); }));
|
|
4292
|
+
const back = homeTour.path() === 'back';
|
|
4293
|
+
sxRow(block, back ? t('The tour of MADRE 1.0') : t('Getting to know MADRE'), back ? t('Everything new, chapter by chapter. ? opens the gestures anywhere.') : t('The essentials, chapter by chapter. ? opens the gestures anywhere.'), sxButton(t('See the tour'), () => { settingsSheet?.close(); homeTour.openChapters(); }));
|
|
4294
|
+
}
|
|
4295
|
+
function fillRelease(host) {
|
|
4296
|
+
const info = versionSeen;
|
|
4297
|
+
const block = sxBlock(host, t('Version'), info ? t('You run {version}. Home restarts with it from here.', { version: info.current }) : null);
|
|
4298
|
+
const check = info?.enabled ? sxButton(t('Check now'), async () => {
|
|
4299
|
+
check.disabled = true;
|
|
4300
|
+
const answer = await fetch('/api/version?force=1', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
4301
|
+
if (answer) versionSeen = answer;
|
|
4302
|
+
drawAttention();
|
|
4303
|
+
drawTopbar();
|
|
4304
|
+
settingsSheet?.sync({ detail: true });
|
|
4305
|
+
}) : null;
|
|
4306
|
+
sxRow(block, `MADRE ${info?.current ?? ''}`.trim(), info ? (info.available ? t('{version} available', { version: info.latest }) : info.enabled === false ? t('not checked') : t('up to date')) : t('Reading…'), check);
|
|
4307
|
+
if (info?.available) {
|
|
4308
|
+
const notes = el('a', 'sx-btn', t('What it brings'));
|
|
4309
|
+
notes.href = info.release ?? 'https://madre.run';
|
|
4310
|
+
notes.target = '_blank';
|
|
4311
|
+
notes.rel = 'noopener';
|
|
4312
|
+
sxRow(block, `MADRE ${info.latest}`, t('MADRE {version} is out.', { version: info.latest }), notes);
|
|
4313
|
+
// Home restarts with it, as a project does (app.js, drawRelease): the whole page waits on the
|
|
4314
|
+
// curtain with MADRE's loader through the version arriving, Home closed, and Home back.
|
|
4315
|
+
if (info.install !== 'source') {
|
|
4316
|
+
const fresh = sxBlock(host, t('Restart Home with {version}', { version: info.latest }), t('Home closes, installs it and comes back on this same address in a few seconds. The open projects keep their version until you restart them.'));
|
|
4317
|
+
const said = el('p', 'sx-hint');
|
|
4318
|
+
const restart = sxButton(t('Restart with {version}', { version: info.latest }), () => void updateHome(info, restart, said), { tone: 'primary' });
|
|
4319
|
+
sxRow(fresh, t('Restart here'), null, restart);
|
|
4320
|
+
fresh.rows.append(said);
|
|
4321
|
+
} else {
|
|
4322
|
+
sxRow(block, t('Pull the repository and start it again.'), null);
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
// Home already runs the newer one: the projects still on the older one, each with its restart.
|
|
4326
|
+
const behind = behindRooms();
|
|
4327
|
+
if (behind.length) {
|
|
4328
|
+
const late = sxBlock(host, t('Projects on an older MADRE'), t('They keep running the version they started with until you restart them. What is running in them stops.'), { aside: behind.length > 1 ? sxButton(t('Restart all'), () => void restartAll(), { tone: 'primary' }) : null });
|
|
4329
|
+
for (const room of behind) sxRow(late, roomLabel(room), `MADRE ${room.open.version}`, sxButton(t('Restart'), () => { settingsSheet?.close(); void restartRoom(room); }));
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
async function updateHome(info, key, said) {
|
|
4333
|
+
key.disabled = true;
|
|
4334
|
+
said.textContent = '';
|
|
4335
|
+
const curtain = el('div', 'reload-curtain');
|
|
4336
|
+
const loader = makeLoader({ progress: 0.04, label: t('Downloading MADRE {version}…', { version: info.latest }) });
|
|
4337
|
+
curtain.append(loader);
|
|
4338
|
+
showCurtain(curtain);
|
|
4339
|
+
const drop = (words) => { stop(); curtain.classList.add('leaving'); setTimeout(() => curtain.remove(), 760); key.disabled = false; said.textContent = words; };
|
|
4340
|
+
let stop = creep(loader, { to: 0.45, rate: 0.02 });
|
|
4341
|
+
const answer = await post('/api/updates/apply');
|
|
4342
|
+
if (!answer.ok) { drop(answer.body.error ?? t('The update did not start.')); return; }
|
|
4343
|
+
stop();
|
|
4344
|
+
setLoader(loader, 0.5, t('Restarting MADRE…'));
|
|
4345
|
+
stop = creep(loader, { to: 0.88, rate: 0.015, every: 400 });
|
|
4346
|
+
for (let attempt = 0; attempt < 90; attempt += 1) {
|
|
4347
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
4348
|
+
const next = await fetch('/api/version', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
4349
|
+
if (next?.current && next.current !== info.current) {
|
|
4350
|
+
stop();
|
|
4351
|
+
setLoader(loader, 0.94, t('MADRE {version} is ready', { version: next.current }));
|
|
4352
|
+
try { sessionStorage.setItem('madre.curtain', loader.dataset.p ?? '0.94'); } catch { /* it simply arrives */ }
|
|
4353
|
+
setTimeout(() => location.reload(), 420);
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
drop(t('Home did not come back on its own. Start MADRE again from your terminal.'));
|
|
4358
|
+
}
|
|
4359
|
+
const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, local: fillLocal, projects: fillProjects, release: fillRelease };
|
|
4360
|
+
|
|
4361
|
+
let settingsSheet = null;
|
|
4362
|
+
function openSettings(section = null) {
|
|
4363
|
+
void loadLocal();
|
|
4364
|
+
if (settingsSheet) { settingsSheet.show(section); return; }
|
|
4365
|
+
const state = { open: null, query: '', stops: [] };
|
|
4366
|
+
const dialog = el('dialog', 'sm-sheet sm-settings');
|
|
4367
|
+
dialog.setAttribute('aria-label', t('Home settings'));
|
|
4368
|
+
// The room's window, by its own name, so it wears exactly what the room's wears.
|
|
4369
|
+
const win = el('div', 'sm-sheet-body sm-settings-window');
|
|
4370
|
+
win.id = 'settings';
|
|
4371
|
+
const head = el('header', 'custom-head settings-head');
|
|
4372
|
+
const search = el('label', 'settings-search');
|
|
4373
|
+
search.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="9" cy="9" r="5.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M13.2 13.2 17 17" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; // trusted: our own SVG
|
|
4374
|
+
const input = el('input');
|
|
4375
|
+
input.type = 'search';
|
|
4376
|
+
input.placeholder = t('Search any setting');
|
|
4377
|
+
input.autocomplete = 'off';
|
|
4378
|
+
input.spellcheck = false;
|
|
4379
|
+
input.setAttribute('aria-label', t('Search any setting'));
|
|
4380
|
+
search.append(input);
|
|
4381
|
+
head.append(el('span', 'custom-title', t('SETTINGS')), search, closeKey(() => dialog.close()));
|
|
4382
|
+
const body = el('div', 'sm-settings-body');
|
|
4383
|
+
win.append(head, body);
|
|
4384
|
+
dialog.append(win);
|
|
4385
|
+
document.body.append(dialog);
|
|
4386
|
+
|
|
4387
|
+
const stop = () => { for (const one of state.stops.splice(0)) one(); };
|
|
4388
|
+
const card = (id) => {
|
|
4389
|
+
const status = setStatus(id);
|
|
4390
|
+
const node = el('div', `settings-card${state.open === id ? ' open' : ''}`);
|
|
4391
|
+
node.tabIndex = 0;
|
|
4392
|
+
node.setAttribute('role', 'button');
|
|
4393
|
+
node.dataset.cat = id;
|
|
4394
|
+
const top = el('div', 'settings-card-head');
|
|
4395
|
+
top.append(setIcon(id), el('b', null, catName(id)), chevron('settings-go'));
|
|
4396
|
+
node.append(top, el('p', 'settings-line', status.line));
|
|
4397
|
+
if (status.warn) node.append(el('p', 'settings-warn', status.warn));
|
|
4398
|
+
const quick = setQuick(id);
|
|
4399
|
+
if (quick) node.append(quick);
|
|
4400
|
+
node.addEventListener('click', () => openCategory(id));
|
|
4401
|
+
node.addEventListener('keydown', (event) => { if (event.target === node && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openCategory(id); } });
|
|
4402
|
+
return node;
|
|
4403
|
+
};
|
|
4404
|
+
const board = () => {
|
|
4405
|
+
const node = el('div', 'settings-board');
|
|
4406
|
+
for (const [name, sub, ids] of SET_SECTIONS) {
|
|
4407
|
+
const section = el('section', 'settings-section');
|
|
4408
|
+
const top = el('header');
|
|
4409
|
+
top.append(el('b', null, t(name)), el('span', null, t(sub)));
|
|
4410
|
+
const cards = el('div', 'settings-cards');
|
|
4411
|
+
for (const id of ids) cards.append(card(id));
|
|
4412
|
+
section.append(top, cards);
|
|
4413
|
+
node.append(section);
|
|
4414
|
+
}
|
|
4415
|
+
return node;
|
|
4416
|
+
};
|
|
4417
|
+
const detail = (id) => {
|
|
4418
|
+
const node = el('div', 'settings-detail');
|
|
4419
|
+
const top = el('div', 'settings-detail-head');
|
|
4420
|
+
const title = el('div', 'settings-detail-title');
|
|
4421
|
+
const back = el('button', 'settings-back');
|
|
4422
|
+
back.type = 'button';
|
|
4423
|
+
back.title = t('Back to settings');
|
|
4424
|
+
back.setAttribute('aria-label', t('Back to settings'));
|
|
4425
|
+
back.append(chevron('settings-back-icon'));
|
|
4426
|
+
back.addEventListener('click', () => closeCategory());
|
|
4427
|
+
title.append(back, setIcon(id), el('b', null, catName(id)));
|
|
4428
|
+
top.append(title, el('p', 'settings-line', setStatus(id).line));
|
|
4429
|
+
const host = el('div', 'settings-host');
|
|
4430
|
+
node.append(top, host);
|
|
4431
|
+
SET_FILL[id](host, (one) => state.stops.push(one));
|
|
4432
|
+
return node;
|
|
4433
|
+
};
|
|
4434
|
+
const results = () => {
|
|
4435
|
+
const wanted = fold(state.query.trim());
|
|
4436
|
+
const list = el('div', 'settings-results');
|
|
4437
|
+
const hits = [];
|
|
4438
|
+
for (const id of SET_ORDER) if (fold(catName(id)).includes(wanted)) hits.push([id, null]);
|
|
4439
|
+
for (const [id, key] of SET_INDEX) if (fold(t(key)).includes(wanted)) hits.push([id, t(key)]);
|
|
4440
|
+
for (const agent of agentsSeen?.agents ?? []) if (agent.id !== 'madre' && fold(agent.label ?? agent.id).includes(wanted)) hits.push(['agents', agent.label ?? agent.id]);
|
|
4441
|
+
if (!hits.length) { list.append(el('p', 'settings-reading', t('Nothing called «{query}».', { query: state.query.trim() }))); return list; }
|
|
4442
|
+
for (const [id, said] of hits.slice(0, 40)) {
|
|
4443
|
+
const row = el('button', 'settings-result');
|
|
4444
|
+
row.type = 'button';
|
|
4445
|
+
row.append(setIcon(id), el('b', null, said ?? catName(id)), el('span', null, catName(id)));
|
|
4446
|
+
row.addEventListener('click', () => { state.query = ''; input.value = ''; openCategory(id, said); });
|
|
4447
|
+
list.append(row);
|
|
4448
|
+
}
|
|
4449
|
+
return list;
|
|
4450
|
+
};
|
|
4451
|
+
const draw = () => {
|
|
4452
|
+
stop();
|
|
4453
|
+
if (state.query.trim()) { body.replaceChildren(results()); return; }
|
|
4454
|
+
if (!state.open) { body.replaceChildren(board()); return; }
|
|
4455
|
+
// Appearance has more to show than a sheet holds: it takes the whole window, as a category
|
|
4456
|
+
// does in the room when a sheet would be cramped (app.js, «push»). The others open over the board.
|
|
4457
|
+
const full = state.open === 'look';
|
|
4458
|
+
const sheet = el('div', `settings-sheet${full ? ' full' : ''}`);
|
|
4459
|
+
const scroll = el('div', 'settings-scroll');
|
|
4460
|
+
scroll.append(detail(state.open));
|
|
4461
|
+
sheet.append(scroll);
|
|
4462
|
+
if (full) { body.replaceChildren(sheet); return; }
|
|
4463
|
+
const scrim = el('div', 'settings-scrim');
|
|
4464
|
+
scrim.addEventListener('click', () => closeCategory());
|
|
4465
|
+
body.replaceChildren(board(), scrim, sheet);
|
|
4466
|
+
};
|
|
4467
|
+
const openCategory = (id, landOn = null) => {
|
|
4468
|
+
state.open = id;
|
|
4469
|
+
draw();
|
|
4470
|
+
dialog.scrollTop = 0;
|
|
4471
|
+
if (!landOn) { body.querySelector('.settings-back')?.focus({ preventScroll: true }); return; }
|
|
4472
|
+
// Land on the setting the search found, once its sheet has drawn.
|
|
4473
|
+
let tries = 0;
|
|
4474
|
+
const land = () => {
|
|
4475
|
+
const hit = [...body.querySelectorAll('.settings-detail :is(.sx-row b, .sx-head h3, .tune-name, button)')].find((node) => fold(node.textContent).includes(fold(landOn)));
|
|
4476
|
+
if (hit) { hit.scrollIntoView({ block: 'center', behavior: 'smooth' }); const row = hit.closest('.sx-row, .tune-row, .sx-block') ?? hit; row.classList.add('settings-found'); setTimeout(() => row.classList.remove('settings-found'), 1600); return; }
|
|
4477
|
+
if ((tries += 1) < 20) setTimeout(land, 150);
|
|
4478
|
+
};
|
|
4479
|
+
setTimeout(land, 60);
|
|
4480
|
+
};
|
|
4481
|
+
const closeCategory = () => {
|
|
4482
|
+
const was = state.open;
|
|
4483
|
+
state.open = null;
|
|
4484
|
+
draw();
|
|
4485
|
+
body.querySelector(`.settings-card[data-cat="${was}"]`)?.focus({ preventScroll: true });
|
|
4486
|
+
};
|
|
4487
|
+
input.addEventListener('input', () => { state.query = input.value; draw(); });
|
|
4488
|
+
// Escape steps back the way the person came: out of the search, out of the sheet, then away.
|
|
4489
|
+
dialog.addEventListener('cancel', (event) => {
|
|
4490
|
+
if (state.query) { event.preventDefault(); state.query = ''; input.value = ''; draw(); return; }
|
|
4491
|
+
if (state.open) { event.preventDefault(); closeCategory(); }
|
|
4492
|
+
});
|
|
4493
|
+
// What the lobby learns while the window is open (an agent signs in, a version is found, a
|
|
4494
|
+
// project opens) reaches the cards and the sheet's line; a card that says the same is left alone,
|
|
4495
|
+
// so nothing under the pointer or the keyboard is drawn again for nothing.
|
|
4496
|
+
const sync = ({ detail: again = false } = {}) => {
|
|
4497
|
+
if (state.query.trim()) return;
|
|
4498
|
+
for (const old of body.querySelectorAll('.settings-card')) {
|
|
4499
|
+
const fresh = card(old.dataset.cat);
|
|
4500
|
+
if (fresh.outerHTML !== old.outerHTML) old.replaceWith(fresh);
|
|
4501
|
+
}
|
|
4502
|
+
const line = body.querySelector('.settings-detail-head .settings-line');
|
|
4503
|
+
if (line && state.open) line.textContent = setStatus(state.open).line;
|
|
4504
|
+
// The sheet stays where it is (it slid in once); only what is inside it is drawn again.
|
|
4505
|
+
const scroll = body.querySelector('.settings-scroll');
|
|
4506
|
+
if (again && state.open && scroll) { stop(); scroll.replaceChildren(detail(state.open)); }
|
|
4507
|
+
};
|
|
4508
|
+
// A look changed (here, in a room's PERSONALIZE at this address, or in another tab): the cards
|
|
4509
|
+
// say so, and Appearance is drawn again with it.
|
|
4510
|
+
const paintLooks = () => { sync({ detail: state.open === 'look' }); };
|
|
4511
|
+
const show = (wanted) => {
|
|
4512
|
+
if (!dialog.open) dialog.showModal();
|
|
4513
|
+
if (wanted && SET_FILL[wanted]) openCategory(wanted); else draw();
|
|
4514
|
+
};
|
|
4515
|
+
const close = () => dialog.close();
|
|
4516
|
+
settingsSheet = { show, paintLooks, sync, close };
|
|
4517
|
+
dialog.addEventListener('close', () => { stop(); dialog.remove(); settingsSheet = null; });
|
|
4518
|
+
show(section);
|
|
4519
|
+
}
|
|
4520
|
+
ui.gear.addEventListener('click', () => openSettings());
|
|
4521
|
+
|
|
4522
|
+
function translateStatic() {
|
|
4523
|
+
document.title = `MADRE · ${t('Home')}`;
|
|
4524
|
+
ui.title.textContent = t('Home');
|
|
4525
|
+
ui.add.textContent = t('Add a project');
|
|
4526
|
+
ui.bar.crumb.textContent = t('Home');
|
|
4527
|
+
ui.bar.open.setAttribute('aria-label', t('Projects on the bar'));
|
|
4528
|
+
ui.bar.agents.setAttribute('aria-label', t('Agents'));
|
|
4529
|
+
ui.bar.links.querySelector('span').textContent = t('Connections');
|
|
4530
|
+
ui.bar.reload.title = t('Reload the page');
|
|
4531
|
+
ui.bar.reload.setAttribute('aria-label', t('Reload the page'));
|
|
4532
|
+
ui.stop.querySelector('span').textContent = t('Stop all');
|
|
4533
|
+
ui.macro.querySelector('span').textContent = t('Project map');
|
|
4534
|
+
ui.gear.title = t('Home settings');
|
|
4535
|
+
ui.gear.setAttribute('aria-label', t('Home settings'));
|
|
4536
|
+
ui.search.placeholder = t('Search a project…');
|
|
4537
|
+
ui.search.setAttribute('aria-label', t('Search a project…'));
|
|
4538
|
+
ui.filter.setAttribute('aria-label', t('Show'));
|
|
4539
|
+
$('sm-drawer-title').textContent = t('Projects');
|
|
4540
|
+
const FILTER_WORDS = { all: t('All projects'), open: t('Open projects'), asleep: t('Asleep projects') };
|
|
4541
|
+
for (const choice of ui.filter.querySelectorAll('[data-filter]')) choice.textContent = FILTER_WORDS[choice.dataset.filter];
|
|
4542
|
+
paintOrderKey();
|
|
4543
|
+
$('sm-bishop-name').textContent = t('Coordinator');
|
|
4544
|
+
ui.bishop.go.textContent = t('Talk to the Coordinator');
|
|
4545
|
+
ui.bishop.tab.title = t('Open the Coordinator in a new tab');
|
|
4546
|
+
ui.bishop.tab.setAttribute('aria-label', t('Open the Coordinator in a new tab'));
|
|
4547
|
+
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.');
|
|
4548
|
+
ui.work.title.textContent = t('Recent summary');
|
|
4549
|
+
ui.work.again.textContent = t('Summarise again');
|
|
4550
|
+
ui.waiting.title.textContent = t('Waiting on you');
|
|
4551
|
+
ui.linksTileTitle.textContent = t('Connections');
|
|
4552
|
+
ui.link.textContent = t('Connect projects');
|
|
4553
|
+
ui.linksAll.textContent = t('See the connections');
|
|
4554
|
+
ui.linksTitle.textContent = t('Connections');
|
|
4555
|
+
ui.linksClose.setAttribute('aria-label', t('Close'));
|
|
4556
|
+
ui.linkMore.textContent = t('Connect projects');
|
|
4557
|
+
ui.memory.title.textContent = t('Memory');
|
|
4558
|
+
ui.decisions.title.textContent = t('Recent decisions');
|
|
4559
|
+
ui.attention.title.textContent = t('Needs you');
|
|
4560
|
+
for (const kind of KINDS) KIND_WORDS[kind] = t(kind.toUpperCase());
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
translateStatic();
|
|
4564
|
+
drawWork();
|
|
4565
|
+
drawDecisions();
|
|
4566
|
+
await refresh();
|
|
4567
|
+
// Arriving from RELOAD (salas-mac.html put the curtain back): Home has read its projects, so the
|
|
4568
|
+
// bar fills and the curtain clears.
|
|
4569
|
+
{
|
|
4570
|
+
const curtain = document.getElementById('reload-curtain');
|
|
4571
|
+
if (curtain) {
|
|
4572
|
+
void finishLoader(curtain.querySelector('.madre-loader')).then(() => {
|
|
4573
|
+
curtain.classList.add('leaving');
|
|
4574
|
+
setTimeout(() => curtain.remove(), 760);
|
|
4575
|
+
});
|
|
4576
|
+
}
|
|
4577
|
+
}
|
|
4578
|
+
void loadAgents();
|
|
4579
|
+
void loadVersion();
|
|
4580
|
+
void loadWork();
|
|
4581
|
+
void loadDecisions();
|
|
4582
|
+
// `#welcome`: the first-run wizard again, on purpose (to connect an agent or add projects in steps).
|
|
4583
|
+
if (location.hash === '#welcome') { history.replaceState(null, '', location.pathname); welcomed = true; openWizard(); }
|
|
4584
|
+
// Sent here for the Macroverso (the key in BISHOP's room): straight into it, on a large screen.
|
|
4585
|
+
if (location.hash === '#macroverso' && data && !ui.macro.hidden) { history.replaceState(null, '', location.pathname); ui.macro.click(); }
|
|
4586
|
+
timer = setInterval(() => { if (!document.hidden && !busy.size && !pins.dropping && !pins.drag) void refresh(); }, 4000);
|
|
4587
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) { void refresh(); void loadAgents(); void loadWork(); void loadDecisions(); } });
|
|
4588
|
+
// The decisions are asked again every few minutes (the route itself asks the rooms no oftener).
|
|
4589
|
+
setInterval(() => { if (!document.hidden) void loadDecisions(); }, 5 * 60 * 1000);
|
|
4590
|
+
|
|
4591
|
+
// Asking for a star or a review (review-ask.js), when Home is quiet: nothing waking or closing,
|
|
4592
|
+
// the bar not being arranged.
|
|
4593
|
+
startReviewAsk({ busy: () => busy.size > 0 || pins.editing || Boolean(pins.drag) || Boolean(pins.dropping) });
|