@jossuealcala/madre 0.7.1 → 1.0.0
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 +833 -1
- package/README.md +92 -28
- package/SECURITY.md +1 -0
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +158 -79
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11599 -4186
- 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 +2863 -1008
- 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 +1497 -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 +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6303 -414
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +49 -8
- 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 +2 -2
- 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 +45 -6
- 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/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +42 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -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 +109 -1
- 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 +19 -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 +9 -5
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +34 -0
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +25 -1
- package/src/outbound.mjs +19 -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 +88 -8
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +32 -13
- package/src/room/runs.mjs +151 -0
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +402 -54
- package/src/router.mjs +3 -1
- package/src/runs.mjs +137 -0
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1578 -167
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -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
|
@@ -0,0 +1,3264 @@
|
|
|
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 versionSeen = null; // what /api/version said of MADRE, last time it was asked
|
|
44
|
+
let work = null; // MADRE's summary of the last work (/api/salas/work)
|
|
45
|
+
let decisions = null; // the recent decisions of the open rooms (/api/salas/decisions)
|
|
46
|
+
let filter = 'all'; // which projects show: all, open or asleep
|
|
47
|
+
let query = ''; // what the search box holds
|
|
48
|
+
// What each card is in the middle of, kept across the refreshes that redraw it.
|
|
49
|
+
const busy = new Map(); // room id → 'starting' | 'closing'
|
|
50
|
+
const asking = new Set(); // room ids whose card asks before closing
|
|
51
|
+
const forgetting = new Set(); // room ids whose card asks before leaving MADRE
|
|
52
|
+
const failed = new Map(); // room id → what went wrong, said on its card
|
|
53
|
+
// One is one: «1 conversaciones» read wrong on every new project's card.
|
|
54
|
+
const conversationsCount = (n) => (n === 1 ? t('1 conversation') : t('{n} conversations', { n }));
|
|
55
|
+
const memoriesCount = (n) => (n === 1 ? t('1 memory') : t('{n} memories', { n }));
|
|
56
|
+
const revoking = new Set(); // link ids whose row asks before revoking
|
|
57
|
+
|
|
58
|
+
// The room's looks, from this browser: the same keys the room's own page keeps (app.js, LOOKS),
|
|
59
|
+
// so a choice made here is the room's too when the room is served at this address.
|
|
60
|
+
const ACCENTS = ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon', 'pacific', 'love', 'sierra', 'pitahaya', 'papaya', 'agave', 'dinamo', 'marina', 'pulso', 'leal', 'dulce'];
|
|
61
|
+
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') };
|
|
62
|
+
// The wallpapers are the room's (app.js, LOOKS.wallpaper; styles.css, --wp-*): the same images, the
|
|
63
|
+
// same veil, the same «mesh» and blur the room's PERSONALIZE sets, read from the same keys.
|
|
64
|
+
const WALLPAPERS = ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania'];
|
|
65
|
+
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' };
|
|
66
|
+
// The conversation's own looks (app.js, LOOKS.bubbles and LOOKS.text): the lobby has no
|
|
67
|
+
// conversation, so here they are only chosen, for the room served at this address.
|
|
68
|
+
const BUBBLES = ['comfortable', 'large', 'compact', 'fitted'];
|
|
69
|
+
const TEXTS = ['comfortable', 'larger', 'smaller', 'visual'];
|
|
70
|
+
// The photographs' two dials, as the room keeps them (app.js, TUNES): the first value is no key.
|
|
71
|
+
const TUNES = {
|
|
72
|
+
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)) },
|
|
73
|
+
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`) },
|
|
74
|
+
};
|
|
75
|
+
// Home's own choices (2026-10-11): how its project cards read, how large its text is and in which
|
|
76
|
+
// order the projects come. Kept in this browser for Home alone: a project keeps its conversation's
|
|
77
|
+
// (bubbles and text) in its own Settings. The first value of each is Home as it always was.
|
|
78
|
+
const HOME_LOOKS = {
|
|
79
|
+
cards: { key: 'pulse.home.cards', values: ['full', 'compact'], words: { full: () => t('With their last conversation'), compact: () => t('Name and state only') } },
|
|
80
|
+
text: { key: 'pulse.home.text', values: ['comfortable', 'larger', 'smaller'], words: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller') } },
|
|
81
|
+
order: { key: 'pulse.home.order', values: ['attention', 'name', 'recent'], words: { attention: () => t('Attention'), name: () => t('Name'), recent: () => t('Recent') } },
|
|
82
|
+
};
|
|
83
|
+
const LOOK_KEYS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', ...Object.values(HOME_LOOKS).map((one) => one.key)];
|
|
84
|
+
function readLooks() {
|
|
85
|
+
let theme = 'auto';
|
|
86
|
+
let accent = null;
|
|
87
|
+
let wallpaper = null;
|
|
88
|
+
let style = null;
|
|
89
|
+
let veil = null;
|
|
90
|
+
let blur = null;
|
|
91
|
+
let bubbles = null;
|
|
92
|
+
let text = null;
|
|
93
|
+
try {
|
|
94
|
+
theme = localStorage.getItem('pulse.theme') ?? 'auto'; accent = localStorage.getItem('pulse.accent'); wallpaper = localStorage.getItem('pulse.wallpaper');
|
|
95
|
+
style = localStorage.getItem('pulse.wallpaper.style'); veil = localStorage.getItem('pulse.wallpaper.veil'); blur = localStorage.getItem('pulse.wallpaper.blur');
|
|
96
|
+
bubbles = localStorage.getItem('pulse.bubbles'); text = localStorage.getItem('pulse.text');
|
|
97
|
+
} catch { /* no storage */ }
|
|
98
|
+
const number = (raw, low, high, fallback) => { const value = raw === null ? Number.NaN : Number(raw); return Number.isFinite(value) && value >= low && value <= high ? value : fallback; };
|
|
99
|
+
return {
|
|
100
|
+
theme: ['auto', 'light', 'dark'].includes(theme) ? theme : 'auto',
|
|
101
|
+
accent: ACCENTS.includes(accent) ? accent : 'madre',
|
|
102
|
+
wallpaper: WALLPAPERS.includes(wallpaper) ? wallpaper : 'default',
|
|
103
|
+
style: style === 'mesh' ? 'mesh' : 'veil',
|
|
104
|
+
veil: number(veil, 0, 140, 100),
|
|
105
|
+
blur: number(blur, 0, 24, 0),
|
|
106
|
+
bubbles: BUBBLES.includes(bubbles) ? bubbles : BUBBLES[0],
|
|
107
|
+
text: TEXTS.includes(text) ? text : TEXTS[0],
|
|
108
|
+
home: Object.fromEntries(Object.entries(HOME_LOOKS).map(([name, spec]) => {
|
|
109
|
+
let value = null;
|
|
110
|
+
try { value = localStorage.getItem(spec.key); } catch { /* no storage */ }
|
|
111
|
+
return [name, spec.values.includes(value) ? value : spec.values[0]];
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function applyLooks() {
|
|
116
|
+
const { theme, accent } = readLooks();
|
|
117
|
+
const light = window.matchMedia?.('(prefers-color-scheme: light)').matches;
|
|
118
|
+
if (theme === 'light' || theme === 'dark') root.dataset.theme = theme; else delete root.dataset.theme;
|
|
119
|
+
root.dataset.scheme = theme === 'auto' ? (light ? 'light' : 'dark') : theme;
|
|
120
|
+
if (accent !== 'madre') root.dataset.accent = accent; else delete root.dataset.accent;
|
|
121
|
+
// The wallpaper, its style and its two dials, as the room applies them (app.js, applyLook, TUNES).
|
|
122
|
+
const looks = readLooks();
|
|
123
|
+
if (looks.wallpaper !== 'default') root.dataset.wallpaper = looks.wallpaper; else delete root.dataset.wallpaper;
|
|
124
|
+
if (looks.style === 'mesh') root.dataset.wpStyle = 'mesh'; else delete root.dataset.wpStyle;
|
|
125
|
+
root.style.setProperty('--veil-k', String(looks.veil / 100));
|
|
126
|
+
root.style.setProperty('--wp-blur', `${looks.blur}px`);
|
|
127
|
+
if (looks.bubbles !== BUBBLES[0]) root.dataset.bubbles = looks.bubbles; else delete root.dataset.bubbles;
|
|
128
|
+
if (looks.text !== TEXTS[0]) root.dataset.text = looks.text; else delete root.dataset.text;
|
|
129
|
+
// Home's own: its cards and its text (salas-mac.css, data-home-*); the order is draw()'s.
|
|
130
|
+
if (looks.home.cards !== 'full') root.dataset.homeCards = looks.home.cards; else delete root.dataset.homeCards;
|
|
131
|
+
if (looks.home.text !== 'comfortable') root.dataset.homeText = looks.home.text; else delete root.dataset.homeText;
|
|
132
|
+
}
|
|
133
|
+
applyLooks();
|
|
134
|
+
function saveLooks({ theme, accent, wallpaper, style, bubbles, text }) {
|
|
135
|
+
// The first value of each look is the room as it always was, kept as no choice at all (app.js, saveLook).
|
|
136
|
+
const keep = (key, value, first) => { if (value === first) localStorage.removeItem(key); else localStorage.setItem(key, value); };
|
|
137
|
+
try {
|
|
138
|
+
if (theme) localStorage.setItem('pulse.theme', theme);
|
|
139
|
+
if (accent) keep('pulse.accent', accent, 'madre');
|
|
140
|
+
if (wallpaper) keep('pulse.wallpaper', wallpaper, 'default');
|
|
141
|
+
if (style) keep('pulse.wallpaper.style', style, 'veil');
|
|
142
|
+
if (bubbles) keep('pulse.bubbles', bubbles, BUBBLES[0]);
|
|
143
|
+
if (text) keep('pulse.text', text, TEXTS[0]);
|
|
144
|
+
} catch { /* the choice lasts as long as the page */ }
|
|
145
|
+
applyLooks();
|
|
146
|
+
settingsSheet?.paintLooks();
|
|
147
|
+
pushLooks();
|
|
148
|
+
}
|
|
149
|
+
function saveHome(name, value) {
|
|
150
|
+
const spec = HOME_LOOKS[name];
|
|
151
|
+
try { if (value === spec.values[0]) localStorage.removeItem(spec.key); else localStorage.setItem(spec.key, value); } catch { /* the choice lasts as long as the page */ }
|
|
152
|
+
applyLooks();
|
|
153
|
+
if (name === 'order' && typeof draw === 'function') draw();
|
|
154
|
+
settingsSheet?.paintLooks();
|
|
155
|
+
}
|
|
156
|
+
// A dial is saved when it is let go and shown while it moves; the sheet is not painted again under
|
|
157
|
+
// the hand (app.js, saveTune).
|
|
158
|
+
function saveTune(name, value) {
|
|
159
|
+
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 */ }
|
|
160
|
+
applyLooks(); pushLooks();
|
|
161
|
+
}
|
|
162
|
+
window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', applyLooks);
|
|
163
|
+
// The looks are the person's, not Home's (src/looks.mjs, 2026-10-12): every project is another
|
|
164
|
+
// origin, so what this browser kept here no project saw. ~/.pulse/looks.json is read when Home
|
|
165
|
+
// opens and whenever it comes back into view, and written when a choice changes. The twin of
|
|
166
|
+
// app.js's pullLooks; Home's own card choices (HOME_LOOKS) stay here.
|
|
167
|
+
const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
|
|
168
|
+
let looksPush = 0;
|
|
169
|
+
function pushLooks() {
|
|
170
|
+
clearTimeout(looksPush);
|
|
171
|
+
looksPush = setTimeout(() => {
|
|
172
|
+
const patch = {};
|
|
173
|
+
try { for (const key of SHARED_LOOKS) patch[key] = localStorage.getItem(key); } catch { return; }
|
|
174
|
+
void fetch('/api/looks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }).catch(() => null);
|
|
175
|
+
}, 250);
|
|
176
|
+
}
|
|
177
|
+
async function pullLooks() {
|
|
178
|
+
let answer = null;
|
|
179
|
+
try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
|
|
180
|
+
if (!answer?.looks) return;
|
|
181
|
+
try {
|
|
182
|
+
// Nothing kept yet: the first page that has a choice of its own hands it to the others.
|
|
183
|
+
if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
|
|
184
|
+
let changed = false;
|
|
185
|
+
for (const key of SHARED_LOOKS) {
|
|
186
|
+
const value = answer.looks[key] ?? null;
|
|
187
|
+
if (localStorage.getItem(key) === value) continue;
|
|
188
|
+
if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
if (changed) { applyLooks(); settingsSheet?.paintLooks(); }
|
|
192
|
+
} catch { /* no storage: Home keeps the look it has */ }
|
|
193
|
+
}
|
|
194
|
+
void pullLooks();
|
|
195
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void pullLooks(); });
|
|
196
|
+
window.addEventListener('storage', (event) => { if (LOOK_KEYS.includes(event.key)) { applyLooks(); settingsSheet?.paintLooks(); } });
|
|
197
|
+
|
|
198
|
+
const el = (tag, className, text) => {
|
|
199
|
+
const node = document.createElement(tag);
|
|
200
|
+
if (className) node.className = className;
|
|
201
|
+
if (text !== undefined && text !== null) node.textContent = text;
|
|
202
|
+
return node;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// The same small words as the phone's SALAS (salas.js).
|
|
206
|
+
const chatName = (title) => (title === 'First conversation' ? t('First conversation') : title === 'Untitled' ? t('Untitled') : title === 'Coordinator' ? t('Coordinator') : title);
|
|
207
|
+
function ago(iso) {
|
|
208
|
+
const at = Date.parse(iso ?? '');
|
|
209
|
+
if (!Number.isFinite(at)) return '';
|
|
210
|
+
const seconds = (at - Date.now()) / 1000;
|
|
211
|
+
const away = Math.abs(seconds);
|
|
212
|
+
const words = new Intl.RelativeTimeFormat(language, { numeric: 'auto', style: 'short' });
|
|
213
|
+
if (away < 60) return t('now');
|
|
214
|
+
if (away < 3600) return words.format(Math.round(seconds / 60), 'minute');
|
|
215
|
+
if (away < 86400) return words.format(Math.round(seconds / 3600), 'hour');
|
|
216
|
+
if (away < 604800) return words.format(Math.round(seconds / 86400), 'day');
|
|
217
|
+
return new Date(at).toLocaleDateString(language, { day: 'numeric', month: 'short' });
|
|
218
|
+
}
|
|
219
|
+
// The modes as the room names them (app.js, MODES): asked when shown, so the language is the page's.
|
|
220
|
+
const modeName = (n) => [t('INCOGNITO'), t('CHAT ONLY'), t('CREATE'), t('EDIT'), t('FULL ACCESS')][n] ?? '';
|
|
221
|
+
function askLine(ask) {
|
|
222
|
+
if (ask.kind === 'mode') return t('@{agent} asks for #{n} {label}', { agent: ask.agent, n: ask.mode, label: modeName(ask.mode) });
|
|
223
|
+
if (ask.kind === 'run') return ask.text ? t('@{agent} wants to run {line}', { agent: ask.agent, line: ask.text }) : t('@{agent} wants to run a command', { agent: ask.agent });
|
|
224
|
+
if (ask.kind === 'bishop') return t('The Coordinator proposes #{n} to @{agent}: unlock it with your key', { n: ask.mode, agent: ask.agent });
|
|
225
|
+
if (ask.kind === 'plan') return ask.text ? t('The Coordinator waits for your press: {reason}', { reason: ask.text }) : t('The Coordinator waits for your press');
|
|
226
|
+
return ask.text ? t('@{agent} asks you: {question}', { agent: ask.agent, question: ask.text }) : t('@{agent} asks you to choose', { agent: ask.agent });
|
|
227
|
+
}
|
|
228
|
+
// The folder, as a person writes it: the home folder is ~.
|
|
229
|
+
const homeWord = (path) => String(path ?? '').replace(/^\/(Users|home)\/[^/]+/, '~');
|
|
230
|
+
// Where a room is reached from this page: this one is here, the rest at their own port.
|
|
231
|
+
const addressOf = (room) => (room.here ? '/' : `http://127.0.0.1:${room.open.port}/`);
|
|
232
|
+
|
|
233
|
+
// The memory kinds, in the colours NOSTROMO gives them (styles.css, --mem-*).
|
|
234
|
+
const KINDS = ['decision', 'fact', 'preference', 'question', 'aberration'];
|
|
235
|
+
const KIND_WORDS = { decision: t('DECISION'), fact: t('FACT'), preference: t('PREFERENCE'), question: t('QUESTION'), aberration: t('ABERRATION') };
|
|
236
|
+
// A kind as a sentence says it: «Decisión», not «DECISIÓN».
|
|
237
|
+
const kindWord = (kind) => { const word = String(KIND_WORDS[kind] ?? kind).toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1); };
|
|
238
|
+
|
|
239
|
+
function memoryStrip(census) {
|
|
240
|
+
const strip = el('div', 'sm-strip');
|
|
241
|
+
const total = census?.memories ?? 0;
|
|
242
|
+
strip.setAttribute('role', 'img');
|
|
243
|
+
if (!total) { strip.classList.add('none'); strip.setAttribute('aria-label', t('No memories yet')); return strip; }
|
|
244
|
+
const said = [];
|
|
245
|
+
for (const kind of KINDS) {
|
|
246
|
+
const count = census.kinds?.[kind] ?? 0;
|
|
247
|
+
if (!count) continue;
|
|
248
|
+
const part = el('i', `k-${kind}`);
|
|
249
|
+
part.style.flexGrow = String(count);
|
|
250
|
+
strip.append(part);
|
|
251
|
+
said.push(`${count} ${KIND_WORDS[kind].toLowerCase()}`);
|
|
252
|
+
}
|
|
253
|
+
strip.setAttribute('aria-label', said.join(', '));
|
|
254
|
+
strip.title = said.join(' · ');
|
|
255
|
+
return strip;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function stateOf(room) {
|
|
259
|
+
const glance = room.glance ?? {};
|
|
260
|
+
if (!room.open) return { key: 'asleep', words: room.lastOpened ? t('Asleep · last open {when}', { when: ago(room.lastOpened) }) : t('Asleep') };
|
|
261
|
+
if (glance.outdated) return { key: 'outdated', words: t('Update MADRE in this project to see it here.') };
|
|
262
|
+
if (glance.unreachable) return { key: 'quiet', words: t('Not answering right now') };
|
|
263
|
+
if (glance.waitingCount) return { key: 'waiting', words: t('Waiting for you · {n}', { n: glance.waitingCount }) };
|
|
264
|
+
if (glance.working?.length) return { key: 'working', words: glance.working.length === 1 ? t('@{agent} is working', { agent: glance.working[0] }) : t('{n} agents working', { n: glance.working.length }) };
|
|
265
|
+
return { key: 'calm', words: t('Calm') };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function button(className, words, onClick) {
|
|
269
|
+
const node = el('button', className, words);
|
|
270
|
+
node.type = 'button';
|
|
271
|
+
node.addEventListener('click', onClick);
|
|
272
|
+
return node;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function card(room) {
|
|
276
|
+
const glance = room.glance ?? {};
|
|
277
|
+
const state = stateOf(room);
|
|
278
|
+
const node = el('article', `sm-tile sm-card is-${state.key}${room.here ? ' here' : ''}`);
|
|
279
|
+
node.dataset.room = room.id;
|
|
280
|
+
// To the bar: dragged there, or pinned from its right-click.
|
|
281
|
+
node.draggable = true;
|
|
282
|
+
node.addEventListener('dragstart', (event) => pinsDragOut(event, room));
|
|
283
|
+
node.addEventListener('dragend', () => pinsDropEnd());
|
|
284
|
+
node.addEventListener('contextmenu', (event) => pinsMenu(event, room));
|
|
285
|
+
|
|
286
|
+
const head = el('header', 'sm-card-head');
|
|
287
|
+
head.append(el('h3', null, room.name));
|
|
288
|
+
if (room.here) head.append(el('span', 'sm-chip here', t('You are here')));
|
|
289
|
+
else if (room.open) head.append(el('span', 'sm-chip port', `:${room.open.port}`));
|
|
290
|
+
// What the right-click offers, for whoever does not right-click: a key of its own.
|
|
291
|
+
const more = button('sm-card-more', null, (event) => {
|
|
292
|
+
event.stopPropagation();
|
|
293
|
+
const box = more.getBoundingClientRect();
|
|
294
|
+
pinsMenu(event, room, { x: box.right - 214, y: box.bottom + 6 });
|
|
295
|
+
});
|
|
296
|
+
more.innerHTML = MORE_ICON; // trusted: our own SVG string
|
|
297
|
+
more.title = t('Options for {name}', { name: room.name });
|
|
298
|
+
more.setAttribute('aria-label', more.title);
|
|
299
|
+
more.setAttribute('aria-haspopup', 'menu');
|
|
300
|
+
head.append(more);
|
|
301
|
+
node.append(head);
|
|
302
|
+
if (room.root) node.append(el('p', 'sm-path', homeWord(room.root)));
|
|
303
|
+
|
|
304
|
+
const line = el('p', 'sm-state');
|
|
305
|
+
line.append(el('i', 'dot'), document.createTextNode(state.words));
|
|
306
|
+
node.append(line);
|
|
307
|
+
|
|
308
|
+
if (glance.waiting?.length) {
|
|
309
|
+
const box = el('div', 'sm-wait');
|
|
310
|
+
box.append(el('span', null, askLine(glance.waiting[0])));
|
|
311
|
+
node.append(box);
|
|
312
|
+
}
|
|
313
|
+
if (glance.chat && !glance.outdated) {
|
|
314
|
+
const chat = el('div', 'sm-chat');
|
|
315
|
+
chat.append(el('b', null, chatName(glance.chat.title)));
|
|
316
|
+
if (glance.chat.preview) chat.append(el('span', null, glance.chat.preview));
|
|
317
|
+
node.append(chat);
|
|
318
|
+
}
|
|
319
|
+
if (room.open && glance.census) {
|
|
320
|
+
node.append(memoryStrip(glance.census));
|
|
321
|
+
const counts = [conversationsCount(glance.chats ?? 0), memoriesCount(glance.census.memories)];
|
|
322
|
+
if (glance.census.memories) counts.push(t('{n} travel', { n: glance.census.travelling }));
|
|
323
|
+
node.append(el('p', 'sm-counts', counts.join(' · ')));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const actions = el('div', 'sm-actions');
|
|
327
|
+
const doing = busy.get(room.id);
|
|
328
|
+
if (failed.has(room.id)) node.append(el('p', 'sm-error', failed.get(room.id)));
|
|
329
|
+
if (doing) actions.append(waitFor(room.id, { starting: t('Waking…'), closing: t('Closing…'), forgetting: t('Removing…') }[doing] ?? ''));
|
|
330
|
+
else if (forgetting.has(room.id)) {
|
|
331
|
+
// Taking it out of MADRE says what goes and what stays before it happens.
|
|
332
|
+
const links = (data.enlaces ?? []).filter((one) => one.rooms?.a === room.id || one.rooms?.b === room.id).length;
|
|
333
|
+
const why = [
|
|
334
|
+
room.open ? t('It closes and leaves Home.') : t('It leaves Home.'),
|
|
335
|
+
t('Its folder stays on this Mac, and its memory in MADRE.'),
|
|
336
|
+
...(links ? [links === 1 ? t('Its connection ends.') : t('Its {n} connections end.', { n: links })] : []),
|
|
337
|
+
].join(' ');
|
|
338
|
+
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(); }));
|
|
339
|
+
} else if (asking.has(room.id)) {
|
|
340
|
+
const why = room.here ? t('This page closes with it.') : glance.working?.length ? t('What is running stops.') : t('Nothing is running.');
|
|
341
|
+
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(); }));
|
|
342
|
+
} else if (room.open) {
|
|
343
|
+
const go = el('a', 'sm-btn primary small', room.here ? t('Back to the project') : t('Open'));
|
|
344
|
+
go.href = addressOf(room);
|
|
345
|
+
actions.append(go);
|
|
346
|
+
if (!glance.outdated) actions.append(button('sm-btn ghost small', t('Close'), () => { failed.delete(room.id); asking.add(room.id); draw(); }));
|
|
347
|
+
} else if (room.root) {
|
|
348
|
+
actions.append(button('sm-btn primary small', t('Open'), () => startRoom(room, { enter: true })));
|
|
349
|
+
}
|
|
350
|
+
node.append(actions);
|
|
351
|
+
return node;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Open first, by what needs the person: waiting, then working, then the rest, newest first.
|
|
355
|
+
const ORDER = { waiting: 0, working: 1, calm: 2, quiet: 3, outdated: 4 };
|
|
356
|
+
// The projects in the order the lobby shows them: the open ones by what needs the person, then
|
|
357
|
+
// the asleep ones, the last opened first. BISHOP's own room is not a project.
|
|
358
|
+
function projectsInOrder() {
|
|
359
|
+
const projects = data.rooms.filter((room) => room.id !== data.salas);
|
|
360
|
+
const open = projects.filter((room) => room.open)
|
|
361
|
+
.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)));
|
|
362
|
+
const asleep = projects.filter((room) => !room.open).sort((a, b) => Date.parse(b.lastOpened ?? b.added ?? 0) - Date.parse(a.lastOpened ?? a.added ?? 0));
|
|
363
|
+
// The order Home's Appearance chose: what needs the person first (as above), by name, or by the
|
|
364
|
+
// last thing that happened in each, open or asleep.
|
|
365
|
+
const order = readLooks().home.order;
|
|
366
|
+
const touched = (room) => Math.max(Date.parse(room.glance?.chat?.at ?? 0) || 0, Date.parse(room.lastOpened ?? room.added ?? 0) || 0);
|
|
367
|
+
const all = order === 'name' ? [...projects].sort((a, b) => a.name.localeCompare(b.name, language))
|
|
368
|
+
: order === 'recent' ? [...projects].sort((a, b) => touched(b) - touched(a))
|
|
369
|
+
: [...open, ...asleep];
|
|
370
|
+
return { all, open, asleep };
|
|
371
|
+
}
|
|
372
|
+
function draw() {
|
|
373
|
+
if (!data) return;
|
|
374
|
+
const { all, open } = projectsInOrder();
|
|
375
|
+
drawBishop(data.rooms.find((room) => room.id === data.salas) ?? null);
|
|
376
|
+
drawWaiting();
|
|
377
|
+
drawProjects(all);
|
|
378
|
+
if (!all.length && !welcomed) { welcomed = true; void homeTour.loaded.then((answer) => { if (!answer?.owed) openWizard(); }); }
|
|
379
|
+
welcomeHome();
|
|
380
|
+
const working = open.some((room) => room.glance?.working?.length);
|
|
381
|
+
ui.stop.hidden = !working;
|
|
382
|
+
drawLinks(all);
|
|
383
|
+
drawMemory();
|
|
384
|
+
drawAttention();
|
|
385
|
+
drawTopbar();
|
|
386
|
+
// The macroverse wants a large screen: on a small one SALAS is the whole of it.
|
|
387
|
+
ui.macro.hidden = !data.rooms.length || window.innerWidth < 900;
|
|
388
|
+
macro?.update(data.rooms, data.enlaces ?? []);
|
|
389
|
+
settingsSheet?.sync();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// The projects, as the search box and the filter leave them. Nothing is hidden by the filter
|
|
393
|
+
// without saying so: an empty result says what was asked.
|
|
394
|
+
function shows(room) {
|
|
395
|
+
if (filter === 'open' && !room.open) return false;
|
|
396
|
+
if (filter === 'asleep' && room.open) return false;
|
|
397
|
+
if (!query) return true;
|
|
398
|
+
const wanted = query.toLocaleLowerCase(language);
|
|
399
|
+
return room.name.toLocaleLowerCase(language).includes(wanted) || homeWord(room.root).toLocaleLowerCase(language).includes(wanted);
|
|
400
|
+
}
|
|
401
|
+
function drawProjects(all = data ? projectsInOrder().all : []) {
|
|
402
|
+
if (!data) return;
|
|
403
|
+
const shown = all.filter(shows);
|
|
404
|
+
ui.projects.replaceChildren(...shown.map(card));
|
|
405
|
+
ui.projects.hidden = !shown.length;
|
|
406
|
+
ui.none.hidden = Boolean(shown.length);
|
|
407
|
+
ui.none.replaceChildren();
|
|
408
|
+
if (!all.length) ui.none.append(el('span', null, t('No projects yet. Add the first one.')), button('sm-btn primary small', t('Add a project'), openAddSheet));
|
|
409
|
+
else if (!shown.length) ui.none.append(el('span', null, query ? t('No project matches «{query}».', { query }) : filter === 'open' ? t('No project is open.') : t('No project is asleep.')));
|
|
410
|
+
}
|
|
411
|
+
ui.search.addEventListener('input', () => { query = ui.search.value.trim(); drawProjects(); });
|
|
412
|
+
for (const choice of ui.filter.querySelectorAll('[data-filter]')) {
|
|
413
|
+
choice.addEventListener('click', () => {
|
|
414
|
+
filter = choice.dataset.filter;
|
|
415
|
+
for (const one of ui.filter.querySelectorAll('[data-filter]')) one.setAttribute('aria-checked', String(one === choice));
|
|
416
|
+
drawProjects();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// WAITING FOR YOU: every question an agent asks the person, from every open room, counted in one
|
|
421
|
+
// number; the first ones said, each leading to its room.
|
|
422
|
+
function drawWaiting() {
|
|
423
|
+
const rooms = data.rooms.filter((room) => room.open && room.glance?.waitingCount);
|
|
424
|
+
const total = rooms.reduce((sum, room) => sum + room.glance.waitingCount, 0);
|
|
425
|
+
ui.waiting.count.textContent = String(total);
|
|
426
|
+
ui.waiting.box.classList.toggle('has', Boolean(total));
|
|
427
|
+
ui.waiting.body.replaceChildren();
|
|
428
|
+
if (!total) { ui.waiting.body.append(el('p', 'sm-hint', t('Nobody needs you. When an agent asks for you, it shows here.'))); return; }
|
|
429
|
+
for (const room of rooms.slice(0, 2)) {
|
|
430
|
+
const row = el('a', 'sm-waiting-row');
|
|
431
|
+
row.href = addressOf(room);
|
|
432
|
+
const first = room.glance.waiting?.[0];
|
|
433
|
+
row.append(el('b', null, roomLabel(room)), el('span', null, first ? askLine(first) : t('Waiting for you · {n}', { n: room.glance.waitingCount })));
|
|
434
|
+
ui.waiting.body.append(row);
|
|
435
|
+
}
|
|
436
|
+
if (rooms.length > 2) ui.waiting.body.append(el('p', 'sm-hint', t('And {n} more projects.', { n: rooms.length - 2 })));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// MEMORY: what the open rooms hold, added up, by kind. A room asleep is not asked, so it is not
|
|
440
|
+
// counted, and the tile says so.
|
|
441
|
+
function drawMemory() {
|
|
442
|
+
const sum = { memories: 0, travelling: 0, kinds: {} };
|
|
443
|
+
const counted = data.rooms.filter((room) => room.open && room.glance?.census);
|
|
444
|
+
for (const room of counted) {
|
|
445
|
+
const census = room.glance.census;
|
|
446
|
+
sum.memories += census.memories ?? 0;
|
|
447
|
+
sum.travelling += census.travelling ?? 0;
|
|
448
|
+
for (const kind of KINDS) sum.kinds[kind] = (sum.kinds[kind] ?? 0) + (census.kinds?.[kind] ?? 0);
|
|
449
|
+
}
|
|
450
|
+
ui.memory.total.textContent = t('{n} memories', { n: sum.memories });
|
|
451
|
+
ui.memory.total.hidden = !counted.length;
|
|
452
|
+
ui.memory.body.replaceChildren();
|
|
453
|
+
if (!counted.length) { ui.memory.body.append(el('p', 'sm-hint', t('Open a project to count its memory.'))); return; }
|
|
454
|
+
ui.memory.body.append(memoryStrip(sum));
|
|
455
|
+
const legend = el('div', 'sm-legend');
|
|
456
|
+
for (const kind of KINDS) {
|
|
457
|
+
if (!sum.kinds[kind]) continue;
|
|
458
|
+
const item = el('span', `k-${kind}`, `${kindWord(kind)} · ${sum.kinds[kind]}`);
|
|
459
|
+
legend.append(item);
|
|
460
|
+
}
|
|
461
|
+
ui.memory.body.append(legend, el('p', 'sm-hint', [sum.memories ? t('{n} travel', { n: sum.travelling }) : null, t('Counted in the open projects.')].filter(Boolean).join(' · ')));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// AGENTS: the ones on this computer, with the session each has. Connected once, every project
|
|
465
|
+
// uses them; the settings sheet installs or signs in the one that needs it, and «Needs you» says
|
|
466
|
+
// which one does.
|
|
467
|
+
function agentState(agent, answer) {
|
|
468
|
+
const session = answer.sessions?.[agent.id]?.state;
|
|
469
|
+
const busyNow = answer.loggingIn === agent.id;
|
|
470
|
+
const ok = agent.ready && session !== 'signed-out';
|
|
471
|
+
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') };
|
|
472
|
+
}
|
|
473
|
+
async function loadAgents() {
|
|
474
|
+
const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
475
|
+
if (!answer?.agents) return;
|
|
476
|
+
agentsSeen = answer;
|
|
477
|
+
drawAttention();
|
|
478
|
+
drawTopbar();
|
|
479
|
+
settingsSheet?.sync();
|
|
480
|
+
}
|
|
481
|
+
// MADRE itself: the version this room runs and the newest one it knows of.
|
|
482
|
+
// Asked again every hour (the server asks npm at most once a day); the first time a newer MADRE
|
|
483
|
+
// shows up while Home is open, it says so once, and the pill stays in the bar.
|
|
484
|
+
let versionAnnounced = null;
|
|
485
|
+
async function loadVersion() {
|
|
486
|
+
const answer = await fetch('/api/version', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
487
|
+
if (!answer) return;
|
|
488
|
+
versionSeen = answer;
|
|
489
|
+
if (answer.available && answer.latest && versionAnnounced !== answer.latest) {
|
|
490
|
+
versionAnnounced = answer.latest;
|
|
491
|
+
toast(t('MADRE {version} is out. Update it from the bar.', { version: answer.latest }), 'look');
|
|
492
|
+
}
|
|
493
|
+
drawAttention();
|
|
494
|
+
drawTopbar();
|
|
495
|
+
settingsSheet?.sync();
|
|
496
|
+
}
|
|
497
|
+
setInterval(() => { if (!document.hidden) void loadVersion(); }, 60 * 60 * 1000);
|
|
498
|
+
|
|
499
|
+
// The open projects that run an older MADRE than Home: Home was updated, they were not restarted.
|
|
500
|
+
const versionParts = (value) => String(value ?? '').split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
501
|
+
function olderVersion(a, b) {
|
|
502
|
+
const [x, y] = [versionParts(a), versionParts(b)];
|
|
503
|
+
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);
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
function behindRooms() {
|
|
507
|
+
if (!data?.version) return [];
|
|
508
|
+
return data.rooms.filter((room) => room.open?.version && !room.here && room.id !== data.salas && olderVersion(room.open.version, data.version) && !busy.has(room.id));
|
|
509
|
+
}
|
|
510
|
+
// A project restarted with the MADRE Home runs: closed, then opened again where it was.
|
|
511
|
+
async function restartRoom(room) {
|
|
512
|
+
await closeRoom(room);
|
|
513
|
+
if (data?.rooms.find((one) => one.id === room.id)?.open) return;
|
|
514
|
+
await startRoom(room);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// NEEDS YOU: what the person would want to know before anything else, from what the rooms, the
|
|
518
|
+
// agents and MADRE itself say: a room on an older MADRE or not answering, an agent without a
|
|
519
|
+
// session, a link about to expire, a newer MADRE. Nothing listed is a guess.
|
|
520
|
+
function attentionItems() {
|
|
521
|
+
const items = [];
|
|
522
|
+
for (const room of data?.rooms ?? []) {
|
|
523
|
+
if (!room.open) continue;
|
|
524
|
+
const name = roomLabel(room);
|
|
525
|
+
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') });
|
|
526
|
+
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.') });
|
|
527
|
+
}
|
|
528
|
+
for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
|
|
529
|
+
const state = agentState(agent, agentsSeen);
|
|
530
|
+
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') });
|
|
531
|
+
}
|
|
532
|
+
const soon = Date.now() + 3 * 86400000;
|
|
533
|
+
for (const enlace of data?.enlaces ?? []) {
|
|
534
|
+
if (!enlace.expires || Date.parse(enlace.expires) > soon) continue;
|
|
535
|
+
const { source, reader } = endsOf(enlace);
|
|
536
|
+
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') });
|
|
537
|
+
}
|
|
538
|
+
if (versionSeen?.available && versionSeen.latest) items.push({ tone: 'ok', words: t('MADRE {version} is out.', { version: versionSeen.latest }), hint: t('You run {version}. Home restarts with it from here.', { version: versionSeen.current }), act: () => openSettings('release'), go: t('Update') });
|
|
539
|
+
// After Home updated, the projects that stayed open still run the version they started with.
|
|
540
|
+
for (const room of behindRooms()) items.push({ tone: 'wait', words: t('{room} runs MADRE {version}.', { room: roomLabel(room), version: room.open.version }), hint: t('Restart it to bring it to {latest}: what is running stops.', { latest: data.version }), act: () => void restartRoom(room), go: t('Restart') });
|
|
541
|
+
return items;
|
|
542
|
+
}
|
|
543
|
+
function drawAttention() {
|
|
544
|
+
const items = attentionItems();
|
|
545
|
+
ui.attention.count.textContent = String(items.length);
|
|
546
|
+
ui.attention.count.hidden = !items.length;
|
|
547
|
+
ui.attention.list.replaceChildren();
|
|
548
|
+
if (!items.length) {
|
|
549
|
+
const calm = el('li', 'sm-attention-item is-calm');
|
|
550
|
+
const words = el('div', 'sm-attention-words');
|
|
551
|
+
words.append(el('b', null, t('All in order.')), el('span', null, t('No project, agent or connection needs you.')));
|
|
552
|
+
calm.append(el('i', 'dot'), words);
|
|
553
|
+
ui.attention.list.append(calm);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
for (const item of items.slice(0, 3)) {
|
|
557
|
+
const row = el('li', `sm-attention-item is-${item.tone}`);
|
|
558
|
+
const words = el('div', 'sm-attention-words');
|
|
559
|
+
words.append(el('b', null, item.words), el('span', null, item.hint));
|
|
560
|
+
row.append(el('i', 'dot'), words);
|
|
561
|
+
if (item.href) {
|
|
562
|
+
const go = el('a', 'sm-btn small', item.go);
|
|
563
|
+
go.href = item.href;
|
|
564
|
+
if (item.external) { go.target = '_blank'; go.rel = 'noopener'; }
|
|
565
|
+
row.append(go);
|
|
566
|
+
} else if (item.act) row.append(button('sm-btn small', item.go, item.act));
|
|
567
|
+
ui.attention.list.append(row);
|
|
568
|
+
}
|
|
569
|
+
if (items.length > 3) ui.attention.list.append(el('li', 'sm-hint', t('And {n} more.', { n: items.length - 3 })));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// RECENT DECISIONS: what the open rooms decided last, in their own words, as each archive keeps
|
|
573
|
+
// them (the notes that travel into turns). Read through the lobby's route, which asks the rooms
|
|
574
|
+
// at most every few minutes.
|
|
575
|
+
async function loadDecisions() {
|
|
576
|
+
const answer = await fetch('/api/salas/decisions', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
577
|
+
if (!answer) return;
|
|
578
|
+
decisions = answer.decisions ?? [];
|
|
579
|
+
drawDecisions();
|
|
580
|
+
}
|
|
581
|
+
function drawDecisions() {
|
|
582
|
+
const list = ui.decisions.list;
|
|
583
|
+
list.replaceChildren();
|
|
584
|
+
if (!decisions) { list.append(el('li', 'sm-hint', t('Reading the open projects…'))); return; }
|
|
585
|
+
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; }
|
|
586
|
+
for (const one of decisions.slice(0, 4)) {
|
|
587
|
+
const item = el('li', 'sm-decision');
|
|
588
|
+
const head = el('div', 'sm-decision-head');
|
|
589
|
+
head.append(roomTag(one.room, one.name), el('time', null, ago(one.at)));
|
|
590
|
+
item.append(head, el('p', null, one.text));
|
|
591
|
+
list.append(item);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// A project's name as a small tag: a way into it when it is open.
|
|
595
|
+
function roomTag(id, name) {
|
|
596
|
+
const room = data?.rooms.find((one) => one.id === id) ?? null;
|
|
597
|
+
// The Coordinator's own project is named as the Coordinator, in the reader's language.
|
|
598
|
+
const tag = el(room?.open ? 'a' : 'span', 'sm-chip port sm-tag-room', id === data?.salas ? t('Coordinator') : name);
|
|
599
|
+
if (room?.open) tag.href = addressOf(room);
|
|
600
|
+
return tag;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// THE LAST WORK: MADRE's summary of the newest conversations of every project, written with its
|
|
604
|
+
// local model in this Mac (src/lobby-work.mjs) and checked against them. Without the model, or
|
|
605
|
+
// while it writes, the conversations show as they are.
|
|
606
|
+
let workTimer = 0;
|
|
607
|
+
async function loadWork({ again = false } = {}) {
|
|
608
|
+
clearTimeout(workTimer);
|
|
609
|
+
if (again) { ui.work.again.disabled = true; ui.work.box.classList.add('is-working'); }
|
|
610
|
+
const answer = await fetch('/api/salas/work', again ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } : { cache: 'no-store' })
|
|
611
|
+
.then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
612
|
+
if (answer) { work = answer; drawWork(); }
|
|
613
|
+
// While MADRE writes, the tile asks again soon; otherwise every two minutes.
|
|
614
|
+
workTimer = setTimeout(() => { if (!document.hidden) void loadWork(); }, answer?.state === 'working' ? 3000 : 120000);
|
|
615
|
+
}
|
|
616
|
+
function drawWork() {
|
|
617
|
+
const w = ui.work;
|
|
618
|
+
w.list.replaceChildren();
|
|
619
|
+
const state = work?.state ?? 'loading';
|
|
620
|
+
w.box.classList.toggle('is-working', state === 'working');
|
|
621
|
+
w.again.hidden = !work || state === 'empty' || state === 'unavailable';
|
|
622
|
+
w.again.disabled = state === 'working';
|
|
623
|
+
if (!work) { w.by.textContent = ''; w.note.textContent = t('Reading your conversations…'); return; }
|
|
624
|
+
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; }
|
|
625
|
+
const bullets = work.bullets ?? [];
|
|
626
|
+
if (bullets.length) {
|
|
627
|
+
// Each line's project is the conversation's it summarises, put by the lobby (src/lobby-work.mjs).
|
|
628
|
+
for (const one of bullets) {
|
|
629
|
+
const item = el('li');
|
|
630
|
+
item.append(roomTag(one.room, one.name), el('span', null, one.text));
|
|
631
|
+
w.list.append(item);
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
for (const one of (work.sources ?? []).slice(0, 5)) {
|
|
635
|
+
const item = el('li', 'raw');
|
|
636
|
+
const words = el('span');
|
|
637
|
+
words.append(el('b', null, chatName(one.title)));
|
|
638
|
+
if (one.last?.text) words.append(document.createTextNode(` — ${one.last.text}`));
|
|
639
|
+
item.append(roomTag(one.room, one.name), words, el('time', null, ago(one.at)));
|
|
640
|
+
w.list.append(item);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
w.by.textContent = bullets.length && work.at ? `@madre · ${work.model ?? ''} · ${ago(new Date(work.at).toISOString())}` : '';
|
|
644
|
+
w.note.textContent = state === 'working' ? t('MADRE is summarising your latest conversations…')
|
|
645
|
+
: state === 'unavailable' ? (bullets.length ? t('MADRE\'s local model is off: this is the last summary it wrote.') : t('MADRE\'s local model is off: these are your latest conversations, as they are.'))
|
|
646
|
+
: state === 'failed' ? t('MADRE could not summarise them this time: these are your latest conversations, as they are.')
|
|
647
|
+
: work.stale ? t('There is newer work than this summary: it is written again in a few minutes, or now if you ask.')
|
|
648
|
+
: t('Written by MADRE on this Mac from your latest conversations.');
|
|
649
|
+
}
|
|
650
|
+
ui.work.again.addEventListener('click', () => void loadWork({ again: true }));
|
|
651
|
+
|
|
652
|
+
// LINKS (docs/MACROVERSO.md, phase 3c): the contracts between rooms, read from /api/salas, each
|
|
653
|
+
// one revocable here, and the sheet that signs a new one. A contract says which room reads which,
|
|
654
|
+
// what kinds cross and until when; what crosses is counted, never shown.
|
|
655
|
+
const roomLabel = (room) => (room.id === data?.salas ? t('Coordinator') : room.name);
|
|
656
|
+
const roomName = (id) => { const room = data?.rooms.find((one) => one.id === id); return room ? roomLabel(room) : id === data?.salas ? t('Coordinator') : id; };
|
|
657
|
+
const kindsWords = (kinds) => (kinds ?? []).map((kind) => (KIND_WORDS[kind] ?? kind).toLowerCase()).join(', ');
|
|
658
|
+
// Who reads and who serves, from the contract's own words: `a→b` means the notes travel from a to b.
|
|
659
|
+
function endsOf(enlace) {
|
|
660
|
+
const { a, b } = enlace.rooms;
|
|
661
|
+
return enlace.direction === 'b→a' ? { source: b, reader: a } : { source: a, reader: b };
|
|
662
|
+
}
|
|
663
|
+
function linkRow(enlace) {
|
|
664
|
+
const { source, reader } = endsOf(enlace);
|
|
665
|
+
const node = el('article', `sm-link${revoking.has(enlace.id) ? ' revoking' : ''}`);
|
|
666
|
+
node.dataset.link = enlace.id;
|
|
667
|
+
node.append(el('p', 'who', enlace.direction === 'both' ? t('{a} and {b} read each other', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) }) : t('{reader} reads from {source}', { reader: roomName(reader), source: roomName(source) })));
|
|
668
|
+
const what = [kindsWords(enlace.kinds), enlace.expires ? t('until {date}', { date: new Date(enlace.expires).toLocaleDateString(language, { day: 'numeric', month: 'short' }) }) : t('no expiry')];
|
|
669
|
+
if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
|
|
670
|
+
// D-S13: a link BISHOP signed on its own says so, so it is never mistaken for one you signed.
|
|
671
|
+
if (enlace.signed?.by === 'bishop') what.push(t('signed by the Coordinator'));
|
|
672
|
+
node.append(el('p', 'what', what.join(' · ')));
|
|
673
|
+
const traffic = enlace.traffic ?? {};
|
|
674
|
+
// S-021: the last reading says who read, as the room that asked said it.
|
|
675
|
+
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');
|
|
676
|
+
node.append(el('p', 'traffic', [t('{n} reads in 24 h', { n: traffic.reads24h ?? 0 }), last].join(' · ')));
|
|
677
|
+
// Fase 3e: recall on every turn, ticked or cleared here by the human.
|
|
678
|
+
const recall = el('label', 'row sm-recall');
|
|
679
|
+
const recallBox = el('input'); recallBox.type = 'checkbox'; recallBox.checked = Boolean(enlace.recall);
|
|
680
|
+
recallBox.addEventListener('change', async () => {
|
|
681
|
+
recallBox.disabled = true;
|
|
682
|
+
const answer = await fetch('/api/salas/enlaces/recall', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: enlace.id, recall: recallBox.checked }) })
|
|
683
|
+
.then((response) => response.ok).catch(() => false);
|
|
684
|
+
if (!answer) recallBox.checked = !recallBox.checked;
|
|
685
|
+
recallBox.disabled = false;
|
|
686
|
+
await refresh();
|
|
687
|
+
});
|
|
688
|
+
recall.append(recallBox, document.createTextNode(t('Always include (up to 3 notes)')));
|
|
689
|
+
node.append(recall);
|
|
690
|
+
const actions = el('div', 'sm-actions');
|
|
691
|
+
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(); }));
|
|
692
|
+
else actions.append(button('sm-btn ghost', t('Revoke'), () => { revoking.add(enlace.id); draw(); }));
|
|
693
|
+
node.append(actions);
|
|
694
|
+
return node;
|
|
695
|
+
}
|
|
696
|
+
function drawLinks(projects) {
|
|
697
|
+
const enlaces = data.enlaces ?? [];
|
|
698
|
+
const enough = data.rooms.length >= 2;
|
|
699
|
+
ui.link.hidden = !enough;
|
|
700
|
+
ui.linkMore.hidden = !enough;
|
|
701
|
+
ui.linksAll.hidden = !enlaces.length;
|
|
702
|
+
const reads = enlaces.reduce((sum, enlace) => sum + (enlace.traffic?.reads24h ?? 0), 0);
|
|
703
|
+
ui.linksSum.textContent = enlaces.length
|
|
704
|
+
? [enlaces.length === 1 ? t('1 in force') : t('{n} in force', { n: enlaces.length }), t('{n} reads in 24 h', { n: reads })].join(' · ')
|
|
705
|
+
: enough ? t('No connections yet.') : t('Add a project to connect it.');
|
|
706
|
+
ui.linksNote.textContent = t('A connection lets one project read the distilled notes of another, read-only, by a contract you sign here. Nothing is written across, and only notes that travel into turns cross.');
|
|
707
|
+
ui.links.replaceChildren(...enlaces.map(linkRow));
|
|
708
|
+
drawMapLegend(enlaces);
|
|
709
|
+
if (!enlaces.length && ui.linksSheet.open) ui.linksSheet.close();
|
|
710
|
+
drawMap(projects, enlaces);
|
|
711
|
+
}
|
|
712
|
+
// Under the map, each connection in words, who looks up whom (2026-10-11): the first two, and how
|
|
713
|
+
// many more there are; the whole list is one press away.
|
|
714
|
+
const mapLegend = el('ul', 'sm-map-legend');
|
|
715
|
+
function drawMapLegend(enlaces) {
|
|
716
|
+
if (!mapLegend.isConnected) ui.map.after(mapLegend);
|
|
717
|
+
mapLegend.replaceChildren();
|
|
718
|
+
mapLegend.hidden = !enlaces.length;
|
|
719
|
+
const said = (enlace) => {
|
|
720
|
+
const { source, reader } = endsOf(enlace);
|
|
721
|
+
if (enlace.direction === 'both') return sayBold('{a} and {b} look each other up', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) });
|
|
722
|
+
if (reader === data.salas) return sayBold('The Coordinator looks up {source}', { source: roomName(source) });
|
|
723
|
+
return sayBold('{reader} looks up {source}', { reader: roomName(reader), source: roomName(source) });
|
|
724
|
+
};
|
|
725
|
+
for (const enlace of enlaces.slice(0, 2)) {
|
|
726
|
+
const line = el('li');
|
|
727
|
+
const words = el('span', 'says');
|
|
728
|
+
words.append(said(enlace));
|
|
729
|
+
line.append(el('i'), words);
|
|
730
|
+
if (enlace.orders?.allowed) line.append(el('span', 'sm-map-orders', t('orders up to #{n}', { n: enlace.orders.maxMode })));
|
|
731
|
+
mapLegend.append(line);
|
|
732
|
+
}
|
|
733
|
+
if (enlaces.length > 2) mapLegend.append(el('li', 'more', t('and {n} more', { n: enlaces.length - 2 })));
|
|
734
|
+
}
|
|
735
|
+
async function revokeLink(id) {
|
|
736
|
+
revoking.delete(id);
|
|
737
|
+
await fetch('/api/salas/enlaces/revoke', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).catch(() => null);
|
|
738
|
+
await refresh();
|
|
739
|
+
}
|
|
740
|
+
ui.linksAll.addEventListener('click', () => { if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
|
|
741
|
+
ui.linksClose.addEventListener('click', () => ui.linksSheet.close());
|
|
742
|
+
ui.linkMore.addEventListener('click', () => openLinkSheet());
|
|
743
|
+
|
|
744
|
+
// The map of the links: BISHOP in the middle, the projects round it (the first ten, in the
|
|
745
|
+
// lobby's order), and a wire for each contract in force, from the room that serves to the one
|
|
746
|
+
// that reads, the notes running along it the way they travel. Drawn at the tile's own size
|
|
747
|
+
// (2026-10-11), so the names read at their real size: the projects a connection touches are
|
|
748
|
+
// drawn whole and named in full; the rest stay quiet round the edge, a dashed ring when asleep.
|
|
749
|
+
// Nothing here is drawn that the contracts do not say.
|
|
750
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
751
|
+
const svgNode = (tag, attributes = {}) => {
|
|
752
|
+
const node = document.createElementNS(SVG, tag);
|
|
753
|
+
for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, String(value));
|
|
754
|
+
return node;
|
|
755
|
+
};
|
|
756
|
+
let mapDrawn = null;
|
|
757
|
+
function drawMap(projects, enlaces) {
|
|
758
|
+
mapDrawn = { projects, enlaces };
|
|
759
|
+
ui.map.replaceChildren();
|
|
760
|
+
if (!projects.length) { ui.map.append(el('p', 'sm-hint', t('Your projects show here, round the Coordinator, with the connections between them.'))); return; }
|
|
761
|
+
const box = ui.map.getBoundingClientRect();
|
|
762
|
+
const W = Math.max(220, Math.round(box.width || 320)), H = Math.max(140, Math.round(box.height || 200));
|
|
763
|
+
const cx = W / 2, cy = H / 2 - 4;
|
|
764
|
+
const shown = projects.slice(0, 10);
|
|
765
|
+
const linked = new Set(enlaces.flatMap((enlace) => [enlace.rooms.a, enlace.rooms.b]));
|
|
766
|
+
const rx = Math.max(64, W / 2 - 50), ry = Math.max(36, H / 2 - 26);
|
|
767
|
+
// The Coordinator's halo (drawn while it is connected) is part of its body for the wires.
|
|
768
|
+
const at = new Map([[data.salas, { x: cx, y: cy, r: 17, pad: linked.has(data.salas) ? 7 : 0, up: false }]]);
|
|
769
|
+
shown.forEach((room, index) => {
|
|
770
|
+
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / shown.length + (shown.length === 2 ? Math.PI / 2 : 0);
|
|
771
|
+
const memories = room.glance?.census?.memories ?? 0;
|
|
772
|
+
const r = (room.open ? 6 + Math.min(4, Math.sqrt(memories) / 3) : 5.5) + (linked.has(room.id) ? 1 : 0);
|
|
773
|
+
// A name above its body in the upper half, below it in the lower, so none runs into the middle.
|
|
774
|
+
at.set(room.id, { x: cx + Math.cos(angle) * rx, y: cy + Math.sin(angle) * ry, r, up: Math.sin(angle) < -0.35 });
|
|
775
|
+
});
|
|
776
|
+
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img' });
|
|
777
|
+
svg.setAttribute('aria-label', ui.linksSum.textContent);
|
|
778
|
+
const defs = svgNode('defs');
|
|
779
|
+
const arrow = svgNode('marker', { id: 'sm-arrow', viewBox: '0 0 10 10', refX: 7, refY: 5, markerWidth: 7, markerHeight: 7, markerUnits: 'userSpaceOnUse', orient: 'auto-start-reverse' });
|
|
780
|
+
arrow.append(svgNode('path', { d: 'M1 1.5L8 5L1 8.5', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));
|
|
781
|
+
defs.append(arrow);
|
|
782
|
+
svg.append(defs);
|
|
783
|
+
// From the edge of one body to the edge of the other.
|
|
784
|
+
const edges = (from, to, gapFrom = 3, gapTo = 4) => {
|
|
785
|
+
const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy) || 1;
|
|
786
|
+
const ux = dx / length, uy = dy / length;
|
|
787
|
+
const reach = (spot, gap) => spot.r + (spot.pad ?? 0) + gap;
|
|
788
|
+
return { start: { x: from.x + ux * reach(from, gapFrom), y: from.y + uy * reach(from, gapFrom) }, end: { x: to.x - ux * reach(to, gapTo), y: to.y - uy * reach(to, gapTo) } };
|
|
789
|
+
};
|
|
790
|
+
const centre = at.get(data.salas);
|
|
791
|
+
for (const room of shown) {
|
|
792
|
+
const { start, end } = edges(centre, at.get(room.id), 2, 2);
|
|
793
|
+
svg.append(svgNode('line', { class: `spoke${linked.has(room.id) ? ' linked' : ''}`, x1: start.x.toFixed(1), y1: start.y.toFixed(1), x2: end.x.toFixed(1), y2: end.y.toFixed(1) }));
|
|
794
|
+
}
|
|
795
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
796
|
+
enlaces.forEach((enlace, index) => {
|
|
797
|
+
const { source, reader } = endsOf(enlace);
|
|
798
|
+
const from = at.get(source), to = at.get(reader);
|
|
799
|
+
if (!from || !to) return;
|
|
800
|
+
const { start, end } = edges(from, to);
|
|
801
|
+
// With the Coordinator at one end the wire runs straight along its spoke; between two
|
|
802
|
+
// projects it bows outwards, away from the middle, so it never crosses the Coordinator.
|
|
803
|
+
let d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} L${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
|
|
804
|
+
if (source !== data.salas && reader !== data.salas) {
|
|
805
|
+
const mx = (start.x + end.x) / 2, my = (start.y + end.y) / 2;
|
|
806
|
+
const ox = mx - cx, oy = my - cy, out = Math.hypot(ox, oy) || 1;
|
|
807
|
+
d = `M${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${(mx + (ox / out) * 34).toFixed(1)} ${(my + (oy / out) * 34).toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`;
|
|
808
|
+
}
|
|
809
|
+
const id = `sm-wire-${index}`;
|
|
810
|
+
const wire = svgNode('path', { id, class: 'wire', d, 'marker-end': 'url(#sm-arrow)' });
|
|
811
|
+
if (enlace.direction === 'both') wire.setAttribute('marker-start', 'url(#sm-arrow)');
|
|
812
|
+
svg.append(wire);
|
|
813
|
+
if (still) return;
|
|
814
|
+
// The notes on their way: two dots one after the other, back the other way too when both read.
|
|
815
|
+
const ways = enlace.direction === 'both' ? [['0;1', 0], ['1;0', 1.2]] : [['0;1', 0]];
|
|
816
|
+
for (const [points, offset] of ways) {
|
|
817
|
+
for (const lag of [0, 1.2]) {
|
|
818
|
+
const dot = svgNode('circle', { class: 'note', r: 2.6, opacity: 0 });
|
|
819
|
+
const begin = `${(lag + offset + index * 0.4).toFixed(1)}s`;
|
|
820
|
+
const move = svgNode('animateMotion', { dur: '2.4s', repeatCount: 'indefinite', begin, keyPoints: points, keyTimes: '0;1', calcMode: 'linear' });
|
|
821
|
+
const along = svgNode('mpath');
|
|
822
|
+
along.setAttribute('href', `#${id}`);
|
|
823
|
+
move.append(along);
|
|
824
|
+
dot.append(move, svgNode('animate', { attributeName: 'opacity', values: '0;1;1;0', keyTimes: '0;.15;.85;1', dur: '2.4s', repeatCount: 'indefinite', begin }));
|
|
825
|
+
svg.append(dot);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
for (const room of shown) {
|
|
830
|
+
const spot = at.get(room.id);
|
|
831
|
+
const on = linked.has(room.id);
|
|
832
|
+
const body = svgNode('circle', { class: `node${room.open ? '' : ' asleep'}${on ? ' linked' : ''}${room.glance?.waitingCount ? ' waiting' : ''}`, cx: spot.x.toFixed(1), cy: spot.y.toFixed(1), r: spot.r.toFixed(1) });
|
|
833
|
+
const title = svgNode('title');
|
|
834
|
+
title.textContent = `${room.name} · ${stateOf(room).words}`;
|
|
835
|
+
body.append(title);
|
|
836
|
+
const name = svgNode('text', { class: `label${on ? ' linked' : ''}`, x: spot.x.toFixed(1), y: (spot.up ? spot.y - spot.r - 6 : spot.y + spot.r + 13).toFixed(1), 'text-anchor': 'middle' });
|
|
837
|
+
name.textContent = room.name.length > 16 ? `${room.name.slice(0, 15)}…` : room.name;
|
|
838
|
+
svg.append(body, name);
|
|
839
|
+
}
|
|
840
|
+
// BISHOP, in its own colours, with its own mark (brands.js), and a halo while it is connected.
|
|
841
|
+
const brand = brandOf('bishop');
|
|
842
|
+
const hub = svgNode('g', { class: `bishop${linked.has(data.salas) ? ' linked' : ''}` });
|
|
843
|
+
if (linked.has(data.salas)) hub.append(svgNode('circle', { class: 'halo', cx, cy, r: 23 }));
|
|
844
|
+
hub.append(svgNode('circle', { cx, cy, r: 17, fill: brand.solid ?? brand.color }));
|
|
845
|
+
const face = svgNode('g', { transform: `translate(${cx - 10.5} ${cy - 10.5})`, color: brand.ink });
|
|
846
|
+
face.innerHTML = brand.mark(); // trusted: our own SVG string from brands.js
|
|
847
|
+
face.firstElementChild?.setAttribute('width', '21');
|
|
848
|
+
face.firstElementChild?.setAttribute('height', '21');
|
|
849
|
+
hub.append(face);
|
|
850
|
+
svg.append(hub);
|
|
851
|
+
ui.map.append(svg);
|
|
852
|
+
}
|
|
853
|
+
// The tile changes size with the window: the map is drawn again at the new size.
|
|
854
|
+
if (typeof ResizeObserver === 'function') {
|
|
855
|
+
let drawnAt = '';
|
|
856
|
+
new ResizeObserver(() => {
|
|
857
|
+
const box = ui.map.getBoundingClientRect();
|
|
858
|
+
const size = `${Math.round(box.width)}x${Math.round(box.height)}`;
|
|
859
|
+
if (!box.width || size === drawnAt || !mapDrawn) return;
|
|
860
|
+
drawnAt = size;
|
|
861
|
+
drawMap(mapDrawn.projects, mapDrawn.enlaces);
|
|
862
|
+
}).observe(ui.map);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// THE WELCOME OF 1.0 (public/tour.js): the bonjour the first time, the chapters after it, the pill
|
|
866
|
+
// that waits in the bar, `?` for the gestures, and the hint on Connections. In Home, «Take me
|
|
867
|
+
// there» opens what lives here; for what lives inside a project it opens the one open most
|
|
868
|
+
// recently, if any is. A new person is handed to the wizard (agents, projects, open) first.
|
|
869
|
+
const agentsReady = () => (agentsSeen?.agents ?? []).some((agent) => agent.id !== 'madre' && agentState(agent, agentsSeen).ok);
|
|
870
|
+
const intoAProject = () => {
|
|
871
|
+
const room = data ? projectsInOrder().open[0] : null;
|
|
872
|
+
return room ? () => leaveTo(addressOf(room)) : undefined;
|
|
873
|
+
};
|
|
874
|
+
const homeTour = startTour({
|
|
875
|
+
page: 'home',
|
|
876
|
+
pillSlot: $('sm-tour-pill'),
|
|
877
|
+
hidden: () => (data?.phonePreview ? [] : ['phone']),
|
|
878
|
+
ready: () => Boolean(data) && !wizard && !sheet,
|
|
879
|
+
welcome: { needed: () => !projectsInOrder().all.length || !agentsReady(), open: (then) => { welcomed = true; openWizard({ then }); } },
|
|
880
|
+
onProjects: () => void refresh(),
|
|
881
|
+
go: {
|
|
882
|
+
get project() { return intoAProject(); },
|
|
883
|
+
get talk() { return intoAProject(); },
|
|
884
|
+
get modes() { return intoAProject(); },
|
|
885
|
+
get modules() { return intoAProject(); },
|
|
886
|
+
get phone() { return intoAProject(); },
|
|
887
|
+
coordinator: () => openBishop(),
|
|
888
|
+
links: () => openLinkSheet(),
|
|
889
|
+
get maps() { return ui.macro.hidden ? undefined : () => ui.macro.click(); },
|
|
890
|
+
settings: () => openSettings(),
|
|
891
|
+
gestures: () => homeTour.openGestures(),
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
// Once Home has drawn: the bonjour if it is owed; if it was given, the hint on Connections — what
|
|
895
|
+
// it is, and, for whoever came from 0.7.2, what the word meant before.
|
|
896
|
+
let welcomeAsked = false;
|
|
897
|
+
function welcomeHome() {
|
|
898
|
+
if (welcomeAsked) return;
|
|
899
|
+
welcomeAsked = true;
|
|
900
|
+
void homeTour.loaded.then(async (answer) => {
|
|
901
|
+
if (answer?.owed) { await homeTour.maybeBonjour(); return; }
|
|
902
|
+
if (!answer?.state?.bonjourAt) return;
|
|
903
|
+
setTimeout(() => {
|
|
904
|
+
const tile = $('sm-links-tile');
|
|
905
|
+
if (!tile || tile.hidden || document.querySelector('dialog[open]')) return;
|
|
906
|
+
void homeTour.hint('links', tile.querySelector('.sm-tile-head') ?? tile, {
|
|
907
|
+
title: t('Connections'),
|
|
908
|
+
text: homeTour.path() === 'back'
|
|
909
|
+
? 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.')
|
|
910
|
+
: t('A connection lets one project look up what another learned, by a contract you sign. Nothing is written across.'),
|
|
911
|
+
});
|
|
912
|
+
}, 2200);
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// LINKING TWO ROOMS, step by step. A link lets one room read the notes of another; nothing is
|
|
917
|
+
// written across. The assistant asks one thing at a time, in the room's own material — the step,
|
|
918
|
+
// the question and what it decides; the choices on a card, each saying what it does, the safe one
|
|
919
|
+
// marked; and, under every step, the link as it stands so far, in words: who serves, what crosses,
|
|
920
|
+
// who reads, until when, how the notes arrive, whether orders go. Safe from the start: a week,
|
|
921
|
+
// decisions and facts, notes read only when asked, no orders. Orders appear only with BISHOP on
|
|
922
|
+
// one side, off, and ask for the room's designation typed, as arming #3 does. It warns when the
|
|
923
|
+
// room that serves is asleep or older, and says the whole contract before it is signed.
|
|
924
|
+
const LINK_KINDS = ['decision', 'fact', 'preference', 'question'];
|
|
925
|
+
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.') };
|
|
926
|
+
const LINK_DAYS = [[1, () => t('A day')], [7, () => t('A week')], [30, () => t('30 days')], [0, () => t('No expiry')]];
|
|
927
|
+
let sheet = null;
|
|
928
|
+
// A sentence whose project names stand out (2026-10-11): the slots named in `bold` (all of them,
|
|
929
|
+
// unless said) are written in bold, the rest as the catalogue says it.
|
|
930
|
+
function sayBold(key, vars, bold = Object.keys(vars)) {
|
|
931
|
+
const out = document.createDocumentFragment();
|
|
932
|
+
const template = t(key);
|
|
933
|
+
let at = 0;
|
|
934
|
+
for (const match of template.matchAll(/\{(\w+)\}/g)) {
|
|
935
|
+
out.append(template.slice(at, match.index));
|
|
936
|
+
const value = Object.hasOwn(vars, match[1]) ? String(vars[match[1]]) : match[0];
|
|
937
|
+
out.append(bold.includes(match[1]) ? el('b', null, value) : value);
|
|
938
|
+
at = match.index + match[0].length;
|
|
939
|
+
}
|
|
940
|
+
out.append(template.slice(at));
|
|
941
|
+
return out;
|
|
942
|
+
}
|
|
943
|
+
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>';
|
|
944
|
+
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>';
|
|
945
|
+
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>';
|
|
946
|
+
function openLinkSheet({ reader = null, source = null } = {}) {
|
|
947
|
+
if (!data || sheet) return;
|
|
948
|
+
const rooms = [...data.rooms].sort((x, y) => Number(Boolean(y.open)) - Number(Boolean(x.open)) || roomLabel(x).localeCompare(roomLabel(y)));
|
|
949
|
+
if (rooms.length < 2) return;
|
|
950
|
+
// Where it starts, unless it was asked for a pair: BISHOP reading, from a room that can hand
|
|
951
|
+
// notes over now (open and up to date) when there is one.
|
|
952
|
+
const ready = (room) => room.open && !room.glance?.outdated && !room.glance?.unreachable;
|
|
953
|
+
// Two projects already connected are not connected again (2026-10-11): whichever way the one in
|
|
954
|
+
// force goes, the wizard says how they are connected and where to change it, and signs nothing.
|
|
955
|
+
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);
|
|
956
|
+
const pick = {
|
|
957
|
+
reader: reader ?? (source !== data.salas && rooms.some((room) => room.id === data.salas) ? data.salas : rooms.find((room) => room.id !== source)?.id),
|
|
958
|
+
source: null, both: false, kinds: new Set(['decision', 'fact']), days: 7, recall: false,
|
|
959
|
+
orders: false, maxMode: 1, perHour: 6, designation: '',
|
|
960
|
+
};
|
|
961
|
+
const free = (room) => room.id !== pick.reader && room.id !== data.salas && !linkedPair(room.id, pick.reader);
|
|
962
|
+
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;
|
|
963
|
+
// The Coordinator never shares its memory (src/enlaces.mjs: it gathers every project and is
|
|
964
|
+
// nobody's source), so the wizard never offers what signing would refuse: asked for with the
|
|
965
|
+
// Coordinator as the one that shares, the two change places; it never reads both ways.
|
|
966
|
+
const coordinator = (id) => Boolean(data.salas) && id === data.salas;
|
|
967
|
+
if (coordinator(pick.source)) [pick.reader, pick.source] = [pick.source, pick.reader];
|
|
968
|
+
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; };
|
|
969
|
+
const roomOf = (id) => rooms.find((room) => room.id === id) ?? null;
|
|
970
|
+
const nameOf = (id) => { const room = roomOf(id); return room ? roomLabel(room) : ''; };
|
|
971
|
+
// Orders only with BISHOP on one side; they would go into the other.
|
|
972
|
+
const ordersInto = () => (data.salas && [pick.reader, pick.source].includes(data.salas) ? [pick.reader, pick.source].find((id) => id !== data.salas) ?? null : null);
|
|
973
|
+
const stepsNow = () => ['who', 'what', 'time', ...(ordersInto() ? ['orders'] : []), 'review'];
|
|
974
|
+
const STEP_WORDS = { who: () => t('Projects'), what: () => t('What it looks up'), time: () => t('How long'), orders: () => t('Orders'), review: () => t('Sign') };
|
|
975
|
+
// The kinds as a sentence says them: «decisiones y hechos».
|
|
976
|
+
const KIND_PLURALS = { decision: () => t('decisions'), fact: () => t('facts'), preference: () => t('preferences'), question: () => t('questions') };
|
|
977
|
+
const kindsSaid = () => new Intl.ListFormat(language, { type: 'conjunction' }).format(LINK_KINDS.filter((kind) => pick.kinds.has(kind)).map((kind) => KIND_PLURALS[kind]()));
|
|
978
|
+
const until = () => new Date(Date.now() + pick.days * 86400000).toLocaleDateString(language, { day: 'numeric', month: 'long' });
|
|
979
|
+
let at = 0;
|
|
980
|
+
sheet = el('dialog', 'sm-sheet sm-wide sm-link-wizard');
|
|
981
|
+
const inner = el('div', 'sm-sheet-body');
|
|
982
|
+
sheet.append(inner);
|
|
983
|
+
document.body.append(sheet);
|
|
984
|
+
|
|
985
|
+
// ---- The pieces every step is made of.
|
|
986
|
+
const card = () => el('div', 'lw-card');
|
|
987
|
+
// A row: what it is and what it does on the left, its control on the right.
|
|
988
|
+
const row = (title, note, control, { recommended = false, dot = null } = {}) => {
|
|
989
|
+
const line = el('div', 'lw-row');
|
|
990
|
+
const words = el('div', 'lw-words');
|
|
991
|
+
const head = el('div', 'lw-title');
|
|
992
|
+
if (dot) head.append(el('i', `lw-dot k-${dot}`));
|
|
993
|
+
// Words, or a sentence with its names in bold (sayBold).
|
|
994
|
+
const name = el('b');
|
|
995
|
+
name.append(title);
|
|
996
|
+
head.append(name);
|
|
997
|
+
if (recommended) head.append(el('span', 'lw-tag', t('Recommended')));
|
|
998
|
+
words.append(head);
|
|
999
|
+
if (note) { const said = el('span', 'lw-note'); said.append(note); words.append(said); }
|
|
1000
|
+
line.append(words);
|
|
1001
|
+
if (control) line.append(control);
|
|
1002
|
+
return line;
|
|
1003
|
+
};
|
|
1004
|
+
const toggle = (on, change, label) => {
|
|
1005
|
+
const wrap = el('label', 'settings-switch');
|
|
1006
|
+
const box = el('input'); box.type = 'checkbox'; box.checked = on; box.setAttribute('aria-label', label);
|
|
1007
|
+
box.addEventListener('change', () => change(box.checked));
|
|
1008
|
+
wrap.append(box, el('i'));
|
|
1009
|
+
return wrap;
|
|
1010
|
+
};
|
|
1011
|
+
const rail = (options, current, choose, label) => {
|
|
1012
|
+
const seg = el('div', 'sx-seg');
|
|
1013
|
+
seg.setAttribute('role', 'radiogroup');
|
|
1014
|
+
seg.setAttribute('aria-label', label);
|
|
1015
|
+
for (const [value, words] of options) {
|
|
1016
|
+
const one = button(null, words, () => { choose(value); render(); });
|
|
1017
|
+
one.setAttribute('role', 'radio');
|
|
1018
|
+
one.setAttribute('aria-checked', String(value === current));
|
|
1019
|
+
seg.append(one);
|
|
1020
|
+
}
|
|
1021
|
+
return seg;
|
|
1022
|
+
};
|
|
1023
|
+
const roomSelect = (chosen, change, label, { shares = false, other = null } = {}) => {
|
|
1024
|
+
const select = el('select', 'lw-select');
|
|
1025
|
+
select.setAttribute('aria-label', label);
|
|
1026
|
+
for (const room of rooms) {
|
|
1027
|
+
if (shares && coordinator(room.id)) continue;
|
|
1028
|
+
const linked = room.id !== other && linkedPair(room.id, other);
|
|
1029
|
+
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);
|
|
1030
|
+
}
|
|
1031
|
+
select.addEventListener('change', () => change(select.value));
|
|
1032
|
+
return select;
|
|
1033
|
+
};
|
|
1034
|
+
// What the connection allows and what it does not, line by line (2026-10-11): a tick for what
|
|
1035
|
+
// it lets happen, a cross for what it never does, each with its reason, so nobody has to
|
|
1036
|
+
// guess why a choice is missing.
|
|
1037
|
+
const allows = (lines) => {
|
|
1038
|
+
const box = el('div', 'lw-allows');
|
|
1039
|
+
const list = el('ul');
|
|
1040
|
+
for (const [yes, words] of lines) {
|
|
1041
|
+
const line = el('li', yes ? 'yes' : 'no');
|
|
1042
|
+
const sign = el('i');
|
|
1043
|
+
sign.innerHTML = yes ? ALLOWED_GLYPH : NOT_ALLOWED_GLYPH; // trusted: our own SVG strings
|
|
1044
|
+
const said = el('span');
|
|
1045
|
+
said.append(words);
|
|
1046
|
+
line.append(sign, said);
|
|
1047
|
+
list.append(line);
|
|
1048
|
+
}
|
|
1049
|
+
box.append(el('span', 'lw-allows-head', t('What this connection allows')), list);
|
|
1050
|
+
return box;
|
|
1051
|
+
};
|
|
1052
|
+
const alreadyLinked = (enlace) => {
|
|
1053
|
+
const ends = endsOf(enlace);
|
|
1054
|
+
const box = el('div', 'lw-allows lw-already');
|
|
1055
|
+
const said = el('p', 'lw-already-says');
|
|
1056
|
+
said.append(enlace.direction === 'both'
|
|
1057
|
+
? sayBold('{a} and {b} already look each other up.', { a: roomName(enlace.rooms.a), b: roomName(enlace.rooms.b) })
|
|
1058
|
+
: coordinator(ends.reader) ? sayBold('The Coordinator already looks up the notes of {source}.', { source: roomName(ends.source) })
|
|
1059
|
+
: sayBold('{reader} already looks up the notes of {source}.', { reader: roomName(ends.reader), source: roomName(ends.source) }));
|
|
1060
|
+
const kinds = new Intl.ListFormat(language, { type: 'conjunction' }).format(LINK_KINDS.filter((kind) => (enlace.kinds ?? []).includes(kind)).map((kind) => KIND_PLURALS[kind]()));
|
|
1061
|
+
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')];
|
|
1062
|
+
if (enlace.orders?.allowed) what.push(t('orders up to #{n}', { n: enlace.orders.maxMode }));
|
|
1063
|
+
const see = button('sm-btn small', t('See the connections'), () => { sheet.close(); if (!ui.linksSheet.open) ui.linksSheet.showModal(); });
|
|
1064
|
+
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);
|
|
1065
|
+
return box;
|
|
1066
|
+
};
|
|
1067
|
+
const caution = (words) => { const line = el('div', 'lw-caution'); const said = el('span'); said.append(words); line.append(el('i'), said); return line; };
|
|
1068
|
+
// What the person should know about the rooms chosen.
|
|
1069
|
+
function cautions() {
|
|
1070
|
+
const said = [];
|
|
1071
|
+
for (const id of pick.both ? [pick.source, pick.reader] : [pick.source]) {
|
|
1072
|
+
const room = roomOf(id);
|
|
1073
|
+
if (!room) continue;
|
|
1074
|
+
if (!room.open) said.push(sayBold('{room} is asleep: while it sleeps, it hands nothing over.', { room: roomLabel(room) }));
|
|
1075
|
+
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) }));
|
|
1076
|
+
}
|
|
1077
|
+
return said;
|
|
1078
|
+
}
|
|
1079
|
+
// The scene over every step, in the welcome's own style (wizardScene): the room that serves and
|
|
1080
|
+
// the one that reads, arriving and floating; BISHOP with its ring; between them the wire, its dashes
|
|
1081
|
+
// running the way the notes travel (both ways when both read), and the notes themselves crossing it,
|
|
1082
|
+
// one dot per kind that crosses, in its kind's colour. Under the scene, in a strip of their own so
|
|
1083
|
+
// they never cover it, the link's terms. It is made once and kept across the steps; a choice
|
|
1084
|
+
// redraws what it says, not the scene.
|
|
1085
|
+
const stage = el('div', 'sm-scene scene-link');
|
|
1086
|
+
const field = el('div', 'lw-field');
|
|
1087
|
+
stage.append(field);
|
|
1088
|
+
const shown = { head: null, page: null };
|
|
1089
|
+
stage.setAttribute('aria-hidden', 'true');
|
|
1090
|
+
const ends = { source: null, reader: null };
|
|
1091
|
+
function endNode(role) {
|
|
1092
|
+
const node = el('div', `sm-scene-node lw-end ${role}`);
|
|
1093
|
+
node.style.left = role === 'source' ? '20%' : '80%';
|
|
1094
|
+
node.style.top = '42%';
|
|
1095
|
+
node.style.setProperty('--d', role === 'source' ? '0ms' : '160ms');
|
|
1096
|
+
field.append(node);
|
|
1097
|
+
return node;
|
|
1098
|
+
}
|
|
1099
|
+
function paintEnd(node, id, role) {
|
|
1100
|
+
if (node.dataset.room !== id) {
|
|
1101
|
+
node.dataset.room = id;
|
|
1102
|
+
node.replaceChildren();
|
|
1103
|
+
const body = id === data.salas ? mark('bishop', 46) : (() => { const disc = el('span', 'lw-room-mark'); disc.innerHTML = FOLDER_GLYPH; return disc; })(); // trusted: drawn here
|
|
1104
|
+
node.classList.toggle('is-bishop', id === data.salas);
|
|
1105
|
+
node.append(body, el('b', null, nameOf(id)), el('small'));
|
|
1106
|
+
}
|
|
1107
|
+
node.lastChild.textContent = role;
|
|
1108
|
+
}
|
|
1109
|
+
function paintStage() {
|
|
1110
|
+
ends.source ??= endNode('source');
|
|
1111
|
+
ends.reader ??= endNode('reader');
|
|
1112
|
+
paintEnd(ends.source, pick.source, pick.both ? t('Shares and looks up') : t('Shares'));
|
|
1113
|
+
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'));
|
|
1114
|
+
stage.querySelector('.lw-wires')?.remove();
|
|
1115
|
+
stage.querySelector('.lw-terms')?.remove();
|
|
1116
|
+
const box = field.getBoundingClientRect();
|
|
1117
|
+
if (box.width) {
|
|
1118
|
+
// In the scene's own pixels, so the dots stay round and the wire meets the bodies.
|
|
1119
|
+
const W = box.width, H = box.height, y = H * 0.42, x0 = W * 0.2 + 34, x1 = W * 0.8 - 34;
|
|
1120
|
+
const wires = svgNode('svg', { class: 'lw-wires', viewBox: `0 0 ${W} ${H}` });
|
|
1121
|
+
const there = `M${x0} ${y} C ${x0 + (x1 - x0) * 0.33} ${y - 18}, ${x0 + (x1 - x0) * 0.66} ${y + 18}, ${x1} ${y}`;
|
|
1122
|
+
const back = `M${x1} ${y + 7} C ${x0 + (x1 - x0) * 0.66} ${y + 26}, ${x0 + (x1 - x0) * 0.33} ${y - 10}, ${x0} ${y + 7}`;
|
|
1123
|
+
const paths = [['lw-there', there], ...(pick.both ? [['lw-back', back]] : [])];
|
|
1124
|
+
paths.forEach(([id, d]) => wires.append(svgNode('path', { id, class: 'sm-wire on', d })));
|
|
1125
|
+
// The notes crossing: one dot per kind, one after another, along each way (still, for whoever
|
|
1126
|
+
// asked for less motion: then the wire alone says it).
|
|
1127
|
+
const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
1128
|
+
const kinds = still ? [] : LINK_KINDS.filter((kind) => pick.kinds.has(kind));
|
|
1129
|
+
paths.forEach(([id], way) => kinds.forEach((kind, index) => {
|
|
1130
|
+
const dot = svgNode('circle', { r: 4.2, class: `lw-crossing k-${kind}`, opacity: 0 });
|
|
1131
|
+
const begin = `${(index * 2.6) / Math.max(1, kinds.length) + way * 1.3}s`;
|
|
1132
|
+
const move = svgNode('animateMotion', { dur: '2.6s', repeatCount: 'indefinite', begin, calcMode: 'spline', keyTimes: '0;1', keySplines: '.45 0 .55 1' });
|
|
1133
|
+
const along = svgNode('mpath');
|
|
1134
|
+
along.setAttribute('href', `#${id}`);
|
|
1135
|
+
move.append(along);
|
|
1136
|
+
dot.append(move, svgNode('animate', { attributeName: 'opacity', values: '0;1;1;0', keyTimes: '0;.12;.88;1', dur: '2.6s', repeatCount: 'indefinite', begin }));
|
|
1137
|
+
wires.append(dot);
|
|
1138
|
+
}));
|
|
1139
|
+
field.prepend(wires);
|
|
1140
|
+
}
|
|
1141
|
+
const terms = el('p', 'lw-terms');
|
|
1142
|
+
const said = [
|
|
1143
|
+
pick.kinds.size ? kindsSaid() : t('nothing yet'),
|
|
1144
|
+
pick.days ? t('Ends {date}', { date: until() }) : t('No expiry'),
|
|
1145
|
+
pick.recall ? t('On every turn') : t('Only when asked'),
|
|
1146
|
+
...(ordersInto() ? [pick.orders ? t('Orders up to #{n}', { n: pick.maxMode }) : t('No orders')] : []),
|
|
1147
|
+
];
|
|
1148
|
+
for (const words of said) terms.append(el('span', null, words));
|
|
1149
|
+
stage.append(terms);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function render({ back = false } = {}) {
|
|
1153
|
+
settle();
|
|
1154
|
+
const steps = stepsNow();
|
|
1155
|
+
at = Math.min(at, steps.length - 1);
|
|
1156
|
+
const kind = steps[at];
|
|
1157
|
+
const head = el('header', 'sm-sheet-head');
|
|
1158
|
+
const dots = el('ol', 'sm-steps');
|
|
1159
|
+
steps.forEach((name, index) => dots.append(el('li', index === at ? 'on' : index < at ? 'done' : null, STEP_WORDS[name]())));
|
|
1160
|
+
head.append(dots, closeKey(() => sheet.close()));
|
|
1161
|
+
const page = el('div', `sm-step lw-step${back ? ' back' : ''}`);
|
|
1162
|
+
// The swap, on the first step: it turns the link round, and the scene shows it.
|
|
1163
|
+
const turn = button('sm-btn small', t('Swap'), () => { [pick.reader, pick.source] = [pick.source, pick.reader]; render(); });
|
|
1164
|
+
const intro = (title, lead) => {
|
|
1165
|
+
const box = el('div', 'lw-intro');
|
|
1166
|
+
box.append(el('span', 'lw-eyebrow', t('Step {n} of {total}', { n: at + 1, total: steps.length })), el('h3', null, title));
|
|
1167
|
+
if (lead) { const said = el('p', 'lw-lead'); said.append(lead); box.append(said); }
|
|
1168
|
+
page.append(box);
|
|
1169
|
+
};
|
|
1170
|
+
const error = el('p', 'error');
|
|
1171
|
+
const actions = el('div', 'actions');
|
|
1172
|
+
const next = (check) => button('sm-btn primary', t('Continue'), () => { const wrong = check?.(); if (wrong) { error.textContent = wrong; return; } at += 1; render(); });
|
|
1173
|
+
if (at > 0) actions.append(button('sm-btn', t('Back'), () => { at -= 1; render({ back: true }); }));
|
|
1174
|
+
|
|
1175
|
+
if (kind === 'who') {
|
|
1176
|
+
// With the Coordinator at one end the step speaks of it: what it reads, the orders it may
|
|
1177
|
+
// give, and why it is never read (src/enlaces.mjs). Between two projects, who reads whom.
|
|
1178
|
+
const withCoordinator = coordinator(pick.reader);
|
|
1179
|
+
const source = nameOf(pick.source), reader = nameOf(pick.reader);
|
|
1180
|
+
intro(t('Which projects do you want to connect?'), withCoordinator
|
|
1181
|
+
? 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.')
|
|
1182
|
+
: 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.'));
|
|
1183
|
+
const box = card();
|
|
1184
|
+
if (pick.both) {
|
|
1185
|
+
// Both ways, neither only serves nor only reads: the two projects side by side, with no
|
|
1186
|
+
// direction to choose and nothing to swap.
|
|
1187
|
+
const pair = el('div', 'lw-pair');
|
|
1188
|
+
const both = el('span', 'lw-both');
|
|
1189
|
+
both.innerHTML = BOTH_WAYS_ICON; // trusted: our own SVG string
|
|
1190
|
+
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 }));
|
|
1191
|
+
const line = row(t('The two projects'), t('Each one looks up the notes of the other. Nothing is written into either.'), null);
|
|
1192
|
+
line.classList.add('lw-stack');
|
|
1193
|
+
line.append(pair);
|
|
1194
|
+
box.append(line);
|
|
1195
|
+
} else {
|
|
1196
|
+
// Nothing to swap with the Coordinator: it is always the one that looks up, so its project
|
|
1197
|
+
// is chosen alone, as wide as the one under it.
|
|
1198
|
+
const sharing = roomSelect(pick.source, (id) => { pick.source = id; render(); }, t('Shares its memory'), { shares: true, other: pick.reader });
|
|
1199
|
+
const serves = withCoordinator ? sharing : el('div', 'lw-control lw-pick');
|
|
1200
|
+
if (!withCoordinator) serves.append(sharing, turn);
|
|
1201
|
+
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));
|
|
1202
|
+
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 })));
|
|
1203
|
+
}
|
|
1204
|
+
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'))));
|
|
1205
|
+
const looks = (who, whose) => sayBold('{reader} looks up the notes of {source}.', { reader: who, source: whose });
|
|
1206
|
+
const existing = pick.reader !== pick.source ? linkedPair(pick.source, pick.reader) : null;
|
|
1207
|
+
if (existing) box.append(alreadyLinked(existing));
|
|
1208
|
+
else box.append(allows(withCoordinator
|
|
1209
|
+
? [
|
|
1210
|
+
[true, sayBold('The Coordinator looks up the notes of {room}.', { room: source })],
|
|
1211
|
+
[true, sayBold('It can ask {room} for tasks, up to #2, if you allow it in the Orders step.', { room: source })],
|
|
1212
|
+
[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 })],
|
|
1213
|
+
]
|
|
1214
|
+
: pick.both
|
|
1215
|
+
? [[true, looks(reader, source)], [true, looks(source, reader)], [false, t('Neither one writes in the other: nothing in either project changes.')]]
|
|
1216
|
+
: [[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.')]]));
|
|
1217
|
+
for (const said of cautions()) box.append(caution(said));
|
|
1218
|
+
page.append(box);
|
|
1219
|
+
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));
|
|
1220
|
+
// Already connected: nothing to go on to; the box above says why and where to change it.
|
|
1221
|
+
onward.disabled = Boolean(existing);
|
|
1222
|
+
actions.append(onward);
|
|
1223
|
+
} else if (kind === 'what') {
|
|
1224
|
+
intro(t('What can it look up?'), t('Turn on only what it needs. Decisions and facts are usually enough.'));
|
|
1225
|
+
const box = card();
|
|
1226
|
+
box.classList.add('lw-grid');
|
|
1227
|
+
for (const kindOf of LINK_KINDS) {
|
|
1228
|
+
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 }));
|
|
1229
|
+
}
|
|
1230
|
+
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.')));
|
|
1231
|
+
actions.append(next(() => (pick.kinds.size ? null : t('Choose at least one kind.'))));
|
|
1232
|
+
} else if (kind === 'time') {
|
|
1233
|
+
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.'));
|
|
1234
|
+
const box = card();
|
|
1235
|
+
const ends = el('div', 'lw-control');
|
|
1236
|
+
ends.append(rail(LINK_DAYS.map(([value, words]) => [value, words()]), pick.days, (value) => { pick.days = value; }, t('It ends')));
|
|
1237
|
+
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 }));
|
|
1238
|
+
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'))));
|
|
1239
|
+
page.append(box);
|
|
1240
|
+
actions.append(next());
|
|
1241
|
+
} else if (kind === 'orders') {
|
|
1242
|
+
const name = nameOf(ordersInto());
|
|
1243
|
+
intro(t('Orders from the coordinator'), sayBold('With orders, the Coordinator can ask {room} for a task, and its agents do it there. Without them, it only reads. Edit (#3) and Full access (#4) never go this way: they arrive as a proposal you unlock in that project.', { room: name }));
|
|
1244
|
+
const box = card();
|
|
1245
|
+
box.append(row(sayBold('Allow orders into {room}', { room: name }), sayBold('Leave it off unless you need the Coordinator to act in {room}.', { room: name }), toggle(pick.orders, (on) => { pick.orders = on; render(); }, t('Allow orders into {room}', { room: name })), { recommended: !pick.orders }));
|
|
1246
|
+
if (pick.orders) {
|
|
1247
|
+
const modes = el('div', 'lw-control');
|
|
1248
|
+
modes.append(rail([1, 2].map((n) => [n, `#${n} ${modeName(n)}`]), pick.maxMode, (value) => { pick.maxMode = value; }, t('Up to which mode')));
|
|
1249
|
+
box.append(row(t('Up to which mode'), null, modes));
|
|
1250
|
+
const hourly = el('div', 'lw-control');
|
|
1251
|
+
hourly.append(rail([3, 6, 12].map((n) => [n, String(n)]), pick.perHour, (value) => { pick.perHour = value; }, t('How many an hour')));
|
|
1252
|
+
box.append(row(t('How many an hour'), null, hourly));
|
|
1253
|
+
const typed = el('input', 'lw-input'); typed.type = 'text'; typed.autocomplete = 'off'; typed.spellcheck = false; typed.value = pick.designation;
|
|
1254
|
+
typed.setAttribute('aria-label', t('Type the name of {room} to allow orders into it', { room: name }));
|
|
1255
|
+
typed.addEventListener('input', () => { pick.designation = typed.value; error.textContent = ''; });
|
|
1256
|
+
const said = row(sayBold('Type the name of {room} to allow orders into it', { room: name }), null, null);
|
|
1257
|
+
said.classList.add('lw-stack');
|
|
1258
|
+
said.append(typed);
|
|
1259
|
+
box.append(said);
|
|
1260
|
+
}
|
|
1261
|
+
page.append(box);
|
|
1262
|
+
actions.append(next(() => (pick.orders && !pick.designation.trim() ? t('Type the project name to allow orders, or turn them off.') : null)));
|
|
1263
|
+
} else {
|
|
1264
|
+
intro(t('Check it and sign'), t('This is the whole connection. Nothing changes until you press Sign.'));
|
|
1265
|
+
const list = el('ul', 'lw-card lw-review');
|
|
1266
|
+
const said = (words) => { const item = el('li'); item.append(words); list.append(item); };
|
|
1267
|
+
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']));
|
|
1268
|
+
said(pick.days ? t('Until {date}.', { date: until() }) : t('With no expiry: it lasts until you revoke it.'));
|
|
1269
|
+
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.'));
|
|
1270
|
+
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.'));
|
|
1271
|
+
said(t('Nothing is written across, and you can revoke it at any moment from Connections.'));
|
|
1272
|
+
page.append(list);
|
|
1273
|
+
const warn = cautions();
|
|
1274
|
+
if (warn.length) { const box = card(); for (const words of warn) box.append(caution(words)); page.append(box); }
|
|
1275
|
+
const sign = button('sm-btn primary', t('Sign'), async () => {
|
|
1276
|
+
error.textContent = '';
|
|
1277
|
+
const body = {
|
|
1278
|
+
a: pick.source, b: pick.reader, direction: pick.both ? 'both' : 'a→b', kinds: [...pick.kinds],
|
|
1279
|
+
expires: pick.days ? new Date(Date.now() + pick.days * 86400000).toISOString() : null,
|
|
1280
|
+
orders: pick.orders && ordersInto() ? { allowed: true, maxMode: pick.maxMode, perHour: pick.perHour } : { allowed: false, maxMode: 0, perHour: pick.perHour },
|
|
1281
|
+
designation: pick.orders && ordersInto() ? pick.designation.trim() : undefined,
|
|
1282
|
+
recall: pick.recall,
|
|
1283
|
+
};
|
|
1284
|
+
sign.disabled = true;
|
|
1285
|
+
sign.textContent = t('Signing…');
|
|
1286
|
+
const answer = await fetch('/api/salas/enlaces', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1287
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
1288
|
+
sign.disabled = false;
|
|
1289
|
+
sign.textContent = t('Sign');
|
|
1290
|
+
if (!answer.ok) { error.textContent = answer.body.error ?? t('The connection could not be signed.'); return; }
|
|
1291
|
+
sheet.close();
|
|
1292
|
+
await refresh();
|
|
1293
|
+
});
|
|
1294
|
+
actions.append(sign);
|
|
1295
|
+
}
|
|
1296
|
+
page.append(error, actions);
|
|
1297
|
+
// The scene stays where it is (taken out and put back, its animations would start over).
|
|
1298
|
+
if (!stage.isConnected) inner.replaceChildren(head, stage, page);
|
|
1299
|
+
else { shown.head.replaceWith(head); shown.page.replaceWith(page); }
|
|
1300
|
+
shown.head = head; shown.page = page;
|
|
1301
|
+
paintStage();
|
|
1302
|
+
}
|
|
1303
|
+
sheet.addEventListener('close', () => { sheet.remove(); sheet = null; });
|
|
1304
|
+
render();
|
|
1305
|
+
sheet.showModal();
|
|
1306
|
+
paintStage();
|
|
1307
|
+
}
|
|
1308
|
+
ui.link.addEventListener('click', () => openLinkSheet());
|
|
1309
|
+
|
|
1310
|
+
async function refresh() {
|
|
1311
|
+
const answer = await fetch('/api/salas', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
1312
|
+
if (!answer) { ui.foot.textContent = t('This project did not answer. It may have closed.'); ui.bar.live.classList.add('is-off'); ui.bar.live.lastChild.textContent = t('Not answering right now'); return; }
|
|
1313
|
+
if (answer.language && answer.language !== language) { language = setLanguage(answer.language); root.lang = language; translateStatic(); drawAttention(); drawDecisions(); drawWork(); homeTour.repaint(); }
|
|
1314
|
+
data = answer;
|
|
1315
|
+
// A project that came up after Home stopped waiting is open: its card no longer says it did not.
|
|
1316
|
+
for (const room of answer.rooms ?? []) if (room.open) failed.delete(room.id);
|
|
1317
|
+
ui.foot.textContent = t('Every project runs on this Mac, on its own, with its own memory.');
|
|
1318
|
+
// The room's corner (app.js, #colophon): the version this Home runs, and NOTICE's own line.
|
|
1319
|
+
if (answer.version) {
|
|
1320
|
+
ui.colophon.replaceChildren(el('b', null, `MADRE ${answer.version}`), document.createTextNode('\u00a0·\u00a0© 2026 Jossue Alcalá'));
|
|
1321
|
+
ui.colophon.hidden = false;
|
|
1322
|
+
}
|
|
1323
|
+
draw();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function startRoom(room, { enter = false } = {}) {
|
|
1327
|
+
busy.set(room.id, 'starting');
|
|
1328
|
+
failed.delete(room.id);
|
|
1329
|
+
draw();
|
|
1330
|
+
const answer = await fetch('/api/salas/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: room.id }) })
|
|
1331
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
1332
|
+
if (answer.ok) await waitDone(room.id); else waitDrop(room.id);
|
|
1333
|
+
busy.delete(room.id);
|
|
1334
|
+
if (!answer.ok) failed.set(room.id, answer.body.error ?? t('The project did not open.'));
|
|
1335
|
+
// Opened from its card, the person goes in: that is what «Open» means.
|
|
1336
|
+
else if (enter && answer.body.port) { leaveTo(`http://127.0.0.1:${answer.body.port}/`); return answer; }
|
|
1337
|
+
await refresh();
|
|
1338
|
+
return answer;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// A card's wait (2026-10-10): MADRE's loader in a line (loader.js), kept across the redraws of the
|
|
1342
|
+
// card so it never starts over, creeping while the project wakes, closes or leaves, and filled when
|
|
1343
|
+
// it is done.
|
|
1344
|
+
const waits = new Map(); // room id → its loader while the card waits
|
|
1345
|
+
function waitFor(id, label) {
|
|
1346
|
+
let loader = waits.get(id);
|
|
1347
|
+
if (!loader) {
|
|
1348
|
+
loader = makeLoader({ small: true, progress: 0.04 });
|
|
1349
|
+
loader.stop = creep(loader, { to: 0.9, rate: 0.03, every: 200 });
|
|
1350
|
+
waits.set(id, loader);
|
|
1351
|
+
}
|
|
1352
|
+
setLoader(loader, 0, label);
|
|
1353
|
+
return loader;
|
|
1354
|
+
}
|
|
1355
|
+
// It did not happen: the loader goes without filling, and the card says why.
|
|
1356
|
+
function waitDrop(id) {
|
|
1357
|
+
waits.get(id)?.stop?.();
|
|
1358
|
+
waits.delete(id);
|
|
1359
|
+
}
|
|
1360
|
+
function waitDone(id) {
|
|
1361
|
+
const loader = waits.get(id);
|
|
1362
|
+
if (!loader) return Promise.resolve();
|
|
1363
|
+
waits.delete(id);
|
|
1364
|
+
loader.stop?.();
|
|
1365
|
+
return finishLoader(loader, { hold: 220 });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Out of MADRE (2026-10-10): Home forgets it (src/salas.mjs, forgetProject); its folder and its
|
|
1369
|
+
// memory stay where they are, and adding the folder again brings them back.
|
|
1370
|
+
async function forgetRoom(room) {
|
|
1371
|
+
forgetting.delete(room.id);
|
|
1372
|
+
busy.set(room.id, 'forgetting');
|
|
1373
|
+
failed.delete(room.id);
|
|
1374
|
+
draw();
|
|
1375
|
+
const answer = await post('/api/salas/forget', { room: room.id });
|
|
1376
|
+
if (answer.ok) await waitDone(room.id); else waitDrop(room.id);
|
|
1377
|
+
busy.delete(room.id);
|
|
1378
|
+
if (!answer.ok) { failed.set(room.id, answer.body.error ?? t('It could not be taken out of MADRE.')); draw(); return; }
|
|
1379
|
+
if (pinsStored()?.includes(room.id)) pinRemove(room.id);
|
|
1380
|
+
toast(t('{name} left MADRE. Its folder is still on this Mac.', { name: answer.body.name ?? room.name }));
|
|
1381
|
+
await refresh();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
async function closeRoom(room) {
|
|
1385
|
+
asking.delete(room.id);
|
|
1386
|
+
busy.set(room.id, 'closing');
|
|
1387
|
+
failed.delete(room.id);
|
|
1388
|
+
draw();
|
|
1389
|
+
const answer = await fetch('/api/salas/close', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: room.id }) })
|
|
1390
|
+
.then(async (response) => ({ ok: response.ok, body: await response.json().catch(() => ({})) })).catch(() => ({ ok: false, body: {} }));
|
|
1391
|
+
if (answer.body.here) {
|
|
1392
|
+
// This page goes with the room that serves it: it says so, and stops asking.
|
|
1393
|
+
clearInterval(timer);
|
|
1394
|
+
document.body.classList.add('sm-gone');
|
|
1395
|
+
ui.none.hidden = false;
|
|
1396
|
+
ui.none.replaceChildren(el('span', null, t('This project closed. Open MADRE again to come back to Home.')));
|
|
1397
|
+
ui.stop.hidden = true;
|
|
1398
|
+
ui.projects.hidden = true;
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
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; }
|
|
1402
|
+
// It closes in a moment; the card stays «closing» until the list says it is asleep.
|
|
1403
|
+
for (let wait = 0; wait < 20; wait += 1) {
|
|
1404
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1405
|
+
await refresh();
|
|
1406
|
+
if (!data?.rooms.find((one) => one.id === room.id)?.open) break;
|
|
1407
|
+
}
|
|
1408
|
+
await waitDone(room.id);
|
|
1409
|
+
busy.delete(room.id);
|
|
1410
|
+
draw();
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// THE MACROVERSE (macroverso.js): the same rooms as universes, entered from here.
|
|
1414
|
+
function macroWords() {
|
|
1415
|
+
return {
|
|
1416
|
+
title: t('Project map'),
|
|
1417
|
+
back: t('Back to Home'),
|
|
1418
|
+
hint: t('Drag the space to move it · wheel to come closer · click a project to look at it · drag it to turn it · carry one onto the Coordinator for the Coordinator to read it, or onto another to connect them · ← → from one to the next · Esc to come back'),
|
|
1419
|
+
bishop: t('Coordinator'),
|
|
1420
|
+
bishopAbout: t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.'),
|
|
1421
|
+
bishopReads: (n) => (n ? (n === 1 ? t('It reads 1 project by a connection') : t('It reads {n} projects by connections', { n })) : t('It reads no project yet: each one it reads is a connection you sign.')),
|
|
1422
|
+
notLinked: t('Not connected yet: touch one to connect it'),
|
|
1423
|
+
talk: t('Talk to the Coordinator'),
|
|
1424
|
+
asleep: t('Asleep'),
|
|
1425
|
+
recenter: t('RECENTER'),
|
|
1426
|
+
// The legend under its name, in the Memory map's words.
|
|
1427
|
+
legend: t('Memory kinds'),
|
|
1428
|
+
kinds: { ...Object.fromEntries(KINDS.map((kind) => [kind, kindWord(kind)])), dim: t('OUT OF CIRCULATION') },
|
|
1429
|
+
veins: (n) => t('{n} connections', { n }),
|
|
1430
|
+
links: t('Connections'),
|
|
1431
|
+
link: t('Connect…'),
|
|
1432
|
+
// One link as seen from one of its rooms.
|
|
1433
|
+
linkRow: (enlace, roomId) => {
|
|
1434
|
+
const other = roomName(enlace.rooms.a === roomId ? enlace.rooms.b : enlace.rooms.a);
|
|
1435
|
+
const kinds = kindsWords(enlace.kinds);
|
|
1436
|
+
if (enlace.direction === 'both') return t('both ways with {room}: {kinds}', { room: other, kinds });
|
|
1437
|
+
return endsOf(enlace).reader === roomId ? t('reads {kinds} from {room}', { kinds, room: other }) : t('{room} reads its {kinds}', { room: other, kinds });
|
|
1438
|
+
},
|
|
1439
|
+
sub: (rooms) => {
|
|
1440
|
+
const open = rooms.filter((room) => room.open);
|
|
1441
|
+
const memories = open.reduce((sum, room) => sum + (room.glance?.census?.memories ?? 0), 0);
|
|
1442
|
+
const waiting = open.filter((room) => room.glance?.waitingCount).length;
|
|
1443
|
+
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(' · ');
|
|
1444
|
+
},
|
|
1445
|
+
state: (room) => stateOf(room).words,
|
|
1446
|
+
path: homeWord,
|
|
1447
|
+
ask: askLine,
|
|
1448
|
+
kind: (kind) => KIND_WORDS[kind] ?? kind,
|
|
1449
|
+
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')),
|
|
1450
|
+
enter: t('Enter the project'),
|
|
1451
|
+
backToRoom: t('Back to the project'),
|
|
1452
|
+
wake: t('Wake'),
|
|
1453
|
+
waking: t('Waking…'),
|
|
1454
|
+
all: t('All the projects'),
|
|
1455
|
+
close: t('Close'),
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
// The macroverse is night in either theme, so it wears the accent as the night does: the hex the
|
|
1459
|
+
// stylesheet gives that accent before the light scheme recolours it (S-014), read from the rule
|
|
1460
|
+
// itself rather than from the page, which may be light.
|
|
1461
|
+
function darkAccent() {
|
|
1462
|
+
const wanted = `[data-accent="${root.dataset.accent || 'madre'}"]`;
|
|
1463
|
+
try {
|
|
1464
|
+
for (const sheet of document.styleSheets) {
|
|
1465
|
+
let rules;
|
|
1466
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
1467
|
+
for (const rule of rules) {
|
|
1468
|
+
if (rule.selectorText !== wanted) continue;
|
|
1469
|
+
const hex = rule.style.getPropertyValue('--acc').trim();
|
|
1470
|
+
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
} catch { /* no sheets to read: the house accent */ }
|
|
1474
|
+
return '#9bff66';
|
|
1475
|
+
}
|
|
1476
|
+
// And the ink that accent wears, read the same way.
|
|
1477
|
+
function darkAccentInk() {
|
|
1478
|
+
const wanted = `[data-accent="${root.dataset.accent || 'madre'}"]`;
|
|
1479
|
+
try {
|
|
1480
|
+
for (const sheet of document.styleSheets) {
|
|
1481
|
+
let rules;
|
|
1482
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
1483
|
+
for (const rule of rules) {
|
|
1484
|
+
if (rule.selectorText !== wanted) continue;
|
|
1485
|
+
const hex = rule.style.getPropertyValue('--acc-ink').trim();
|
|
1486
|
+
if (/^#[0-9a-f]{6}$/i.test(hex)) return hex;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
} catch { /* no sheets to read: the house ink */ }
|
|
1490
|
+
return '#0a1405';
|
|
1491
|
+
}
|
|
1492
|
+
ui.macro.addEventListener('click', async () => {
|
|
1493
|
+
if (macro || !data) return;
|
|
1494
|
+
const { openMacroverso } = await import('./macroverso.js');
|
|
1495
|
+
document.body.classList.add('sm-in-macro');
|
|
1496
|
+
macro = openMacroverso({
|
|
1497
|
+
host: document.body,
|
|
1498
|
+
rooms: data.rooms,
|
|
1499
|
+
here: data.here,
|
|
1500
|
+
enlaces: data.enlaces ?? [],
|
|
1501
|
+
accent: darkAccent(),
|
|
1502
|
+
accentInk: darkAccentInk(),
|
|
1503
|
+
// BISHOP at the centre (D-S12): the macroverse draws its body itself (macroverso.js, bishopOrb).
|
|
1504
|
+
bishop: data.salas ? { id: data.salas } : null,
|
|
1505
|
+
words: macroWords(),
|
|
1506
|
+
actions: {
|
|
1507
|
+
wake: async (room) => { await startRoom(room); },
|
|
1508
|
+
// Dragged from one universe onto another: the notes of the first would travel to the second.
|
|
1509
|
+
link: (source, reader) => openLinkSheet({ source: source?.id ?? null, reader: reader?.id ?? null }),
|
|
1510
|
+
closed: () => { macro = null; document.body.classList.remove('sm-in-macro'); ui.macro.focus(); },
|
|
1511
|
+
},
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
window.addEventListener('resize', () => { if (data) ui.macro.hidden = !data.rooms.length || window.innerWidth < 900; });
|
|
1515
|
+
|
|
1516
|
+
ui.stop.addEventListener('click', async () => {
|
|
1517
|
+
if (!arming) {
|
|
1518
|
+
ui.stop.classList.add('armed');
|
|
1519
|
+
ui.stop.querySelector('span').textContent = t('Touch again to stop all');
|
|
1520
|
+
arming = setTimeout(() => { arming = 0; ui.stop.classList.remove('armed'); ui.stop.querySelector('span').textContent = t('Stop all'); }, 3000);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
clearTimeout(arming);
|
|
1524
|
+
arming = 0;
|
|
1525
|
+
ui.stop.classList.remove('armed');
|
|
1526
|
+
ui.stop.disabled = true;
|
|
1527
|
+
await fetch('/api/salas/stop-all', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
|
|
1528
|
+
ui.stop.disabled = false;
|
|
1529
|
+
ui.stop.querySelector('span').textContent = t('Stop all');
|
|
1530
|
+
await refresh();
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
// ---- THE BAR, as a room's: the logo and where you are (and, served by a project's room, the way
|
|
1534
|
+
// back to it); this lobby answering; the rooms on the bar, each with how it is and a way into it; the
|
|
1535
|
+
// agents with their sessions; who waits for you; a newer MADRE; what is running and the key that
|
|
1536
|
+
// stops it; the links in force; the macroverse; reload and settings apart, as in the room.
|
|
1537
|
+
function drawTopbar() {
|
|
1538
|
+
if (!data) return;
|
|
1539
|
+
const b = ui.bar;
|
|
1540
|
+
// Where you are, and the way back to the room that serves this page, when it is a project's.
|
|
1541
|
+
const here = data.rooms.find((room) => room.id === data.here) ?? null;
|
|
1542
|
+
b.backRoom.hidden = !here || here.id === data.salas;
|
|
1543
|
+
if (here && here.id !== data.salas) { b.backRoom.href = '/'; b.backRoom.lastChild.textContent = here.name; b.backRoom.title = t('Back to the project'); }
|
|
1544
|
+
b.live.classList.remove('is-off');
|
|
1545
|
+
b.live.lastChild.textContent = data.mac ?? '';
|
|
1546
|
+
// The rooms on the bar: the open projects, or the ones the person pinned, in their order.
|
|
1547
|
+
drawPins();
|
|
1548
|
+
// The agents of this computer, each with its session.
|
|
1549
|
+
b.agents.replaceChildren();
|
|
1550
|
+
for (const agent of (agentsSeen?.agents ?? []).filter((one) => one.id !== 'madre')) {
|
|
1551
|
+
const state = agentState(agent, agentsSeen);
|
|
1552
|
+
const key = button(`sm-agent-key ${state.key}`, null, () => openSettings('agents'));
|
|
1553
|
+
key.append(mark(agent.id, 24));
|
|
1554
|
+
key.title = `${agent.label ?? agent.id} · ${state.words}`;
|
|
1555
|
+
key.setAttribute('aria-label', key.title);
|
|
1556
|
+
b.agents.append(key);
|
|
1557
|
+
}
|
|
1558
|
+
b.agents.hidden = !b.agents.childElementCount;
|
|
1559
|
+
// Who waits for you: how many, and a way into the first room that asks.
|
|
1560
|
+
const asking = data.rooms.filter((room) => room.open && room.glance?.waitingCount);
|
|
1561
|
+
const waiting = asking.reduce((sum, room) => sum + room.glance.waitingCount, 0);
|
|
1562
|
+
b.waiting.hidden = !waiting;
|
|
1563
|
+
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(' · '); }
|
|
1564
|
+
// A newer MADRE.
|
|
1565
|
+
b.update.hidden = !(versionSeen?.available && versionSeen.latest);
|
|
1566
|
+
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 }); }
|
|
1567
|
+
// The links in force.
|
|
1568
|
+
const links = (data.enlaces ?? []).length;
|
|
1569
|
+
b.linksCount.hidden = !links;
|
|
1570
|
+
b.linksCount.textContent = String(links);
|
|
1571
|
+
}
|
|
1572
|
+
// ---- THE BAR'S ROOMS, the way you want them (2026-10-10), as the room's bar is arranged (app.js,
|
|
1573
|
+
// «The bar, the way you want it»). Until the person arranges it, the bar holds the projects that
|
|
1574
|
+
// are open, the ones that need the person first. A project is pinned from its card (right-click
|
|
1575
|
+
// it, or drag the card to the bar); right-click a room on the bar and the bar opens for editing:
|
|
1576
|
+
// the rooms tremble, each wears a «−», they can be carried to another place, and a card under the
|
|
1577
|
+
// bar holds the projects that are not on it yet. What is on the bar, and in which order, is this
|
|
1578
|
+
// browser's own (localStorage), as the room keeps its bar.
|
|
1579
|
+
const PIN_KEY = 'pulse.lobbyBar';
|
|
1580
|
+
const PIN_TYPE = 'application/x-madre-room';
|
|
1581
|
+
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>';
|
|
1582
|
+
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>';
|
|
1583
|
+
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>';
|
|
1584
|
+
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>';
|
|
1585
|
+
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>';
|
|
1586
|
+
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>';
|
|
1587
|
+
const pins = {
|
|
1588
|
+
saved: null, // what this page decided, for when storage will not keep it
|
|
1589
|
+
chips: new Map(), // room id → its key on the bar
|
|
1590
|
+
badges: new Map(), // room id → its «−»
|
|
1591
|
+
layer: null, // where the «−» live, over the rooms
|
|
1592
|
+
more: null, // «+n», when the rooms do not all fit
|
|
1593
|
+
spilled: [], // the rooms behind «+n»
|
|
1594
|
+
moreCard: null,
|
|
1595
|
+
card: null, // the card under the bar while it is being arranged
|
|
1596
|
+
cardSaid: '',
|
|
1597
|
+
menu: null, // a card's right-click menu
|
|
1598
|
+
editing: false,
|
|
1599
|
+
frame: 0,
|
|
1600
|
+
drag: null, // a room being carried along the bar
|
|
1601
|
+
dropping: null, // a project's card being dragged towards the bar
|
|
1602
|
+
slot: null, // where it would land
|
|
1603
|
+
slotAt: null,
|
|
1604
|
+
};
|
|
1605
|
+
const motionStill = () => Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches);
|
|
1606
|
+
const motionMs = (ms) => ms * (Number.parseFloat(getComputedStyle(root).getPropertyValue('--motion-scale')) || 1);
|
|
1607
|
+
|
|
1608
|
+
function pinsStored() {
|
|
1609
|
+
if (pins.saved) return [...pins.saved];
|
|
1610
|
+
try {
|
|
1611
|
+
const raw = JSON.parse(localStorage.getItem(PIN_KEY) ?? 'null');
|
|
1612
|
+
if (Array.isArray(raw) && raw.every((id) => typeof id === 'string')) return [...new Set(raw)];
|
|
1613
|
+
} catch { /* no storage, or not a list: the bar as it comes */ }
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
// The rooms on the bar, in their order: the person's, or the open projects until they arrange it.
|
|
1617
|
+
// A project that left the lobby is skipped; BISHOP's own room is never a project.
|
|
1618
|
+
function pinsShown() {
|
|
1619
|
+
if (!data) return [];
|
|
1620
|
+
const stored = pinsStored();
|
|
1621
|
+
if (!stored) return projectsInOrder().open.map((room) => room.id);
|
|
1622
|
+
const known = new Set(data.rooms.filter((room) => room.id !== data.salas).map((room) => room.id));
|
|
1623
|
+
return stored.filter((id) => known.has(id));
|
|
1624
|
+
}
|
|
1625
|
+
function pinsWrite(list) {
|
|
1626
|
+
pins.saved = [...list];
|
|
1627
|
+
try { localStorage.setItem(PIN_KEY, JSON.stringify(pins.saved)); } catch { /* this page only */ }
|
|
1628
|
+
drawTopbar();
|
|
1629
|
+
settingsSheet?.sync({ detail: true });
|
|
1630
|
+
}
|
|
1631
|
+
function pinAdd(id, at = null) {
|
|
1632
|
+
const list = pinsShown().filter((one) => one !== id);
|
|
1633
|
+
list.splice(at === null ? list.length : Math.max(0, Math.min(at, list.length)), 0, id);
|
|
1634
|
+
pinsWrite(list);
|
|
1635
|
+
// It arrives where it was put, growing into its place rather than appearing in it.
|
|
1636
|
+
const chip = pins.chips.get(id);
|
|
1637
|
+
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)' });
|
|
1638
|
+
}
|
|
1639
|
+
function pinRemove(id) { pinsWrite(pinsShown().filter((one) => one !== id)); }
|
|
1640
|
+
function pinsReset() {
|
|
1641
|
+
pins.saved = null;
|
|
1642
|
+
try { localStorage.removeItem(PIN_KEY); } catch { /* this page only */ }
|
|
1643
|
+
drawTopbar();
|
|
1644
|
+
settingsSheet?.sync({ detail: true });
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// From six rooms on, each is a square with its two letters (brands.js, monogram).
|
|
1648
|
+
const PINS_SQUARE = 6;
|
|
1649
|
+
|
|
1650
|
+
// A room's key on the bar: its lamp and its name, or, from six rooms on, a square with its two
|
|
1651
|
+
// letters and the lamp at its corner (its name is then its title). Open, it is a link to its room;
|
|
1652
|
+
// asleep, pressing it wakes it and goes in, as «Open» on its card does.
|
|
1653
|
+
function pinChip(id) {
|
|
1654
|
+
let chip = pins.chips.get(id);
|
|
1655
|
+
if (chip) return chip;
|
|
1656
|
+
chip = el('a', 'sm-tb-room');
|
|
1657
|
+
chip.dataset.barItem = id;
|
|
1658
|
+
chip.draggable = false;
|
|
1659
|
+
chip.append(el('i'), el('span'), el('b', 'sm-tb-mono'));
|
|
1660
|
+
chip.addEventListener('click', (event) => {
|
|
1661
|
+
if (pins.editing) return;
|
|
1662
|
+
const room = data?.rooms.find((one) => one.id === id);
|
|
1663
|
+
if (!room || room.open) return;
|
|
1664
|
+
event.preventDefault();
|
|
1665
|
+
if (!busy.has(room.id)) void startRoom(room, { enter: true });
|
|
1666
|
+
});
|
|
1667
|
+
chip.addEventListener('keydown', (event) => { if (!chip.hasAttribute('href') && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); chip.click(); } });
|
|
1668
|
+
chip.addEventListener('contextmenu', (event) => { event.preventDefault(); event.pinHandled = true; pinsEdit(!pins.editing); });
|
|
1669
|
+
chip.addEventListener('pointerdown', (event) => { if (pins.editing) pinsDragStart(event, id); });
|
|
1670
|
+
pins.chips.set(id, chip);
|
|
1671
|
+
return chip;
|
|
1672
|
+
}
|
|
1673
|
+
// Puts the keys where the order says. Only what is out of place moves, so a key that stays is
|
|
1674
|
+
// never taken off the page (and keeps its focus, and its tremble).
|
|
1675
|
+
function pinsPlace(ids) {
|
|
1676
|
+
const box = ui.bar.open;
|
|
1677
|
+
const want = ids.map((id) => pins.chips.get(id)).filter(Boolean);
|
|
1678
|
+
if (pins.dropping && pins.slot) want.splice(Math.min(pins.slotAt ?? want.length, want.length), 0, pins.slot);
|
|
1679
|
+
want.push(pins.more);
|
|
1680
|
+
let next = pins.layer.parentNode === box ? pins.layer : null;
|
|
1681
|
+
for (let index = want.length - 1; index >= 0; index -= 1) {
|
|
1682
|
+
const node = want[index];
|
|
1683
|
+
if (node.parentNode !== box || node.nextElementSibling !== next) box.insertBefore(node, next);
|
|
1684
|
+
next = node;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function drawPins() {
|
|
1688
|
+
const box = ui.bar.open;
|
|
1689
|
+
const ids = pinsShown();
|
|
1690
|
+
for (const id of ids) {
|
|
1691
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
1692
|
+
const state = stateOf(room);
|
|
1693
|
+
const chip = pinChip(id);
|
|
1694
|
+
const lifted = pins.drag?.moved && pins.drag.id === id;
|
|
1695
|
+
chip.className = `sm-tb-room is-${busy.get(id) === 'starting' ? 'working' : state.key}${lifted ? ' bar-lifted' : ''}${pins.dropping === id ? ' is-moving' : ''}`;
|
|
1696
|
+
chip.querySelector('span').textContent = room.name;
|
|
1697
|
+
chip.lastChild.textContent = monogram(room.name);
|
|
1698
|
+
chip.title = `${room.name} · ${busy.get(id) === 'starting' ? t('Waking…') : state.words}`;
|
|
1699
|
+
chip.setAttribute('aria-label', room.name);
|
|
1700
|
+
if (room.open) { chip.href = addressOf(room); chip.removeAttribute('role'); chip.removeAttribute('tabindex'); }
|
|
1701
|
+
else { chip.removeAttribute('href'); chip.setAttribute('role', 'button'); chip.tabIndex = 0; }
|
|
1702
|
+
}
|
|
1703
|
+
for (const [id, chip] of pins.chips) if (!ids.includes(id)) { chip.remove(); pins.chips.delete(id); }
|
|
1704
|
+
pinsPlace(pins.drag?.order ?? (pins.dropping ? ids.filter((id) => id !== pins.dropping) : ids));
|
|
1705
|
+
box.classList.toggle('is-square', ids.length + (pins.dropping && !ids.includes(pins.dropping) ? 1 : 0) >= PINS_SQUARE);
|
|
1706
|
+
box.hidden = !ids.length && !pins.dropping && !pins.editing;
|
|
1707
|
+
pinsFit();
|
|
1708
|
+
if (pins.editing) { pinsBadges(); pinsDrawCard(); }
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// When the rooms do not all fit, the last ones go behind «+n», which opens a small card with them.
|
|
1712
|
+
// While the bar is arranged every room shows.
|
|
1713
|
+
function pinsFit() {
|
|
1714
|
+
const box = ui.bar.open;
|
|
1715
|
+
for (const chip of pins.chips.values()) chip.classList.remove('is-spilled');
|
|
1716
|
+
pins.spilled = [];
|
|
1717
|
+
pins.more.hidden = true;
|
|
1718
|
+
if (pins.editing || pins.dropping || box.hidden || !box.clientWidth) { pinsCloseMore(); return; }
|
|
1719
|
+
const spills = () => box.scrollWidth > box.clientWidth + 1;
|
|
1720
|
+
if (!spills()) { pinsCloseMore(); return; }
|
|
1721
|
+
pins.more.hidden = false;
|
|
1722
|
+
const ids = pinsShown();
|
|
1723
|
+
while (ids.length && spills()) {
|
|
1724
|
+
const id = ids.pop();
|
|
1725
|
+
pins.chips.get(id)?.classList.add('is-spilled');
|
|
1726
|
+
pins.spilled.unshift(id);
|
|
1727
|
+
pins.more.textContent = `+${pins.spilled.length}`;
|
|
1728
|
+
}
|
|
1729
|
+
pins.more.title = t('{n} more', { n: pins.spilled.length });
|
|
1730
|
+
if (pins.moreCard && !pins.moreCard.hidden) pinsDrawMore();
|
|
1731
|
+
}
|
|
1732
|
+
function pinTile(room, onPress) {
|
|
1733
|
+
const tile = button('bar-tile', null, onPress);
|
|
1734
|
+
tile.dataset.barItem = room.id;
|
|
1735
|
+
const glyph = el('span', `bar-tile-glyph sm-pin-glyph is-${stateOf(room).key}`);
|
|
1736
|
+
glyph.innerHTML = FOLDER_GLYPH; // trusted: our own SVG string
|
|
1737
|
+
glyph.append(el('i'));
|
|
1738
|
+
tile.append(glyph, el('span', 'bar-tile-name', room.name));
|
|
1739
|
+
return tile;
|
|
1740
|
+
}
|
|
1741
|
+
function pinsDrawMore() {
|
|
1742
|
+
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; })();
|
|
1743
|
+
const grid = el('div', 'bar-grid');
|
|
1744
|
+
for (const id of pins.spilled) {
|
|
1745
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
1746
|
+
if (!room) continue;
|
|
1747
|
+
const tile = pinTile(room, () => { pinsCloseMore(); pins.chips.get(id)?.click(); });
|
|
1748
|
+
tile.setAttribute('role', 'menuitem');
|
|
1749
|
+
grid.append(tile);
|
|
1750
|
+
}
|
|
1751
|
+
card.replaceChildren(grid);
|
|
1752
|
+
card.hidden = false;
|
|
1753
|
+
const key = pins.more.getBoundingClientRect();
|
|
1754
|
+
const width = card.offsetWidth || 240;
|
|
1755
|
+
card.style.top = `${Math.round(key.bottom + 10)}px`;
|
|
1756
|
+
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, key.left)))}px`;
|
|
1757
|
+
card.style.right = 'auto';
|
|
1758
|
+
pins.more.setAttribute('aria-expanded', 'true');
|
|
1759
|
+
}
|
|
1760
|
+
function pinsCloseMore() {
|
|
1761
|
+
if (pins.moreCard) pins.moreCard.hidden = true;
|
|
1762
|
+
pins.more?.setAttribute('aria-expanded', 'false');
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// The «−» of every room on the bar. They live in a layer of their own over the rooms (a link
|
|
1766
|
+
// cannot hold a button) and follow their room every frame: its place, its tremble, its carry.
|
|
1767
|
+
function pinsBadges() {
|
|
1768
|
+
const ids = pinsShown();
|
|
1769
|
+
for (const [id, badge] of pins.badges) if (!ids.includes(id)) { badge.remove(); pins.badges.delete(id); }
|
|
1770
|
+
ids.forEach((id, index) => {
|
|
1771
|
+
const delay = `${-((index * 97) % 260)}ms`;
|
|
1772
|
+
pins.chips.get(id)?.style.setProperty('--jiggle-delay', delay);
|
|
1773
|
+
let badge = pins.badges.get(id);
|
|
1774
|
+
if (!badge) {
|
|
1775
|
+
const name = data.rooms.find((one) => one.id === id)?.name ?? id;
|
|
1776
|
+
badge = button('bar-badge', null, (event) => { event.stopPropagation(); pinRemove(id); });
|
|
1777
|
+
badge.innerHTML = PIN_MINUS; // trusted: our own SVG string
|
|
1778
|
+
badge.title = t('Remove {name} from the bar', { name });
|
|
1779
|
+
badge.setAttribute('aria-label', badge.title);
|
|
1780
|
+
pins.badges.set(id, badge);
|
|
1781
|
+
pins.layer.append(badge);
|
|
1782
|
+
}
|
|
1783
|
+
badge.style.setProperty('--jiggle-delay', delay);
|
|
1784
|
+
});
|
|
1785
|
+
pinsFollow();
|
|
1786
|
+
}
|
|
1787
|
+
function pinsFollow() {
|
|
1788
|
+
for (const [id, badge] of pins.badges) {
|
|
1789
|
+
const chip = pins.chips.get(id);
|
|
1790
|
+
badge.hidden = !chip?.offsetWidth;
|
|
1791
|
+
if (badge.hidden) continue;
|
|
1792
|
+
badge.style.left = `${chip.offsetLeft - 5}px`;
|
|
1793
|
+
badge.style.top = `${chip.offsetTop - 5}px`;
|
|
1794
|
+
badge.style.transformOrigin = `${chip.offsetWidth / 2 + 5}px ${chip.offsetHeight / 2 + 5}px`;
|
|
1795
|
+
badge.style.translate = chip.style.translate || '';
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
function pinsTick() {
|
|
1799
|
+
pins.frame = 0;
|
|
1800
|
+
if (!pins.editing) return;
|
|
1801
|
+
pinsFollow();
|
|
1802
|
+
pinsPlaceCard();
|
|
1803
|
+
pins.frame = requestAnimationFrame(pinsTick);
|
|
1804
|
+
}
|
|
1805
|
+
// The card under the bar: the projects that are not on it, a key to finish, and how to come back.
|
|
1806
|
+
function pinsDrawCard() {
|
|
1807
|
+
if (!pins.card) {
|
|
1808
|
+
pins.card = el('section', 'bar-card sm-pins-card');
|
|
1809
|
+
pins.card.setAttribute('role', 'dialog');
|
|
1810
|
+
pins.card.setAttribute('aria-label', t('Add to the bar'));
|
|
1811
|
+
document.body.append(pins.card);
|
|
1812
|
+
}
|
|
1813
|
+
const ids = pinsShown();
|
|
1814
|
+
const offered = projectsInOrder().all.filter((room) => !ids.includes(room.id));
|
|
1815
|
+
// Drawn again only when what it offers changed, so a tile under the pointer stays put.
|
|
1816
|
+
const said = offered.map((room) => `${room.id}:${stateOf(room).key}`).join() + language;
|
|
1817
|
+
if (said === pins.cardSaid && !pins.card.hidden) { pinsPlaceCard(); return; }
|
|
1818
|
+
pins.cardSaid = said;
|
|
1819
|
+
const head = el('header', 'bar-card-head');
|
|
1820
|
+
const done = sxButton(t('Done'), () => pinsEdit(false), { tone: 'primary' });
|
|
1821
|
+
done.classList.add('bar-done');
|
|
1822
|
+
head.append(el('h3', null, t('Add to the bar')), done);
|
|
1823
|
+
const grid = el('div', 'bar-grid');
|
|
1824
|
+
for (const room of offered) {
|
|
1825
|
+
const tile = pinTile(room, () => pinAdd(room.id));
|
|
1826
|
+
tile.title = t('Add {name}', { name: room.name });
|
|
1827
|
+
tile.setAttribute('aria-label', tile.title);
|
|
1828
|
+
grid.append(tile);
|
|
1829
|
+
}
|
|
1830
|
+
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.')));
|
|
1831
|
+
pins.card.hidden = false;
|
|
1832
|
+
pinsPlaceCard();
|
|
1833
|
+
}
|
|
1834
|
+
function pinsPlaceCard() {
|
|
1835
|
+
const card = pins.card;
|
|
1836
|
+
if (!card || card.hidden) return;
|
|
1837
|
+
const bar = ui.bar.open.closest('.sm-topbar').getBoundingClientRect();
|
|
1838
|
+
const from = ui.bar.open.getBoundingClientRect();
|
|
1839
|
+
const width = card.offsetWidth || 360;
|
|
1840
|
+
card.style.top = `${Math.round(bar.bottom + 10)}px`;
|
|
1841
|
+
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, from.left - 6)))}px`;
|
|
1842
|
+
card.style.right = 'auto';
|
|
1843
|
+
}
|
|
1844
|
+
function pinsEdit(on) {
|
|
1845
|
+
if (on === pins.editing) return;
|
|
1846
|
+
pins.editing = on;
|
|
1847
|
+
pinsCloseMenu();
|
|
1848
|
+
ui.bar.open.classList.toggle('is-editing', on);
|
|
1849
|
+
if (on) {
|
|
1850
|
+
pins.cardSaid = '';
|
|
1851
|
+
drawPins();
|
|
1852
|
+
pins.card?.querySelector('.bar-tile, .bar-done')?.focus({ preventScroll: true });
|
|
1853
|
+
if (!pins.frame) pins.frame = requestAnimationFrame(pinsTick);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
cancelAnimationFrame(pins.frame);
|
|
1857
|
+
pins.frame = 0;
|
|
1858
|
+
pinsDragEnd({ save: true });
|
|
1859
|
+
for (const badge of pins.badges.values()) badge.remove();
|
|
1860
|
+
pins.badges.clear();
|
|
1861
|
+
if (pins.card) { pins.card.hidden = true; pins.card.replaceChildren(); }
|
|
1862
|
+
drawPins();
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Carrying a room to another place: it follows the pointer, the others make room as it passes
|
|
1866
|
+
// their middle, and it settles where it is let go. The order is saved then.
|
|
1867
|
+
function pinsDragStart(event, id) {
|
|
1868
|
+
if (event.button !== 0 || pins.drag) return;
|
|
1869
|
+
const chip = pins.chips.get(id);
|
|
1870
|
+
if (!chip || event.target.closest?.('.bar-badge')) return;
|
|
1871
|
+
const drag = { id, node: chip, x0: event.clientX, base: 0, moved: false, order: pinsShown() };
|
|
1872
|
+
drag.move = (moving) => pinsDragMove(moving);
|
|
1873
|
+
drag.up = () => pinsDragEnd({ save: true });
|
|
1874
|
+
pins.drag = drag;
|
|
1875
|
+
window.addEventListener('pointermove', drag.move);
|
|
1876
|
+
window.addEventListener('pointerup', drag.up);
|
|
1877
|
+
window.addEventListener('pointercancel', drag.up);
|
|
1878
|
+
}
|
|
1879
|
+
function pinsDragMove(event) {
|
|
1880
|
+
const drag = pins.drag;
|
|
1881
|
+
if (!drag) return;
|
|
1882
|
+
const dx = event.clientX - drag.x0;
|
|
1883
|
+
if (!drag.moved) {
|
|
1884
|
+
if (Math.abs(dx) < 5) return;
|
|
1885
|
+
drag.moved = true;
|
|
1886
|
+
drag.base = drag.node.offsetLeft;
|
|
1887
|
+
drag.node.classList.add('bar-lifted');
|
|
1888
|
+
}
|
|
1889
|
+
const centre = drag.base + dx + drag.node.offsetWidth / 2;
|
|
1890
|
+
const others = drag.order.filter((one) => one !== drag.id).map((one) => [one, pins.chips.get(one)]).filter(([, node]) => node);
|
|
1891
|
+
const past = others.find(([, node]) => centre < node.offsetLeft + node.offsetWidth / 2)?.[0] ?? null;
|
|
1892
|
+
const order = drag.order.filter((one) => one !== drag.id);
|
|
1893
|
+
order.splice(past ? order.indexOf(past) : order.length, 0, drag.id);
|
|
1894
|
+
if (order.join() !== drag.order.join()) {
|
|
1895
|
+
const before = new Map(others.map(([one, node]) => [one, node.offsetLeft]));
|
|
1896
|
+
drag.order = order;
|
|
1897
|
+
pinsPlace(order);
|
|
1898
|
+
// The rooms that moved slide from where they were, instead of jumping.
|
|
1899
|
+
for (const [one, node] of others) {
|
|
1900
|
+
const shift = before.get(one) - node.offsetLeft;
|
|
1901
|
+
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)' });
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
drag.node.style.translate = `${drag.base + dx - drag.node.offsetLeft}px 0`;
|
|
1905
|
+
}
|
|
1906
|
+
function pinsDragEnd({ save = false } = {}) {
|
|
1907
|
+
const drag = pins.drag;
|
|
1908
|
+
if (!drag) return;
|
|
1909
|
+
pins.drag = null;
|
|
1910
|
+
window.removeEventListener('pointermove', drag.move);
|
|
1911
|
+
window.removeEventListener('pointerup', drag.up);
|
|
1912
|
+
window.removeEventListener('pointercancel', drag.up);
|
|
1913
|
+
if (!drag.moved) return;
|
|
1914
|
+
const from = drag.node.style.translate;
|
|
1915
|
+
drag.node.style.translate = '';
|
|
1916
|
+
drag.node.classList.remove('bar-lifted');
|
|
1917
|
+
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)' });
|
|
1918
|
+
if (save) pinsWrite(drag.order);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// A project's card, right-clicked: pin it to the bar (or take it off), or arrange the bar.
|
|
1922
|
+
function pinsCloseMenu() { pins.menu?.remove(); pins.menu = null; }
|
|
1923
|
+
function pinsMenu(event, room, at = null) {
|
|
1924
|
+
event.preventDefault();
|
|
1925
|
+
pinsCloseMenu();
|
|
1926
|
+
const menu = el('div', 'bar-card sm-menu');
|
|
1927
|
+
menu.setAttribute('role', 'menu');
|
|
1928
|
+
menu.setAttribute('aria-label', room.name);
|
|
1929
|
+
const item = (words, icon, act, { danger = false } = {}) => {
|
|
1930
|
+
const one = button(`sm-menu-item${danger ? ' danger' : ''}`, null, () => { pinsCloseMenu(); act(); });
|
|
1931
|
+
one.setAttribute('role', 'menuitem');
|
|
1932
|
+
one.innerHTML = icon; // trusted: our own SVG strings
|
|
1933
|
+
one.append(el('span', null, words));
|
|
1934
|
+
menu.append(one);
|
|
1935
|
+
};
|
|
1936
|
+
const on = pinsShown().includes(room.id);
|
|
1937
|
+
item(on ? t('Remove from the bar') : t('Pin to the bar'), on ? UNPIN_ICON : PIN_ICON, () => (on ? pinRemove(room.id) : pinAdd(room.id)));
|
|
1938
|
+
item(t('Arrange the bar'), ARRANGE_ICON, () => pinsEdit(true));
|
|
1939
|
+
menu.append(el('i', 'sm-menu-sep'));
|
|
1940
|
+
item(t('Remove from MADRE…'), FORGET_ICON, () => { forgetting.add(room.id); asking.delete(room.id); failed.delete(room.id); draw(); }, { danger: true });
|
|
1941
|
+
menu.addEventListener('keydown', (key) => {
|
|
1942
|
+
const items = [...menu.querySelectorAll('.sm-menu-item')];
|
|
1943
|
+
const at = items.indexOf(document.activeElement);
|
|
1944
|
+
if (key.key === 'ArrowDown') { key.preventDefault(); items[(at + 1) % items.length].focus(); }
|
|
1945
|
+
if (key.key === 'ArrowUp') { key.preventDefault(); items[(at - 1 + items.length) % items.length].focus(); }
|
|
1946
|
+
});
|
|
1947
|
+
document.body.append(menu);
|
|
1948
|
+
pins.menu = menu;
|
|
1949
|
+
const width = menu.offsetWidth;
|
|
1950
|
+
const height = menu.offsetHeight;
|
|
1951
|
+
const point = at ?? { x: event.clientX, y: event.clientY };
|
|
1952
|
+
menu.style.left = `${Math.round(Math.max(8, Math.min(window.innerWidth - width - 8, point.x)))}px`;
|
|
1953
|
+
menu.style.top = `${Math.round(Math.max(8, Math.min(window.innerHeight - height - 8, point.y)))}px`;
|
|
1954
|
+
menu.style.right = 'auto';
|
|
1955
|
+
menu.querySelector('.sm-menu-item')?.focus({ preventScroll: true });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// A project's card, dragged to the bar: the bar shows where it would land, following the pointer,
|
|
1959
|
+
// and it is pinned there when it is let go. A room already on the bar is moved instead.
|
|
1960
|
+
function pinsDragOut(event, room) {
|
|
1961
|
+
if (!event.dataTransfer) return;
|
|
1962
|
+
event.dataTransfer.setData(PIN_TYPE, room.id);
|
|
1963
|
+
event.dataTransfer.setData('text/plain', room.name);
|
|
1964
|
+
event.dataTransfer.effectAllowed = 'copyMove';
|
|
1965
|
+
pinsCloseMenu();
|
|
1966
|
+
pins.dropping = room.id;
|
|
1967
|
+
pins.slotAt = null;
|
|
1968
|
+
pins.slot = el('span', 'sm-tb-room sm-tb-slot');
|
|
1969
|
+
pins.slot.append(el('i'), el('span', null, room.name), el('b', 'sm-tb-mono', monogram(room.name)));
|
|
1970
|
+
document.body.classList.add('sm-pin-dragging');
|
|
1971
|
+
// The source card stays as it is while it is carried: the lobby is not drawn again under it.
|
|
1972
|
+
requestAnimationFrame(() => drawPins());
|
|
1973
|
+
}
|
|
1974
|
+
function pinsDropEnd() {
|
|
1975
|
+
if (!pins.dropping) return;
|
|
1976
|
+
pins.dropping = null;
|
|
1977
|
+
pins.slot?.remove();
|
|
1978
|
+
pins.slot = null;
|
|
1979
|
+
pins.slotAt = null;
|
|
1980
|
+
document.body.classList.remove('sm-pin-dragging');
|
|
1981
|
+
drawPins();
|
|
1982
|
+
}
|
|
1983
|
+
function pinsIndexAt(x) {
|
|
1984
|
+
const ids = pinsShown().filter((id) => id !== pins.dropping);
|
|
1985
|
+
const index = ids.findIndex((id) => { const box = pins.chips.get(id)?.getBoundingClientRect(); return box && x < box.left + box.width / 2; });
|
|
1986
|
+
return index < 0 ? ids.length : index;
|
|
1987
|
+
}
|
|
1988
|
+
{
|
|
1989
|
+
const box = ui.bar.open;
|
|
1990
|
+
pins.layer = el('div', 'bar-badges');
|
|
1991
|
+
box.append(pins.layer);
|
|
1992
|
+
pins.more = button('sm-tb-room is-more', '', (event) => { event.stopPropagation(); if (pins.moreCard && !pins.moreCard.hidden) pinsCloseMore(); else pinsDrawMore(); });
|
|
1993
|
+
pins.more.hidden = true;
|
|
1994
|
+
pins.more.setAttribute('aria-haspopup', 'menu');
|
|
1995
|
+
box.append(pins.more);
|
|
1996
|
+
const bar = box.closest('.sm-topbar');
|
|
1997
|
+
// While the bar is arranged a room is carried, not followed; a right-click between the rooms
|
|
1998
|
+
// puts the bar back, as a second right-click on a room does.
|
|
1999
|
+
box.addEventListener('click', (event) => {
|
|
2000
|
+
if (!pins.editing || event.target.closest?.('.bar-badge') || !event.target.closest?.('[data-bar-item]')) return;
|
|
2001
|
+
event.preventDefault();
|
|
2002
|
+
event.stopPropagation();
|
|
2003
|
+
}, true);
|
|
2004
|
+
box.addEventListener('contextmenu', (event) => { if (pins.editing && !event.pinHandled) { event.preventDefault(); pinsEdit(false); } });
|
|
2005
|
+
// A press anywhere but the bar and its card puts the bar back; Escape too.
|
|
2006
|
+
document.addEventListener('pointerdown', (event) => {
|
|
2007
|
+
if (pins.menu && !pins.menu.contains(event.target)) pinsCloseMenu();
|
|
2008
|
+
if (pins.moreCard && !pins.moreCard.hidden && !pins.moreCard.contains(event.target) && !pins.more.contains(event.target)) pinsCloseMore();
|
|
2009
|
+
if (pins.editing && !box.contains(event.target) && !pins.card?.contains(event.target)) pinsEdit(false);
|
|
2010
|
+
}, true);
|
|
2011
|
+
document.addEventListener('keydown', (event) => {
|
|
2012
|
+
if (event.key !== 'Escape') return;
|
|
2013
|
+
if (pins.menu) { pinsCloseMenu(); return; }
|
|
2014
|
+
if (pins.moreCard && !pins.moreCard.hidden) { pinsCloseMore(); return; }
|
|
2015
|
+
if (pins.editing) { event.preventDefault(); pinsEdit(false); }
|
|
2016
|
+
});
|
|
2017
|
+
window.addEventListener('resize', () => { pinsCloseMenu(); if (data) drawPins(); });
|
|
2018
|
+
window.addEventListener('scroll', () => pinsCloseMenu(), { passive: true });
|
|
2019
|
+
// The drop: anywhere on the bar, at the place nearest the pointer.
|
|
2020
|
+
bar.addEventListener('dragover', (event) => {
|
|
2021
|
+
if (!pins.dropping || !event.dataTransfer?.types.includes(PIN_TYPE)) return;
|
|
2022
|
+
event.preventDefault();
|
|
2023
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
2024
|
+
const at = pinsIndexAt(event.clientX);
|
|
2025
|
+
if (at !== pins.slotAt) { pins.slotAt = at; bar.classList.add('is-drop'); pinsPlace(pinsShown().filter((id) => id !== pins.dropping)); }
|
|
2026
|
+
});
|
|
2027
|
+
bar.addEventListener('dragleave', (event) => {
|
|
2028
|
+
if (!pins.dropping || bar.contains(event.relatedTarget)) return;
|
|
2029
|
+
pins.slotAt = null;
|
|
2030
|
+
bar.classList.remove('is-drop');
|
|
2031
|
+
pinsPlace(pinsShown().filter((id) => id !== pins.dropping));
|
|
2032
|
+
});
|
|
2033
|
+
bar.addEventListener('drop', (event) => {
|
|
2034
|
+
const id = event.dataTransfer?.getData(PIN_TYPE);
|
|
2035
|
+
if (!id) return;
|
|
2036
|
+
event.preventDefault();
|
|
2037
|
+
const at = pins.slotAt ?? pinsIndexAt(event.clientX);
|
|
2038
|
+
bar.classList.remove('is-drop');
|
|
2039
|
+
pinsDropEnd();
|
|
2040
|
+
pinAdd(id, at);
|
|
2041
|
+
});
|
|
2042
|
+
// Another tab arranged the bar: this one follows.
|
|
2043
|
+
window.addEventListener('storage', (event) => { if (event.key === PIN_KEY) { pins.saved = null; if (data) drawPins(); } });
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
ui.bar.links.addEventListener('click', () => { if ((data?.enlaces ?? []).length) { if (!ui.linksSheet.open) ui.linksSheet.showModal(); } else openLinkSheet(); });
|
|
2047
|
+
ui.bar.update.addEventListener('click', () => openSettings('release'));
|
|
2048
|
+
// RELOAD, as a room reloads (app.js, reloadWithGrace): the curtain of Home's own ground with MADRE's
|
|
2049
|
+
// loader; the page goes with the share it reached and the new one fills the rest (salas-mac.html).
|
|
2050
|
+
function reloadWithGrace() {
|
|
2051
|
+
if (motionStill() || document.querySelector('.reload-curtain')) { location.reload(); return; }
|
|
2052
|
+
const curtain = el('div', 'reload-curtain');
|
|
2053
|
+
const loader = makeLoader({ progress: 0.04 });
|
|
2054
|
+
curtain.append(loader);
|
|
2055
|
+
showCurtain(curtain);
|
|
2056
|
+
creep(loader, { to: 0.34, rate: 0.09 });
|
|
2057
|
+
setTimeout(() => {
|
|
2058
|
+
try { sessionStorage.setItem('madre.curtain', loader.dataset.p ?? '0.3'); } catch { /* the new page simply arrives */ }
|
|
2059
|
+
location.reload();
|
|
2060
|
+
}, 900);
|
|
2061
|
+
}
|
|
2062
|
+
ui.bar.reload.addEventListener('click', reloadWithGrace);
|
|
2063
|
+
|
|
2064
|
+
// From Home into a project, and back (2026-10-10): the page settles back under a veil of its own
|
|
2065
|
+
// ground and goes (styles.css, madre-leaving); the room lifts the veil as it arrives. Every way in
|
|
2066
|
+
// is a link — a card, a key on the bar, a tag, the Coordinator, the map — so a plain press on any
|
|
2067
|
+
// link to a MADRE page goes through here; ⌘-click, a new tab or a page elsewhere is left alone.
|
|
2068
|
+
function leaveTo(url) {
|
|
2069
|
+
if (motionStill()) { location.href = url; return; }
|
|
2070
|
+
root.classList.add('madre-leaving');
|
|
2071
|
+
setTimeout(() => { location.href = url; }, 200);
|
|
2072
|
+
setTimeout(() => root.classList.remove('madre-leaving'), 2500);
|
|
2073
|
+
}
|
|
2074
|
+
window.addEventListener('pageshow', () => root.classList.remove('madre-leaving'));
|
|
2075
|
+
document.addEventListener('click', (event) => {
|
|
2076
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
2077
|
+
const link = event.target.closest?.('a[href]');
|
|
2078
|
+
if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return;
|
|
2079
|
+
let to = null;
|
|
2080
|
+
try { to = new URL(link.href, location.href); } catch { return; }
|
|
2081
|
+
if (to.protocol !== 'http:' || !/^(127\.0\.0\.1|localhost)$/.test(to.hostname)) return;
|
|
2082
|
+
if (to.origin === location.origin && to.pathname === location.pathname) return;
|
|
2083
|
+
event.preventDefault();
|
|
2084
|
+
leaveTo(to.href);
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
// What just happened, in a line (the room's notice: styles.css, .toast), at the foot of the screen
|
|
2088
|
+
// and over any open sheet, for a few seconds.
|
|
2089
|
+
let toastNode = null;
|
|
2090
|
+
let toastTimer = 0;
|
|
2091
|
+
function toast(words, tone = 'done') {
|
|
2092
|
+
toastNode?.remove();
|
|
2093
|
+
const node = el('div', 'toast sm-toast');
|
|
2094
|
+
node.setAttribute('role', 'status');
|
|
2095
|
+
node.dataset.tone = tone;
|
|
2096
|
+
node.append(el('i', 'toast-dot'), el('span', 'toast-text', words));
|
|
2097
|
+
document.body.append(node);
|
|
2098
|
+
if (typeof node.showPopover === 'function') { node.popover = 'manual'; node.showPopover(); }
|
|
2099
|
+
toastNode = node;
|
|
2100
|
+
clearTimeout(toastTimer);
|
|
2101
|
+
toastTimer = setTimeout(() => { node.remove(); if (toastNode === node) toastNode = null; }, 5000);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// ---- THE LOBBY (2026-10-10). The first screen of MADRE: every project on this Mac, BISHOP above
|
|
2105
|
+
// them, and the way to add more. A bare `madre` opens here; `madre start` in a folder still opens
|
|
2106
|
+
// that project directly.
|
|
2107
|
+
|
|
2108
|
+
const post = (url, payload) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload ?? {}) })
|
|
2109
|
+
.then(async (response) => ({ ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }))
|
|
2110
|
+
.catch(() => ({ ok: false, status: 0, body: {} }));
|
|
2111
|
+
function mark(id, size = 40) {
|
|
2112
|
+
const brand = brandOf(id);
|
|
2113
|
+
const node = el('span', 'sm-mark');
|
|
2114
|
+
node.style.setProperty('--b', brand.solid ?? brand.color);
|
|
2115
|
+
node.style.setProperty('--b-ink', brand.ink);
|
|
2116
|
+
node.style.width = node.style.height = `${size}px`;
|
|
2117
|
+
node.innerHTML = brand.mark(); // trusted: our own SVG strings from brands.js
|
|
2118
|
+
return node;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// BISHOP, at the head of the lobby: the super agent that reads across the projects, signs links
|
|
2122
|
+
// between them and carries orders from one to another. It is talked to in its own room (MADRE's
|
|
2123
|
+
// folder, not a project), here or in a new tab; asleep, it is woken first. What it waits for is
|
|
2124
|
+
// read from that room. Under it, the last work of every project, summarised by MADRE.
|
|
2125
|
+
ui.bishop.mark.replaceWith(mark('bishop', 44));
|
|
2126
|
+
function drawBishop(room) {
|
|
2127
|
+
const b = ui.bishop;
|
|
2128
|
+
const glance = room?.glance ?? {};
|
|
2129
|
+
const state = room?.open ? stateOf(room) : { key: 'asleep', words: t('Asleep') };
|
|
2130
|
+
b.box.className = `sm-tile sm-bishop c-8 r-3 is-${state.key}`;
|
|
2131
|
+
b.state.querySelector('span').textContent = state.words;
|
|
2132
|
+
const waking = busy.get(data?.salas) === 'starting';
|
|
2133
|
+
for (const way of [b.go, b.tab]) way.setAttribute('aria-disabled', String(waking));
|
|
2134
|
+
b.go.textContent = waking ? t('Waking…') : t('Talk to the Coordinator');
|
|
2135
|
+
syncBishopLinks();
|
|
2136
|
+
// What it waits for, each one a door into its room.
|
|
2137
|
+
b.asks.replaceChildren();
|
|
2138
|
+
b.asks.hidden = !glance.waiting?.length;
|
|
2139
|
+
if (!b.asks.hidden) {
|
|
2140
|
+
b.asks.append(el('span', 'sm-hint', t('Waiting for you')));
|
|
2141
|
+
for (const ask of glance.waiting.slice(0, 3)) b.asks.append(button('sm-chip ask', askLine(ask), () => openBishop()));
|
|
2142
|
+
}
|
|
2143
|
+
b.error.hidden = !failed.has(data?.salas);
|
|
2144
|
+
b.error.textContent = failed.get(data?.salas) ?? '';
|
|
2145
|
+
}
|
|
2146
|
+
// Both ways into BISHOP's room are links, so ⌘-click and «open in a new tab» do what they always
|
|
2147
|
+
// do. Asleep, it is woken first; a new tab is then opened at the press, while the press is still
|
|
2148
|
+
// the person's, and sent on when the room answers: a tab opened after waiting is blocked.
|
|
2149
|
+
function bishopAddress() {
|
|
2150
|
+
const room = data?.rooms.find((one) => one.id === data.salas);
|
|
2151
|
+
return room?.open ? addressOf(room) : null;
|
|
2152
|
+
}
|
|
2153
|
+
function syncBishopLinks() {
|
|
2154
|
+
const address = bishopAddress() ?? '/salas';
|
|
2155
|
+
ui.bishop.go.href = address;
|
|
2156
|
+
ui.bishop.tab.href = address;
|
|
2157
|
+
}
|
|
2158
|
+
function wakeBishop({ newTab = false } = {}) {
|
|
2159
|
+
const tab = newTab ? window.open('about:blank', '_blank') : null;
|
|
2160
|
+
if (tab) tab.opener = null;
|
|
2161
|
+
void startRoom({ id: data.salas }).then((answer) => {
|
|
2162
|
+
if (!answer.ok || !answer.body.port) { tab?.close(); return; }
|
|
2163
|
+
const address = `http://127.0.0.1:${answer.body.port}/`;
|
|
2164
|
+
if (tab) tab.location.href = address;
|
|
2165
|
+
else leaveTo(address);
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
function openBishop({ newTab = false } = {}) {
|
|
2169
|
+
if (!data) return;
|
|
2170
|
+
const address = bishopAddress();
|
|
2171
|
+
if (!address) { wakeBishop({ newTab }); return; }
|
|
2172
|
+
if (newTab) window.open(address, '_blank', 'noopener');
|
|
2173
|
+
else leaveTo(address);
|
|
2174
|
+
}
|
|
2175
|
+
for (const [way, alwaysNew] of [[ui.bishop.go, false], [ui.bishop.tab, true]]) {
|
|
2176
|
+
way.addEventListener('click', (event) => {
|
|
2177
|
+
if (way.getAttribute('aria-disabled') === 'true' || !data) { event.preventDefault(); return; }
|
|
2178
|
+
// Awake, the link goes where it points.
|
|
2179
|
+
if (bishopAddress()) return;
|
|
2180
|
+
event.preventDefault();
|
|
2181
|
+
wakeBishop({ newTab: alwaysNew || event.metaKey || event.ctrlKey || event.shiftKey });
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// ---- Choosing folders (2026-10-10): tiles, a page at a time. Suggestions first (folders that look
|
|
2186
|
+
// like projects in the places people keep them), or the folders of the home folder, walked one
|
|
2187
|
+
// level at a time; a search narrows either. Several can be chosen; a folder already in Home says
|
|
2188
|
+
// so and is not offered again. The sheet never runs past the screen: the tiles take as many rows
|
|
2189
|
+
// as it holds, and ‹ › turn the page, so nothing scrolls inside it.
|
|
2190
|
+
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>';
|
|
2191
|
+
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>';
|
|
2192
|
+
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>';
|
|
2193
|
+
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>';
|
|
2194
|
+
const FOLD_ROWS = 5;
|
|
2195
|
+
|
|
2196
|
+
// The tiles and their pages. How many rows is measured, not guessed: from five down to two, the
|
|
2197
|
+
// most that keep the sheet inside the screen, measured again when the screen changes size. The
|
|
2198
|
+
// grid keeps the height of its rows whatever the page holds, so turning a page or searching never
|
|
2199
|
+
// moves the keys under it; a list that does not change (`snug`) takes only the rows it fills.
|
|
2200
|
+
function foldPages({ snug = false } = {}) {
|
|
2201
|
+
const grid = el('div', 'sm-fold-grid');
|
|
2202
|
+
const pager = el('div', 'sm-pager');
|
|
2203
|
+
const back = button('sm-icon-key sm-page', null, () => turn(-1));
|
|
2204
|
+
const on = button('sm-icon-key sm-page', null, () => turn(1));
|
|
2205
|
+
back.innerHTML = PAGE_BACK; // trusted: our own SVG strings
|
|
2206
|
+
on.innerHTML = PAGE_ON;
|
|
2207
|
+
back.setAttribute('aria-label', t('Previous'));
|
|
2208
|
+
on.setAttribute('aria-label', t('Next'));
|
|
2209
|
+
const where = el('span', 'sm-page-where');
|
|
2210
|
+
pager.append(back, where, on);
|
|
2211
|
+
let items = [];
|
|
2212
|
+
let tile = () => null;
|
|
2213
|
+
let none = null;
|
|
2214
|
+
let page = 0;
|
|
2215
|
+
let rows = 4;
|
|
2216
|
+
let cols = 3;
|
|
2217
|
+
let seen = '';
|
|
2218
|
+
const per = () => rows * cols;
|
|
2219
|
+
const paint = () => {
|
|
2220
|
+
const pages = Math.max(1, Math.ceil(items.length / per()));
|
|
2221
|
+
page = Math.max(0, Math.min(page, pages - 1));
|
|
2222
|
+
grid.style.setProperty('--cols', String(cols));
|
|
2223
|
+
grid.style.setProperty('--rows', String(snug ? Math.max(1, Math.min(rows, Math.ceil(items.length / cols))) : rows));
|
|
2224
|
+
grid.replaceChildren(...items.slice(page * per(), (page + 1) * per()).map((item) => tile(item)));
|
|
2225
|
+
if (!items.length && none) grid.append(el('p', 'sm-fold-none', none()));
|
|
2226
|
+
pager.hidden = pages < 2;
|
|
2227
|
+
where.textContent = t('{n} of {total}', { n: page + 1, total: pages });
|
|
2228
|
+
back.disabled = page === 0;
|
|
2229
|
+
on.disabled = page >= pages - 1;
|
|
2230
|
+
};
|
|
2231
|
+
const turn = (step) => {
|
|
2232
|
+
page += step;
|
|
2233
|
+
paint();
|
|
2234
|
+
// The key pressed stays under the finger; at the last page it is the other one.
|
|
2235
|
+
if (document.activeElement === document.body || document.activeElement?.disabled) (step > 0 ? back : on).focus({ preventScroll: true });
|
|
2236
|
+
};
|
|
2237
|
+
const fit = () => {
|
|
2238
|
+
const layer = grid.closest('dialog');
|
|
2239
|
+
if (!grid.isConnected || !layer?.open || !grid.clientWidth) return;
|
|
2240
|
+
const key = `${grid.clientWidth}x${layer.clientHeight}`;
|
|
2241
|
+
if (key === seen) return;
|
|
2242
|
+
seen = key;
|
|
2243
|
+
cols = grid.clientWidth >= 540 ? 3 : grid.clientWidth >= 340 ? 2 : 1;
|
|
2244
|
+
rows = FOLD_ROWS;
|
|
2245
|
+
paint();
|
|
2246
|
+
while (rows > 2 && layer.scrollHeight > layer.clientHeight + 1) { rows -= 1; paint(); }
|
|
2247
|
+
};
|
|
2248
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => requestAnimationFrame(fit)).observe(grid);
|
|
2249
|
+
const resized = () => { if (!grid.isConnected) { window.removeEventListener('resize', resized); return; } seen = ''; requestAnimationFrame(fit); };
|
|
2250
|
+
window.addEventListener('resize', resized);
|
|
2251
|
+
return {
|
|
2252
|
+
grid,
|
|
2253
|
+
pager,
|
|
2254
|
+
show(list, { draw, empty = null, keepPage = false }) {
|
|
2255
|
+
items = list;
|
|
2256
|
+
tile = draw;
|
|
2257
|
+
none = empty;
|
|
2258
|
+
if (!keepPage) page = 0;
|
|
2259
|
+
paint();
|
|
2260
|
+
},
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// One folder as a tile: its mark (green when it looks like a project), its name and where it is.
|
|
2265
|
+
// Chosen, the tile takes the accent and its mark a tick; a folder already in Home is only shown.
|
|
2266
|
+
function foldTile({ name, path, note = null, project = true, known = false, checked = false, onToggle = null, enter = null }) {
|
|
2267
|
+
const item = el('label', `sm-fold${project ? ' is-project' : ''}${known ? ' known' : ''}${checked ? ' is-on' : ''}`);
|
|
2268
|
+
item.title = homeWord(path);
|
|
2269
|
+
const box = el('input');
|
|
2270
|
+
box.type = 'checkbox';
|
|
2271
|
+
box.checked = checked;
|
|
2272
|
+
box.disabled = known;
|
|
2273
|
+
box.addEventListener('change', () => { item.classList.toggle('is-on', box.checked); onToggle?.(box.checked); });
|
|
2274
|
+
const glyph = el('span', 'sm-fold-glyph');
|
|
2275
|
+
glyph.innerHTML = FOLDER_GLYPH; // trusted: our own SVG strings
|
|
2276
|
+
const tick = el('i', 'sm-fold-tick');
|
|
2277
|
+
tick.innerHTML = FOLD_TICK;
|
|
2278
|
+
glyph.append(tick);
|
|
2279
|
+
const words = el('span', 'sm-fold-words');
|
|
2280
|
+
words.append(el('b', null, name), el('small', null, note ?? homeWord(path)));
|
|
2281
|
+
item.append(box, glyph, words);
|
|
2282
|
+
if (enter) {
|
|
2283
|
+
const into = button('sm-into', null, (event) => { event.preventDefault(); enter(); });
|
|
2284
|
+
into.innerHTML = PAGE_ON;
|
|
2285
|
+
into.setAttribute('aria-label', t('Open {name}', { name }));
|
|
2286
|
+
into.title = t('Open {name}', { name });
|
|
2287
|
+
item.append(into);
|
|
2288
|
+
}
|
|
2289
|
+
return item;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function projectPicker({ onAdded = null, onChange = null, withAdd = true } = {}) {
|
|
2293
|
+
const chosen = new Map(); // path → name
|
|
2294
|
+
const node = el('div', 'sm-picker');
|
|
2295
|
+
const pages = foldPages();
|
|
2296
|
+
let source = 'found'; // found · browse
|
|
2297
|
+
let found = null; // the suggestions, once read
|
|
2298
|
+
let here = null; // the folder being walked, as /api/salas/folders answered
|
|
2299
|
+
let query = '';
|
|
2300
|
+
let touched = false; // the person chose the list: an empty one is not left on its own
|
|
2301
|
+
const head = el('div', 'sm-picker-head');
|
|
2302
|
+
let seg = el('span');
|
|
2303
|
+
const search = el('label', 'settings-search sm-picker-search');
|
|
2304
|
+
search.innerHTML = SEARCH_GLYPH; // trusted: our own SVG string
|
|
2305
|
+
const input = el('input');
|
|
2306
|
+
input.type = 'search';
|
|
2307
|
+
input.autocomplete = 'off';
|
|
2308
|
+
input.spellcheck = false;
|
|
2309
|
+
input.placeholder = t('Search a folder…');
|
|
2310
|
+
input.setAttribute('aria-label', t('Search a folder…'));
|
|
2311
|
+
input.addEventListener('input', () => { query = input.value.trim().toLocaleLowerCase(language); draw(); });
|
|
2312
|
+
search.append(input);
|
|
2313
|
+
head.append(seg, search);
|
|
2314
|
+
const line = el('div', 'sm-picker-line');
|
|
2315
|
+
const footer = el('div', 'sm-picker-foot');
|
|
2316
|
+
const count = el('span', 'sm-picked');
|
|
2317
|
+
const addButton = withAdd ? button('sm-btn primary', t('Add'), () => void add()) : null;
|
|
2318
|
+
const error = el('p', 'error');
|
|
2319
|
+
footer.append(pages.pager, count);
|
|
2320
|
+
if (addButton) footer.append(addButton);
|
|
2321
|
+
node.append(head, line, pages.grid, footer, error);
|
|
2322
|
+
const sync = () => {
|
|
2323
|
+
count.textContent = chosen.size ? (chosen.size === 1 ? t('1 folder chosen') : t('{n} folders chosen', { n: chosen.size })) : t('Choose one or more folders');
|
|
2324
|
+
if (addButton) {
|
|
2325
|
+
addButton.disabled = !chosen.size;
|
|
2326
|
+
addButton.textContent = chosen.size > 1 ? t('Add {n} projects', { n: chosen.size }) : t('Add project');
|
|
2327
|
+
}
|
|
2328
|
+
onChange?.(chosen.size);
|
|
2329
|
+
};
|
|
2330
|
+
const matches = (one) => !query || one.name.toLocaleLowerCase(language).includes(query) || homeWord(one.path).toLocaleLowerCase(language).includes(query);
|
|
2331
|
+
const tile = (one) => foldTile({
|
|
2332
|
+
...one,
|
|
2333
|
+
note: one.known ? t('In Home') : null,
|
|
2334
|
+
checked: chosen.has(one.path),
|
|
2335
|
+
onToggle: (on) => { if (on) chosen.set(one.path, one.name); else chosen.delete(one.path); sync(); },
|
|
2336
|
+
});
|
|
2337
|
+
function draw({ keepPage = false } = {}) {
|
|
2338
|
+
const fresh = customSeg([['found', t('Found on this Mac')], ['browse', t('Your folders')]], source, (value) => {
|
|
2339
|
+
touched = true;
|
|
2340
|
+
source = value;
|
|
2341
|
+
draw();
|
|
2342
|
+
if (value === 'browse' && !here) void walk();
|
|
2343
|
+
}, 'two');
|
|
2344
|
+
seg.replaceWith(fresh);
|
|
2345
|
+
seg = fresh;
|
|
2346
|
+
const noMatch = () => t('No folder matches «{query}».', { query: input.value.trim() });
|
|
2347
|
+
if (source === 'found') {
|
|
2348
|
+
line.replaceChildren(el('span', 'sm-picker-note', found ? t('Folders that look like projects, in the places people keep them.') : t('Looking…')));
|
|
2349
|
+
pages.show(found ? found.filter(matches) : [], {
|
|
2350
|
+
draw: tile,
|
|
2351
|
+
empty: () => (!found ? t('Looking…') : query ? noMatch() : t('Nothing that looks like a project in the usual places. Look for it in Your folders.')),
|
|
2352
|
+
keepPage,
|
|
2353
|
+
});
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
line.replaceChildren();
|
|
2357
|
+
if (here?.parent) {
|
|
2358
|
+
const up = button('sm-btn ghost small sm-up', null, () => void walk(here.parent));
|
|
2359
|
+
up.innerHTML = PAGE_BACK; // trusted: our own SVG string
|
|
2360
|
+
up.append(el('span', null, t('Up')));
|
|
2361
|
+
line.append(up);
|
|
2362
|
+
}
|
|
2363
|
+
line.append(el('span', 'sm-where', here?.path ? homeWord(here.path) : t('Looking…')));
|
|
2364
|
+
const list = here?.entries
|
|
2365
|
+
? [...(here.path !== here.home ? [{ name: t('This folder'), path: here.path, project: false }] : []), ...here.entries.map((one) => ({ ...one, enter: () => void walk(one.path) }))]
|
|
2366
|
+
: [];
|
|
2367
|
+
pages.show(list.filter(matches), {
|
|
2368
|
+
draw: tile,
|
|
2369
|
+
empty: () => (!here ? t('Looking…') : !here.entries ? (here.error ?? t('That folder is not there.')) : query ? noMatch() : t('No folders inside.')),
|
|
2370
|
+
keepPage,
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
async function loadFound() {
|
|
2374
|
+
const answer = await fetch('/api/salas/suggestions', { cache: 'no-store' }).then((response) => response.json()).catch(() => ({ suggestions: [] }));
|
|
2375
|
+
found = answer.suggestions ?? [];
|
|
2376
|
+
// Nothing found and nothing chosen yet: the folders of the home folder, where it must be.
|
|
2377
|
+
if (!found.length && !touched && source === 'found') { source = 'browse'; if (!here) void walk(); }
|
|
2378
|
+
draw({ keepPage: true });
|
|
2379
|
+
}
|
|
2380
|
+
async function walk(path = null) {
|
|
2381
|
+
const answer = await fetch(`/api/salas/folders${path ? `?path=${encodeURIComponent(path)}` : ''}`, { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
|
|
2382
|
+
here = answer ?? { error: t('That folder is not there.') };
|
|
2383
|
+
if (path !== null) { query = ''; input.value = ''; }
|
|
2384
|
+
if (source === 'browse') draw();
|
|
2385
|
+
}
|
|
2386
|
+
async function add() {
|
|
2387
|
+
error.textContent = '';
|
|
2388
|
+
if (addButton) addButton.disabled = true;
|
|
2389
|
+
const answer = await post('/api/salas/projects', { roots: [...chosen.keys()] });
|
|
2390
|
+
if (addButton) addButton.disabled = false;
|
|
2391
|
+
if (!answer.ok) { error.textContent = answer.body.refused?.[0]?.error ?? answer.body.error ?? t('They could not be added.'); return false; }
|
|
2392
|
+
if (answer.body.refused?.length) error.textContent = answer.body.refused.map((one) => `${homeWord(one.root)}: ${one.error}`).join(' · ');
|
|
2393
|
+
chosen.clear();
|
|
2394
|
+
sync();
|
|
2395
|
+
await refresh();
|
|
2396
|
+
const added = answer.body.added ?? [];
|
|
2397
|
+
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 }));
|
|
2398
|
+
onAdded?.(added);
|
|
2399
|
+
void loadFound();
|
|
2400
|
+
if (here) void walk(here.path === here.home ? null : here.path);
|
|
2401
|
+
return true;
|
|
2402
|
+
}
|
|
2403
|
+
draw();
|
|
2404
|
+
void loadFound();
|
|
2405
|
+
sync();
|
|
2406
|
+
return { node, add, count: () => chosen.size };
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// The room's close (styles.css, .surface-modes · .x-close): a sunk track holding one key, the
|
|
2410
|
+
// cross that turns as the pointer comes to it.
|
|
2411
|
+
function closeKey(onClick) {
|
|
2412
|
+
const track = el('span', 'surface-modes');
|
|
2413
|
+
const key = button('surface-mode close x-close x-only', null, onClick);
|
|
2414
|
+
key.setAttribute('aria-label', t('Close'));
|
|
2415
|
+
const icon = el('span', 'x-icon');
|
|
2416
|
+
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>';
|
|
2417
|
+
key.append(icon);
|
|
2418
|
+
track.append(key);
|
|
2419
|
+
return track;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// «Add a project», on its own: the picker in a sheet, under the welcome's scene of the projects.
|
|
2423
|
+
let addSheet = null;
|
|
2424
|
+
function openAddSheet() {
|
|
2425
|
+
if (addSheet) return;
|
|
2426
|
+
addSheet = el('dialog', 'sm-sheet sm-wide sm-add');
|
|
2427
|
+
const inner = el('div', 'sm-sheet-body');
|
|
2428
|
+
const head = el('header', 'sm-sheet-head');
|
|
2429
|
+
head.append(el('h3', null, t('Add a project')), closeKey(() => addSheet.close()));
|
|
2430
|
+
// The welcome's own scene over it: the folders dropping into Home.
|
|
2431
|
+
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.')));
|
|
2432
|
+
inner.append(projectPicker({ onAdded: () => addSheet?.close() }).node);
|
|
2433
|
+
addSheet.append(inner);
|
|
2434
|
+
document.body.append(addSheet);
|
|
2435
|
+
addSheet.addEventListener('close', () => { addSheet.remove(); addSheet = null; });
|
|
2436
|
+
addSheet.showModal();
|
|
2437
|
+
}
|
|
2438
|
+
ui.add.addEventListener('click', openAddSheet);
|
|
2439
|
+
|
|
2440
|
+
// The agents of this computer, each with its session and the one action it needs (install, or
|
|
2441
|
+
// sign in), kept current while it is on screen: polled, and told by the room's events what an
|
|
2442
|
+
// install or a sign-in says as it goes. The wizard's first step and the settings sheet show it.
|
|
2443
|
+
function agentsPanel({ onPaint = null } = {}) {
|
|
2444
|
+
const list = el('div', 'sm-agents');
|
|
2445
|
+
const lastLine = new Map(); // agent → what its install or sign-in said last
|
|
2446
|
+
let poll = 0;
|
|
2447
|
+
let stream = null;
|
|
2448
|
+
const paint = async () => {
|
|
2449
|
+
const answer = await fetch('/api/settings', { cache: 'no-store' }).then((response) => response.json()).catch(() => null);
|
|
2450
|
+
if (!answer?.agents) return;
|
|
2451
|
+
agentsSeen = answer;
|
|
2452
|
+
drawAttention();
|
|
2453
|
+
list.replaceChildren();
|
|
2454
|
+
let ready = 0;
|
|
2455
|
+
for (const agent of answer.agents.filter((one) => one.id !== 'madre')) {
|
|
2456
|
+
const state = agentState(agent, answer);
|
|
2457
|
+
if (state.ok) ready += 1;
|
|
2458
|
+
const item = el('div', `sm-agent${state.ok ? ' ok' : ''}`);
|
|
2459
|
+
const words = el('span', 'sm-agent-words');
|
|
2460
|
+
words.append(el('b', null, agent.label ?? agent.id));
|
|
2461
|
+
words.append(el('small', `session-lamp ${state.key}`, state.words));
|
|
2462
|
+
if (lastLine.has(agent.id) && !state.ok) {
|
|
2463
|
+
const said = lastLine.get(agent.id);
|
|
2464
|
+
const line = el('small', 'sm-agent-line', said.line);
|
|
2465
|
+
if (said.url) { const link = el('a', null, t('Open the sign-in page')); link.href = said.url; link.target = '_blank'; link.rel = 'noopener'; line.append(' ', link); }
|
|
2466
|
+
words.append(line);
|
|
2467
|
+
}
|
|
2468
|
+
item.append(mark(agent.id, 34), words);
|
|
2469
|
+
if (!state.ok && !state.busyNow) {
|
|
2470
|
+
if (!agent.detected && agent.install) item.append(button('sm-btn ghost small', t('Install'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/install`); void paint(); }));
|
|
2471
|
+
else if (agent.detected && agent.login) item.append(button('sm-btn ghost small', t('Sign in'), async (event) => { event.currentTarget.disabled = true; await post(`/api/agents/${agent.id}/login`); void paint(); }));
|
|
2472
|
+
}
|
|
2473
|
+
list.append(item);
|
|
2474
|
+
}
|
|
2475
|
+
onPaint?.(ready, answer);
|
|
2476
|
+
};
|
|
2477
|
+
const live = () => {
|
|
2478
|
+
if (!list.isConnected || poll) return;
|
|
2479
|
+
poll = setInterval(() => { if (!document.hidden) void paint(); }, 2500);
|
|
2480
|
+
try {
|
|
2481
|
+
stream = new EventSource('/api/events');
|
|
2482
|
+
stream.onmessage = (message) => {
|
|
2483
|
+
try {
|
|
2484
|
+
const event = JSON.parse(message.data);
|
|
2485
|
+
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 });
|
|
2486
|
+
if (/^connection\./.test(event.type)) void paint();
|
|
2487
|
+
} catch { /* not ours */ }
|
|
2488
|
+
};
|
|
2489
|
+
} catch { /* no stream: the poll keeps it current */ }
|
|
2490
|
+
};
|
|
2491
|
+
const stop = () => { clearInterval(poll); poll = 0; stream?.close(); stream = null; };
|
|
2492
|
+
return { node: list, paint, live, stop };
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// ---- The first time: three steps. The agents (connected once, for every project: a CLI's session
|
|
2496
|
+
// is this computer's, not a project's), the projects, and which to open now. Over each step, a
|
|
2497
|
+
// scene of what it is about, moving: the agents come to MADRE and light up as they are connected;
|
|
2498
|
+
// the folders drop into the lobby; the rooms light up one after another as the beat runs through
|
|
2499
|
+
// them. Drawn here, from MADRE's own marks; still for whoever asked for less motion.
|
|
2500
|
+
const SCENE_AGENTS = [['codex', 17, 30], ['claude', 27, 78], ['gemini', 83, 30], ['opencode', 73, 78]];
|
|
2501
|
+
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>';
|
|
2502
|
+
const BEAT_PATH = 'M0 22H70L78 22L86 6L96 38L104 22H190L198 22L206 6L216 38L224 22H320L328 22L336 6L346 38L354 22H420';
|
|
2503
|
+
function wizardScene(kind) {
|
|
2504
|
+
const scene = el('div', `sm-scene scene-${kind}`);
|
|
2505
|
+
scene.setAttribute('aria-hidden', 'true');
|
|
2506
|
+
if (kind === 'agents') {
|
|
2507
|
+
const wires = svgNode('svg', { class: 'sm-wires', viewBox: '0 0 100 100', preserveAspectRatio: 'none' });
|
|
2508
|
+
SCENE_AGENTS.forEach(([id, x, y], index) => {
|
|
2509
|
+
wires.append(svgNode('line', { class: 'sm-wire', 'data-agent': id, x1: 50, y1: 54, x2: x, y2: y, 'vector-effect': 'non-scaling-stroke' }));
|
|
2510
|
+
const node = el('span', 'sm-scene-node agent');
|
|
2511
|
+
node.dataset.agent = id;
|
|
2512
|
+
node.style.left = `${x}%`;
|
|
2513
|
+
node.style.top = `${y}%`;
|
|
2514
|
+
node.style.setProperty('--d', `${index * 160}ms`);
|
|
2515
|
+
node.append(mark(id, 42));
|
|
2516
|
+
scene.append(node);
|
|
2517
|
+
});
|
|
2518
|
+
const core = el('span', 'sm-scene-node core');
|
|
2519
|
+
core.style.left = '50%';
|
|
2520
|
+
core.style.top = '54%';
|
|
2521
|
+
core.append(mark('madre', 60));
|
|
2522
|
+
scene.prepend(wires);
|
|
2523
|
+
scene.append(core);
|
|
2524
|
+
} else if (kind === 'projects') {
|
|
2525
|
+
// Each folder lives in its own slot of the tray and drops into it, one after another.
|
|
2526
|
+
const tray = el('div', 'sm-tray');
|
|
2527
|
+
for (let index = 0; index < 5; index += 1) {
|
|
2528
|
+
const slot = el('i', 'sm-slot');
|
|
2529
|
+
slot.style.setProperty('--i', String(index));
|
|
2530
|
+
const folder = el('span', 'sm-fly');
|
|
2531
|
+
folder.innerHTML = FOLDER_GLYPH; // trusted: drawn here
|
|
2532
|
+
slot.append(folder);
|
|
2533
|
+
tray.append(slot);
|
|
2534
|
+
}
|
|
2535
|
+
scene.append(tray);
|
|
2536
|
+
} else {
|
|
2537
|
+
const rooms = el('div', 'sm-wins');
|
|
2538
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2539
|
+
const win = el('span', 'sm-win');
|
|
2540
|
+
win.style.setProperty('--i', String(index));
|
|
2541
|
+
win.append(el('i', 'sm-win-bar'), el('i', 'sm-win-line'), el('i', 'sm-win-line short'));
|
|
2542
|
+
rooms.append(win);
|
|
2543
|
+
}
|
|
2544
|
+
const beat = svgNode('svg', { class: 'sm-beat', viewBox: '0 0 420 44', preserveAspectRatio: 'none' });
|
|
2545
|
+
beat.append(svgNode('path', { d: BEAT_PATH, pathLength: 100, 'vector-effect': 'non-scaling-stroke' }));
|
|
2546
|
+
scene.append(beat, rooms);
|
|
2547
|
+
}
|
|
2548
|
+
return scene;
|
|
2549
|
+
}
|
|
2550
|
+
let wizard = null;
|
|
2551
|
+
let wizardThen = null; // what comes once the wizard closes: the chapters, after a bonjour
|
|
2552
|
+
function openWizard({ then = null } = {}) {
|
|
2553
|
+
if (wizard) return;
|
|
2554
|
+
wizardThen = then;
|
|
2555
|
+
wizard = el('dialog', 'sm-sheet sm-wide sm-wizard');
|
|
2556
|
+
const inner = el('div', 'sm-sheet-body');
|
|
2557
|
+
wizard.append(inner);
|
|
2558
|
+
document.body.append(wizard);
|
|
2559
|
+
let step = 0;
|
|
2560
|
+
let panel = null;
|
|
2561
|
+
let scene = null;
|
|
2562
|
+
const steps = [t('Agents'), t('Projects'), t('Open')];
|
|
2563
|
+
const KINDS_OF_STEP = ['agents', 'projects', 'open'];
|
|
2564
|
+
const stop = () => { panel?.stop(); panel = null; };
|
|
2565
|
+
const frame = (title, note, body, actions, { back = false } = {}) => {
|
|
2566
|
+
stop();
|
|
2567
|
+
inner.replaceChildren();
|
|
2568
|
+
const head = el('header', 'sm-sheet-head');
|
|
2569
|
+
const dots = el('ol', 'sm-steps');
|
|
2570
|
+
steps.forEach((name, index) => { const item = el('li', index === step ? 'on' : index < step ? 'done' : null, name); dots.append(item); });
|
|
2571
|
+
head.append(dots, closeKey(() => wizard.close()));
|
|
2572
|
+
scene = wizardScene(KINDS_OF_STEP[step]);
|
|
2573
|
+
const page = el('div', `sm-step${back ? ' back' : ''}`);
|
|
2574
|
+
page.append(el('h3', null, title));
|
|
2575
|
+
if (note) page.append(el('p', 'hint', note));
|
|
2576
|
+
page.append(body, actions);
|
|
2577
|
+
inner.append(head, scene, page);
|
|
2578
|
+
};
|
|
2579
|
+
async function agentsStep(options) {
|
|
2580
|
+
const actions = el('div', 'actions');
|
|
2581
|
+
const next = button('sm-btn primary', t('Continue'), () => { step = 1; projectsStep(); });
|
|
2582
|
+
actions.append(next);
|
|
2583
|
+
const fresh = agentsPanel({
|
|
2584
|
+
onPaint: (ready, answer) => {
|
|
2585
|
+
next.textContent = ready ? t('Continue') : t('Continue without agents');
|
|
2586
|
+
// The scene lights the agents that are connected, and their wire to MADRE.
|
|
2587
|
+
for (const agent of answer.agents) {
|
|
2588
|
+
const on = agentState(agent, answer).ok;
|
|
2589
|
+
scene?.querySelector(`.sm-scene-node[data-agent="${agent.id}"]`)?.classList.toggle('on', on);
|
|
2590
|
+
scene?.querySelector(`.sm-wire[data-agent="${agent.id}"]`)?.classList.toggle('on', on);
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2593
|
+
});
|
|
2594
|
+
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);
|
|
2595
|
+
panel = fresh;
|
|
2596
|
+
await fresh.paint();
|
|
2597
|
+
fresh.live();
|
|
2598
|
+
}
|
|
2599
|
+
function projectsStep(options) {
|
|
2600
|
+
// What is chosen here is added on the way to the next step: one key, not two.
|
|
2601
|
+
const actions = el('div', 'actions');
|
|
2602
|
+
const next = button('sm-btn primary', t('Continue'), async () => {
|
|
2603
|
+
if (picker.count()) {
|
|
2604
|
+
next.disabled = true;
|
|
2605
|
+
const added = await picker.add();
|
|
2606
|
+
next.disabled = false;
|
|
2607
|
+
if (!added) return;
|
|
2608
|
+
}
|
|
2609
|
+
step = 2;
|
|
2610
|
+
openStep();
|
|
2611
|
+
});
|
|
2612
|
+
const picker = projectPicker({ withAdd: false, onChange: (n) => { next.textContent = n ? t('Add and continue') : t('Continue'); } });
|
|
2613
|
+
actions.append(button('sm-btn', t('Back'), () => { step = 0; void agentsStep({ back: true }); }), next);
|
|
2614
|
+
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);
|
|
2615
|
+
}
|
|
2616
|
+
function openStep() {
|
|
2617
|
+
const projects = (data?.rooms ?? []).filter((room) => room.id !== data?.salas);
|
|
2618
|
+
const chosen = new Set();
|
|
2619
|
+
const pages = foldPages({ snug: true });
|
|
2620
|
+
const list = el('div', 'sm-picker');
|
|
2621
|
+
const foot = el('div', 'sm-picker-foot');
|
|
2622
|
+
foot.append(pages.pager);
|
|
2623
|
+
list.append(pages.grid, foot);
|
|
2624
|
+
pages.show(projects, {
|
|
2625
|
+
draw: (room) => foldTile({
|
|
2626
|
+
name: room.name,
|
|
2627
|
+
path: room.root ?? '',
|
|
2628
|
+
note: room.open ? t('Already open') : null,
|
|
2629
|
+
checked: Boolean(room.open) || chosen.has(room.id),
|
|
2630
|
+
known: Boolean(room.open),
|
|
2631
|
+
onToggle: (on) => { if (on) chosen.add(room.id); else chosen.delete(room.id); },
|
|
2632
|
+
}),
|
|
2633
|
+
empty: () => t('No projects yet. You can add them from Home whenever you like.'),
|
|
2634
|
+
});
|
|
2635
|
+
const actions = el('div', 'actions');
|
|
2636
|
+
const go = button('sm-btn primary', t('Done'), async () => {
|
|
2637
|
+
go.disabled = true;
|
|
2638
|
+
for (const id of chosen) {
|
|
2639
|
+
go.textContent = t('Opening {name}…', { name: projects.find((room) => room.id === id)?.name ?? id });
|
|
2640
|
+
await startRoom({ id });
|
|
2641
|
+
}
|
|
2642
|
+
wizard.close();
|
|
2643
|
+
});
|
|
2644
|
+
actions.append(button('sm-btn', t('Back'), () => { step = 1; projectsStep({ back: true }); }), go);
|
|
2645
|
+
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);
|
|
2646
|
+
}
|
|
2647
|
+
wizard.addEventListener('close', () => { stop(); wizard.remove(); wizard = null; void loadAgents(); const after = wizardThen; wizardThen = null; after?.(); });
|
|
2648
|
+
wizard.showModal();
|
|
2649
|
+
void agentsStep();
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// ---- AJUSTES, as the room's (app.js, SETTINGS_SECTIONS and its sheets; 2026-10-10): a board of
|
|
2653
|
+
// three sections, a card per category that already says how it stands and holds the one switch
|
|
2654
|
+
// people reach for, the category in a sheet over the board, and one search for any setting. The
|
|
2655
|
+
// window is the room's own (styles.css: #settings, .settings-*, .sx-*), not a likeness of it. The
|
|
2656
|
+
// lobby sets what is the lobby's — how it looks, what its bar holds, the agents of this Mac,
|
|
2657
|
+
// BISHOP's ways in, the projects and the version; a room's turns, memory or permissions are set
|
|
2658
|
+
// in that room. Nothing scrolls inside the window: the screen behind it does.
|
|
2659
|
+
const SET_SECTIONS = [
|
|
2660
|
+
['You', 'How Home looks and what its bar holds', ['look', 'bar']],
|
|
2661
|
+
['The agents', 'Who works on this Mac', ['agents', 'bishop']],
|
|
2662
|
+
['System', 'The projects of this Mac and the version', ['projects', 'release']],
|
|
2663
|
+
];
|
|
2664
|
+
// The room's own icons where the room has the category (app.js, SETTINGS_CATS); the bar, BISHOP
|
|
2665
|
+
// and the projects drawn here in the same hand: 20×20, one round stroke.
|
|
2666
|
+
const SET_CATS = {
|
|
2667
|
+
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"/>' },
|
|
2668
|
+
bar: { name: 'Top bar', icon: '<rect x="2.6" y="5.4" width="14.8" height="9.2" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6.2 10h2.2M11.6 10h2.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>' },
|
|
2669
|
+
agents: { name: 'Agents', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
2670
|
+
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"/>' },
|
|
2671
|
+
projects: { name: 'Projects', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
|
|
2672
|
+
release: { name: 'Version', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
2673
|
+
};
|
|
2674
|
+
const SET_ORDER = ['look', 'bar', 'agents', 'bishop', 'projects', 'release'];
|
|
2675
|
+
// The words the search knows, each pointing to the category that holds it (app.js, SETTINGS_INDEX).
|
|
2676
|
+
const SET_INDEX = [
|
|
2677
|
+
['look', 'Preview'], ['look', 'Theme'], ['look', 'Accent'], ['look', 'Wallpaper'], ['look', 'Intensity'], ['look', 'Blur'], ['look', 'Project cards'], ['look', 'Text'], ['look', 'Project order'], ['look', 'Language'],
|
|
2678
|
+
['bar', 'Arrange the bar'], ['bar', 'Back to default'],
|
|
2679
|
+
['bishop', 'Talk to the Coordinator'], ['bishop', 'Open the Coordinator in a new tab'],
|
|
2680
|
+
['projects', 'Add a project'], ['projects', 'See the welcome again'], ['projects', 'The tour of MADRE 1.0'],
|
|
2681
|
+
['release', 'Check now'],
|
|
2682
|
+
];
|
|
2683
|
+
const THEME_WORDS = { auto: () => t('Automatic'), light: () => t('Light'), dark: () => t('Dark') };
|
|
2684
|
+
const catName = (id) => t(SET_CATS[id].name);
|
|
2685
|
+
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
|
|
2686
|
+
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>';
|
|
2687
|
+
const chevron = (className) => { const mark = el('span', `${className} chev-icon`); mark.innerHTML = CHEVRON; return mark; }; // trusted: our own SVG string
|
|
2688
|
+
const fold = (text) => String(text).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
2689
|
+
const bishopRoom = () => data?.rooms.find((room) => room.id === data.salas) ?? null;
|
|
2690
|
+
|
|
2691
|
+
// The pieces every sheet is built from, the room's (app.js, sxBlock, sxRow, sxButton): a block is a
|
|
2692
|
+
// card with a title and a line about it, then its rows; a row says what it is on the left and
|
|
2693
|
+
// holds its control on the right.
|
|
2694
|
+
function sxBlock(host, title, about = null, { aside = null, className = '' } = {}) {
|
|
2695
|
+
const block = el('section', `sx-block${className ? ` ${className}` : ''}`);
|
|
2696
|
+
const head = el('header', 'sx-head');
|
|
2697
|
+
const words = el('div', 'sx-words');
|
|
2698
|
+
words.append(el('h3', null, title));
|
|
2699
|
+
if (about) words.append(el('p', null, about));
|
|
2700
|
+
head.append(words);
|
|
2701
|
+
if (aside) head.append(aside);
|
|
2702
|
+
block.rows = el('div', 'sx-rows');
|
|
2703
|
+
block.append(head, block.rows);
|
|
2704
|
+
host.append(block);
|
|
2705
|
+
return block;
|
|
2706
|
+
}
|
|
2707
|
+
function sxRow(block, label, hint, control = null, { stack = false } = {}) {
|
|
2708
|
+
const row = el('div', `sx-row${stack ? ' stack' : ''}`);
|
|
2709
|
+
const words = el('div', 'sx-words');
|
|
2710
|
+
words.append(el('b', null, label));
|
|
2711
|
+
if (hint) words.append(el('span', 'sx-hint', hint));
|
|
2712
|
+
row.append(words);
|
|
2713
|
+
if (control) { const box = el('div', 'sx-control'); box.append(control); row.append(box); }
|
|
2714
|
+
block.rows.append(row);
|
|
2715
|
+
return row;
|
|
2716
|
+
}
|
|
2717
|
+
const sxButton = (words, click, { tone = null } = {}) => button(`sx-btn${tone ? ` ${tone}` : ''}`, words, click);
|
|
2718
|
+
// A choice of a few, on the rail PERSONALIZE uses (app.js, drawCustom, segmented).
|
|
2719
|
+
function customSeg(options, current, pick, size = '') {
|
|
2720
|
+
const seg = el('div', `custom-seg${size ? ` ${size}` : ''}`);
|
|
2721
|
+
seg.setAttribute('role', 'radiogroup');
|
|
2722
|
+
for (const [value, words] of options) {
|
|
2723
|
+
const option = button('seg-option', words, () => { if (value !== current) pick(value); });
|
|
2724
|
+
option.setAttribute('role', 'radio');
|
|
2725
|
+
option.setAttribute('aria-checked', String(value === current));
|
|
2726
|
+
seg.append(option);
|
|
2727
|
+
}
|
|
2728
|
+
return seg;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// How each category stands, in a line, from what the lobby already knows; nothing is guessed.
|
|
2732
|
+
function setStatus(id) {
|
|
2733
|
+
const looks = readLooks();
|
|
2734
|
+
if (id === 'look') return { line: `${THEME_WORDS[looks.theme]()} · ${WALLPAPER_WORDS[looks.wallpaper]()} · ${ACCENT_WORDS[looks.accent]()}` };
|
|
2735
|
+
if (id === 'bar') {
|
|
2736
|
+
if (!pinsStored()) return { line: t('The open projects, as they open') };
|
|
2737
|
+
const count = pinsShown().length;
|
|
2738
|
+
return { line: count ? t('{n} projects pinned', { n: count }) : t('Nothing pinned') };
|
|
2739
|
+
}
|
|
2740
|
+
if (id === 'agents') {
|
|
2741
|
+
if (!agentsSeen) return { line: t('Reading…') };
|
|
2742
|
+
const here = agentsSeen.agents.filter((agent) => agent.id !== 'madre' && agent.detected);
|
|
2743
|
+
const off = here.filter((agent) => !agentState(agent, agentsSeen).ok);
|
|
2744
|
+
return { line: t('{n} of {total} signed in', { n: here.length - off.length, total: here.length }), warn: off.length ? t('{agents} without a session', { agents: off.map((agent) => agent.label ?? agent.id).join(', ') }) : null };
|
|
2745
|
+
}
|
|
2746
|
+
if (id === 'bishop') { const room = bishopRoom(); return { line: room ? stateOf(room).words : t('Reading…') }; }
|
|
2747
|
+
if (id === 'projects') {
|
|
2748
|
+
if (!data) return { line: t('Reading…') };
|
|
2749
|
+
const { all, open } = projectsInOrder();
|
|
2750
|
+
return { line: t('{n} projects · {open} open', { n: all.length, open: open.length }) };
|
|
2751
|
+
}
|
|
2752
|
+
if (id === 'release') {
|
|
2753
|
+
if (!versionSeen) return { line: t('Reading…') };
|
|
2754
|
+
return { line: `MADRE ${versionSeen.current} · ${versionSeen.available ? t('{version} available', { version: versionSeen.latest }) : versionSeen.enabled === false ? t('not checked') : t('up to date')}`, warn: versionSeen.available ? t('{version} is on npm', { version: versionSeen.latest }) : null };
|
|
2755
|
+
}
|
|
2756
|
+
return { line: '' };
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// The one thing people reach for, on the card itself: changing it opens nothing.
|
|
2760
|
+
function setQuick(id) {
|
|
2761
|
+
const rows = el('div', 'settings-quick');
|
|
2762
|
+
const add = (label, control) => { const row = el('div', 'settings-quick-row'); row.append(el('span', null, label), control); rows.append(row); };
|
|
2763
|
+
if (id === 'look') {
|
|
2764
|
+
const { theme } = readLooks();
|
|
2765
|
+
const seg = el('div', 'settings-seg');
|
|
2766
|
+
for (const [value, word] of [['auto', t('Auto')], ['light', t('Light')], ['dark', t('Dark')]]) {
|
|
2767
|
+
seg.append(button(value === theme ? 'on' : null, word, (event) => { event.stopPropagation(); saveLooks({ theme: value }); }));
|
|
2768
|
+
}
|
|
2769
|
+
add(t('Theme'), seg);
|
|
2770
|
+
}
|
|
2771
|
+
if (id === 'bar') add(t('Arrange the bar'), sxButton(t('Arrange'), (event) => { event.stopPropagation(); settingsSheet?.close(); pinsEdit(true); }));
|
|
2772
|
+
if (id === 'agents' && agentsSeen) {
|
|
2773
|
+
for (const agent of agentsSeen.agents.filter((one) => one.id !== 'madre' && one.detected)) {
|
|
2774
|
+
const state = agentState(agent, agentsSeen);
|
|
2775
|
+
const row = el('div', 'settings-quick-row agent');
|
|
2776
|
+
const who = el('span', 'who', agent.label ?? agent.id);
|
|
2777
|
+
who.style.setProperty('--agent', brandOf(agent.id).color);
|
|
2778
|
+
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')));
|
|
2779
|
+
rows.append(row);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
return rows.children.length ? rows : null;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// What MADRE looks like with these choices: the room's preview, built as the room builds it
|
|
2786
|
+
// (app.js, drawCustom), wearing the choices, so the bubbles and the text — which the lobby has no
|
|
2787
|
+
// conversation to show — are seen before they are lived with.
|
|
2788
|
+
// The preview is Home itself, small (2026-10-11): its bar with the projects on it, the
|
|
2789
|
+
// Coordinator, and two of your projects as cards, on the wallpaper, theme and accent chosen, with
|
|
2790
|
+
// the cards, the text and the order Home's own choices give them. It wears the room's preview
|
|
2791
|
+
// (styles.css, .look-preview) for the ground, so the wallpaper reads exactly as it will.
|
|
2792
|
+
function lookPreview(looks) {
|
|
2793
|
+
const preview = el('div', 'look-preview sm-home-preview');
|
|
2794
|
+
preview.setAttribute('aria-hidden', 'true');
|
|
2795
|
+
Object.assign(preview.dataset, { accent: looks.accent, wallpaper: looks.wallpaper, wpStyle: looks.style, homeCards: looks.home.cards, homeText: looks.home.text });
|
|
2796
|
+
preview.style.setProperty('--veil-k', String(looks.veil / 100));
|
|
2797
|
+
preview.style.setProperty('--wp-blur', `${looks.blur}px`);
|
|
2798
|
+
preview.dataset.scheme = root.dataset.scheme ?? 'dark';
|
|
2799
|
+
const shown = (data ? projectsInOrder().all : []).slice(0, 2);
|
|
2800
|
+
const names = shown.length ? shown.map((room) => room.name) : ['pulse', 'tienda'];
|
|
2801
|
+
const bar = el('div', 'hp-bar');
|
|
2802
|
+
bar.append(el('i', 'hp-logo'), el('b', null, t('Home')));
|
|
2803
|
+
for (const name of names) { const pin = el('span', 'hp-pin'); pin.append(el('i'), name); bar.append(pin); }
|
|
2804
|
+
const grid = el('div', 'hp-grid');
|
|
2805
|
+
const coord = el('div', 'hp-tile hp-coord');
|
|
2806
|
+
const head = el('div', 'hp-head');
|
|
2807
|
+
head.append(el('span', 'hp-mark'), el('b', null, t('Coordinator')));
|
|
2808
|
+
coord.append(head, el('small', null, t('Calm')), el('span', 'hp-key', t('Talk to the Coordinator')));
|
|
2809
|
+
grid.append(coord);
|
|
2810
|
+
names.forEach((name, index) => {
|
|
2811
|
+
const card = el('div', 'hp-tile hp-card');
|
|
2812
|
+
const state = el('small', 'hp-state');
|
|
2813
|
+
state.append(el('i'), index ? t('Asleep') : t('Calm'));
|
|
2814
|
+
card.append(el('b', null, name), state, el('span', 'hp-chat'), el('span', 'hp-strip'), el('span', `hp-key${index ? '' : ' on'}`, t('Open')));
|
|
2815
|
+
grid.append(card);
|
|
2816
|
+
});
|
|
2817
|
+
preview.append(bar, grid);
|
|
2818
|
+
return preview;
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// The sheets. Appearance is the room's own sheet of PERSONALIZE inside AJUSTES (app.js,
|
|
2822
|
+
// drawCustom with customWindow.instant): the preview first, then the colours, the wallpaper, the
|
|
2823
|
+
// conversation and the language, each applied as it is touched.
|
|
2824
|
+
function fillLook(host) {
|
|
2825
|
+
host.classList.add('settings-look');
|
|
2826
|
+
const looks = readLooks();
|
|
2827
|
+
// Where the choices are kept, and the way back to MADRE as it comes, on one line above it all.
|
|
2828
|
+
const top = el('div', 'sm-look-top');
|
|
2829
|
+
top.append(el('p', 'sx-intro', t('Kept in this browser. Theme, accent and wallpaper also dress the project served at this address.')));
|
|
2830
|
+
host.append(top);
|
|
2831
|
+
// Two columns when the window is wide (it opens over the whole window): the preview, with how
|
|
2832
|
+
// the conversation reads and the language under it; the colours and the wallpaper beside it. So
|
|
2833
|
+
// the whole of Appearance is seen at once.
|
|
2834
|
+
const frame = el('div', 'sx-look sm-look');
|
|
2835
|
+
const side = el('div', 'sm-look-side');
|
|
2836
|
+
const preview = lookPreview(looks);
|
|
2837
|
+
sxBlock(side, t('Preview'), t('Your choices, on Home itself.'), { className: 'sx-preview' }).rows.append(preview);
|
|
2838
|
+
const page = el('div', 'sx-look-controls');
|
|
2839
|
+
frame.append(side, page);
|
|
2840
|
+
|
|
2841
|
+
const named = el('span', 'custom-current', ACCENT_WORDS[looks.accent]());
|
|
2842
|
+
const colour = sxBlock(page, t('Colour'), null, { aside: named });
|
|
2843
|
+
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'));
|
|
2844
|
+
const swatches = el('div', 'custom-swatches');
|
|
2845
|
+
swatches.setAttribute('role', 'radiogroup');
|
|
2846
|
+
for (const id of ACCENTS) {
|
|
2847
|
+
const swatch = button('swatch', null, () => saveLooks({ accent: id }));
|
|
2848
|
+
swatch.dataset.swatch = id;
|
|
2849
|
+
swatch.setAttribute('role', 'radio');
|
|
2850
|
+
swatch.setAttribute('aria-checked', String(id === looks.accent));
|
|
2851
|
+
swatch.setAttribute('aria-label', ACCENT_WORDS[id]());
|
|
2852
|
+
// The colour's name sits beside COLOUR: the one picked, or the one under the pointer.
|
|
2853
|
+
const say = () => { named.textContent = ACCENT_WORDS[id](); named.classList.toggle('peek', id !== looks.accent); };
|
|
2854
|
+
const back = () => { named.textContent = ACCENT_WORDS[looks.accent](); named.classList.remove('peek'); };
|
|
2855
|
+
swatch.addEventListener('pointerenter', say);
|
|
2856
|
+
swatch.addEventListener('focus', say);
|
|
2857
|
+
swatch.addEventListener('pointerleave', back);
|
|
2858
|
+
swatch.addEventListener('blur', back);
|
|
2859
|
+
swatches.append(swatch);
|
|
2860
|
+
}
|
|
2861
|
+
sxRow(colour, t('Accent'), t('Each colour in dark (top) and in light (bottom). The permission modes keep their own.'), swatches, { stack: true });
|
|
2862
|
+
|
|
2863
|
+
const photo = looks.wallpaper !== 'default';
|
|
2864
|
+
const papers = el('div', 'custom-papers');
|
|
2865
|
+
papers.setAttribute('role', 'radiogroup');
|
|
2866
|
+
for (const value of WALLPAPERS) {
|
|
2867
|
+
const paper = button('paper', null, () => saveLooks({ wallpaper: value }));
|
|
2868
|
+
paper.dataset.wp = value;
|
|
2869
|
+
paper.setAttribute('role', 'radio');
|
|
2870
|
+
paper.setAttribute('aria-checked', String(value === looks.wallpaper));
|
|
2871
|
+
paper.append(el('span', 'paper-swatch'), el('span', 'paper-name', WALLPAPER_WORDS[value]()));
|
|
2872
|
+
papers.append(paper);
|
|
2873
|
+
}
|
|
2874
|
+
const paperBox = el('div', 'paper-box');
|
|
2875
|
+
paperBox.append(papers);
|
|
2876
|
+
if (photo) {
|
|
2877
|
+
// A photograph: layer or mesh, how strong, how blurred. The dials move the lobby and the
|
|
2878
|
+
// preview while they are dragged, and are kept when they are let go.
|
|
2879
|
+
const tune = el('div', 'paper-tune');
|
|
2880
|
+
tune.append(customSeg([['veil', t('Layer')], ['mesh', t('Mesh')]], looks.style, (value) => saveLooks({ style: value }), 'two'));
|
|
2881
|
+
const dial = (name, label) => {
|
|
2882
|
+
const spec = TUNES[name];
|
|
2883
|
+
const line = el('label', 'tune-row');
|
|
2884
|
+
const value = el('span', 'tune-value', spec.shown(looks[name]));
|
|
2885
|
+
const input = el('input', 'tune-range');
|
|
2886
|
+
input.type = 'range';
|
|
2887
|
+
input.min = String(spec.min); input.max = String(spec.max); input.step = String(spec.step);
|
|
2888
|
+
input.value = String(looks[name]);
|
|
2889
|
+
const fill = (v) => input.style.setProperty('--fill', `${((v - spec.min) / (spec.max - spec.min)) * 100}%`);
|
|
2890
|
+
fill(looks[name]);
|
|
2891
|
+
input.addEventListener('input', () => {
|
|
2892
|
+
const v = Number(input.value);
|
|
2893
|
+
spec.apply(v);
|
|
2894
|
+
if (name === 'veil') preview.style.setProperty('--veil-k', String(v / 100)); else preview.style.setProperty('--wp-blur', `${v}px`);
|
|
2895
|
+
value.textContent = spec.shown(v);
|
|
2896
|
+
fill(v);
|
|
2897
|
+
});
|
|
2898
|
+
input.addEventListener('change', () => saveTune(name, Number(input.value)));
|
|
2899
|
+
line.append(el('span', 'tune-name', label), input, value);
|
|
2900
|
+
return line;
|
|
2901
|
+
};
|
|
2902
|
+
tune.append(dial('veil', t('Intensity')), dial('blur', t('Blur')));
|
|
2903
|
+
paperBox.append(tune);
|
|
2904
|
+
}
|
|
2905
|
+
sxBlock(page, t('Wallpaper'), photo
|
|
2906
|
+
? t('A photograph wears the project\'s ground as a layer or as a mesh of its own colours: choose how strong it is and how much the photograph blurs.')
|
|
2907
|
+
: t('Behind Home, and behind the conversation of the project served at this address. Light and dark each have their own.')).rows.append(paperBox);
|
|
2908
|
+
|
|
2909
|
+
// Home's own: its cards, its text, the order of its projects. The conversation's bubbles and text
|
|
2910
|
+
// are each project's, in its own Settings.
|
|
2911
|
+
const home = sxBlock(side, t('Home'), t('Only for Home: each project keeps its conversation as you set it there.'));
|
|
2912
|
+
const homeSeg = (name) => customSeg(HOME_LOOKS[name].values.map((value) => [value, HOME_LOOKS[name].words[value]()]), looks.home[name], (value) => saveHome(name, value), HOME_LOOKS[name].values.length === 2 ? 'two' : 'three');
|
|
2913
|
+
sxRow(home, t('Project cards'), null, homeSeg('cards'), { stack: true });
|
|
2914
|
+
sxRow(home, t('Text'), null, homeSeg('text'), { stack: true });
|
|
2915
|
+
sxRow(home, t('Project order'), null, homeSeg('order'), { stack: true });
|
|
2916
|
+
|
|
2917
|
+
const words = sxBlock(side, t('Language'));
|
|
2918
|
+
sxRow(words, t('Interface'), t('The page reloads in it when you choose to.'), customSeg([['es', 'Español'], ['en', 'English']], language, async (code) => {
|
|
2919
|
+
try { localStorage.setItem('pulse.language', code); } catch { /* no storage */ }
|
|
2920
|
+
await post('/api/language', { language: code });
|
|
2921
|
+
reloadWithGrace();
|
|
2922
|
+
}, 'two'));
|
|
2923
|
+
|
|
2924
|
+
const homeDefault = Object.entries(HOME_LOOKS).every(([name, spec]) => looks.home[name] === spec.values[0]);
|
|
2925
|
+
const atDefault = looks.accent === 'madre' && looks.wallpaper === 'default' && looks.style === 'veil' && looks.veil === TUNES.veil.value && looks.blur === TUNES.blur.value && homeDefault;
|
|
2926
|
+
if (!atDefault) {
|
|
2927
|
+
top.append(sxButton(t('Back to default'), () => {
|
|
2928
|
+
for (const name of ['veil', 'blur']) saveTune(name, TUNES[name].value);
|
|
2929
|
+
for (const [name, spec] of Object.entries(HOME_LOOKS)) { try { localStorage.removeItem(spec.key); } catch { /* this page only */ } }
|
|
2930
|
+
saveLooks({ accent: 'madre', wallpaper: 'default', style: 'veil' });
|
|
2931
|
+
draw();
|
|
2932
|
+
}));
|
|
2933
|
+
}
|
|
2934
|
+
host.append(frame);
|
|
2935
|
+
}
|
|
2936
|
+
function fillBar(host) {
|
|
2937
|
+
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.'));
|
|
2938
|
+
const ids = pinsShown();
|
|
2939
|
+
if (!ids.length) sxRow(block, t('Nothing pinned'), null);
|
|
2940
|
+
for (const id of ids) {
|
|
2941
|
+
const room = data.rooms.find((one) => one.id === id);
|
|
2942
|
+
sxRow(block, room.name, stateOf(room).words, sxButton(t('Remove'), () => pinRemove(id)));
|
|
2943
|
+
}
|
|
2944
|
+
const acts = el('div', 'sx-actions end');
|
|
2945
|
+
if (pinsStored()) acts.append(sxButton(t('Back to default'), () => pinsReset()));
|
|
2946
|
+
acts.append(sxButton(t('Arrange the bar'), () => { settingsSheet?.close(); pinsEdit(true); }, { tone: 'primary' }));
|
|
2947
|
+
host.append(acts);
|
|
2948
|
+
}
|
|
2949
|
+
function fillAgents(host, keep) {
|
|
2950
|
+
const block = sxBlock(host, t('Agents'), t('MADRE works with the AI tools already on this computer. Each one is connected once, and every project uses it.'));
|
|
2951
|
+
const panel = agentsPanel({ onPaint: () => settingsSheet?.sync() });
|
|
2952
|
+
block.rows.append(panel.node);
|
|
2953
|
+
keep(panel.stop);
|
|
2954
|
+
void panel.paint().then(() => panel.live());
|
|
2955
|
+
}
|
|
2956
|
+
function fillBishop(host) {
|
|
2957
|
+
const room = bishopRoom();
|
|
2958
|
+
const block = sxBlock(host, t('Coordinator'), t('Its agent and its mode are chosen in its own project.'));
|
|
2959
|
+
sxRow(block, t('Talk to the Coordinator'), room ? stateOf(room).words : null, sxButton(t('Open'), () => { settingsSheet?.close(); openBishop(); }, { tone: 'primary' }));
|
|
2960
|
+
sxRow(block, t('Open the Coordinator in a new tab'), null, sxButton(t('Open'), () => openBishop({ newTab: true })));
|
|
2961
|
+
}
|
|
2962
|
+
function fillProjects(host) {
|
|
2963
|
+
const block = sxBlock(host, t('Projects'), t('A project is a folder: MADRE gives it its own conversations and its own memory. Adding it does not open it; you open it from its card.'));
|
|
2964
|
+
sxRow(block, t('Add a project'), null, sxButton(t('Add'), () => { settingsSheet?.close(); openAddSheet(); }, { tone: 'primary' }));
|
|
2965
|
+
sxRow(block, t('See the welcome again'), null, sxButton(t('Open'), () => { settingsSheet?.close(); welcomed = true; openWizard(); }));
|
|
2966
|
+
const back = homeTour.path() === 'back';
|
|
2967
|
+
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(); }));
|
|
2968
|
+
}
|
|
2969
|
+
function fillRelease(host) {
|
|
2970
|
+
const info = versionSeen;
|
|
2971
|
+
const block = sxBlock(host, t('Version'), info ? t('You run {version}. Home restarts with it from here.', { version: info.current }) : null);
|
|
2972
|
+
const check = info?.enabled ? sxButton(t('Check now'), async () => {
|
|
2973
|
+
check.disabled = true;
|
|
2974
|
+
const answer = await fetch('/api/version?force=1', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
2975
|
+
if (answer) versionSeen = answer;
|
|
2976
|
+
drawAttention();
|
|
2977
|
+
drawTopbar();
|
|
2978
|
+
settingsSheet?.sync({ detail: true });
|
|
2979
|
+
}) : null;
|
|
2980
|
+
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);
|
|
2981
|
+
if (info?.available) {
|
|
2982
|
+
const notes = el('a', 'sx-btn', t('What it brings'));
|
|
2983
|
+
notes.href = info.release ?? 'https://madre.run';
|
|
2984
|
+
notes.target = '_blank';
|
|
2985
|
+
notes.rel = 'noopener';
|
|
2986
|
+
sxRow(block, `MADRE ${info.latest}`, t('MADRE {version} is out.', { version: info.latest }), notes);
|
|
2987
|
+
// Home restarts with it, as a project does (app.js, drawRelease): the whole page waits on the
|
|
2988
|
+
// curtain with MADRE's loader through the version arriving, Home closed, and Home back.
|
|
2989
|
+
if (info.install !== 'source') {
|
|
2990
|
+
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.'));
|
|
2991
|
+
const said = el('p', 'sx-hint');
|
|
2992
|
+
const restart = sxButton(t('Restart with {version}', { version: info.latest }), () => void updateHome(info, restart, said), { tone: 'primary' });
|
|
2993
|
+
sxRow(fresh, t('Restart here'), null, restart);
|
|
2994
|
+
fresh.rows.append(said);
|
|
2995
|
+
} else {
|
|
2996
|
+
sxRow(block, t('Pull the repository and start it again.'), null);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
// Home already runs the newer one: the projects still on the older one, each with its restart.
|
|
3000
|
+
const behind = behindRooms();
|
|
3001
|
+
if (behind.length) {
|
|
3002
|
+
const late = sxBlock(host, t('Projects on an older MADRE'), t('They keep running the version they started with until you restart them. What is running in them stops.'));
|
|
3003
|
+
for (const room of behind) sxRow(late, roomLabel(room), `MADRE ${room.open.version}`, sxButton(t('Restart'), () => { settingsSheet?.close(); void restartRoom(room); }));
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
async function updateHome(info, key, said) {
|
|
3007
|
+
key.disabled = true;
|
|
3008
|
+
said.textContent = '';
|
|
3009
|
+
const curtain = el('div', 'reload-curtain');
|
|
3010
|
+
const loader = makeLoader({ progress: 0.04, label: t('Downloading MADRE {version}…', { version: info.latest }) });
|
|
3011
|
+
curtain.append(loader);
|
|
3012
|
+
showCurtain(curtain);
|
|
3013
|
+
const drop = (words) => { stop(); curtain.classList.add('leaving'); setTimeout(() => curtain.remove(), 760); key.disabled = false; said.textContent = words; };
|
|
3014
|
+
let stop = creep(loader, { to: 0.45, rate: 0.02 });
|
|
3015
|
+
const answer = await post('/api/updates/apply');
|
|
3016
|
+
if (!answer.ok) { drop(answer.body.error ?? t('The update did not start.')); return; }
|
|
3017
|
+
stop();
|
|
3018
|
+
setLoader(loader, 0.5, t('Restarting MADRE…'));
|
|
3019
|
+
stop = creep(loader, { to: 0.88, rate: 0.015, every: 400 });
|
|
3020
|
+
for (let attempt = 0; attempt < 90; attempt += 1) {
|
|
3021
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
3022
|
+
const next = await fetch('/api/version', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
3023
|
+
if (next?.current && next.current !== info.current) {
|
|
3024
|
+
stop();
|
|
3025
|
+
setLoader(loader, 0.94, t('MADRE {version} is ready', { version: next.current }));
|
|
3026
|
+
try { sessionStorage.setItem('madre.curtain', loader.dataset.p ?? '0.94'); } catch { /* it simply arrives */ }
|
|
3027
|
+
setTimeout(() => location.reload(), 420);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
drop(t('Home did not come back on its own. Start MADRE again from your terminal.'));
|
|
3032
|
+
}
|
|
3033
|
+
const SET_FILL = { look: fillLook, bar: fillBar, agents: fillAgents, bishop: fillBishop, projects: fillProjects, release: fillRelease };
|
|
3034
|
+
|
|
3035
|
+
let settingsSheet = null;
|
|
3036
|
+
function openSettings(section = null) {
|
|
3037
|
+
if (settingsSheet) { settingsSheet.show(section); return; }
|
|
3038
|
+
const state = { open: null, query: '', stops: [] };
|
|
3039
|
+
const dialog = el('dialog', 'sm-sheet sm-settings');
|
|
3040
|
+
dialog.setAttribute('aria-label', t('Home settings'));
|
|
3041
|
+
// The room's window, by its own name, so it wears exactly what the room's wears.
|
|
3042
|
+
const win = el('div', 'sm-sheet-body sm-settings-window');
|
|
3043
|
+
win.id = 'settings';
|
|
3044
|
+
const head = el('header', 'custom-head settings-head');
|
|
3045
|
+
const search = el('label', 'settings-search');
|
|
3046
|
+
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
|
|
3047
|
+
const input = el('input');
|
|
3048
|
+
input.type = 'search';
|
|
3049
|
+
input.placeholder = t('Search any setting');
|
|
3050
|
+
input.autocomplete = 'off';
|
|
3051
|
+
input.spellcheck = false;
|
|
3052
|
+
input.setAttribute('aria-label', t('Search any setting'));
|
|
3053
|
+
search.append(input);
|
|
3054
|
+
head.append(el('span', 'custom-title', t('SETTINGS')), search, closeKey(() => dialog.close()));
|
|
3055
|
+
const body = el('div', 'sm-settings-body');
|
|
3056
|
+
win.append(head, body);
|
|
3057
|
+
dialog.append(win);
|
|
3058
|
+
document.body.append(dialog);
|
|
3059
|
+
|
|
3060
|
+
const stop = () => { for (const one of state.stops.splice(0)) one(); };
|
|
3061
|
+
const card = (id) => {
|
|
3062
|
+
const status = setStatus(id);
|
|
3063
|
+
const node = el('div', `settings-card${state.open === id ? ' open' : ''}`);
|
|
3064
|
+
node.tabIndex = 0;
|
|
3065
|
+
node.setAttribute('role', 'button');
|
|
3066
|
+
node.dataset.cat = id;
|
|
3067
|
+
const top = el('div', 'settings-card-head');
|
|
3068
|
+
top.append(setIcon(id), el('b', null, catName(id)), chevron('settings-go'));
|
|
3069
|
+
node.append(top, el('p', 'settings-line', status.line));
|
|
3070
|
+
if (status.warn) node.append(el('p', 'settings-warn', status.warn));
|
|
3071
|
+
const quick = setQuick(id);
|
|
3072
|
+
if (quick) node.append(quick);
|
|
3073
|
+
node.addEventListener('click', () => openCategory(id));
|
|
3074
|
+
node.addEventListener('keydown', (event) => { if (event.target === node && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openCategory(id); } });
|
|
3075
|
+
return node;
|
|
3076
|
+
};
|
|
3077
|
+
const board = () => {
|
|
3078
|
+
const node = el('div', 'settings-board');
|
|
3079
|
+
for (const [name, sub, ids] of SET_SECTIONS) {
|
|
3080
|
+
const section = el('section', 'settings-section');
|
|
3081
|
+
const top = el('header');
|
|
3082
|
+
top.append(el('b', null, t(name)), el('span', null, t(sub)));
|
|
3083
|
+
const cards = el('div', 'settings-cards');
|
|
3084
|
+
for (const id of ids) cards.append(card(id));
|
|
3085
|
+
section.append(top, cards);
|
|
3086
|
+
node.append(section);
|
|
3087
|
+
}
|
|
3088
|
+
return node;
|
|
3089
|
+
};
|
|
3090
|
+
const detail = (id) => {
|
|
3091
|
+
const node = el('div', 'settings-detail');
|
|
3092
|
+
const top = el('div', 'settings-detail-head');
|
|
3093
|
+
const title = el('div', 'settings-detail-title');
|
|
3094
|
+
const back = el('button', 'settings-back');
|
|
3095
|
+
back.type = 'button';
|
|
3096
|
+
back.title = t('Back to settings');
|
|
3097
|
+
back.setAttribute('aria-label', t('Back to settings'));
|
|
3098
|
+
back.append(chevron('settings-back-icon'));
|
|
3099
|
+
back.addEventListener('click', () => closeCategory());
|
|
3100
|
+
title.append(back, setIcon(id), el('b', null, catName(id)));
|
|
3101
|
+
top.append(title, el('p', 'settings-line', setStatus(id).line));
|
|
3102
|
+
const host = el('div', 'settings-host');
|
|
3103
|
+
node.append(top, host);
|
|
3104
|
+
SET_FILL[id](host, (one) => state.stops.push(one));
|
|
3105
|
+
return node;
|
|
3106
|
+
};
|
|
3107
|
+
const results = () => {
|
|
3108
|
+
const wanted = fold(state.query.trim());
|
|
3109
|
+
const list = el('div', 'settings-results');
|
|
3110
|
+
const hits = [];
|
|
3111
|
+
for (const id of SET_ORDER) if (fold(catName(id)).includes(wanted)) hits.push([id, null]);
|
|
3112
|
+
for (const [id, key] of SET_INDEX) if (fold(t(key)).includes(wanted)) hits.push([id, t(key)]);
|
|
3113
|
+
for (const agent of agentsSeen?.agents ?? []) if (agent.id !== 'madre' && fold(agent.label ?? agent.id).includes(wanted)) hits.push(['agents', agent.label ?? agent.id]);
|
|
3114
|
+
if (!hits.length) { list.append(el('p', 'settings-reading', t('Nothing called «{query}».', { query: state.query.trim() }))); return list; }
|
|
3115
|
+
for (const [id, said] of hits.slice(0, 40)) {
|
|
3116
|
+
const row = el('button', 'settings-result');
|
|
3117
|
+
row.type = 'button';
|
|
3118
|
+
row.append(setIcon(id), el('b', null, said ?? catName(id)), el('span', null, catName(id)));
|
|
3119
|
+
row.addEventListener('click', () => { state.query = ''; input.value = ''; openCategory(id, said); });
|
|
3120
|
+
list.append(row);
|
|
3121
|
+
}
|
|
3122
|
+
return list;
|
|
3123
|
+
};
|
|
3124
|
+
const draw = () => {
|
|
3125
|
+
stop();
|
|
3126
|
+
if (state.query.trim()) { body.replaceChildren(results()); return; }
|
|
3127
|
+
if (!state.open) { body.replaceChildren(board()); return; }
|
|
3128
|
+
// Appearance has more to show than a sheet holds: it takes the whole window, as a category
|
|
3129
|
+
// does in the room when a sheet would be cramped (app.js, «push»). The others open over the board.
|
|
3130
|
+
const full = state.open === 'look';
|
|
3131
|
+
const sheet = el('div', `settings-sheet${full ? ' full' : ''}`);
|
|
3132
|
+
const scroll = el('div', 'settings-scroll');
|
|
3133
|
+
scroll.append(detail(state.open));
|
|
3134
|
+
sheet.append(scroll);
|
|
3135
|
+
if (full) { body.replaceChildren(sheet); return; }
|
|
3136
|
+
const scrim = el('div', 'settings-scrim');
|
|
3137
|
+
scrim.addEventListener('click', () => closeCategory());
|
|
3138
|
+
body.replaceChildren(board(), scrim, sheet);
|
|
3139
|
+
};
|
|
3140
|
+
const openCategory = (id, landOn = null) => {
|
|
3141
|
+
state.open = id;
|
|
3142
|
+
draw();
|
|
3143
|
+
dialog.scrollTop = 0;
|
|
3144
|
+
if (!landOn) { body.querySelector('.settings-back')?.focus({ preventScroll: true }); return; }
|
|
3145
|
+
// Land on the setting the search found, once its sheet has drawn.
|
|
3146
|
+
let tries = 0;
|
|
3147
|
+
const land = () => {
|
|
3148
|
+
const hit = [...body.querySelectorAll('.settings-detail :is(.sx-row b, .sx-head h3, .tune-name, button)')].find((node) => fold(node.textContent).includes(fold(landOn)));
|
|
3149
|
+
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; }
|
|
3150
|
+
if ((tries += 1) < 20) setTimeout(land, 150);
|
|
3151
|
+
};
|
|
3152
|
+
setTimeout(land, 60);
|
|
3153
|
+
};
|
|
3154
|
+
const closeCategory = () => {
|
|
3155
|
+
const was = state.open;
|
|
3156
|
+
state.open = null;
|
|
3157
|
+
draw();
|
|
3158
|
+
body.querySelector(`.settings-card[data-cat="${was}"]`)?.focus({ preventScroll: true });
|
|
3159
|
+
};
|
|
3160
|
+
input.addEventListener('input', () => { state.query = input.value; draw(); });
|
|
3161
|
+
// Escape steps back the way the person came: out of the search, out of the sheet, then away.
|
|
3162
|
+
dialog.addEventListener('cancel', (event) => {
|
|
3163
|
+
if (state.query) { event.preventDefault(); state.query = ''; input.value = ''; draw(); return; }
|
|
3164
|
+
if (state.open) { event.preventDefault(); closeCategory(); }
|
|
3165
|
+
});
|
|
3166
|
+
// What the lobby learns while the window is open (an agent signs in, a version is found, a
|
|
3167
|
+
// project opens) reaches the cards and the sheet's line; a card that says the same is left alone,
|
|
3168
|
+
// so nothing under the pointer or the keyboard is drawn again for nothing.
|
|
3169
|
+
const sync = ({ detail: again = false } = {}) => {
|
|
3170
|
+
if (state.query.trim()) return;
|
|
3171
|
+
for (const old of body.querySelectorAll('.settings-card')) {
|
|
3172
|
+
const fresh = card(old.dataset.cat);
|
|
3173
|
+
if (fresh.outerHTML !== old.outerHTML) old.replaceWith(fresh);
|
|
3174
|
+
}
|
|
3175
|
+
const line = body.querySelector('.settings-detail-head .settings-line');
|
|
3176
|
+
if (line && state.open) line.textContent = setStatus(state.open).line;
|
|
3177
|
+
// The sheet stays where it is (it slid in once); only what is inside it is drawn again.
|
|
3178
|
+
const scroll = body.querySelector('.settings-scroll');
|
|
3179
|
+
if (again && state.open && scroll) { stop(); scroll.replaceChildren(detail(state.open)); }
|
|
3180
|
+
};
|
|
3181
|
+
// A look changed (here, in a room's PERSONALIZE at this address, or in another tab): the cards
|
|
3182
|
+
// say so, and Appearance is drawn again with it.
|
|
3183
|
+
const paintLooks = () => { sync({ detail: state.open === 'look' }); };
|
|
3184
|
+
const show = (wanted) => {
|
|
3185
|
+
if (!dialog.open) dialog.showModal();
|
|
3186
|
+
if (wanted && SET_FILL[wanted]) openCategory(wanted); else draw();
|
|
3187
|
+
};
|
|
3188
|
+
const close = () => dialog.close();
|
|
3189
|
+
settingsSheet = { show, paintLooks, sync, close };
|
|
3190
|
+
dialog.addEventListener('close', () => { stop(); dialog.remove(); settingsSheet = null; });
|
|
3191
|
+
show(section);
|
|
3192
|
+
}
|
|
3193
|
+
ui.gear.addEventListener('click', () => openSettings());
|
|
3194
|
+
|
|
3195
|
+
function translateStatic() {
|
|
3196
|
+
document.title = `MADRE · ${t('Home')}`;
|
|
3197
|
+
ui.title.textContent = t('Home');
|
|
3198
|
+
ui.add.textContent = t('Add a project');
|
|
3199
|
+
ui.bar.crumb.textContent = t('Home');
|
|
3200
|
+
ui.bar.open.setAttribute('aria-label', t('Projects on the bar'));
|
|
3201
|
+
ui.bar.agents.setAttribute('aria-label', t('Agents'));
|
|
3202
|
+
ui.bar.links.querySelector('span').textContent = t('Connections');
|
|
3203
|
+
ui.bar.reload.title = t('Reload the page');
|
|
3204
|
+
ui.bar.reload.setAttribute('aria-label', t('Reload the page'));
|
|
3205
|
+
ui.stop.querySelector('span').textContent = t('Stop all');
|
|
3206
|
+
ui.macro.querySelector('span').textContent = t('Project map');
|
|
3207
|
+
ui.gear.title = t('Home settings');
|
|
3208
|
+
ui.gear.setAttribute('aria-label', t('Home settings'));
|
|
3209
|
+
ui.search.placeholder = t('Search a project…');
|
|
3210
|
+
ui.search.setAttribute('aria-label', t('Search a project…'));
|
|
3211
|
+
ui.filter.setAttribute('aria-label', t('Show'));
|
|
3212
|
+
const FILTER_WORDS = { all: t('All projects'), open: t('Open projects'), asleep: t('Asleep projects') };
|
|
3213
|
+
for (const choice of ui.filter.querySelectorAll('[data-filter]')) choice.textContent = FILTER_WORDS[choice.dataset.filter];
|
|
3214
|
+
$('sm-bishop-name').textContent = t('Coordinator');
|
|
3215
|
+
ui.bishop.go.textContent = t('Talk to the Coordinator');
|
|
3216
|
+
ui.bishop.tab.title = t('Open the Coordinator in a new tab');
|
|
3217
|
+
ui.bishop.tab.setAttribute('aria-label', t('Open the Coordinator in a new tab'));
|
|
3218
|
+
ui.bishop.about.textContent = t('Your coordinator. It reads across your projects, signs the connections between them and carries an order from one to another.');
|
|
3219
|
+
ui.work.title.textContent = t('Recent summary');
|
|
3220
|
+
ui.work.again.textContent = t('Summarise again');
|
|
3221
|
+
ui.waiting.title.textContent = t('Waiting on you');
|
|
3222
|
+
ui.linksTileTitle.textContent = t('Connections');
|
|
3223
|
+
ui.link.textContent = t('Connect projects');
|
|
3224
|
+
ui.linksAll.textContent = t('See the connections');
|
|
3225
|
+
ui.linksTitle.textContent = t('Connections');
|
|
3226
|
+
ui.linksClose.setAttribute('aria-label', t('Close'));
|
|
3227
|
+
ui.linkMore.textContent = t('Connect projects');
|
|
3228
|
+
ui.memory.title.textContent = t('Memory');
|
|
3229
|
+
ui.decisions.title.textContent = t('Recent decisions');
|
|
3230
|
+
ui.attention.title.textContent = t('Needs you');
|
|
3231
|
+
for (const kind of KINDS) KIND_WORDS[kind] = t(kind.toUpperCase());
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
translateStatic();
|
|
3235
|
+
drawWork();
|
|
3236
|
+
drawDecisions();
|
|
3237
|
+
await refresh();
|
|
3238
|
+
// Arriving from RELOAD (salas-mac.html put the curtain back): Home has read its projects, so the
|
|
3239
|
+
// bar fills and the curtain clears.
|
|
3240
|
+
{
|
|
3241
|
+
const curtain = document.getElementById('reload-curtain');
|
|
3242
|
+
if (curtain) {
|
|
3243
|
+
void finishLoader(curtain.querySelector('.madre-loader')).then(() => {
|
|
3244
|
+
curtain.classList.add('leaving');
|
|
3245
|
+
setTimeout(() => curtain.remove(), 760);
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
void loadAgents();
|
|
3250
|
+
void loadVersion();
|
|
3251
|
+
void loadWork();
|
|
3252
|
+
void loadDecisions();
|
|
3253
|
+
// `#welcome`: the first-run wizard again, on purpose (to connect an agent or add projects in steps).
|
|
3254
|
+
if (location.hash === '#welcome') { history.replaceState(null, '', location.pathname); welcomed = true; openWizard(); }
|
|
3255
|
+
// Sent here for the Macroverso (the key in BISHOP's room): straight into it, on a large screen.
|
|
3256
|
+
if (location.hash === '#macroverso' && data && !ui.macro.hidden) { history.replaceState(null, '', location.pathname); ui.macro.click(); }
|
|
3257
|
+
timer = setInterval(() => { if (!document.hidden && !busy.size && !pins.dropping && !pins.drag) void refresh(); }, 4000);
|
|
3258
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) { void refresh(); void loadAgents(); void loadWork(); void loadDecisions(); } });
|
|
3259
|
+
// The decisions are asked again every few minutes (the route itself asks the rooms no oftener).
|
|
3260
|
+
setInterval(() => { if (!document.hidden) void loadDecisions(); }, 5 * 60 * 1000);
|
|
3261
|
+
|
|
3262
|
+
// Asking for a star or a review (review-ask.js), when Home is quiet: nothing waking or closing,
|
|
3263
|
+
// the bar not being arranged.
|
|
3264
|
+
startReviewAsk({ busy: () => busy.size > 0 || pins.editing || Boolean(pins.drag) || Boolean(pins.dropping) });
|