@jossuealcala/madre 0.7.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
package/public/tour.js
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// The welcome of MADRE 1.0 (2026-10-11), in Home and in every project: option B of «Recorrido de
|
|
2
|
+
// MADRE 1.0» — a bonjour, then a window of chapters read at the person's own pace — with two
|
|
3
|
+
// pieces of option C: a one-line hint the first time someone reaches a place that confuses, and
|
|
4
|
+
// the sheet of gestures that `?` opens anywhere.
|
|
5
|
+
//
|
|
6
|
+
// Where the person is lives in ~/.pulse/tour.json (src/tour.mjs), not in this browser: every
|
|
7
|
+
// project is its own origin, and a flag here would be forgotten or said twice. The page tells this
|
|
8
|
+
// module what it can do — `go`, one function per chapter that takes the person there — and when
|
|
9
|
+
// it must wait (`ready`): never over a turn that runs, never before an agent is connected.
|
|
10
|
+
//
|
|
11
|
+
// The bonjour asks nothing it can answer itself. Whoever wrote in a project before came from
|
|
12
|
+
// 0.7.2: their projects are brought back to Home first, with the loader's real progress (one step
|
|
13
|
+
// per project), and the chapters tell what changed — what moved first, then what is new, each
|
|
14
|
+
// chapter with one line on what is different. A new person is welcomed and, in Home, handed to the
|
|
15
|
+
// assistant that connects agents and adds projects; then the chapters tell the essentials, in the
|
|
16
|
+
// order they are used (public/tour-chapters.js, ORDER).
|
|
17
|
+
|
|
18
|
+
import { LOADER_MARK, finishLoader, makeLoader, setLoader } from './loader.js';
|
|
19
|
+
import { language, t } from './i18n.js';
|
|
20
|
+
import { CHAPTERS, GESTURES, ORDER } from './tour-chapters.js';
|
|
21
|
+
|
|
22
|
+
const say = (pair) => (typeof pair === 'string' ? pair : pair?.[language()] ?? pair?.es ?? '');
|
|
23
|
+
const fill = (template, vars) => template.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
|
|
24
|
+
const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
25
|
+
const CLOSE_ICON = '<svg viewBox="0 0 20 20" 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"/></svg>';
|
|
26
|
+
const FOLDER = '<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"/></svg>';
|
|
27
|
+
const ROBOT = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="8" width="14" height="11" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 4.5V8M3 13v2M21 13v2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="9.5" cy="13.5" r="1.3" fill="currentColor"/><circle cx="14.5" cy="13.5" r="1.3" fill="currentColor"/></svg>';
|
|
28
|
+
|
|
29
|
+
function el(tag, className = null, text = null) {
|
|
30
|
+
const node = document.createElement(tag);
|
|
31
|
+
if (className) node.className = className;
|
|
32
|
+
if (text !== null && text !== undefined) node.textContent = text;
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
function key(className, words, act) {
|
|
36
|
+
const node = el('button', className, words);
|
|
37
|
+
node.type = 'button';
|
|
38
|
+
node.addEventListener('click', act);
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tourApi = {
|
|
43
|
+
read: () => fetch('/api/tour', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null),
|
|
44
|
+
change: (change) => fetch('/api/tour', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(change) })
|
|
45
|
+
.then((response) => (response.ok ? response.json() : null)).catch(() => null),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// The scenes: one short drawing per chapter, made of a few shapes that move once and loop slowly.
|
|
49
|
+
// With reduced motion they stand still and say the same.
|
|
50
|
+
const SCENES = {
|
|
51
|
+
moved: () => '',
|
|
52
|
+
home: () => `<div class="ts-bar"><i class="ts-logo"></i><span class="ts-pin"></span><span class="ts-pin"></span><span class="ts-pin ts-arrive"></span></div>
|
|
53
|
+
<div class="ts-grid"><span class="ts-card"></span><span class="ts-card ts-lift"></span><span class="ts-card"></span><span class="ts-card ts-dim"></span></div>`,
|
|
54
|
+
project: () => `<div class="ts-bar"><i class="ts-logo"></i><span style="flex:1"></span><span class="ts-key"></span><span class="ts-key ts-lamp"></span><span class="ts-key"></span><span class="ts-key"></span></div>
|
|
55
|
+
<div class="ts-switch"><span class="ts-tile"><i>${FOLDER}</i></span><span class="ts-tile on"><i>${FOLDER}</i></span><span class="ts-tile"><i>${FOLDER}</i></span></div>`,
|
|
56
|
+
talk: () => `<div class="ts-bubble"></div><div class="ts-bubble mine"></div>
|
|
57
|
+
<div class="ts-box"><span class="ts-type"><b>@codex</b><b>#2</b><b>!app.js</b><b>/gh</b></span></div>`,
|
|
58
|
+
modes: () => `<div class="ts-modes">${[0, 1, 2, 3, 4].map((n) => `<span class="ts-mode m${n}">#${n}${n >= 3 ? '<i class="ts-lock"></i>' : ''}</span>`).join('')}</div>`,
|
|
59
|
+
coordinator: () => `<svg class="ts-wires" viewBox="0 0 320 150" preserveAspectRatio="none" aria-hidden="true"><path id="ts-c1" d="M160 75L60 40"/><path id="ts-c2" d="M160 75L262 42"/><path id="ts-c3" d="M160 75L160 132"/>
|
|
60
|
+
${still() ? '' : ['ts-c1', 'ts-c2', 'ts-c3'].map((id, index) => `<path d="M0 0h0" class="ts-note"><animateMotion dur="2.6s" begin="${index * 0.8}s" repeatCount="indefinite"><mpath href="#${id}"/></animateMotion></path>`).join('')}</svg>
|
|
61
|
+
<span class="ts-node" style="left:18.75%;top:26.6%">${FOLDER}</span><span class="ts-node" style="left:81.9%;top:28%">${FOLDER}</span><span class="ts-node" style="left:50%;top:88%">${FOLDER}</span>
|
|
62
|
+
<span class="ts-hub">${ROBOT}</span>`,
|
|
63
|
+
links: () => `<svg class="ts-wires" viewBox="0 0 320 150" preserveAspectRatio="none" aria-hidden="true"><path id="ts-l1" class="ts-dash" d="M86 75C140 50 180 100 234 75"/>
|
|
64
|
+
${still() ? '' : [0, 0.9, 1.8].map((lag) => `<path d="M0 0h0" class="ts-note"><animateMotion dur="2.7s" begin="${lag}s" repeatCount="indefinite"><mpath href="#ts-l1"/></animateMotion></path>`).join('')}</svg>
|
|
65
|
+
<span class="ts-node big" style="left:23%;top:50%">${FOLDER}</span><span class="ts-node big" style="left:77%;top:50%">${FOLDER}</span>`,
|
|
66
|
+
maps: () => `<div class="ts-orbit"><i class="ts-core"></i>${[0, 1, 2, 3, 4, 5].map((n) => `<b class="ts-star s${n}"></b>`).join('')}</div>`,
|
|
67
|
+
settings: () => `<div class="ts-window"><span class="ts-search"><i></i><em></em></span><div class="ts-tiles"><span></span><span></span><span></span><span></span><span></span><span></span></div></div><span class="ts-cap">⌘ ,</span>`,
|
|
68
|
+
modules: () => `<div class="ts-modules">${[0, 1, 2, 3, 4, 5].map((n) => `<span class="ts-module m${n}"><i></i></span>`).join('')}</div>`,
|
|
69
|
+
phone: () => `<span class="ts-mac"><i></i></span><svg class="ts-wires" viewBox="0 0 320 150" preserveAspectRatio="none" aria-hidden="true"><path id="ts-p1" class="ts-dash" d="M206 80C178 62 150 62 124 76"/>
|
|
70
|
+
${still() ? '' : '<path d="M0 0h0" class="ts-note"><animateMotion dur="2.4s" repeatCount="indefinite" keyPoints="0;1;0" keyTimes="0;.5;1" calcMode="linear"><mpath href="#ts-p1"/></animateMotion></path>'}</svg>
|
|
71
|
+
<span class="ts-phone"><i class="ts-qr"></i></span>`,
|
|
72
|
+
board: () => SCENES.home(),
|
|
73
|
+
widgets: () => `<div class="ts-grid ts-widgets"><span class="ts-card"><i style="--w:.62"></i><i style="--w:.34"></i></span><span class="ts-card"><i style="--w:.8"></i><i style="--w:.5"></i></span><span class="ts-card"><i style="--w:.4"></i><i style="--w:.7"></i></span><span class="ts-card ts-dim"><i style="--w:.25"></i><i style="--w:.55"></i></span></div>`,
|
|
74
|
+
local: () => SCENES.modules(),
|
|
75
|
+
gestures: () => `<div class="ts-caps"><span class="ts-cap c0">⌘ ,</span><span class="ts-cap c1">Esc</span><span class="ts-cap c2">?</span></div>`,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// `onChange` hears the kept state each time it is known or changes (null if it could not be read).
|
|
79
|
+
// `phone`: none of it here (a phone, or the right half of a split window); `device`: this page is a
|
|
80
|
+
// phone, which gets one card the first time instead of the bonjour.
|
|
81
|
+
// `hidden`: the chapters whose thing is not offered on this copy; they are not told at all.
|
|
82
|
+
export function startTour({ page, go = {}, ready = () => true, phone = false, device = false, pillSlot = null, welcome = null, onProjects = null, onChange = null, hidden = () => [] } = {}) {
|
|
83
|
+
let info = null;
|
|
84
|
+
let state = null;
|
|
85
|
+
let windowNode = null;
|
|
86
|
+
let sheetNode = null;
|
|
87
|
+
let hintNode = null;
|
|
88
|
+
let current = null;
|
|
89
|
+
const loaded = (phone ? Promise.resolve(null) : tourApi.read()).then((answer) => {
|
|
90
|
+
info = answer;
|
|
91
|
+
state = answer?.state ?? null;
|
|
92
|
+
paintPill();
|
|
93
|
+
onChange?.(state);
|
|
94
|
+
return answer;
|
|
95
|
+
});
|
|
96
|
+
const keep = async (change) => {
|
|
97
|
+
const answer = await tourApi.change(change);
|
|
98
|
+
if (answer?.state) state = answer.state;
|
|
99
|
+
paintPill();
|
|
100
|
+
onChange?.(state);
|
|
101
|
+
return state;
|
|
102
|
+
};
|
|
103
|
+
// Three people (src/tour.mjs): new, back from 0.7.2, or back from 1.0 (`since`).
|
|
104
|
+
const path = () => state?.path ?? (info?.from === '1.0' ? 'since' : info?.returning ? 'back' : 'new');
|
|
105
|
+
const chapters = () => (ORDER[path()] ?? ORDER.new).filter((id) => !hidden().includes(id)).map((id) => CHAPTERS.find((chapter) => chapter.id === id)).filter((chapter) => chapter && (!chapter.only || chapter.only === language()));
|
|
106
|
+
const busyWith = () => Boolean(windowNode?.open || sheetNode?.open || document.querySelector('dialog.tour-bonjour[open]'));
|
|
107
|
+
|
|
108
|
+
// ---- One tab at a time. The tab that shows the bonjour or the chapters holds the tour and says
|
|
109
|
+
// so every few seconds (src/tour.mjs, HELD_MS); another tab that finds it held says it is open
|
|
110
|
+
// there, with a way to go on here, instead of a second bonjour. Tabs are different origins (one
|
|
111
|
+
// port per project), so this is told through ~/.pulse, not between the tabs.
|
|
112
|
+
const tab = `tab-${String(globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)).replace(/[^\w-]/g, '').slice(0, 24)}`;
|
|
113
|
+
let holding = 0;
|
|
114
|
+
const hold = () => {
|
|
115
|
+
if (holding || phone) return;
|
|
116
|
+
void keep({ hold: tab });
|
|
117
|
+
holding = setInterval(() => { void tourApi.change({ hold: tab }); }, 15000);
|
|
118
|
+
};
|
|
119
|
+
const letGo = () => {
|
|
120
|
+
if (!holding) return;
|
|
121
|
+
clearInterval(holding);
|
|
122
|
+
holding = 0;
|
|
123
|
+
void fetch('/api/tour', { method: 'POST', keepalive: true, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ release: tab }) }).catch(() => null);
|
|
124
|
+
};
|
|
125
|
+
const letGoIfDone = () => setTimeout(() => { if (!busyWith()) letGo(); }, 0);
|
|
126
|
+
const heldElsewhere = (kept) => Boolean(kept?.held && kept.held.by !== tab && Date.now() - Date.parse(kept.held.at) < 45000);
|
|
127
|
+
globalThis.addEventListener?.('pagehide', letGo);
|
|
128
|
+
|
|
129
|
+
// ---- A bubble that says something once, near the top, with its keys: the card of the phone,
|
|
130
|
+
// the tour open in another tab.
|
|
131
|
+
function notice({ id, title, lines = [], actions = [] }) {
|
|
132
|
+
document.querySelector(`.tour-notice[data-id="${id}"]`)?.remove();
|
|
133
|
+
const card = el('section', 'tour-notice');
|
|
134
|
+
card.dataset.id = id;
|
|
135
|
+
card.setAttribute('role', 'status');
|
|
136
|
+
card.append(el('b', null, title));
|
|
137
|
+
for (const line of lines) card.append(el('p', null, line));
|
|
138
|
+
const acts = el('div', 'tour-notice-acts');
|
|
139
|
+
for (const [words, act, primary] of actions) acts.append(key(`tour-btn small${primary ? ' primary' : ' quiet'}`, words, () => { card.remove(); act?.(); }));
|
|
140
|
+
card.append(acts);
|
|
141
|
+
if (typeof card.showPopover === 'function') card.popover = 'manual';
|
|
142
|
+
document.body.append(card);
|
|
143
|
+
try { card.showPopover?.(); } catch { /* not a popover: on the page */ }
|
|
144
|
+
return card;
|
|
145
|
+
}
|
|
146
|
+
function elsewhere() {
|
|
147
|
+
notice({
|
|
148
|
+
id: 'elsewhere',
|
|
149
|
+
title: t('The tour is open in another tab'),
|
|
150
|
+
lines: [t('It goes on there. If you closed that tab, go on here.')],
|
|
151
|
+
actions: [[t('Not now'), null, false], [t('Go on here'), () => { if (!state?.bonjourAt) showBonjour(); else openChapters(state.at); }, true]],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// The phone, the first time: what it does and what stays on the Mac, in three sentences. A phone
|
|
155
|
+
// changes nothing in ~/.pulse, so this one is remembered by the phone itself.
|
|
156
|
+
const PHONE_CARD = 'madre.phone.welcome';
|
|
157
|
+
function phoneCard() {
|
|
158
|
+
try { if (localStorage.getItem(PHONE_CARD)) return; } catch { return; }
|
|
159
|
+
notice({
|
|
160
|
+
id: 'phone',
|
|
161
|
+
title: t('MADRE on your phone'),
|
|
162
|
+
lines: [
|
|
163
|
+
t('Here you follow your projects and talk to your agents, up to #2.'),
|
|
164
|
+
t('#3 and #4, permissions and anything that installs stay on the Mac.'),
|
|
165
|
+
t('What you start here you carry on on the Mac, and the other way round.'),
|
|
166
|
+
],
|
|
167
|
+
actions: [[t('Got it'), () => { try { localStorage.setItem(PHONE_CARD, '1'); } catch { /* asked again next time */ } }, true]],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (device) setTimeout(phoneCard, 1200);
|
|
171
|
+
|
|
172
|
+
// ---- The pill: «Continue the tour · 4 of 12», while there is a tour to continue.
|
|
173
|
+
function paintPill() {
|
|
174
|
+
if (!pillSlot) return;
|
|
175
|
+
pillSlot.replaceChildren();
|
|
176
|
+
const list = chapters();
|
|
177
|
+
const show = state?.bonjourAt && !state.finished && !windowNode?.open && !phone;
|
|
178
|
+
pillSlot.hidden = !show;
|
|
179
|
+
if (!show) return;
|
|
180
|
+
const done = list.filter((chapter) => state.done.includes(chapter.id)).length;
|
|
181
|
+
const pill = el('span', 'tour-pill');
|
|
182
|
+
const open = key('tour-pill-go', fill(t('Continue the tour · {n} of {total}'), { n: done, total: list.length }), () => openChapters(state.at));
|
|
183
|
+
const away = key('tour-pill-x', null, () => { void keep({ finished: true }); });
|
|
184
|
+
away.innerHTML = CLOSE_ICON; // trusted: our own SVG string
|
|
185
|
+
away.setAttribute('aria-label', t('Hide the tour'));
|
|
186
|
+
away.title = t('Hide the tour. It stays in Settings.');
|
|
187
|
+
pill.append(open, away);
|
|
188
|
+
pillSlot.append(pill);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- The bonjour.
|
|
192
|
+
async function maybeBonjour() {
|
|
193
|
+
const answer = await loaded;
|
|
194
|
+
if (!answer?.owed || phone) return false;
|
|
195
|
+
// Never over work in progress, a window the person opened, or an agent not yet connected.
|
|
196
|
+
for (let tries = 0; tries < 720 && (!ready() || document.querySelector('dialog[open]')); tries += 1) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
197
|
+
if (!ready() || document.querySelector('dialog[open]')) return false;
|
|
198
|
+
// Asked again now: another tab may have shown it while this one waited.
|
|
199
|
+
const fresh = await tourApi.read();
|
|
200
|
+
if (fresh?.state) state = fresh.state;
|
|
201
|
+
if (!fresh?.owed) return false;
|
|
202
|
+
if (heldElsewhere(fresh.state)) { elsewhere(); return false; }
|
|
203
|
+
showBonjour();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
function showBonjour() {
|
|
207
|
+
hold();
|
|
208
|
+
const since = path() === 'since';
|
|
209
|
+
const back = path() === 'back';
|
|
210
|
+
const node = el('dialog', 'tour-bonjour');
|
|
211
|
+
node.setAttribute('aria-labelledby', 'tour-bonjour-title');
|
|
212
|
+
const stage = el('div', 'tour-bonjour-stage');
|
|
213
|
+
const mark = el('span', 'madre-loader-mark tour-hello-mark');
|
|
214
|
+
mark.innerHTML = LOADER_MARK; // trusted: our own SVG string
|
|
215
|
+
const title = el('h1', 'tour-hello', back || since ? t('Hello again.') : t('Hello.'));
|
|
216
|
+
title.id = 'tour-bonjour-title';
|
|
217
|
+
const body = el('p', 'tour-hello-says', since
|
|
218
|
+
? t('Since 1.0, Home became a board you arrange, with widgets; the local AI is chosen project by project, and MADRE speaks more plainly. I tell you what changed, at your own pace.')
|
|
219
|
+
: back
|
|
220
|
+
? t('MADRE changed a lot since 0.7.2. First I bring your projects to Home; then I tell you what is new, at your own pace.')
|
|
221
|
+
: t('This is MADRE: all your AI agents in one place, with your projects, their memory and your permissions.'));
|
|
222
|
+
const note = el('p', 'tour-hello-note');
|
|
223
|
+
if (since) note.textContent = t('Nothing you set up changed: your projects, agents and permissions are as you left them.');
|
|
224
|
+
else if (back) note.innerHTML = t('<code>madre</code> now opens Home; <code>madre start</code> still opens the project in the folder.'); // trusted: our own catalogue
|
|
225
|
+
else note.textContent = page === 'home' && welcome?.needed?.() ? t('First we get an agent and a project ready; then I tell you the essentials, at your own pace.') : t('I tell you the essentials in a few short chapters, at your own pace.');
|
|
226
|
+
const acts = el('div', 'tour-hello-acts');
|
|
227
|
+
const later = key('tour-btn quiet', t('Later'), async () => {
|
|
228
|
+
await keep({ path: path(), bonjour: true, closed: true });
|
|
229
|
+
node.close();
|
|
230
|
+
// The chapters can wait; the projects cannot (2026-10-12): left for later, someone coming from
|
|
231
|
+
// 0.7.2 found Home empty and nothing offered to bring them again. They come quietly.
|
|
232
|
+
if (back) void recoverProjects().then(({ found }) => { if (found.length) onProjects?.(); });
|
|
233
|
+
});
|
|
234
|
+
const onward = key('tour-btn primary', back || since ? t('Continue') : t('Start'), async () => {
|
|
235
|
+
later.disabled = true; onward.disabled = true;
|
|
236
|
+
await keep({ path: path(), bonjour: true });
|
|
237
|
+
void fetch('/api/whats-new/seen', { method: 'POST' }).catch(() => null); // the chapters replace What's new 1.0
|
|
238
|
+
if (back) { await bringProjects(stage); return; }
|
|
239
|
+
node.close();
|
|
240
|
+
if (since) { openChapters(chapters()[0]?.id ?? null); return; }
|
|
241
|
+
if (page === 'home' && welcome?.needed?.()) welcome.open(() => openChapters('home'));
|
|
242
|
+
else openChapters(page === 'home' ? 'home' : 'project');
|
|
243
|
+
});
|
|
244
|
+
acts.append(later, onward);
|
|
245
|
+
stage.append(mark, title, body, note, acts);
|
|
246
|
+
node.append(stage);
|
|
247
|
+
node.addEventListener('cancel', (event) => { event.preventDefault(); later.click(); });
|
|
248
|
+
node.addEventListener('close', () => { letGoIfDone(); setTimeout(() => node.remove(), 400); });
|
|
249
|
+
document.body.append(node);
|
|
250
|
+
node.showModal();
|
|
251
|
+
onward.focus();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- 0.7.2: the projects Home cannot list, brought back one by one, the loader saying how far.
|
|
255
|
+
async function recoverProjects(step = () => {}) {
|
|
256
|
+
const rooms = (await fetch('/api/tour/projects', { cache: 'no-store' }).then((response) => response.json()).catch(() => null))?.rooms ?? [];
|
|
257
|
+
const found = [];
|
|
258
|
+
const missing = [];
|
|
259
|
+
for (const [index, room] of rooms.entries()) {
|
|
260
|
+
step(index, rooms.length);
|
|
261
|
+
const answer = await fetch('/api/tour/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: room.id }) })
|
|
262
|
+
.then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
263
|
+
if (answer?.found) found.push(answer.name); else missing.push(room.name);
|
|
264
|
+
}
|
|
265
|
+
return { found, missing };
|
|
266
|
+
}
|
|
267
|
+
async function bringProjects(stage) {
|
|
268
|
+
const loader = makeLoader({ progress: 0.04, label: t('Looking for your projects of 0.7.2…') });
|
|
269
|
+
stage.replaceChildren(loader);
|
|
270
|
+
const { found, missing } = await recoverProjects((index, total) => setLoader(loader, 0.08 + (0.84 * index) / Math.max(1, total), fill(t('Bringing your projects of 0.7.2 · {n} of {total}'), { n: index + 1, total })));
|
|
271
|
+
await finishLoader(loader, { hold: 380 });
|
|
272
|
+
if (found.length) onProjects?.();
|
|
273
|
+
const list = (names) => new Intl.ListFormat(language(), { type: 'conjunction' }).format(names.map((name) => `«${name}»`));
|
|
274
|
+
const done = el('div', 'tour-bonjour-done');
|
|
275
|
+
const mark = el('span', 'madre-loader-mark tour-hello-mark');
|
|
276
|
+
mark.innerHTML = LOADER_MARK; // trusted: our own SVG string
|
|
277
|
+
done.append(mark, el('h1', 'tour-hello', found.length ? t('Your projects are in Home.') : t('Home is ready.')));
|
|
278
|
+
done.append(el('p', 'tour-hello-says', found.length
|
|
279
|
+
? (found.length === 1 ? fill(t('I brought {list} to Home, with its conversations and its memory.'), { list: list(found) }) : fill(t('I brought {n} projects to Home, with their conversations and their memory: {list}.'), { n: found.length, list: list(found) }))
|
|
280
|
+
: t('Your projects were already in Home.')));
|
|
281
|
+
if (missing.length) done.append(el('p', 'tour-hello-note', missing.length === 1 ? t('One I could not find: its folder is no longer where it was. If you moved it, add it in Home and it gets back its conversations.') : fill(t('{n} I could not find: their folders are no longer where they were. If you moved them, add them in Home and they get back their conversations.'), { n: missing.length })));
|
|
282
|
+
const acts = el('div', 'tour-hello-acts');
|
|
283
|
+
const node = stage.closest('dialog');
|
|
284
|
+
acts.append(key('tour-btn primary', t('See what is new'), () => { node.close(); openChapters('moved'); }));
|
|
285
|
+
done.append(acts);
|
|
286
|
+
stage.replaceChildren(done);
|
|
287
|
+
acts.firstChild.focus();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- The chapters.
|
|
291
|
+
function openChapters(at = null) {
|
|
292
|
+
const list = chapters();
|
|
293
|
+
current = list.find((chapter) => chapter.id === at) ?? list.find((chapter) => !state?.done.includes(chapter.id)) ?? list[0];
|
|
294
|
+
if (!windowNode) build();
|
|
295
|
+
hold();
|
|
296
|
+
if (!windowNode.open) windowNode.showModal();
|
|
297
|
+
paintPill();
|
|
298
|
+
show(current);
|
|
299
|
+
}
|
|
300
|
+
function build() {
|
|
301
|
+
windowNode = el('dialog', 'whats-new tour-window');
|
|
302
|
+
windowNode.setAttribute('aria-labelledby', 'tour-window-title');
|
|
303
|
+
const sheet = el('div', 'wn-sheet tour-sheet');
|
|
304
|
+
const head = el('header', 'tour-head');
|
|
305
|
+
const words = el('div', 'tour-head-words');
|
|
306
|
+
const title = el('h2');
|
|
307
|
+
title.id = 'tour-window-title';
|
|
308
|
+
words.append(el('span', 'wn-eyebrow'), title);
|
|
309
|
+
const close = key('tour-close', null, () => windowNode.close());
|
|
310
|
+
close.innerHTML = CLOSE_ICON; // trusted: our own SVG string
|
|
311
|
+
close.setAttribute('aria-label', t('Close'));
|
|
312
|
+
head.append(words, close);
|
|
313
|
+
const body = el('div', 'tour-body');
|
|
314
|
+
const nav = el('ol', 'tour-nav');
|
|
315
|
+
nav.setAttribute('aria-label', t('Chapters'));
|
|
316
|
+
const main = el('section', 'tour-main');
|
|
317
|
+
main.setAttribute('aria-live', 'polite');
|
|
318
|
+
body.append(nav, main);
|
|
319
|
+
sheet.append(head, body);
|
|
320
|
+
windowNode.append(sheet);
|
|
321
|
+
windowNode.addEventListener('close', () => {
|
|
322
|
+
letGoIfDone();
|
|
323
|
+
const list = chapters();
|
|
324
|
+
const all = list.every((chapter) => state?.done.includes(chapter.id));
|
|
325
|
+
void keep(all ? { finished: true } : { closed: true });
|
|
326
|
+
});
|
|
327
|
+
windowNode.addEventListener('keydown', (event) => {
|
|
328
|
+
if (event.target.closest?.('input, textarea')) return;
|
|
329
|
+
const list = chapters();
|
|
330
|
+
const index = list.indexOf(current);
|
|
331
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { event.preventDefault(); show(list[Math.min(list.length - 1, index + 1)]); }
|
|
332
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { event.preventDefault(); show(list[Math.max(0, index - 1)]); }
|
|
333
|
+
});
|
|
334
|
+
document.body.append(windowNode);
|
|
335
|
+
}
|
|
336
|
+
function show(shown) {
|
|
337
|
+
const chapter = shown;
|
|
338
|
+
current = shown;
|
|
339
|
+
const back = path() === 'back';
|
|
340
|
+
const since = path() === 'since';
|
|
341
|
+
windowNode.querySelector('.tour-head .wn-eyebrow').textContent = since ? t('What is new since 1.0') : back ? t('The tour of MADRE 1.0') : t('Getting to know MADRE');
|
|
342
|
+
windowNode.querySelector('#tour-window-title').textContent = since ? t('What changed, at your pace') : back ? t('Everything new, at your pace') : t('The essentials, at your pace');
|
|
343
|
+
void keep({ chapter: chapter.id, at: chapter.id });
|
|
344
|
+
if (!state.done.includes(chapter.id)) state.done = [...state.done, chapter.id];
|
|
345
|
+
const list = chapters();
|
|
346
|
+
const index = list.indexOf(shown);
|
|
347
|
+
const nav = windowNode.querySelector('.tour-nav');
|
|
348
|
+
nav.replaceChildren(...list.map((one, at) => {
|
|
349
|
+
const item = el('li');
|
|
350
|
+
const pick = key(`tour-nav-key${one === shown ? ' on' : ''}${state.done.includes(one.id) ? ' done' : ''}`, null, () => show(one));
|
|
351
|
+
pick.append(el('span', 'tour-nav-n', String(at + 1)), el('span', 'tour-nav-name', say(one.title)));
|
|
352
|
+
if (one === shown) pick.setAttribute('aria-current', 'step');
|
|
353
|
+
item.append(pick);
|
|
354
|
+
return item;
|
|
355
|
+
}));
|
|
356
|
+
const main = windowNode.querySelector('.tour-main');
|
|
357
|
+
const page2 = el('article', `tour-chapter c-${chapter.id}`);
|
|
358
|
+
const scene = el('div', `tour-scene s-${chapter.id}${chapter.moved ? ' s-table' : ''}`);
|
|
359
|
+
scene.setAttribute('aria-hidden', 'true');
|
|
360
|
+
if (chapter.moved) {
|
|
361
|
+
const table = el('div', 'ts-moved');
|
|
362
|
+
for (const [was, now] of chapter.moved) {
|
|
363
|
+
const line = el('div', 'ts-moved-row');
|
|
364
|
+
line.append(el('span', 'was', say(was)), el('i', null, '→'), el('span', 'now', say(now)));
|
|
365
|
+
table.append(line);
|
|
366
|
+
}
|
|
367
|
+
scene.append(table);
|
|
368
|
+
} else scene.innerHTML = SCENES[chapter.id]?.() ?? ''; // trusted: our own markup
|
|
369
|
+
const words = el('div', 'tour-words');
|
|
370
|
+
words.append(el('span', 'tour-step', fill(t('{n} of {total}'), { n: index + 1, total: list.length })), el('h3', null, say(chapter.title)));
|
|
371
|
+
// Whoever came from 0.7.2 reads first what is new or different here.
|
|
372
|
+
if (back && chapter.news) {
|
|
373
|
+
const news = el('p', `tour-news ${chapter.news.kind}`);
|
|
374
|
+
const said = el('span');
|
|
375
|
+
said.innerHTML = say(chapter.news); // trusted: our own copy
|
|
376
|
+
news.append(el('b', 'tour-news-tag', chapter.news.kind === 'new' ? t('New in 1.0') : t('Changed in 1.0')), said);
|
|
377
|
+
words.append(news);
|
|
378
|
+
}
|
|
379
|
+
// Whoever came from 1.0 reads first what is new or different since.
|
|
380
|
+
if (since && chapter.since) {
|
|
381
|
+
const news = el('p', `tour-news ${chapter.since.kind}`);
|
|
382
|
+
const said = el('span');
|
|
383
|
+
said.innerHTML = say(chapter.since); // trusted: our own copy
|
|
384
|
+
news.append(el('b', 'tour-news-tag', chapter.since.kind === 'new' ? t('New since 1.0') : t('Changed since 1.0')), said);
|
|
385
|
+
words.append(news);
|
|
386
|
+
}
|
|
387
|
+
words.append(el('p', 'tour-lead', say(chapter.lead)));
|
|
388
|
+
const points = chapter.points?.[language()] ?? chapter.points?.es ?? [];
|
|
389
|
+
if (points.length) {
|
|
390
|
+
const ul = el('ul', 'tour-points');
|
|
391
|
+
for (const point of points) { const item = el('li'); item.innerHTML = point; ul.append(item); } // trusted: our own copy
|
|
392
|
+
words.append(ul);
|
|
393
|
+
}
|
|
394
|
+
if (chapter.id === 'gestures') words.append(gestureGrid());
|
|
395
|
+
else if (chapter.keys?.length) {
|
|
396
|
+
const keys = el('dl', 'tour-keys');
|
|
397
|
+
for (const [what, how] of chapter.keys) keys.append(el('dt', null, say(what)), el('dd', null, say(how)));
|
|
398
|
+
words.append(keys);
|
|
399
|
+
}
|
|
400
|
+
const acts = el('div', 'tour-acts');
|
|
401
|
+
const here = typeof go[chapter.id] === 'function';
|
|
402
|
+
if (here) {
|
|
403
|
+
acts.append(key('tour-btn', t('Take me there'), () => {
|
|
404
|
+
void keep({ at: chapter.id, closed: true });
|
|
405
|
+
windowNode.close();
|
|
406
|
+
setTimeout(() => go[chapter.id](), 60);
|
|
407
|
+
}));
|
|
408
|
+
} else if (chapter.where?.includes(page)) {
|
|
409
|
+
acts.append(el('span', 'tour-where', t('It is right here, on this screen.')));
|
|
410
|
+
} else if (chapter.where?.length) {
|
|
411
|
+
acts.append(el('span', 'tour-where', chapter.where.includes('room') ? t('You try this inside a project.') : t('You try this in Home.')));
|
|
412
|
+
}
|
|
413
|
+
acts.append(el('span', 'tour-gap'));
|
|
414
|
+
if (index > 0) acts.append(key('tour-btn quiet', t('Back'), () => show(list[index - 1])));
|
|
415
|
+
if (index < list.length - 1) acts.append(key('tour-btn primary', t('Next chapter'), () => show(list[index + 1])));
|
|
416
|
+
else acts.append(key('tour-btn primary', t('Finish'), () => { void keep({ finished: true }); windowNode.close(); }));
|
|
417
|
+
words.append(acts);
|
|
418
|
+
page2.append(scene, words);
|
|
419
|
+
main.replaceChildren(page2);
|
|
420
|
+
main.scrollTop = 0;
|
|
421
|
+
acts.querySelector('.tour-btn.primary')?.focus({ preventScroll: true });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- `?`: the sheet of gestures, anywhere.
|
|
425
|
+
function gestureGrid() {
|
|
426
|
+
const grid = el('div', 'tour-gestures');
|
|
427
|
+
for (const [group, rows] of GESTURES) {
|
|
428
|
+
const box = el('section', 'tour-gesture-group');
|
|
429
|
+
box.append(el('h4', null, say(group)));
|
|
430
|
+
const keys = el('dl', 'tour-keys');
|
|
431
|
+
for (const [what, how] of rows) keys.append(el('dt', null, say(what)), el('dd', null, say(how)));
|
|
432
|
+
box.append(keys);
|
|
433
|
+
grid.append(box);
|
|
434
|
+
}
|
|
435
|
+
return grid;
|
|
436
|
+
}
|
|
437
|
+
function openGestures() {
|
|
438
|
+
if (windowNode?.open) { show(chapters().find((chapter) => chapter.id === 'gestures')); return; }
|
|
439
|
+
if (!sheetNode) {
|
|
440
|
+
sheetNode = el('dialog', 'whats-new tour-gesture-window');
|
|
441
|
+
sheetNode.setAttribute('aria-labelledby', 'tour-gestures-title');
|
|
442
|
+
const sheet = el('div', 'wn-sheet tour-sheet tour-gesture-sheet');
|
|
443
|
+
const head = el('header', 'tour-head');
|
|
444
|
+
const words = el('div', 'tour-head-words');
|
|
445
|
+
words.append(el('span', 'wn-eyebrow', t('Press ? at any moment')));
|
|
446
|
+
const title = el('h2', null, t('Gestures and shortcuts'));
|
|
447
|
+
title.id = 'tour-gestures-title';
|
|
448
|
+
words.append(title);
|
|
449
|
+
const close = key('tour-close', null, () => sheetNode.close());
|
|
450
|
+
close.innerHTML = CLOSE_ICON; // trusted: our own SVG string
|
|
451
|
+
close.setAttribute('aria-label', t('Close'));
|
|
452
|
+
head.append(words, close);
|
|
453
|
+
const body = el('div', 'tour-gesture-body');
|
|
454
|
+
body.append(gestureGrid());
|
|
455
|
+
sheet.append(head, body);
|
|
456
|
+
sheetNode.append(sheet);
|
|
457
|
+
sheetNode.addEventListener('click', (event) => { if (event.target === sheetNode) sheetNode.close(); });
|
|
458
|
+
document.body.append(sheetNode);
|
|
459
|
+
}
|
|
460
|
+
if (!sheetNode.open) sheetNode.showModal();
|
|
461
|
+
}
|
|
462
|
+
const typing = (target) => Boolean(target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]'));
|
|
463
|
+
if (!phone) {
|
|
464
|
+
document.addEventListener('keydown', (event) => {
|
|
465
|
+
if (event.key !== '?' || event.metaKey || event.ctrlKey || event.altKey || typing(event.target)) return;
|
|
466
|
+
if (sheetNode?.open) { sheetNode.close(); return; }
|
|
467
|
+
event.preventDefault();
|
|
468
|
+
openGestures();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---- The hints: one line, once, the first time someone reaches a place that confuses. Never
|
|
473
|
+
// two at once, never over the bonjour or the chapters, never before the bonjour was seen.
|
|
474
|
+
// `inside`: the hint goes into that box, at its top (a window that is modal, where a card
|
|
475
|
+
// floating over the page could not be pressed); it counts as seen when the box closes.
|
|
476
|
+
async function hint(id, anchor, { title, text, inside = false }) {
|
|
477
|
+
const answer = await loaded;
|
|
478
|
+
if (phone || !answer || !state?.bonjourAt || state.hints?.[id] || hintNode || busyWith()) return false;
|
|
479
|
+
if (!anchor?.isConnected) return false;
|
|
480
|
+
const box = anchor.getBoundingClientRect();
|
|
481
|
+
if (!inside && !box.width && !box.height) return false;
|
|
482
|
+
const card = el('section', `tour-hint${inside ? ' inline' : ''}`);
|
|
483
|
+
card.setAttribute('role', 'note');
|
|
484
|
+
card.append(el('span', 'tour-hint-eyebrow', t('The first time')), el('b', null, title), el('p', null, text));
|
|
485
|
+
const done = () => {
|
|
486
|
+
if (!hintNode) return;
|
|
487
|
+
void keep({ hint: id });
|
|
488
|
+
card.remove();
|
|
489
|
+
hintNode = null;
|
|
490
|
+
document.removeEventListener('keydown', onKey, true);
|
|
491
|
+
};
|
|
492
|
+
const onKey = (event) => { if (event.key === 'Escape' && hintNode && !inside) { event.stopPropagation(); done(); } };
|
|
493
|
+
card.append(key('tour-btn small primary', t('Got it'), done));
|
|
494
|
+
hintNode = card;
|
|
495
|
+
if (inside) {
|
|
496
|
+
anchor.prepend(card);
|
|
497
|
+
anchor.closest('dialog')?.addEventListener('close', done, { once: true });
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
if (typeof card.showPopover === 'function') card.popover = 'manual';
|
|
501
|
+
document.body.append(card);
|
|
502
|
+
try { card.showPopover?.(); } catch { /* not a popover: on the page */ }
|
|
503
|
+
// A bubble beside the place it speaks of, with no beak: under it when there is room, over it
|
|
504
|
+
// otherwise, lined up with the side of the screen the place is on, never off the screen.
|
|
505
|
+
const width = Math.min(300, window.innerWidth - 24);
|
|
506
|
+
card.style.width = `${width}px`;
|
|
507
|
+
const height = card.getBoundingClientRect().height;
|
|
508
|
+
const onTheRight = box.left + box.width / 2 > window.innerWidth / 2;
|
|
509
|
+
const left = Math.max(12, Math.min(window.innerWidth - width - 12, onTheRight ? box.right - width : box.left));
|
|
510
|
+
const below = box.bottom + 10 + height < window.innerHeight;
|
|
511
|
+
Object.assign(card.style, { left: `${left}px`, top: `${below ? box.bottom + 10 : Math.max(12, box.top - height - 10)}px` });
|
|
512
|
+
document.addEventListener('keydown', onKey, true);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- What a key opens, said the first time it opens (1.1): one line beside what opened — inside
|
|
517
|
+
// it when it is a modal window, where a card floating over the page could not be pressed. `opened`
|
|
518
|
+
// finds what the click opened; nothing found, the line goes beside the key itself.
|
|
519
|
+
function firstOpen(id, node, { opened = () => null, inside = null, title, text, after = 450 }) {
|
|
520
|
+
if (!node || node.dataset.firstOpen === id) return;
|
|
521
|
+
node.dataset.firstOpen = id;
|
|
522
|
+
node.addEventListener('click', () => {
|
|
523
|
+
if (state?.hints?.[id]) return;
|
|
524
|
+
setTimeout(() => {
|
|
525
|
+
const box = opened();
|
|
526
|
+
const modal = Boolean(box?.closest?.('dialog')?.matches?.(':modal'));
|
|
527
|
+
const into = modal ? (inside?.(box) ?? box) : null;
|
|
528
|
+
void hint(id, into ?? box ?? node, { title: title(), text: text(), inside: modal });
|
|
529
|
+
}, after);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
loaded,
|
|
535
|
+
firstOpen,
|
|
536
|
+
// The page changed its language after it drew the pill: the pill says it again in the new one.
|
|
537
|
+
repaint: () => paintPill(),
|
|
538
|
+
maybeBonjour,
|
|
539
|
+
bonjour: showBonjour,
|
|
540
|
+
openChapters,
|
|
541
|
+
openGestures,
|
|
542
|
+
hint,
|
|
543
|
+
state: () => state,
|
|
544
|
+
path,
|
|
545
|
+
};
|
|
546
|
+
}
|