openvibers/openvibe.shared Commits
Vibe in the Dock, on every site, with the mic (4.74.0) (#226)
Changes
@@@ -4,6 +4,22 @@ 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.74.0 — 2026-11-01++++- **Vibe in the Dock, on every site** (plan L48 V2; the owner, 2026-11-01: "access your agent or the AI… from across++ all websites… use a voice feature to tell it to set a reminder… have a conversation with your Vibe AI").++ - The Dock's first tab is Vibe: the same conversation as openvibe.chat/vibe (OpenVibe.Chat's `/api/vibe/chat` and++ `/api/vibe/ask`, with the person's own token), so a chat started on Live carries on on Chat and back.++ - Starters show before the first message, and Vibe's answer arrives in place. The tab polls quickly for a minute++ and a half after a message, then every 15 s while it is open. An https link is a link, and "Open in Chat" goes++ to the same conversation.++ - The mic is the browser's own speech recognition. Hold to talk, click to start and stop, or press Alt+V from any++ page, which opens the Dock on Vibe. It listens only while the person asks: no wake word, never always on.++ - `dock-vibe.js`, a new served file, holds the conversation and the mic (4.1 KB brotli). It loads the first time++ the tab opens. `dock.js` grows by the tab, its loader and Alt+V (0.55 KB; the budget note says so).++ - The Network's Dock state keeps only the tabs it knows. The Vibe tab is remembered on the site until the Network++ learns it.++## 4.73.0 — 2026-11-01- **Solid drawings for the most-used shapes** (plan L49 step 3; the owner, 2026-11-01: "really tiny icons… a solid@@@ -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.73.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.74.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.73.0",++ "version": "4.74.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)",nnew file mode 100644@@@ -0,0 +1,194 @@++/**++ * openvibe-shared/dock-vibe.js: the Dock's Vibe tab (plan L48 V2), loaded by dock.js the first time the tab opens.++ * OpenVibeDockVibe.mount(panel, { chatCall, active, styles, icons }) → { refresh(), pause(), talk() }++ * chatCall is the Dock's call to OpenVibe.Chat with the person's Network token; active() says whether the Vibe tab is++ * the one showing; styles() is the Dock's own <style> (this file adds its rules there, so a popped-out Dock has them).++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';++ const CSS = `++ .ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }++ .ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }++ .ovdk-away:hover { text-decoration: underline; }++ .ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }++ .ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }++ .ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }++ .ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }++ .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }++ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }++ .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }++ .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }++ .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }++ .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }++ .ovdk-mic:hover { color: var(--text-primary, #ececf1); }++ .ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }++ @media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }++`;++ let ctx = null, G = {};++ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };++++ // ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice++ // feature to tell it to set a reminder… have a conversation with your Vibe AI") ──++ // 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.++ // 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 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');++ String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {++ if (i % 2) { const a = h('a', '', part); a.href = part; a.target = '_blank'; a.rel = 'noopener noreferrer'; span.append(a); } else if (part) span.append(part);++ });++ return span;++ }++ function vbubble(m, prev) {++ const mine = m.from === 'you';++ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`);++ if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe'));++ li.append(linkified(m.text));++ if (m.at) li.title = new Date(m.at).toLocaleString();++ return li;++ }++ async function openVibe() {++ clearTimeout(vibe.timer);++ const panel = vibe.panel; if (!panel) return;++ vibe.msgs = []; vibe.last = 0;++ panel.textContent = '';++ const head = h('div', 'ovdk-thread-h ovdk-vibe-h');++ const title = h('b', '', 'Vibe');++ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';++ head.append(icon(G.vibe), title, away);++ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');++ 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 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) {++ 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);++ }++ form.append(box, go);++ panel.append(head, scroll, err, form);++ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };++ box.addEventListener('input', fit);++ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });++++ async function send(text) {++ if (!text || vibe.busy) return;++ vibe.busy = true; go.disabled = true; err.hidden = true;++ try {++ let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }++ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });++ if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }++ box.value = ''; fit();++ const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();++ if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }++ vibe.waitUntil = Date.now() + 90000;++ list.append(typing);++ scroll.scrollTop = scroll.scrollHeight;++ clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);++ } catch (x) {++ err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';++ err.hidden = false;++ } finally { vibe.busy = false; go.disabled = false; box.focus(); }++ }++ form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });++++ async function pull(first) {++ let got;++ try {++ got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);++ } catch (e) {++ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }++ return;++ }++ if (!ctx.active() || vibe.panel !== panel) return;++ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }++ if (first) list.textContent = '';++ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;++ let answered = false;++ (got.messages || []).forEach((m) => {++ if (vibe.msgs.some((x) => x.id === m.id)) return;++ list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null);++ vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0);++ if (m.from === 'vibe') answered = true;++ });++ 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.'));++ 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);++ list.append(start);++ }++ if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;++ clearTimeout(vibe.timer);++ if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);++ }++++ // The mic: hold to talk (or a click to start and another to stop), Alt+V from anywhere while the Dock is open.++ if (mic) {++ let said = '', heard = false;++ const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };++ const start = () => {++ if (vibe.rec) return stop();++ const r = new SR();++ r.lang = document.documentElement.lang || navigator.language || 'en-US';++ r.interimResults = true; r.continuous = false;++ said = ''; heard = false;++ r.onresult = (ev) => {++ let text = '';++ for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;++ said = text.trim(); heard = true; box.value = said; fit();++ };++ r.onerror = (ev) => {++ if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { err.textContent = 'The microphone is off for this site: allow it in the browser to talk to Vibe.'; err.hidden = false; }++ };++ r.onend = () => {++ vibe.rec = null; mic.classList.remove('is-on'); mic.setAttribute('aria-pressed', 'false');++ if (heard && said) send(said);++ };++ vibe.rec = r;++ mic.classList.add('is-on'); mic.setAttribute('aria-pressed', 'true');++ try { r.start(); } catch { vibe.rec = null; mic.classList.remove('is-on'); }++ };++ let held = 0;++ mic.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; held = Date.now(); start(); });++ mic.addEventListener('pointerup', () => { if (held && Date.now() - held > 400) stop(); held = 0; });++ mic.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); } });++ vibe.talk = start;++ }++ list.append(h('li', 'ovdk-wait', null));++ list.firstChild.innerHTML = '<span></span>';++ await pull(true);++ setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++++ function mount(panel, c) {++ ctx = c || {};++ G = { mic: MIC, ...(ctx.icons || {}) };++ const st = typeof ctx.styles === 'function' ? ctx.styles() : null;++ if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;++ else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }++ vibe.panel = panel;++ openVibe();++ return {++ refresh: () => { vibe.panel = panel; return openVibe(); },++ pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },++ talk: () => { if (vibe.talk) vibe.talk(); },++ };++ }++ root.OpenVibeDockVibe = { mount };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -38,8 +38,12 @@chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',++ vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',};-- const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];++ // Vibe first (L48 V2): the person's AI, on every site.++ const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];++ // The tabs the Network keeps in the person's Dock state (network.dock-state@1); a newer one stays on this site's copy.++ const SAVED_TABS = ['agent', 'chats', 'activity'];const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;@@@ -409,14 +413,24 @@function setTab(id, remember) {tab = TABS.some(([t]) => t === id) ? id : 'activity';if (tab !== 'chats') clearTimeout(chats.timer);++ if (tab !== 'vibe' && vibe.api) vibe.api.pause();el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });-- if (remember && state().tab !== tab) save({ tab });++ if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });fill(tab);}async function fill(id) {const panel = el.querySelector(`[data-panel="${id}"]`);++ if (id === 'vibe') {++ vibe.panel = panel;++ if (vibe.api) return vibe.api.refresh();++ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';++ const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));++ if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send } });++ return;++ }if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }mounts[id] = { refresh: null };@@@ -607,6 +621,18 @@if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);}++ // ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on++ // every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from++ // any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).++ const vibe = { panel: null, api: null };++ const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);++ document.addEventListener('keydown', (e) => {++ if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;++ e.preventDefault();++ const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };++ if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();++ });++// ── The pill ─────────────────────────────────────────────let pill = null, readT = 0, bound = false;function store(v) {@@@ -771,7 +797,7 @@if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');setTab(want, false);readUnread();-- if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();++ if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);}function close() {@@@ -22,6 +22,7 @@ const BROWSER = Object.freeze(['notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person++ 'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'account-switcher.js','user-card.js','ov-mark.js', // the OV brand mark drop-in@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.73.0",++ "version": "4.74.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -47,6 +47,7 @@"./notification-live": "./notification-live.js","./action-center": "./action-center.js","./dock": "./dock.js",++ "./dock-vibe": "./dock-vibe.js","./notification-ui": "./notification-ui.js","./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js",@@@ -11,6 +11,8 @@// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone// never reads as a tap outside the sheet;++// • Vibe (L48 V2): the person's Vibe from Chat's /api/vibe, starters before the first message, a question posted and++// Vibe's answer arriving in place (an https link as a link), the Vibe tab never sent to the Network's state;// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the// panel back; the Network is told.@@@ -50,6 +52,9 @@ const NOTES = [// The stand-in's state for the scenario under way.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 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:,">@@@ -94,6 +99,20 @@ const server = http.createServer((req, res) => {.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));return json(200, { messages: all.filter((m) => m.id > after) });}++ if (u.pathname === '/api/vibe/chat') {++ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'Vibe is asked with the person\'s own token');++ 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/ask' && req.method === 'POST') {++ const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');++ asked.push(b);++ vibeChat = vibeChat || { slug: 'v1', name: 'Vibe', url: 'https://openvibe.chat/vibe/v1' };++ const mine = { id: 500 + vibeMsgs.length, from: 'you', text: b.message, at: iso(0) };++ vibeMsgs.push(mine);++ setTimeout(() => vibeMsgs.push({ id: 600 + vibeMsgs.length, from: 'vibe', text: 'Reminder set: Stretch, every 2 hours. See them at https://openvibe.today/reminders', at: iso(0) }), 700);++ return json(201, { chat: vibeChat, message: mine });++ }if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });@@@ -298,7 +317,7 @@ async function drag(p, from, to) {await p.evaluate(`${POP}.click()`);await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');-- assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 3, 'the same three tabs');++ assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 4, 'the same four tabs');assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');const inWindow = await until(p, PILL, 'the pill stays');@@@ -315,6 +334,32 @@ async function drag(p, from, to) {await p.close();}++ // ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the tab stays off the Network's state. ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };++ puts.length = 0;++ const p = await fresh(1280, 860);++ 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, CARD, 'the card');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');++ await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");++ await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');++ await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");++ await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');++ assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');++ assert.ok(asked[0].timezone, 'with the person\'s time zone, for "at 9"');++ const answer = await until(p, "(() => { const m = document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-vibe:not(.ovdk-typing)'); return m ? { text: m.innerText, link: (m.querySelector('a') || {}).href || null } : null; })()", 'Vibe\'s answer', 6000);++ assert.match(answer.text, /Reminder set: Stretch, every 2 hours/);++ assert.strictEqual(answer.link, 'https://openvibe.today/reminders', 'an https link is a link');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');++ await wait(900);++ assert.ok(!puts.some((b) => b.tab === 'vibe'), `the Network's Dock state never gets a tab it does not know (${JSON.stringify(puts)})`);++ await p.close();++ }++// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──{scenario = { missing: true };@@@ -331,7 +376,7 @@ async function drag(p, from, to) {assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');await p.close();}-- console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');++ console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');} finally {await browser.close();server.close();@@@ -12,8 +12,10 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget was that// plus 1.5 KB. 4.56.0 adds the async token (the extension refreshes it) and the read-only scope controls; the// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.--const DOCK_BROTLI_BUDGET = 14336; // 14.00 KB (14219 after 4.56.0 + 117 bytes of headroom)--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];++// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation++// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.++const DOCK_BROTLI_BUDGET = 14848; // 14.50 KB (14757 after 4.74.0 + 91 bytes of headroom)++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js'];// ── the budget ──────────────────────────────────────────────{
@@ -4,6 +4,22 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,22 @@ 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.74.0 — 2026-11-01
+
+- **Vibe in the Dock, on every site** (plan L48 V2; the owner, 2026-11-01: "access your agent or the AI… from across
+ all websites… use a voice feature to tell it to set a reminder… have a conversation with your Vibe AI").
+ - The Dock's first tab is Vibe: the same conversation as openvibe.chat/vibe (OpenVibe.Chat's `/api/vibe/chat` and
+ `/api/vibe/ask`, with the person's own token), so a chat started on Live carries on on Chat and back.
+ - Starters show before the first message, and Vibe's answer arrives in place. The tab polls quickly for a minute
+ and a half after a message, then every 15 s while it is open. An https link is a link, and "Open in Chat" goes
+ to the same conversation.
+ - The mic is the browser's own speech recognition. Hold to talk, click to start and stop, or press Alt+V from any
+ page, which opens the Dock on Vibe. It listens only while the person asks: no wake word, never always on.
+ - `dock-vibe.js`, a new served file, holds the conversation and the mic (4.1 KB brotli). It loads the first time
+ the tab opens. `dock.js` grows by the tab, its loader and Alt+V (0.55 KB; the budget note says so).
+ - The Network's Dock state keeps only the tabs it knows. The Vibe tab is remembered on the site until the Network
+ learns it.
+
## 4.73.0 — 2026-11-01
## 4.73.0 — 2026-11-01
- **Solid drawings for the most-used shapes** (plan L49 step 3; the owner, 2026-11-01: "really tiny icons… a solid
- **Solid drawings for the most-used shapes** (plan L49 step 3; the owner, 2026-11-01: "really tiny icons… a solid
@@ -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.73.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.74.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.73.0",
+ "version": "4.74.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)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+/**
+ * openvibe-shared/dock-vibe.js: the Dock's Vibe tab (plan L48 V2), loaded by dock.js the first time the tab opens.
+ * OpenVibeDockVibe.mount(panel, { chatCall, active, styles, icons }) → { refresh(), pause(), talk() }
+ * chatCall is the Dock's call to OpenVibe.Chat with the person's Network token; active() says whether the Vibe tab is
+ * the one showing; styles() is the Dock's own <style> (this file adds its rules there, so a popped-out Dock has them).
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const MIC = '<rect x="9" y="3.5" width="6" height="10.5" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3"/>';
+ const CSS = `
+ .ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
+ .ovdk-away { margin-left: auto; font-size: 12.5px; font-weight: 650; color: var(--accent, #3b82f6); text-decoration: none; white-space: nowrap; }
+ .ovdk-away:hover { text-decoration: underline; }
+ .ovdk-msg.is-vibe .ovdk-text a { color: var(--accent, #3b82f6); text-decoration: underline; word-break: break-all; }
+ .ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }
+ .ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }
+ .ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }
+ .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
+ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }
+ .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
+ .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
+ .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
+ .ovdk-mic { width: 40px; height: 40px; flex: none; margin-left: -8px; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
+ .ovdk-mic:hover { color: var(--text-primary, #ececf1); }
+ .ovdk-mic.is-on { color: var(--on-accent, #fff); background: var(--live-red, #e5484d); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #e5484d) 28%, transparent); }
+ @media (prefers-reduced-motion: reduce) { .ovdk-typing i { animation: none; } }
+`;
+ let ctx = null, G = {};
+ const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
+
+ // ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice
+ // feature to tell it to set a reminder… have a conversation with your Vibe AI") ──
+ // 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.
+ // 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 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');
+ String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {
+ if (i % 2) { const a = h('a', '', part); a.href = part; a.target = '_blank'; a.rel = 'noopener noreferrer'; span.append(a); } else if (part) span.append(part);
+ });
+ return span;
+ }
+ function vbubble(m, prev) {
+ const mine = m.from === 'you';
+ const li = h('li', `ovdk-msg${mine ? ' is-mine' : ' is-vibe'}${prev && prev.from === m.from ? ' is-cont' : ''}`);
+ if (!mine && !(prev && prev.from === m.from)) li.append(h('span', 'ovdk-who', 'Vibe'));
+ li.append(linkified(m.text));
+ if (m.at) li.title = new Date(m.at).toLocaleString();
+ return li;
+ }
+ async function openVibe() {
+ clearTimeout(vibe.timer);
+ const panel = vibe.panel; if (!panel) return;
+ vibe.msgs = []; vibe.last = 0;
+ panel.textContent = '';
+ const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
+ const title = h('b', '', 'Vibe');
+ const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener';
+ head.append(icon(G.vibe), title, away);
+ const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
+ 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 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) {
+ 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);
+ }
+ form.append(box, go);
+ panel.append(head, scroll, err, form);
+ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
+ box.addEventListener('input', fit);
+ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
+
+ async function send(text) {
+ if (!text || vibe.busy) return;
+ vibe.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
+ const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, timezone }) });
+ if (out.chat) { vibe.chat = out.chat; away.href = out.chat.url; }
+ box.value = ''; fit();
+ const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
+ if (out.message && !vibe.msgs.some((x) => x.id === out.message.id)) { list.append(vbubble(out.message, vibe.msgs[vibe.msgs.length - 1])); vibe.msgs.push(out.message); vibe.last = Math.max(vibe.last, out.message.id || 0); }
+ vibe.waitUntil = Date.now() + 90000;
+ list.append(typing);
+ scroll.scrollTop = scroll.scrollHeight;
+ clearTimeout(vibe.timer); vibe.timer = setTimeout(() => pull(), 1200);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'That is a lot of questions at once: wait a minute, then ask again.' : x.status === 401 ? 'Sign in again to talk to Vibe.' : 'That did not reach Vibe. Try again.';
+ err.hidden = false;
+ } finally { vibe.busy = false; go.disabled = false; box.focus(); }
+ }
+ form.addEventListener('submit', (e) => { e.preventDefault(); send(box.value.trim()); });
+
+ async function pull(first) {
+ let got;
+ try {
+ got = await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
+ } catch (e) {
+ if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 401 ? 'Sign in to OpenVibe.Chat to talk to Vibe.' : 'Vibe did not answer just now. Try again in a moment.')); }
+ return;
+ }
+ if (!ctx.active() || vibe.panel !== panel) return;
+ if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; }
+ if (first) list.textContent = '';
+ const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
+ let answered = false;
+ (got.messages || []).forEach((m) => {
+ if (vibe.msgs.some((x) => x.id === m.id)) return;
+ list.insertBefore(vbubble(m, vibe.msgs[vibe.msgs.length - 1]), typing.parentNode === list ? typing : null);
+ vibe.msgs.push(m); vibe.last = Math.max(vibe.last, Number(m.id) || 0);
+ if (m.from === 'vibe') answered = true;
+ });
+ 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.'));
+ 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);
+ list.append(start);
+ }
+ if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;
+ clearTimeout(vibe.timer);
+ if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);
+ }
+
+ // The mic: hold to talk (or a click to start and another to stop), Alt+V from anywhere while the Dock is open.
+ if (mic) {
+ let said = '', heard = false;
+ const stop = () => { if (vibe.rec) { try { vibe.rec.stop(); } catch { /* */ } } };
+ const start = () => {
+ if (vibe.rec) return stop();
+ const r = new SR();
+ r.lang = document.documentElement.lang || navigator.language || 'en-US';
+ r.interimResults = true; r.continuous = false;
+ said = ''; heard = false;
+ r.onresult = (ev) => {
+ let text = '';
+ for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
+ said = text.trim(); heard = true; box.value = said; fit();
+ };
+ r.onerror = (ev) => {
+ if (ev.error === 'not-allowed' || ev.error === 'service-not-allowed') { err.textContent = 'The microphone is off for this site: allow it in the browser to talk to Vibe.'; err.hidden = false; }
+ };
+ r.onend = () => {
+ vibe.rec = null; mic.classList.remove('is-on'); mic.setAttribute('aria-pressed', 'false');
+ if (heard && said) send(said);
+ };
+ vibe.rec = r;
+ mic.classList.add('is-on'); mic.setAttribute('aria-pressed', 'true');
+ try { r.start(); } catch { vibe.rec = null; mic.classList.remove('is-on'); }
+ };
+ let held = 0;
+ mic.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; held = Date.now(); start(); });
+ mic.addEventListener('pointerup', () => { if (held && Date.now() - held > 400) stop(); held = 0; });
+ mic.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(); } });
+ vibe.talk = start;
+ }
+ list.append(h('li', 'ovdk-wait', null));
+ list.firstChild.innerHTML = '<span></span>';
+ await pull(true);
+ setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+
+ function mount(panel, c) {
+ ctx = c || {};
+ G = { mic: MIC, ...(ctx.icons || {}) };
+ const st = typeof ctx.styles === 'function' ? ctx.styles() : null;
+ if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
+ else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
+ vibe.panel = panel;
+ openVibe();
+ return {
+ refresh: () => { vibe.panel = panel; return openVibe(); },
+ pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
+ talk: () => { if (vibe.talk) vibe.talk(); },
+ };
+ }
+ root.OpenVibeDockVibe = { mount };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -38,8 +38,12 @@
@@ -38,8 +38,12 @@
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
+ vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
};
};
- const TABS = [['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
+ // Vibe first (L48 V2): the person's AI, on every site.
+ const TABS = [['vibe', 'Vibe', G.vibe], ['agent', 'Agents', G.spark], ['chats', 'Chats', G.chat], ['activity', 'Activity', G.bell]];
+ // The tabs the Network keeps in the person's Dock state (network.dock-state@1); a newer one stays on this site's copy.
+ const SAVED_TABS = ['agent', 'chats', 'activity'];
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
@@ -409,14 +413,24 @@
@@ -409,14 +413,24 @@
function setTab(id, remember) {
function setTab(id, remember) {
tab = TABS.some(([t]) => t === id) ? id : 'activity';
tab = TABS.some(([t]) => t === id) ? id : 'activity';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'chats') clearTimeout(chats.timer);
+ if (tab !== 'vibe' && vibe.api) vibe.api.pause();
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
- if (remember && state().tab !== tab) save({ tab });
+ if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
fill(tab);
fill(tab);
}
}
async function fill(id) {
async function fill(id) {
const panel = el.querySelector(`[data-panel="${id}"]`);
const panel = el.querySelector(`[data-panel="${id}"]`);
+ if (id === 'vibe') {
+ vibe.panel = panel;
+ if (vibe.api) return vibe.api.refresh();
+ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
+ const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
+ if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
+ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send } });
+ return;
+ }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
@@ -607,6 +621,18 @@
@@ -607,6 +621,18 @@
if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
}
}
+ // ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on
+ // every page for signed-in people; Vibe's conversation and mic are not needed until then). Alt+V talks to Vibe from
+ // any page: it opens the Dock on Vibe and starts listening (only where the browser can listen).
+ const vibe = { panel: null, api: null };
+ const canListen = () => !!(root.SpeechRecognition || root.webkitSpeechRecognition);
+ document.addEventListener('keydown', (e) => {
+ if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
+ e.preventDefault();
+ const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };
+ if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();
+ });
+
// ── The pill ─────────────────────────────────────────────
// ── The pill ─────────────────────────────────────────────
let pill = null, readT = 0, bound = false;
let pill = null, readT = 0, bound = false;
function store(v) {
function store(v) {
@@ -771,7 +797,7 @@
@@ -771,7 +797,7 @@
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
setTab(want, false);
setTab(want, false);
readUnread();
readUnread();
- if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
+ if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
function close() {
function close() {
@@ -22,6 +22,7 @@ const BROWSER = Object.freeze([
@@ -22,6 +22,7 @@ const BROWSER = Object.freeze([
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
+ 'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.73.0",
+ "version": "4.74.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": {
@@ -47,6 +47,7 @@
@@ -47,6 +47,7 @@
"./notification-live": "./notification-live.js",
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
+ "./dock-vibe": "./dock-vibe.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
// never reads as a tap outside the sheet;
// never reads as a tap outside the sheet;
+// • Vibe (L48 V2): the person's Vibe from Chat's /api/vibe, starters before the first message, a question posted and
+// Vibe's answer arriving in place (an https link as a link), the Vibe tab never sent to the Network's state;
// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with
// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with
// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the
// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the
// panel back; the Network is told.
// panel back; the Network is told.
@@ -50,6 +52,9 @@ const NOTES = [
@@ -50,6 +52,9 @@ const NOTES = [
// The stand-in's state for the scenario under way.
// The stand-in's state for the scenario under way.
let scenario = {};
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.
+let vibeChat = null;
+const vibeMsgs = [], asked = [];
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:,">
@@ -94,6 +99,20 @@ const server = http.createServer((req, res) => {
@@ -94,6 +99,20 @@ const server = http.createServer((req, res) => {
.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
.concat(said.map((m, i) => ({ id: 901 + i, username: 'ada', display_name: 'Ada', message: m, created_at: iso(0) })));
return json(200, { messages: all.filter((m) => m.id > after) });
return json(200, { messages: all.filter((m) => m.id > after) });
}
}
+ if (u.pathname === '/api/vibe/chat') {
+ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'Vibe is asked with the person\'s own token');
+ 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/ask' && req.method === 'POST') {
+ const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
+ asked.push(b);
+ vibeChat = vibeChat || { slug: 'v1', name: 'Vibe', url: 'https://openvibe.chat/vibe/v1' };
+ const mine = { id: 500 + vibeMsgs.length, from: 'you', text: b.message, at: iso(0) };
+ vibeMsgs.push(mine);
+ setTimeout(() => vibeMsgs.push({ id: 600 + vibeMsgs.length, from: 'vibe', text: 'Reminder set: Stretch, every 2 hours. See them at https://openvibe.today/reminders', at: iso(0) }), 700);
+ return json(201, { chat: vibeChat, message: mine });
+ }
if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });
if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });
if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });
if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });
@@ -298,7 +317,7 @@ async function drag(p, from, to) {
@@ -298,7 +317,7 @@ async function drag(p, from, to) {
await p.evaluate(`${POP}.click()`);
await p.evaluate(`${POP}.click()`);
await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');
- assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 3, 'the same three tabs');
+ assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 4, 'the same four tabs');
assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");
assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");
assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');
assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');
const inWindow = await until(p, PILL, 'the pill stays');
const inWindow = await until(p, PILL, 'the pill stays');
@@ -315,6 +334,32 @@ async function drag(p, from, to) {
@@ -315,6 +334,32 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the tab stays off the Network's state. ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };
+ puts.length = 0;
+ const p = await fresh(1280, 860);
+ 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, CARD, 'the card');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab');
+ await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
+ await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');
+ await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
+ await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
+ assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');
+ assert.ok(asked[0].timezone, 'with the person\'s time zone, for "at 9"');
+ const answer = await until(p, "(() => { const m = document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-vibe:not(.ovdk-typing)'); return m ? { text: m.innerText, link: (m.querySelector('a') || {}).href || null } : null; })()", 'Vibe\'s answer', 6000);
+ assert.match(answer.text, /Reminder set: Stretch, every 2 hours/);
+ assert.strictEqual(answer.link, 'https://openvibe.today/reminders', 'an https link is a link');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat');
+ await wait(900);
+ assert.ok(!puts.some((b) => b.tab === 'vibe'), `the Network's Dock state never gets a tab it does not know (${JSON.stringify(puts)})`);
+ await p.close();
+ }
+
// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
{
{
scenario = { missing: true };
scenario = { missing: true };
@@ -331,7 +376,7 @@ async function drag(p, from, to) {
@@ -331,7 +376,7 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
await p.close();
}
}
- console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
+ console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -12,8 +12,10 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -12,8 +12,10 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget was that
// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget was that
// plus 1.5 KB. 4.56.0 adds the async token (the extension refreshes it) and the read-only scope controls; the
// plus 1.5 KB. 4.56.0 adds the async token (the extension refreshes it) and the read-only scope controls; the
// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.
// budget is 14.0 KB, a 0.25 KB increase, so both still have to stay inside it.
-const DOCK_BROTLI_BUDGET = 14336; // 14.00 KB (14219 after 4.56.0 + 117 bytes of headroom)
-const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];
+// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation
+// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.
+const DOCK_BROTLI_BUDGET = 14848; // 14.50 KB (14757 after 4.74.0 + 91 bytes of headroom)
+const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{