openvibers/openvibe.shared Commits
Talk to Vibe in any browser; the glyphs load when a page first shows an icon (4.77.0) (#229)
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.77.0 — 2026-11-01++++- **Talk to Vibe in any browser** (plan L48 V2). Where the browser has no speech recognition of its own (Firefox),++ the Dock's mic records instead: hold it (or tap, then tap again), up to 30 s, and OpenVibe.Chat's++ `POST /api/vibe/listen` turns the recording into text on the network's own speech-to-text (OpenVibe.AI, nothing++ kept). What was said is asked like typed text. The microphone is released as soon as the recording ends.++ - The Dock's Chat call (`chatCall`) can send a body that is not JSON: `{ body, type }`.++- **The glyphs load when a page first shows an icon** (plan L54 K4). The navbar loaded `ov-glyphs.js` only if a page++ had a Font Awesome-style icon when it mounted, so sites loaded it themselves for icons their own scripts add later.++ Now the navbar watches for the first one (a mutation schedules one look 250 ms later; the watch ends once the++ glyphs load), so a page without icons pays nothing and the sites' own `<script src="/shared/ov-glyphs.js">` can go.++## 4.76.0 — 2026-11-01- **A timer in the Dock, on every site** (plan L48 R2; the owner, 2026-11-01: "a reminder system… timer or however@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.76.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.77.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.76.0",++ "version": "4.77.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -36,10 +36,16 @@// The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is// asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll// for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own-- // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on.++ // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on. A++ // browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text++ // (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).// Everything is set as text; a link is a link only when it is https.const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;++ const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;++ const TALK = Boolean(SR || MR);++ const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];++ const REC_MAX_MS = 30000;const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];function linkified(text) {const span = h('span', 'ovdk-text');@@@ -69,11 +75,11 @@const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');const form = h('form', 'ovdk-send');-- const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = SR ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');++ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));const err = h('p', 'ovdk-err'); err.hidden = true;let mic = null;-- if (SR) {++ if (TALK) {mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');form.append(mic);@@@ -127,7 +133,7 @@if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }if (first && !vibe.msgs.length) {const start = h('li', 'ovdk-vibe-start');-- start.append(h('b', '', 'Ask Vibe anything'), h('span', '', SR ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));++ start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));const chips = h('div', 'ovdk-chips');STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });start.append(chips);@@@ -142,8 +148,42 @@if (mic) {let said = '', heard = false;const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };++ const on = (yes) => { mic.classList.toggle('is-on', yes); mic.setAttribute('aria-pressed', yes ? 'true' : 'false'); };++ const fail = (text) => { err.textContent = text; err.hidden = false; };++ // No speech recognition in this browser: record, then Chat's /api/vibe/listen says what was said.++ const record = async () => {++ const type = REC_TYPES.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';++ let stream;++ try { stream = await root.navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) {++ return fail(e && e.name === 'NotAllowedError' ? 'The microphone is off for this site: allow it in the browser to talk to Vibe.' : 'No microphone was found.');++ }++ const chunks = [];++ let r;++ try { r = new MR(stream, { ...(type ? { mimeType: type } : {}), audioBitsPerSecond: 32000 }); } catch { stream.getTracks().forEach((t) => t.stop()); return fail('This browser cannot record here.'); }++ const cap = setTimeout(stop, REC_MAX_MS);++ r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };++ r.onstop = async () => {++ clearTimeout(cap); vibe.rec = null; on(false);++ stream.getTracks().forEach((t) => t.stop());++ const kind = String(r.mimeType || type || 'audio/webm').split(';')[0];++ const blob = new Blob(chunks, { type: kind });++ if (blob.size < 800) { box.placeholder = 'Ask Vibe, or hold the mic'; return; }++ box.placeholder = 'Working out what you said…'; mic.disabled = true;++ try {++ const lang = String(document.documentElement.lang || root.navigator.language || 'en').slice(0, 2).toLowerCase();++ const out = await ctx.chatCall(`/api/vibe/listen?lang=${encodeURIComponent(/^[a-z]{2}$/.test(lang) ? lang : 'en')}`, { method: 'POST', body: blob, type: kind });++ const said = String((out && out.text) || '').trim();++ if (said) { box.value = said; fit(); send(said); } else fail('I could not hear that. Try again, a little closer.');++ } catch (e) {++ fail(e && e.status === 429 ? 'That is a lot of talking for one minute: type it, or try again shortly.' : e && e.status === 413 ? 'Keep it under 30 seconds.' : 'I could not hear that just now. Try again, or type it.');++ } finally { mic.disabled = false; box.placeholder = 'Ask Vibe, or hold the mic'; }++ };++ vibe.rec = r; err.hidden = true; on(true); box.placeholder = 'Listening… let go or tap to send';++ try { r.start(); } catch { vibe.rec = null; on(false); stream.getTracks().forEach((t) => t.stop()); }++ };const start = () => {if (vibe.rec) return stop();++ if (!SR) return record();const r = new SR();r.lang = document.documentElement.lang || navigator.language || 'en-US';r.interimResults = true; r.continuous = false;@@@ -483,8 +483,9 @@const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };async function chatCall(path, init = {}) {const t = await tokenOf();-- const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };-- return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ const { type: _type, ...rest } = init;++ return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {const b = await r.json().catch(() => ({}));if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }return b;@@@ -506,6 +506,26 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;++ // ov-glyphs.js, when a page shows a Font Awesome-style icon: now, or the first time one appears. One watch per page,++ // made here and not in the bar's render, so it holds nothing of a bar that a page move (Boost) re-renders. A++ // mutation only schedules one look 250 ms later, and the watch ends once the glyphs are loading.++ const GLYPH_ICONS = 'i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]';++ let glyphWatch = null;++ function loadGlyphs() {++ if (root.OVGlyphs || document.getElementById('ov-glyphs-loader') || document.querySelector('script[src*="/ov-glyphs.js"]')) return true;++ if (!document.querySelector(GLYPH_ICONS)) return false;++ try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ return true;++ }++ function watchGlyphs() {++ if (glyphWatch || !document.querySelector || loadGlyphs() || typeof MutationObserver !== 'function' || !document.body) return;++ let soon = 0;++ glyphWatch = new MutationObserver(() => {++ if (soon) return;++ soon = setTimeout(() => { soon = 0; if (loadGlyphs() && glyphWatch) { glyphWatch.disconnect(); glyphWatch = true; } }, 250);++ });++ glyphWatch.observe(document.body, { childList: true, subtree: true });++ }function sibling(name) {const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;@@@ -1677,10 +1697,9 @@if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }}-- // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome.-- if (_config.glyphs !== false && !window.OVGlyphs && !document.getElementById('ov-glyphs-loader') && document.querySelector && document.querySelector('i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]')) {-- try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }-- }++ // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,++ // or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).++ if (_config.glyphs !== false) watchGlyphs();nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.76.0",++ "version": "4.77.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -56,7 +56,7 @@ let scenario = {};const puts = [], decided = [], said = [], reads = [];// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.let vibeChat = null;--const vibeMsgs = [], asked = [];++const vibeMsgs = [], asked = [], listened = [];const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">@@@ -106,6 +106,10 @@ const server = http.createServer((req, res) => {const after = Number(u.searchParams.get('after')) || 0;return json(200, { chat: vibeChat, messages: vibeMsgs.filter((m) => m.id > after) });}++ if (u.pathname === '/api/vibe/listen' && req.method === 'POST') {++ listened.push({ type: req.headers['content-type'], bytes: Buffer.concat(chunks).length, lang: u.searchParams.get('lang'), auth: req.headers.authorization });++ return json(200, { text: 'remind me to drink water at 3' });++ }if (u.pathname === '/api/vibe/ask' && req.method === 'POST') {const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');asked.push(b);@@@ -362,6 +366,54 @@ async function drag(p, from, to) {await p.close();}++ // ── Vibe by voice without the browser's speech recognition (Firefox): the mic records, Chat's /api/vibe/listen++ // says what was said, and it is asked like typed text. The recorder is a stand-in: no microphone in CI. ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true, tab: 'vibe' } };++ asked.length = 0;++ const p = await fresh(1280, 860);++ await p.send('Page.addScriptToEvaluateOnNewDocument', { source: `++ delete window.SpeechRecognition; delete window.webkitSpeechRecognition;++ window.__tracksStopped = 0;++ navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [{ stop() { window.__tracksStopped++; } }] });++ window.MediaRecorder = class {++ static isTypeSupported(t) { return t === 'audio/ogg;codecs=opus'; }++ constructor(stream, o) { this.mimeType = (o && o.mimeType) || ''; this.state = 'inactive'; }++ start() { this.state = 'recording'; window.__recording = true; }++ stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }++ };` });++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, PILL, 'the pinned pill');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');++ await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");++ await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');++ await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");++ await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');++ assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');++ assert.strictEqual(asked[0].message, 'remind me to drink water at 3');++ assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false');++ await p.close();++ }++++ // ── The navbar loads the OpenVibe glyphs only when a page shows an icon: none at first costs nothing, and an++ // icon the page's own script adds later is drawn all the same (L54 K4: Font Awesome is gone everywhere). ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };++ const p = await fresh(1280, 860);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');++ await wait(600);++ assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded');++ await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");++ await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');++ await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once');++ await p.close();++ }++// ── The timer (L48 R2): start from the menu, count down in the pill, end, and a focus round moves on by itself. ──{scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };@@@ -11,5 +11,7 @@ const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');assert.ok(/const siblingV = \(name\) => `\$\{sibling\(name\)\}\$\{SIBLING_REV\[name\] \? `\?v=\$\{SIBLING_REV\[name\]\}` : ''\}`;/.test(src), 'siblingV adds the hash');assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url(${siblingV('ov-brand.woff2')})") && src.includes("sc.src = siblingV('ov-glyphs.js')"), 'the mark, the face and the glyphs load by hash');--assert.ok(/_config\.glyphs !== false[^\n]*i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');++assert.ok(/GLYPH_ICONS = 'i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');++assert.ok(/glyphWatch = new MutationObserver[\s\S]{0,300}glyphWatch\.disconnect\(\)/.test(src), 'and when the page adds its first icon later, then the watch ends');++assert.ok(/_config\.glyphs !== false\) watchGlyphs\(\)/.test(src), 'the render only asks; the one watch lives outside it (a Boost page move re-renders the bar)');console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.77.0 — 2026-11-01
+
+- **Talk to Vibe in any browser** (plan L48 V2). Where the browser has no speech recognition of its own (Firefox),
+ the Dock's mic records instead: hold it (or tap, then tap again), up to 30 s, and OpenVibe.Chat's
+ `POST /api/vibe/listen` turns the recording into text on the network's own speech-to-text (OpenVibe.AI, nothing
+ kept). What was said is asked like typed text. The microphone is released as soon as the recording ends.
+ - The Dock's Chat call (`chatCall`) can send a body that is not JSON: `{ body, type }`.
+- **The glyphs load when a page first shows an icon** (plan L54 K4). The navbar loaded `ov-glyphs.js` only if a page
+ had a Font Awesome-style icon when it mounted, so sites loaded it themselves for icons their own scripts add later.
+ Now the navbar watches for the first one (a mutation schedules one look 250 ms later; the watch ends once the
+ glyphs load), so a page without icons pays nothing and the sites' own `<script src="/shared/ov-glyphs.js">` can go.
+
## 4.76.0 — 2026-11-01
## 4.76.0 — 2026-11-01
- **A timer in the Dock, on every site** (plan L48 R2; the owner, 2026-11-01: "a reminder system… timer or however
- **A timer in the Dock, on every site** (plan L48 R2; the owner, 2026-11-01: "a reminder system… timer or however
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.76.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.77.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.76.0",
+ "version": "4.77.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -36,10 +36,16 @@
@@ -36,10 +36,16 @@
// The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is
// The same conversation as openvibe.chat/vibe, through Chat's /api/vibe with the person's Network token: what is
// asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll
// asked on Live carries on on Chat and back. Vibe answers in the background; the tab waits for it (a quick poll
// for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own
// for a minute and a half after a message, then every 15 s while the tab is open). The mic is the browser's own
- // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on.
+ // speech recognition, hold to talk or Alt+V, and only while the person asks: no wake word, never always on. A
+ // browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
+ // (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
// Everything is set as text; a link is a link only when it is https.
// Everything is set as text; a link is a link only when it is https.
const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
const vibe = { panel: null, chat: null, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
+ const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
+ const TALK = Boolean(SR || MR);
+ const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];
+ const REC_MAX_MS = 30000;
const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];
const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];
function linkified(text) {
function linkified(text) {
const span = h('span', 'ovdk-text');
const span = h('span', 'ovdk-text');
@@ -69,11 +75,11 @@
@@ -69,11 +75,11 @@
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
const form = h('form', 'ovdk-send');
const form = h('form', 'ovdk-send');
- const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = SR ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
+ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
let mic = null;
let mic = null;
- if (SR) {
+ if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
form.append(mic);
form.append(mic);
@@ -127,7 +133,7 @@
@@ -127,7 +133,7 @@
if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
if (answered || Date.now() > vibe.waitUntil) { typing.remove(); vibe.waitUntil = 0; }
if (first && !vibe.msgs.length) {
if (first && !vibe.msgs.length) {
const start = h('li', 'ovdk-vibe-start');
const start = h('li', 'ovdk-vibe-start');
- start.append(h('b', '', 'Ask Vibe anything'), h('span', '', SR ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
+ start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
const chips = h('div', 'ovdk-chips');
const chips = h('div', 'ovdk-chips');
STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });
STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });
start.append(chips);
start.append(chips);
@@ -142,8 +148,42 @@
@@ -142,8 +148,42 @@
if (mic) {
if (mic) {
let said = '', heard = false;
let said = '', heard = false;
const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };
const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };
+ const on = (yes) => { mic.classList.toggle('is-on', yes); mic.setAttribute('aria-pressed', yes ? 'true' : 'false'); };
+ const fail = (text) => { err.textContent = text; err.hidden = false; };
+ // No speech recognition in this browser: record, then Chat's /api/vibe/listen says what was said.
+ const record = async () => {
+ const type = REC_TYPES.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';
+ let stream;
+ try { stream = await root.navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) {
+ return fail(e && e.name === 'NotAllowedError' ? 'The microphone is off for this site: allow it in the browser to talk to Vibe.' : 'No microphone was found.');
+ }
+ const chunks = [];
+ let r;
+ try { r = new MR(stream, { ...(type ? { mimeType: type } : {}), audioBitsPerSecond: 32000 }); } catch { stream.getTracks().forEach((t) => t.stop()); return fail('This browser cannot record here.'); }
+ const cap = setTimeout(stop, REC_MAX_MS);
+ r.ondataavailable = (ev) => { if (ev.data && ev.data.size) chunks.push(ev.data); };
+ r.onstop = async () => {
+ clearTimeout(cap); vibe.rec = null; on(false);
+ stream.getTracks().forEach((t) => t.stop());
+ const kind = String(r.mimeType || type || 'audio/webm').split(';')[0];
+ const blob = new Blob(chunks, { type: kind });
+ if (blob.size < 800) { box.placeholder = 'Ask Vibe, or hold the mic'; return; }
+ box.placeholder = 'Working out what you said…'; mic.disabled = true;
+ try {
+ const lang = String(document.documentElement.lang || root.navigator.language || 'en').slice(0, 2).toLowerCase();
+ const out = await ctx.chatCall(`/api/vibe/listen?lang=${encodeURIComponent(/^[a-z]{2}$/.test(lang) ? lang : 'en')}`, { method: 'POST', body: blob, type: kind });
+ const said = String((out && out.text) || '').trim();
+ if (said) { box.value = said; fit(); send(said); } else fail('I could not hear that. Try again, a little closer.');
+ } catch (e) {
+ fail(e && e.status === 429 ? 'That is a lot of talking for one minute: type it, or try again shortly.' : e && e.status === 413 ? 'Keep it under 30 seconds.' : 'I could not hear that just now. Try again, or type it.');
+ } finally { mic.disabled = false; box.placeholder = 'Ask Vibe, or hold the mic'; }
+ };
+ vibe.rec = r; err.hidden = true; on(true); box.placeholder = 'Listening… let go or tap to send';
+ try { r.start(); } catch { vibe.rec = null; on(false); stream.getTracks().forEach((t) => t.stop()); }
+ };
const start = () => {
const start = () => {
if (vibe.rec) return stop();
if (vibe.rec) return stop();
+ if (!SR) return record();
const r = new SR();
const r = new SR();
r.lang = document.documentElement.lang || navigator.language || 'en-US';
r.lang = document.documentElement.lang || navigator.language || 'en-US';
r.interimResults = true; r.continuous = false;
r.interimResults = true; r.continuous = false;
@@ -483,8 +483,9 @@
@@ -483,8 +483,9 @@
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
async function chatCall(path, init = {}) {
async function chatCall(path, init = {}) {
const t = await tokenOf();
const t = await tokenOf();
- const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
- return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ const { type: _type, ...rest } = init;
+ return fetch(chatBase() + path, { ...rest, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
if (!r.ok) { const e = new Error(b.error || `HTTP ${r.status}`); e.status = r.status; throw e; }
return b;
return b;
@@ -506,6 +506,26 @@
@@ -506,6 +506,26 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
+ // ov-glyphs.js, when a page shows a Font Awesome-style icon: now, or the first time one appears. One watch per page,
+ // made here and not in the bar's render, so it holds nothing of a bar that a page move (Boost) re-renders. A
+ // mutation only schedules one look 250 ms later, and the watch ends once the glyphs are loading.
+ const GLYPH_ICONS = 'i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]';
+ let glyphWatch = null;
+ function loadGlyphs() {
+ if (root.OVGlyphs || document.getElementById('ov-glyphs-loader') || document.querySelector('script[src*="/ov-glyphs.js"]')) return true;
+ if (!document.querySelector(GLYPH_ICONS)) return false;
+ try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ return true;
+ }
+ function watchGlyphs() {
+ if (glyphWatch || !document.querySelector || loadGlyphs() || typeof MutationObserver !== 'function' || !document.body) return;
+ let soon = 0;
+ glyphWatch = new MutationObserver(() => {
+ if (soon) return;
+ soon = setTimeout(() => { soon = 0; if (loadGlyphs() && glyphWatch) { glyphWatch.disconnect(); glyphWatch = true; } }, 250);
+ });
+ glyphWatch.observe(document.body, { childList: true, subtree: true });
+ }
function sibling(name) {
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@ -1677,10 +1697,9 @@
@@ -1677,10 +1697,9 @@
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
}
- // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome.
- if (_config.glyphs !== false && !window.OVGlyphs && !document.getElementById('ov-glyphs-loader') && document.querySelector && document.querySelector('i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]')) {
- try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
- }
+ // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
+ // or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
+ if (_config.glyphs !== false) watchGlyphs();
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.76.0",
+ "version": "4.77.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -56,7 +56,7 @@ let scenario = {};
@@ -56,7 +56,7 @@ let scenario = {};
const puts = [], decided = [], said = [], reads = [];
const puts = [], decided = [], said = [], reads = [];
// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.
// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.
let vibeChat = null;
let vibeChat = null;
-const vibeMsgs = [], asked = [];
+const vibeMsgs = [], asked = [], listened = [];
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
@@ -106,6 +106,10 @@ const server = http.createServer((req, res) => {
@@ -106,6 +106,10 @@ const server = http.createServer((req, res) => {
const after = Number(u.searchParams.get('after')) || 0;
const after = Number(u.searchParams.get('after')) || 0;
return json(200, { chat: vibeChat, messages: vibeMsgs.filter((m) => m.id > after) });
return json(200, { chat: vibeChat, messages: vibeMsgs.filter((m) => m.id > after) });
}
}
+ if (u.pathname === '/api/vibe/listen' && req.method === 'POST') {
+ listened.push({ type: req.headers['content-type'], bytes: Buffer.concat(chunks).length, lang: u.searchParams.get('lang'), auth: req.headers.authorization });
+ return json(200, { text: 'remind me to drink water at 3' });
+ }
if (u.pathname === '/api/vibe/ask' && req.method === 'POST') {
if (u.pathname === '/api/vibe/ask' && req.method === 'POST') {
const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
asked.push(b);
asked.push(b);
@@ -362,6 +366,54 @@ async function drag(p, from, to) {
@@ -362,6 +366,54 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── Vibe by voice without the browser's speech recognition (Firefox): the mic records, Chat's /api/vibe/listen
+ // says what was said, and it is asked like typed text. The recorder is a stand-in: no microphone in CI. ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true, tab: 'vibe' } };
+ asked.length = 0;
+ const p = await fresh(1280, 860);
+ await p.send('Page.addScriptToEvaluateOnNewDocument', { source: `
+ delete window.SpeechRecognition; delete window.webkitSpeechRecognition;
+ window.__tracksStopped = 0;
+ navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [{ stop() { window.__tracksStopped++; } }] });
+ window.MediaRecorder = class {
+ static isTypeSupported(t) { return t === 'audio/ogg;codecs=opus'; }
+ constructor(stream, o) { this.mimeType = (o && o.mimeType) || ''; this.state = 'inactive'; }
+ start() { this.state = 'recording'; window.__recording = true; }
+ stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }
+ };` });
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, PILL, 'the pinned pill');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic');
+ await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
+ await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');
+ await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
+ await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');
+ assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');
+ assert.strictEqual(asked[0].message, 'remind me to drink water at 3');
+ assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false');
+ await p.close();
+ }
+
+ // ── The navbar loads the OpenVibe glyphs only when a page shows an icon: none at first costs nothing, and an
+ // icon the page's own script adds later is drawn all the same (L54 K4: Font Awesome is gone everywhere). ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };
+ const p = await fresh(1280, 860);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!document.querySelector('.openvibe-navbar')", 'the navbar');
+ await wait(600);
+ assert.strictEqual(await p.evaluate("!!document.getElementById('ov-glyphs-loader')"), false, 'no icon on the page: no glyphs loaded');
+ await p.evaluate("(() => { const i = document.createElement('i'); i.className = 'fa-solid fa-gear'; i.id = 'late-icon'; document.querySelector('main').prepend(i); })()");
+ await until(p, "!!document.getElementById('ov-glyphs-loader') && !!window.OVGlyphs", 'the glyphs load once an icon appears');
+ await until(p, "(() => { const e = document.getElementById('late-icon'); return !e || !!e.querySelector('svg') || e.tagName.toLowerCase() === 'svg'; })()", 'and the late icon is drawn');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"ov-glyphs.js\"]').length"), 1, 'once');
+ await p.close();
+ }
+
// ── The timer (L48 R2): start from the menu, count down in the pill, end, and a focus round moves on by itself. ──
// ── The timer (L48 R2): start from the menu, count down in the pill, end, and a focus round moves on by itself. ──
{
{
scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };
scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };
@@ -11,5 +11,7 @@ const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
@@ -11,5 +11,7 @@ const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');
assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');
assert.ok(/const siblingV = \(name\) => `\$\{sibling\(name\)\}\$\{SIBLING_REV\[name\] \? `\?v=\$\{SIBLING_REV\[name\]\}` : ''\}`;/.test(src), 'siblingV adds the hash');
assert.ok(/const siblingV = \(name\) => `\$\{sibling\(name\)\}\$\{SIBLING_REV\[name\] \? `\?v=\$\{SIBLING_REV\[name\]\}` : ''\}`;/.test(src), 'siblingV adds the hash');
assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url(${siblingV('ov-brand.woff2')})") && src.includes("sc.src = siblingV('ov-glyphs.js')"), 'the mark, the face and the glyphs load by hash');
assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url(${siblingV('ov-brand.woff2')})") && src.includes("sc.src = siblingV('ov-glyphs.js')"), 'the mark, the face and the glyphs load by hash');
-assert.ok(/_config\.glyphs !== false[^\n]*i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');
+assert.ok(/GLYPH_ICONS = 'i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');
+assert.ok(/glyphWatch = new MutationObserver[\s\S]{0,300}glyphWatch\.disconnect\(\)/.test(src), 'and when the page adds its first icon later, then the watch ends');
+assert.ok(/_config\.glyphs !== false\) watchGlyphs\(\)/.test(src), 'the render only asks; the one watch lives outside it (a Boost page move re-renders the bar)');
console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');
console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');