openvibers/openvibe.shared Commits

Talk to Vibe in any browser; the glyphs load when a page first shows an icon (4.77.0) (#229)

OP OpenVibers committed 12 days ago · 70620a9ef4 · 9 files, +141 −15

* Talk to Vibe in any browser; the glyphs load when a page first shows an icon (4.77.0)

- The Dock's mic records where the browser has no speech recognition (Firefox) and Chat's
  /api/vibe/listen turns it into text; what was said is asked like typed text (plan L48 V2).
- The navbar watches for a page's first icon and loads ov-glyphs.js then, so sites need not
  load it themselves for icons their scripts add later (plan L54 K4).

* One glyph watch per page, outside the bar's render

A watch made in the render held that render's scope (the old bar) for as long as it watched, and
a Boost page move re-renders the bar: +266 nodes a lap in boost-navbar-chrome. The watch is now a
single module-level observer that holds nothing of any bar.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 967cc3b..740ec43 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/README.md b/README.md
iindex 3ab1154..3a7455c 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 635a815..52a20e0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 90fb4b6..f60491c 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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;
ddiff --git a/dock.js b/dock.js
iindex f6d59d3..ab45961 100644
---- a/dock.js
++++ b/dock.js
@@@ -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;
ddiff --git a/navbar.js b/navbar.js
iindex 606c862..de250eb 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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">
ddiff --git a/package.json b/package.json
iindex 595bd81..c993d9e 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex a317ff0..a8ca566 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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() } };
ddiff --git a/test/sibling-revs.test.js b/test/sibling-revs.test.js
iindex 97afe74..1558e80 100644
---- a/test/sibling-revs.test.js
++++ b/test/sibling-revs.test.js
@@@ -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');