openvibers/openvibe.shared Commits
4.200.0: the button becomes its tab; Vibe holds Chat and Agents; every site from the site's name (#436)
Changes
@@@ -1,5 +1,38 @@# Changelog++## 4.200.0 — 2026-11-10++++**The button becomes its tab; Vibe holds Chat and Agents; every site from the site's name.** From the owner's notes of++2026-11-10 (asks 334 to 338).++++- **The button becomes its tab (`search.js`, `navbar.js`).** "When you click this button, I want it to animate into the++ tab it becomes. Same with the other buttons." The bar's button that was pressed (the menu button, the picture; the++ bell once it opens You) flies to its tab's icon in the menu and turns into it on the way: its own picture fades out as++ the tab's fades in, along one short arc, and the tab settles with a small give. The bell's count rides along to the++ tab's count. Closing flies it back into the bar. When a button flies, the sheet's close X is simply there (the menu++ button no longer travels to it). Motion Off skips it; Reduced plays it short and straight. A flying button is hidden++ by opacity, so a quick second press still lands on it. `OpenVibeSearch.open({ from })` takes the pressed element.++- **Vibe, with Chat and Agents (`search.js`, `dock-vibe.js`).** The menu's "AI" place is called Vibe; its two-part++ switch is Chat · Agents (your chats with Vibe, and your agents). The list loses its "Vibe conversations" heading and++ the large "New conversation" button: a + icon (new chat) and an ↗ icon (open in OpenVibe.Chat) stand in for them.++- **Chat, where it was Messages (`search.js`, `dock.js`).** The menu's Messages place is called Chat (on++ openvibe.chat itself, whose own tab is already "Chat", it keeps "Messages" so the row never shows one word twice). An++ ↗ icon opens OpenVibe.Chat over your conversations and in a thread; the footer that said the same in words is gone.++ The "AI chats" row among your conversations is "Vibe". The Dock's own tab is "Chat" too.++- **A place's icons in the search pill (`search.js`).** Inside the menu, a place's icons (+ and ↗) sit in the search++ pill, left of the close button, so they cost no row; the floating Dock keeps them in a slim row of their own. The++ menu mirrors the panel's buttons and each mirror presses the panel's own.++- **Every site from the site's name (`ov-drop.js`, `navbar.js`, `search.js`).** The dropdown under a site's name ends++ with "All OpenVibe sites", which opens the menu scrolled past the site's own pages to every site. The scroll had never++ run: the menu read the tab after "sites" had become "here".++- **The site's name, highlighted (`navbar.js`, ask 342).** The name that opens its pages gets its highlight as a spread++ shadow with room around it (no added width, so the name still reads whole at 360 px): a faint tint on hover; open, a++ deeper tint with a hairline ring that comes up on a small spring. Motion Off keeps it still (the theme loader's rule).++- **A quick close and reopen keeps the menu open (`search.js`).** Closing the menu steps back in history; that step's++ event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.++- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loaded++ Dock; the Vibe-only button rules stay in `dock-vibe.js`.++## 4.199.0 — 2026-11-10**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.199.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.200.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.199.0",++ "version": "4.200.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)",@@@ -9,11 +9,15 @@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 PLUS = '<path d="M12 5.5v13M5.5 12h13"/>';++ const OUT = '<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>';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-ib.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-ib.is-new:hover, .ovdk-ib.is-new:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }-- .ovdk-vibe-list { list-style: none; padding: 6px; margin: 0; display: grid; gap: 3px; }-- .ovdk-vibe-list button { width: 100%; min-height: 60px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }++ .ovdk-vibe-list { list-style: none; padding: 2px 6px 6px; margin: 0; display: grid; gap: 2px; }++ .ovdk-vibe-list button { width: 100%; min-height: 56px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }.ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }.ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }.ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }@@@ -21,11 +25,7 @@.ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }.ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }-- .ovdk-vibe-list .ovdk-vibe-new { display: flex; gap: 8px; justify-content: center; min-height: 44px; margin: 2px 0 6px; border-radius: 999px; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); font-weight: 750; font-size: 14px; }-- .ovdk-vibe-list .ovdk-vibe-new:hover, .ovdk-vibe-list .ovdk-vibe-new:focus-visible { background: var(--accent-strong, var(--accent, #2563eb)); }.ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }-- .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; }@@@ -56,6 +56,10 @@`;let ctx = null, G = {};const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };++ /** A round icon button with its name as label and tooltip. */++ const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };++ /** Open Vibe in OpenVibe.Chat, as an icon (the thread's own address when one is open). */++ const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', OUT, 'Open in Chat'); a.href = 'https://openvibe.chat/vibe'; a.target = '_blank'; a.rel = 'noopener'; return a; };// ── 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") ──@@@ -133,16 +137,18 @@clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;const panel = vibe.panel; if (!panel) return;panel.textContent = '';-- const head = h('div', 'ovdk-thread-h ovdk-vibe-h'); head.append(icon(G.vibe), h('b', '', 'Vibe conversations'));-- const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener'; head.append(away);++ // No heading (ask 335, the owner: "remove the text … new conversation … an icon plus button … open in Chat a++ // little button icon"): one slim row of two icon buttons over your chats.++ const head = h('div', 'ovdk-bar');++ const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', PLUS, 'New chat'); newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });++ head.append(newer, awayLink());const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');-- const newer = h('button', 'ovdk-vibe-new', 'New conversation'); newer.type = 'button'; newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });-- const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);++ scroll.append(list); panel.append(head, scroll);try {const out = await ctx.chatCall('/api/vibe/conversations');if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return;const conversations = Array.isArray(out.conversations) ? out.conversations : [];-- if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));++ if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your chats with Vibe will appear here. Press + to start one.'));conversations.forEach((c) => {const li = h('li'), b = h('button'); b.type = 'button';const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));@@@ -168,8 +174,8 @@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';-- const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to Vibe conversations'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });++ const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });++ const away = awayLink();head.append(back, 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);@@@ -28,7 +28,7 @@};// Vibe first (L48 V2): the person's AI, on every site.const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row-- const TABS = [['vibe', 'Vibe', G.spark], ['agent', 'Agents', ROBOT], ['chats', 'Messages', G.chat], ['activity', 'Activity', G.bell]];++ const TABS = [['vibe', 'Vibe', G.spark], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat], ['activity', 'Activity', G.bell]];// The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a// newer one stays on this site's copy until the Network learns it.const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];@@@ -88,7 +88,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': 'eb2b2a51ed49', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };++ const DOCK_REV = { 'dock-vibe.js': '4eb6d94db445', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -233,6 +233,11 @@.ovdk-muted { color: var(--text-muted, #8b8b96); }.ovdk-i { display: inline-grid; place-items: center; }.ovdk-i svg { width: 18px; height: 18px; }++ .ovdk-bar { flex: none; display: flex; justify-content: flex-end; gap: 4px; padding: 4px 8px 0; }++ .ovdk-ib { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; text-decoration: none; }++ .ovdk-ib svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }++ .ovdk-ib:hover, .ovdk-ib:focus-visible { background: var(--hover-bg, rgba(127,127,127,.12)); color: var(--text-primary, #ececf1); }++ .ovdk-thread-h .ovdk-ib { margin-left: auto; }.ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }.ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }@@@ -508,7 +513,7 @@n('agent', agent, c.waiting ? 'waiting' : '');n('activity', c.unread || 0);n('chats', chats.unread || 0);-- el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Messages, ${chats.unread} unread` : 'Messages');++ el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chat, ${chats.unread} unread` : 'Chat');el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');}@@@ -610,6 +615,7 @@const canWriteChat = () => !scopes() || scopes().includes('chat.write');const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };++ const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };async function chatCall(path, init = {}) {const t = await tokenOf();const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };@@@ -648,12 +654,6 @@}const others = (c) => (c.participants || []).filter((p) => p.username !== me());const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';-- function chatFoot(panel) {-- const foot = h('div', 'ovdk-foot');-- const a = h('a', '', 'Open Chat →'); a.href = `${chatBase()}/messages`; a.target = '_blank'; a.rel = 'noopener';-- foot.append(h('span', 'ovdk-muted', 'Your conversations and DMs'), a);-- panel.append(foot);-- }async function chatList() {clearTimeout(chats.timer);chats.open = null;@@@ -669,6 +669,8 @@if (panel !== chats.panel || !panelActive('chats') || chats.open) return;panel.textContent = '';if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));++ // Open in Chat, as an icon over your conversations (ask 336).++ const bar = h('div', 'ovdk-bar'); bar.append(toChat('https://openvibe.chat/messages')); panel.append(bar);const scroll = h('div', 'ovdk-scroll');if (!chats.list.length && !chats.vibeSummary) {const none = h('div', 'ovdk-none');@@@ -679,11 +681,11 @@[...chats.list, ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {if (c.kind === 'vibe') {const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';-- const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'AI chats'), h('span', 'ovdk-chat-p', `${c.count} conversation${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));++ const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'Vibe'), h('span', 'ovdk-chat-p', `${c.count} chat${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));-- b.append(av, body, meta); b.setAttribute('aria-label', `AI chats, ${c.count} conversations${at ? `, ${answered(at)}` : ''}`);++ b.append(av, body, meta); b.setAttribute('aria-label', `Vibe, ${c.count} chats${at ? `, ${answered(at)}` : ''}`);b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });li.append(b); ul.append(li); return;}@@@ -705,7 +707,6 @@scroll.append(ul);}panel.append(scroll);-- chatFoot(panel);}function bubble(m, prev) {const mine = m.username === me();@@@ -730,7 +731,7 @@const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));back.addEventListener('click', () => { save({ chat: null }); chatList(); });const title = h('b', '', conv ? titleOf(conv) : 'Conversation');-- head.append(back, title);++ head.append(back, title, toChat('https://openvibe.chat/messages'));// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');@@@ -75,7 +75,10 @@.openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; }.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; }.openvibe-navbar .openvibe-navbar-brand .name a.has-drop::after { content: ""; flex: none; display: inline-block; width: 5px; height: 5px; margin: 0 2px 3px 6px; border: solid currentColor; border-width: 0 1.8px 1.8px 0; transform: rotate(45deg); opacity: .65; transition: transform .2s; }-- .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }++ .openvibe-navbar .openvibe-navbar-brand .name { overflow-clip-margin: 8px; }++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop { border-radius: 8px; transition: color .15s, background .15s, box-shadow .26s cubic-bezier(.2,1.4,.3,1); }++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop:hover { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t); }++ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 17%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t), 0 0 0 7px color-mix(in srgb, var(--accent, #3b82f6) 32%, transparent); }.openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); }.openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; }.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }@@@ -562,14 +565,14 @@const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '496bc3134c08', 'dock.js': 'c606aca212ab', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '0bd4b26af88a', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b14cdbd6a82f', 'dock.js': '2eafa431ce28', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '0bd4b26af88a', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';let _lazyWidth = null;let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable-- // "Ask Vibe": the page's selected text (≤1000) and https address (no hash, ≤400) into a new chat with Vibe (openvibe.chat).++ // "Ask Vibe": the selected text (≤1000) and the page's address (≤400) into a new chat with Vibe.function selectedText() { try { const s = root.getSelection(); return s && !s.isCollapsed ? String(s).replace(/\s+/g, ' ').trim().slice(0, 1000) : ''; } catch { return ''; } }function askVibeHref(t) {const c = 'https://openvibe.chat/vibe', u = new URL(location.href); u.hash = '';@@@ -1183,7 +1186,7 @@closeDrawer();});nav.querySelectorAll('.ovnav-dd').forEach((dd) => regPanel(dd, 'nav-dropdown', () => dd.classList.remove('open'), dd.querySelector(':scope > a')));-- // Dropdowns hang below the links row, which scrolls sideways and would clip them: fixed, under the link (less a filtered bar's offset).++ // Dropdowns are fixed under their link: the links row scrolls sideways and would clip them.const placeDd = (dd) => {const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;let cb = dd.parentElement, o = { top: 0, left: 0 };@@@ -2229,7 +2232,7 @@const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }const orbs = nav.querySelectorAll ? [...nav.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];-- openSearch('', 'you', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);++ openSearch('', 'you', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null, avatarBtn);});@@@ -2312,7 +2315,7 @@if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);}/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */-- function openSearch(q, tab, fromEl) {++ function openSearch(q, tab, fromEl, flyEl) {// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));// The menu drops from the button that opened it (the orb, or the burger), under the bar.@@@ -2326,7 +2329,7 @@if (orb) orb.classList.remove('is-busy');if (!root.OpenVibeSearch) return;if (_navEl) _navEl.classList.remove('ovnav-hide');-- root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,++ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: flyEl || fromEl || null,frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },@@@ -2372,7 +2375,8 @@a.addEventListener('click', (e) => {if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;e.preventDefault();-- const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon);++ const orb = () => [...nav.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')].find((o) => o.getBoundingClientRect().width > 0) || null;++ const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon, _config.search === false ? null : { label: 'All OpenVibe sites', onClick: () => openSearch('', 'sites', orb()) });if (root.OVDrop) return go();if (document.getElementById('ov-drop-loader')) return;const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);@@@ -12,7 +12,8 @@opts: { label (what the list is, said to a screen reader and shown small on top),align: 'start' | 'end', minWidth, onClose }OVDrop.attach(button, items, opts) the button opens and closes it, with aria-expanded kept true to what is shown-- OVDrop.pages(anchor, links, icon) a site's pages from its bar links, Home first, the present page marked++ OVDrop.pages(anchor, links, icon, more) a site's pages from its bar links, Home first, the present page marked;++ more: { label, onClick } ends it with a way out (the navbar's: every OpenVibe site)OVDrop.close() close the one that is open */(function (root) {'use strict';@@@ -132,7 +133,8 @@* A site's pages, from the bar's links (the navbar calls this when the site's name is pressed): Home first, the* page you are on marked. links: [{ label, href, icon, hidden }]; icon(name) gives an icon's SVG text.*/-- function pages(anchor, links, icon) {++ const GRID = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"><rect x="4" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.6"/></svg>';++ function pages(anchor, links, icon, more) {const path = (h) => String(h).split(/[?#]/)[0].replace(/\/+$/, '') || '/';const list = (links || []).filter((l) => l && l.label && l.href && !l.hidden && !/^https?:/i.test(l.href));const at = path(root.location.pathname);@@@ -141,6 +143,9 @@const draw = (name) => { try { return typeof icon === 'function' ? icon(name) : ''; } catch { return ''; } };const items = list.map((l, i) => ({ label: l.label, href: l.href, icon: draw(l.icon || 'fa-arrow-right'), current: i === best }));if (!list.some((l) => path(l.href) === '/')) items.unshift({ label: 'Home', href: anchor.getAttribute('href') || '/', icon: draw('fa-house'), current: at === '/' });++ // The way to every site (ask 338, the owner: "a button … to view all sites … past the list of pages … to the list++ // of actual domains"): one row under a hairline.++ if (more && typeof more.onClick === 'function') items.push(null, { label: more.label || 'All OpenVibe sites', icon: GRID, onClick: more.onClick });return open(anchor, items, { label: `Pages on ${anchor.textContent.trim()}` });}root.OVDrop = { open, close: () => close(), attach, pages, get isOpen() { return !!cur; } };@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.199.0",++ "version": "4.200.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.199.0",++ "version": "4.200.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.199.0",++ "version": "4.200.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -79,6 +79,7 @@let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;// A place of the Dock shown inside the menu (a phone, 312 step 2): which one, its mount, and AI's part.let door = null, doorEl = null, doorHost = null, doorSw = null, doorMount = null, aiPart = 'vibe';++ let actsEl = null, actsObs = null, actsTick = 0;const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };function siteOf(url) {@@@ -191,6 +192,13 @@st.textContent = `.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }++/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */++.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }++.ovs-fly-part { position: absolute; display: grid; place-items: center; color: var(--ovk-text, var(--text-primary, #e8ecf4)); will-change: transform, opacity; }++.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }++.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }++.ovs-fly-part--count { width: auto !important; height: auto !important; }++.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }@@@ -275,6 +283,13 @@.ovs-door { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }.ovs-door[hidden] { display: none; }.ovs-door-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }++/* A place's actions in the search pill (4.200.0); the panel's own row of them is hidden inside the menu. */++.ovs-acts { display: flex; flex: none; gap: 2px; }++.ovs-acts:empty, .ovs-door-host .ovdk-bar { display: none; }++.ovs-act { width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++.ovs-act svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }++.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }++.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }.ovs-door-sw { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin: 8px 12px 4px; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }.ovs-door-sw[hidden] { display: none; }@@@ -451,7 +466,7 @@el.setAttribute('role', 'dialog');el.setAttribute('aria-modal', 'true');el.setAttribute('aria-label', 'Search and every OpenVibe site');-- el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`++ el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><div class="ovs-acts"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`+ '<div class="ovs-tabs ovs-starttabs" hidden></div>'+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';@@@ -459,13 +474,15 @@ghost = el.querySelector('.ovs-ghost');listEl = el.querySelector('.ovs-list');doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;-- doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', 'AI');-- doorSw.innerHTML = [['vibe', 'Vibe'], ['agent', 'Agents']].map(([id, label]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[id]}</span><span>${label}</span></button>`).join('');++ doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', 'Vibe');++ // Vibe holds two things (ask 335, the owner: "under Vibe is Chat and Agents"): your chats with it, and your agents.++ doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</span><span>${label}</span></button>`).join('');doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';doorEl.append(doorSw, doorHost);listEl.after(doorEl);tabsEl = el.querySelector('.ovs-tabs');++ actsEl = el.querySelector('.ovs-acts');rtabsEl = el.querySelector('.ovs-rtabs');rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });@@@ -1035,6 +1052,32 @@try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');}++ // A place's own actions (+ for a new chat, ↗ to open it in Chat) sit in the search pill, left of the close orb, so++ // they cost no row (asks 335, 336: "an icon plus button that looks nice and integrated", "open in Chat … a button++ // icon"). The Dock's panel draws them in its slim row; inside the menu that row is hidden and mirrored here, and++ // each mirror presses the panel's own button (an address opens from the person's own tap).++ function mirrorActs() {++ actsTick = 0;++ if (!actsEl) return;++ actsEl.textContent = '';++ if (!door || !doorHost) return;++ doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib').forEach((orig) => {++ const b = document.createElement('button'); b.type = 'button';++ b.className = 'ovs-act' + (orig.classList.contains('is-new') ? ' is-new' : '');++ const label = orig.getAttribute('aria-label') || ''; b.setAttribute('aria-label', label); b.title = label;++ b.innerHTML = orig.innerHTML;++ b.addEventListener('click', (e) => { e.stopPropagation(); orig.click(); });++ actsEl.appendChild(b);++ });++ }++ function watchActs(on) {++ if (actsObs) { actsObs.disconnect(); actsObs = null; }++ if (on && doorHost && typeof MutationObserver === 'function') {++ actsObs = new MutationObserver(() => { if (!actsTick) actsTick = requestAnimationFrame(mirrorActs); });++ actsObs.observe(doorHost, { childList: true, subtree: true });++ }++ mirrorActs();++ }/** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */function openDoor(id) {if (!doorEl || !inSheet()) return;@@@ -1045,7 +1088,8 @@listEl.hidden = true; doorEl.hidden = false; el.classList.add('ovs-in-door');try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: want }); else doorMount.show(want); }catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }-- input.placeholder = id === 'chats' ? 'Search your messages' : `Search ${here()}, or ask Vibe`;++ watchActs(true);++ input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;paintTabs();}/** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */@@@ -1053,6 +1097,7 @@if (!door) return;door = null;if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }++ watchActs(false);if (doorEl) doorEl.hidden = true;if (listEl) listEl.hidden = false;if (el) el.classList.remove('ovs-in-door');@@@ -1071,7 +1116,7 @@try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }// One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and// AI are the Dock's; they are in this row, between Feed and You, where the bar can open the Dock for this person.-- const doors = typeof ctx.dock === 'function' ? [['chats', 'Messages'], ['vibe', 'AI']] : [];++ const doors = typeof ctx.dock === 'function' ? [['chats', ctx.service === 'chat' ? 'Messages' : 'Chat'], ['vibe', 'Vibe']] : [];const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];tabsEl.classList.toggle('has-doors', doors.length > 0);for (const [id, label, isDoor] of row) {@@@ -1110,6 +1155,7 @@const BRIDGE_SVG = {vibe: bsvg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),agent: bsvg('<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13v.01M14.5 13v.01M3 12.5v2M21 12.5v2"/>'),++ talk: bsvg('<path d="M4.6 11.6a7.4 6.4 0 1 1 3.3 5.4l-3.4 1.3 1-3.1a6 6 0 0 1-.9-3.6z"/><path d="M9 11.6h.01M12 11.6h.01M15 11.6h.01"/>'),chats: bsvg('<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7L8 20v-3.5H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5z"/>'),activity: bsvg('<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>'),};@@@ -1503,11 +1549,82 @@try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }}++ // ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the++ // tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications++ // tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's++ // icon and turns into it on the way (its own picture fades out as the tab's fades in, along one short arc); the++ // bell's count flies with it to the tab's count. Closing flies it back. Motion Off skips it; Reduced plays it short++ // and straight (Calm reduces, only Off removes).++ let flyFrom = null;++ const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';++ function tabFace(id) {++ const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);++ return b ? (b.querySelector('.ovs-tab-ic') || b) : null;++ }++ /** What the eye sees of a button: its picture or its glyph, without ids or handlers. */++ function faceOf(node) {++ const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;++ const g = pic.cloneNode(true);++ if (g.removeAttribute) g.removeAttribute('id');++ if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));++ return g;++ }++ const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);++ function fly(fromEl, toEl, { back = false } = {}) {++ const level = motionLevel();++ if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;++ const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();++ if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;++ const short = level === 'reduced';++ const D = short ? 180 : back ? 300 : 420, E = short ? 'ease-out' : 'cubic-bezier(.45,0,.2,1)';++ const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');++ const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };++ const src = part(faceOf(fromEl), A), dst = part(faceOf(toEl), B), riders = [];++ document.body.appendChild(layer);++ const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;++ const lift = short ? 0 : Math.min(36, Math.hypot(dx, dy) * .12);++ const at = (k, sc) => `translate(${dx * k}px, ${dy * k - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;++ const back1 = (k, sc) => `translate(${-dx * (1 - k)}px, ${-dy * (1 - k) - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;++ src.animate([{ transform: 'none', opacity: 1 }, { transform: at(.5, (1 + s) / 2), opacity: .85, offset: .5 }, { transform: at(1, s), opacity: 0 }], { duration: D, easing: E, fill: 'forwards' });++ const end = dst.animate([{ transform: back1(0, 1 / s), opacity: 0 }, { transform: back1(.5, (1 + 1 / s) / 2), opacity: .5, offset: .5 }, { transform: 'none', opacity: 1 }], { duration: D, easing: E, fill: 'forwards' });++ // The count rides along (the bell's red count to the tab's, and back), when both are showing.++ const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);++ const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));++ if (c1 && c2) {++ const R1 = c1.getBoundingClientRect(), R2 = c2.getBoundingClientRect();++ const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();++ const box = part(pill, R1, ' ovs-fly-part--count');++ const mx = R2.left - R1.left, my = R2.top - R1.top;++ box.animate([{ transform: 'none' }, { transform: `translate(${mx / 2}px, ${my / 2 - lift}px)`, offset: .5 }, { transform: `translate(${mx}px, ${my}px)` }], { duration: D, easing: E, fill: 'forwards' });++ c1.style.opacity = c2.style.opacity = '0';++ riders.push(c1, c2);++ }++ const hidden = [fromEl, toEl, ...riders];++ hidden.forEach((n) => { n.style.opacity = '0'; }); // opacity, not visibility: a button still takes a press while it flies++ const done = () => {++ hidden.forEach((n) => { n.style.opacity = ''; });++ layer.remove();++ const tabBtn = !back && toEl.closest && toEl.closest('.ovs-tab');++ if (tabBtn && !short) tabBtn.animate([{ transform: 'scale(.92)' }, { transform: 'scale(1.03)', offset: .6 }, { transform: 'none' }], { duration: 200, easing: 'ease-out' });++ };++ end.finished.then(done, done);++ }++ /** After the menu has painted its tabs: the sheet's own entrance is finished at once (the flight is the motion). */++ function flyIn() {++ const from = flyFrom;++ if (!from || !el) return;++ const to = tabFace(door || tab);++ if (!to) return;++ try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }++ fly(from, to);++ }++function open(opts = {}) {homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opensif (typeof document === 'undefined') return;ctx = opts || {};if (!el) build();++ flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;lastFocus = document.activeElement;// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's// tab, whose second half is every site; "tools" is You); else this site.@@@ -1537,15 +1654,19 @@if (orbEl) {orbEl.classList.remove('is-x');if (phoneSheet()) {-- // From the button's centre to the pill's right end: the same orb, moving into the menu.++ // From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed++ // button flies into its tab instead, the orb is simply there as the close X.const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);-- orbEl.classList.add('is-from');-- void orbEl.offsetWidth;-- requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });++ if (flyFrom) orbEl.classList.add('is-x');++ else {++ orbEl.classList.add('is-from');++ void orbEl.offsetWidth;++ requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });++ }}}if (root.OVSurfaces) {@@@ -1557,6 +1678,7 @@input.value = opts.q || '';paintGhost();render(input.value);++ if (flyFrom && !input.value) requestAnimationFrame(flyIn);// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);@@@ -1566,17 +1688,23 @@if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });-- if (tab === 'tools' || tab === 'sites') setTimeout(() => { const t = listEl.querySelector(`[data-sec="${tab === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);++ const jump = opts.tab === 'tools' || opts.tab === 'sites' ? opts.tab : '';++ if (jump) setTimeout(() => { const t = listEl.querySelector(`[data-sec="${jump === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);}-- let pushed = false, popping = false, surface = null;-- if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });++ // ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened++ // again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).++ let pushed = false, popping = false, surface = null, ownBack = false;++ if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });function close({ navigating = false } = {}) {++ // The tab flies back into the bar button that opened it (not when a link was followed: the page is going).++ const backTo = flyFrom; flyFrom = null;++ if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true });if (view === 'results' && rtabsEl) leaveResults();leaveDoor();if (!el || closing) return;if (navigating) pushed = false;// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.-- if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) history.back(); } catch { /* */ } }++ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.if (phoneSheet() && !calm() && el.classList.contains('is-open')) {el.classList.add('is-closing');@@@ -97,7 +97,7 @@ assert.ok(!vibe.includes('box.focus()') || /touch|coarse|pointer/.test(vibe), 'tassert.ok(dock.includes("load('dock-runs.js'"), 'the Dock loads the Agents tab');assert.ok(dock.includes("load('ai-marks-client.js'"), 'and the maker marks with it');assert.ok(dock.includes('ovdk-runs-foot'), 'the Agents tab has its own foot');--assert.ok(dock.includes('AI chats'), "the Chats tab has the AI chats row");++assert.ok(dock.includes("h('b', '', 'Vibe')"), 'the Chat tab has the Vibe row (your chats with Vibe)');assert.ok(dock.includes('vibeOnList'), 'and it opens the Vibe tab on its list');assert.ok(dock.includes('groupAvatar'), "a group's row shows faces, not a number sign");assert.ok(dock.includes('answered'), 'and when Vibe last answered');@@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// time something is sent, not on every page.// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer// runs or its menu opens; the header's tab rail and narrow-width rules came with it.--const DOCK_BROTLI_BUDGET = 20787; // 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)++const DOCK_BROTLI_BUDGET = 20940; // 20.45 KB with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];// ── the budget ──────────────────────────────────────────────@@@ -39,15 +39,24 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maassert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places`);await wait(350);const row = await page.evaluate(ROW);-- assert.deepEqual(row.map((t) => t.label + (t.chosen ? '*' : '') + (t.door ? `>${t.door}` : '')), ['Chat*', 'Feed', 'Messages>chats', 'AI>vibe', 'You'], `${name}: this site, Feed, Messages, AI, You, in that order`);++ assert.deepEqual(row.map((t) => t.label + (t.chosen ? '*' : '') + (t.door ? `>${t.door}` : '')), ['Chat*', 'Feed', 'Messages>chats', 'Vibe>vibe', 'You'], `${name}: this site, Feed, Messages (Chat's own site keeps the word: its tab is Chat), Vibe, You, in that order`);++ if (width <= 640) {++ // On another site the door is called Chat (ask 336).++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);++ await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));++ assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);++ assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`);++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);++ await page.evaluate(OPEN(true)); await wait(350);++ }assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);-- // AI opens the Dock on Vibe; Messages on the conversations; neither becomes the chosen tab.++ // Vibe opens the Dock on its chats; Messages on the conversations; neither becomes the chosen tab.await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);-- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI opens the Dock's Vibe`);++ assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);@@@ -66,10 +75,10 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).machosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);if (width <= 640) {-- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Vibe* Agents', chosen: 'AI' }, `${name}: AI opens inside the menu, on Vibe`);++ assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`);assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);-- assert.equal((await inside()).sw, 'Vibe Agents*', `${name}: the switch goes to Agents`);++ assert.equal((await inside()).sw, 'Chat Agents*', `${name}: the switch goes to Agents`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);@@@ -77,10 +77,16 @@ const server = http.createServer((req, res) => {// Work together: a new workspace room on OpenVibe.Chat (a team and Vibe, a work queue, a shared screen).assert.strictEqual(await p.evaluate(`document.querySelector('[data-ov-work]').href`), 'https://openvibe.chat/work');++ // The menu loads on the first press (search.js): each press waits for the state it asked for, so a second++ // press never lands before the first has opened the menu (2026-11-10: a press 20 ms after the first was++ // swallowed while the script loaded, and the third press then closed the menu).++ const settle = async (open) => { const end = Date.now() + 6000; while (Date.now() < end) { if (await p.evaluate(`!!document.querySelector('.ovs.is-open') === ${open}`)) return; await new Promise((r) => setTimeout(r, 40)); } };// Nothing selected: the plain console.await press();assert.strictEqual(await askHref(), 'https://openvibe.chat/vibe');++ await settle(true);await press();++ await settle(false);// Text selected on the page, then a real press on the avatar: the selection is the ask (spaces folded).await p.evaluate(`(() => { const r = document.createRange(); r.selectNodeContents(document.getElementById('text')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); })()`);
@@ -1,5 +1,38 @@
@@ -1,5 +1,38 @@
# Changelog
# Changelog
+## 4.200.0 — 2026-11-10
+
+**The button becomes its tab; Vibe holds Chat and Agents; every site from the site's name.** From the owner's notes of
+2026-11-10 (asks 334 to 338).
+
+- **The button becomes its tab (`search.js`, `navbar.js`).** "When you click this button, I want it to animate into the
+ tab it becomes. Same with the other buttons." The bar's button that was pressed (the menu button, the picture; the
+ bell once it opens You) flies to its tab's icon in the menu and turns into it on the way: its own picture fades out as
+ the tab's fades in, along one short arc, and the tab settles with a small give. The bell's count rides along to the
+ tab's count. Closing flies it back into the bar. When a button flies, the sheet's close X is simply there (the menu
+ button no longer travels to it). Motion Off skips it; Reduced plays it short and straight. A flying button is hidden
+ by opacity, so a quick second press still lands on it. `OpenVibeSearch.open({ from })` takes the pressed element.
+- **Vibe, with Chat and Agents (`search.js`, `dock-vibe.js`).** The menu's "AI" place is called Vibe; its two-part
+ switch is Chat · Agents (your chats with Vibe, and your agents). The list loses its "Vibe conversations" heading and
+ the large "New conversation" button: a + icon (new chat) and an ↗ icon (open in OpenVibe.Chat) stand in for them.
+- **Chat, where it was Messages (`search.js`, `dock.js`).** The menu's Messages place is called Chat (on
+ openvibe.chat itself, whose own tab is already "Chat", it keeps "Messages" so the row never shows one word twice). An
+ ↗ icon opens OpenVibe.Chat over your conversations and in a thread; the footer that said the same in words is gone.
+ The "AI chats" row among your conversations is "Vibe". The Dock's own tab is "Chat" too.
+- **A place's icons in the search pill (`search.js`).** Inside the menu, a place's icons (+ and ↗) sit in the search
+ pill, left of the close button, so they cost no row; the floating Dock keeps them in a slim row of their own. The
+ menu mirrors the panel's buttons and each mirror presses the panel's own.
+- **Every site from the site's name (`ov-drop.js`, `navbar.js`, `search.js`).** The dropdown under a site's name ends
+ with "All OpenVibe sites", which opens the menu scrolled past the site's own pages to every site. The scroll had never
+ run: the menu read the tab after "sites" had become "here".
+- **The site's name, highlighted (`navbar.js`, ask 342).** The name that opens its pages gets its highlight as a spread
+ shadow with room around it (no added width, so the name still reads whole at 360 px): a faint tint on hover; open, a
+ deeper tint with a hairline ring that comes up on a small spring. Motion Off keeps it still (the theme loader's rule).
+- **A quick close and reopen keeps the menu open (`search.js`).** Closing the menu steps back in history; that step's
+ event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.
+- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loaded
+ Dock; the Vibe-only button rules stay in `dock-vibe.js`.
+
## 4.199.0 — 2026-11-10
## 4.199.0 — 2026-11-10
**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.199.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.200.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.199.0",
+ "version": "4.200.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)",
@@ -9,11 +9,15 @@
@@ -9,11 +9,15 @@
if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;
if (typeof document === 'undefined' || root.OpenVibeDockVibe) return;
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
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 h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const PLUS = '<path d="M12 5.5v13M5.5 12h13"/>';
+ const OUT = '<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>';
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 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 = `
const CSS = `
+ .ovdk-ib.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-ib.is-new:hover, .ovdk-ib.is-new:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
.ovdk-vibe-h .ovdk-i { color: var(--accent, #3b82f6); margin-left: 6px; }
- .ovdk-vibe-list { list-style: none; padding: 6px; margin: 0; display: grid; gap: 3px; }
- .ovdk-vibe-list button { width: 100%; min-height: 60px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }
+ .ovdk-vibe-list { list-style: none; padding: 2px 6px 6px; margin: 0; display: grid; gap: 2px; }
+ .ovdk-vibe-list button { width: 100%; min-height: 56px; display: grid; grid-template-columns: 36px minmax(0,1fr) auto; grid-template-rows: auto auto; gap: 2px 12px; align-items: center; padding: 9px 10px; border: 0; border-radius: 14px; background: transparent; color: var(--text-primary,#ececf1); text-align: left; cursor: pointer; font: inherit; }
.ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-vibe-list .ovdk-vibe-site { grid-row: 1 / 3; width: 36px; height: 36px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
.ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }
.ovdk-vibe-list .ovdk-vibe-site svg { width: 19px; height: 19px; }
.ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
.ovdk-vibe-list b { font-size: 14px; } .ovdk-vibe-list span { font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
@@ -21,11 +25,7 @@
@@ -21,11 +25,7 @@
.ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }
.ovdk-vibe-list button:hover, .ovdk-vibe-list button:focus-visible { background: var(--hover-bg,rgba(127,127,127,.12)); }
.ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-vibe-list b, .ovdk-vibe-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }
.ovdk-vibe-list small, .ovdk-vibe-list time { color: var(--text-muted,#8b8b96); font-size: 12px; }
- .ovdk-vibe-list .ovdk-vibe-new { display: flex; gap: 8px; justify-content: center; min-height: 44px; margin: 2px 0 6px; border-radius: 999px; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); font-weight: 750; font-size: 14px; }
- .ovdk-vibe-list .ovdk-vibe-new:hover, .ovdk-vibe-list .ovdk-vibe-new:focus-visible { background: var(--accent-strong, var(--accent, #2563eb)); }
.ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
.ovdk-vibe-back { min-width: 44px; min-height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
- .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-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 .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 { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }
@@ -56,6 +56,10 @@
@@ -56,6 +56,10 @@
`;
`;
let ctx = null, G = {};
let ctx = null, G = {};
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; };
+ /** A round icon button with its name as label and tooltip. */
+ const iconButton = (tag, cls, path, label) => { const b = h(tag, cls); if (tag === 'button') b.type = 'button'; b.setAttribute('aria-label', label); b.setAttribute('data-ov-tip', label); b.title = label; b.innerHTML = svg(path); return b; };
+ /** Open Vibe in OpenVibe.Chat, as an icon (the thread's own address when one is open). */
+ const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', OUT, 'Open in Chat'); a.href = 'https://openvibe.chat/vibe'; a.target = '_blank'; a.rel = 'noopener'; return a; };
// ── Vibe (plan L48 V2; the owner, 11-01: "access your agent or the AI… from across all websites… use a voice
// ── 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") ──
// feature to tell it to set a reminder… have a conversation with your Vibe AI") ──
@@ -133,16 +137,18 @@
@@ -133,16 +137,18 @@
clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
const panel = vibe.panel; if (!panel) return;
const panel = vibe.panel; if (!panel) return;
panel.textContent = '';
panel.textContent = '';
- const head = h('div', 'ovdk-thread-h ovdk-vibe-h'); head.append(icon(G.vibe), h('b', '', 'Vibe conversations'));
- const away = h('a', 'ovdk-away', 'Open in Chat'); away.href = 'https://openvibe.chat/vibe'; away.target = '_blank'; away.rel = 'noopener'; head.append(away);
+ // No heading (ask 335, the owner: "remove the text … new conversation … an icon plus button … open in Chat a
+ // little button icon"): one slim row of two icon buttons over your chats.
+ const head = h('div', 'ovdk-bar');
+ const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', PLUS, 'New chat'); newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
+ head.append(newer, awayLink());
const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');
const scroll = h('div', 'ovdk-scroll'); const list = h('ul', 'ovdk-vibe-list');
- const newer = h('button', 'ovdk-vibe-new', 'New conversation'); newer.type = 'button'; newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
- const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);
+ scroll.append(list); panel.append(head, scroll);
try {
try {
const out = await ctx.chatCall('/api/vibe/conversations');
const out = await ctx.chatCall('/api/vibe/conversations');
if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return;
if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return;
const conversations = Array.isArray(out.conversations) ? out.conversations : [];
const conversations = Array.isArray(out.conversations) ? out.conversations : [];
- if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));
+ if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your chats with Vibe will appear here. Press + to start one.'));
conversations.forEach((c) => {
conversations.forEach((c) => {
const li = h('li'), b = h('button'); b.type = 'button';
const li = h('li'), b = h('button'); b.type = 'button';
const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));
const title = h('b', '', c.name || 'Conversation'); const time = h('time', '', age(c.last_message_at));
@@ -168,8 +174,8 @@
@@ -168,8 +174,8 @@
panel.textContent = '';
panel.textContent = '';
const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
const head = h('div', 'ovdk-thread-h ovdk-vibe-h');
const title = h('b', '', 'Vibe');
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';
- const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to Vibe conversations'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });
+ const back = h('button', 'ovdk-vibe-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.textContent = '←'; back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });
+ const away = awayLink();
head.append(back, icon(G.vibe), title, away);
head.append(back, icon(G.vibe), title, away);
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
};
};
// Vibe first (L48 V2): the person's AI, on every site.
// Vibe first (L48 V2): the person's AI, on every site.
const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row
const ROBOT = '<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13h.01M14.5 13h.01M3 12.5v2M21 12.5v2"/>'; // as in the menu's row
- const TABS = [['vibe', 'Vibe', G.spark], ['agent', 'Agents', ROBOT], ['chats', 'Messages', G.chat], ['activity', 'Activity', G.bell]];
+ const TABS = [['vibe', 'Vibe', G.spark], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat], ['activity', 'Activity', G.bell]];
// The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a
// The tabs the Network keeps in the person's Dock state (network.dock-state@1; vibe since contracts 0.270.0); a
// newer one stays on this site's copy until the Network learns it.
// newer one stays on this site's copy until the Network learns it.
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': 'eb2b2a51ed49', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
+ const DOCK_REV = { 'dock-vibe.js': '4eb6d94db445', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -233,6 +233,11 @@
@@ -233,6 +233,11 @@
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i svg { width: 18px; height: 18px; }
.ovdk-i svg { width: 18px; height: 18px; }
+ .ovdk-bar { flex: none; display: flex; justify-content: flex-end; gap: 4px; padding: 4px 8px 0; }
+ .ovdk-ib { flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; text-decoration: none; }
+ .ovdk-ib svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }
+ .ovdk-ib:hover, .ovdk-ib:focus-visible { background: var(--hover-bg, rgba(127,127,127,.12)); color: var(--text-primary, #ececf1); }
+ .ovdk-thread-h .ovdk-ib { margin-left: auto; }
.ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }
.ovdk-chats { list-style: none; margin: 0; padding: 6px; display: grid; gap: 2px; }
.ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;
.ovdk-chat { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 60px; padding: 8px 10px; border: 0; border-radius: 14px;
background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
@@ -508,7 +513,7 @@
@@ -508,7 +513,7 @@
n('agent', agent, c.waiting ? 'waiting' : '');
n('agent', agent, c.waiting ? 'waiting' : '');
n('activity', c.unread || 0);
n('activity', c.unread || 0);
n('chats', chats.unread || 0);
n('chats', chats.unread || 0);
- el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Messages, ${chats.unread} unread` : 'Messages');
+ el.querySelector('#ovdk-tab-chats').setAttribute('aria-label', chats.unread ? `Chat, ${chats.unread} unread` : 'Chat');
el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));
el.querySelector('#ovdk-tab-agent').setAttribute('aria-label', [`Agents`, c.waiting ? `${c.waiting} need${c.waiting === 1 ? 's' : ''} you` : '', c.running ? `${c.running} working` : ''].filter(Boolean).join(', '));
el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');
el.querySelector('#ovdk-tab-activity').setAttribute('aria-label', c.unread ? `Activity, ${c.unread} unread` : 'Activity');
}
}
@@ -610,6 +615,7 @@
@@ -610,6 +615,7 @@
const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
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; };
+ const toChat = (href) => { const a = h('a', 'ovdk-ib ovdk-away'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Open in Chat'; a.setAttribute('aria-label', 'Open in Chat'); a.setAttribute('data-ov-tip', 'Open in Chat'); a.innerHTML = svg('<path d="M13.5 5.5h5v5M18.5 5.5l-7.5 7.5M17 13.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 17.5v-9A1.5 1.5 0 0 1 6.5 7h4"/>'); return a; };
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': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': init.type || 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
@@ -648,12 +654,6 @@
@@ -648,12 +654,6 @@
}
}
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const others = (c) => (c.participants || []).filter((p) => p.username !== me());
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
const titleOf = (c) => c.name || others(c).map((p) => p.display_name || p.username).join(', ') || 'Just you';
- function chatFoot(panel) {
- const foot = h('div', 'ovdk-foot');
- const a = h('a', '', 'Open Chat →'); a.href = `${chatBase()}/messages`; a.target = '_blank'; a.rel = 'noopener';
- foot.append(h('span', 'ovdk-muted', 'Your conversations and DMs'), a);
- panel.append(foot);
- }
async function chatList() {
async function chatList() {
clearTimeout(chats.timer);
clearTimeout(chats.timer);
chats.open = null;
chats.open = null;
@@ -669,6 +669,8 @@
@@ -669,6 +669,8 @@
if (panel !== chats.panel || !panelActive('chats') || chats.open) return;
if (panel !== chats.panel || !panelActive('chats') || chats.open) return;
panel.textContent = '';
panel.textContent = '';
if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
+ // Open in Chat, as an icon over your conversations (ask 336).
+ const bar = h('div', 'ovdk-bar'); bar.append(toChat('https://openvibe.chat/messages')); panel.append(bar);
const scroll = h('div', 'ovdk-scroll');
const scroll = h('div', 'ovdk-scroll');
if (!chats.list.length && !chats.vibeSummary) {
if (!chats.list.length && !chats.vibeSummary) {
const none = h('div', 'ovdk-none');
const none = h('div', 'ovdk-none');
@@ -679,11 +681,11 @@
@@ -679,11 +681,11 @@
[...chats.list, ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {
[...chats.list, ...(chats.vibeSummary ? [chats.vibeSummary] : [])].sort((a, b) => String(b.last_message_at || b.updated_at || '').localeCompare(String(a.last_message_at || a.updated_at || ''))).forEach((c) => {
if (c.kind === 'vibe') {
if (c.kind === 'vibe') {
const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';
const li = h('li'), b = h('button', 'ovdk-chat'); b.type = 'button';
- const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'AI chats'), h('span', 'ovdk-chat-p', `${c.count} conversation${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));
+ const body = h('span', 'ovdk-chat-b'); body.append(h('b', '', 'Vibe'), h('span', 'ovdk-chat-p', `${c.count} chat${c.count === 1 ? '' : 's'}${c.latest && c.latest.name ? ` · ${c.latest.name}` : ''}`));
const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
- b.append(av, body, meta); b.setAttribute('aria-label', `AI chats, ${c.count} conversations${at ? `, ${answered(at)}` : ''}`);
+ b.append(av, body, meta); b.setAttribute('aria-label', `Vibe, ${c.count} chats${at ? `, ${answered(at)}` : ''}`);
b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });
b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });
li.append(b); ul.append(li); return;
li.append(b); ul.append(li); return;
}
}
@@ -705,7 +707,6 @@
@@ -705,7 +707,6 @@
scroll.append(ul);
scroll.append(ul);
}
}
panel.append(scroll);
panel.append(scroll);
- chatFoot(panel);
}
}
function bubble(m, prev) {
function bubble(m, prev) {
const mine = m.username === me();
const mine = m.username === me();
@@ -730,7 +731,7 @@
@@ -730,7 +731,7 @@
const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
const back = h('button', 'ovdk-back'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your chats'); back.append(icon(G.back));
back.addEventListener('click', () => { save({ chat: null }); chatList(); });
back.addEventListener('click', () => { save({ chat: null }); chatList(); });
const title = h('b', '', conv ? titleOf(conv) : 'Conversation');
const title = h('b', '', conv ? titleOf(conv) : 'Conversation');
- head.append(back, title);
+ head.append(back, title, toChat('https://openvibe.chat/messages'));
// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
// Opened from the Dock's state on another page: the list is not here yet, so the title comes with it.
if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });
if (!conv) chatCall('/api/dm/conversations').then((b) => { chats.list = b.conversations || []; const c = chats.list.find((x) => String(x.id) === String(id)); if (c && chats.open === String(id)) title.textContent = titleOf(c); }).catch(() => { /* the title stays */ });
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');
@@ -75,7 +75,10 @@
@@ -75,7 +75,10 @@
.openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; }
.openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; }
.openvibe-navbar .openvibe-navbar-brand .name a.has-drop::after { content: ""; flex: none; display: inline-block; width: 5px; height: 5px; margin: 0 2px 3px 6px; border: solid currentColor; border-width: 0 1.8px 1.8px 0; transform: rotate(45deg); opacity: .65; transition: transform .2s; }
.openvibe-navbar .openvibe-navbar-brand .name a.has-drop::after { content: ""; flex: none; display: inline-block; width: 5px; height: 5px; margin: 0 2px 3px 6px; border: solid currentColor; border-width: 0 1.8px 1.8px 0; transform: rotate(45deg); opacity: .65; transition: transform .2s; }
- .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
+ .openvibe-navbar .openvibe-navbar-brand .name { overflow-clip-margin: 8px; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop { border-radius: 8px; transition: color .15s, background .15s, box-shadow .26s cubic-bezier(.2,1.4,.3,1); }
+ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop:hover { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t); }
+ .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 17%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t), 0 0 0 7px color-mix(in srgb, var(--accent, #3b82f6) 32%, transparent); }
.openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); }
.openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); }
.openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
@@ -562,14 +565,14 @@
@@ -562,14 +565,14 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '496bc3134c08', 'dock.js': 'c606aca212ab', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '0bd4b26af88a', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
+ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b14cdbd6a82f', 'dock.js': '2eafa431ce28', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '0bd4b26af88a', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
let _lazyWidth = null;
let _lazyWidth = null;
let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable
let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable
- // "Ask Vibe": the page's selected text (≤1000) and https address (no hash, ≤400) into a new chat with Vibe (openvibe.chat).
+ // "Ask Vibe": the selected text (≤1000) and the page's address (≤400) into a new chat with Vibe.
function selectedText() { try { const s = root.getSelection(); return s && !s.isCollapsed ? String(s).replace(/\s+/g, ' ').trim().slice(0, 1000) : ''; } catch { return ''; } }
function selectedText() { try { const s = root.getSelection(); return s && !s.isCollapsed ? String(s).replace(/\s+/g, ' ').trim().slice(0, 1000) : ''; } catch { return ''; } }
function askVibeHref(t) {
function askVibeHref(t) {
const c = 'https://openvibe.chat/vibe', u = new URL(location.href); u.hash = '';
const c = 'https://openvibe.chat/vibe', u = new URL(location.href); u.hash = '';
@@ -1183,7 +1186,7 @@
@@ -1183,7 +1186,7 @@
closeDrawer();
closeDrawer();
});
});
nav.querySelectorAll('.ovnav-dd').forEach((dd) => regPanel(dd, 'nav-dropdown', () => dd.classList.remove('open'), dd.querySelector(':scope > a')));
nav.querySelectorAll('.ovnav-dd').forEach((dd) => regPanel(dd, 'nav-dropdown', () => dd.classList.remove('open'), dd.querySelector(':scope > a')));
- // Dropdowns hang below the links row, which scrolls sideways and would clip them: fixed, under the link (less a filtered bar's offset).
+ // Dropdowns are fixed under their link: the links row scrolls sideways and would clip them.
const placeDd = (dd) => {
const placeDd = (dd) => {
const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;
const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return;
let cb = dd.parentElement, o = { top: 0, left: 0 };
let cb = dd.parentElement, o = { top: 0, left: 0 };
@@ -2229,7 +2232,7 @@
@@ -2229,7 +2232,7 @@
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null;
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
const orbs = nav.querySelectorAll ? [...nav.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
const orbs = nav.querySelectorAll ? [...nav.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
- openSearch('', 'you', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);
+ openSearch('', 'you', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null, avatarBtn);
});
});
@@ -2312,7 +2315,7 @@
@@ -2312,7 +2315,7 @@
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
}
}
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
- function openSearch(q, tab, fromEl) {
+ function openSearch(q, tab, fromEl, flyEl) {
// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).
// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).
const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));
const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));
// The menu drops from the button that opened it (the orb, or the burger), under the bar.
// The menu drops from the button that opened it (the orb, or the burger), under the bar.
@@ -2326,7 +2329,7 @@
@@ -2326,7 +2329,7 @@
if (orb) orb.classList.remove('is-busy');
if (orb) orb.classList.remove('is-busy');
if (!root.OpenVibeSearch) return;
if (!root.OpenVibeSearch) return;
if (_navEl) _navEl.classList.remove('ovnav-hide');
if (_navEl) _navEl.classList.remove('ovnav-hide');
- root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
+ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: flyEl || fromEl || null,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
@@ -2372,7 +2375,8 @@
@@ -2372,7 +2375,8 @@
a.addEventListener('click', (e) => {
a.addEventListener('click', (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
e.preventDefault();
- const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon);
+ const orb = () => [...nav.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')].find((o) => o.getBoundingClientRect().width > 0) || null;
+ const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon, _config.search === false ? null : { label: 'All OpenVibe sites', onClick: () => openSearch('', 'sites', orb()) });
if (root.OVDrop) return go();
if (root.OVDrop) return go();
if (document.getElementById('ov-drop-loader')) return;
if (document.getElementById('ov-drop-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
opts: { label (what the list is, said to a screen reader and shown small on top),
opts: { label (what the list is, said to a screen reader and shown small on top),
align: 'start' | 'end', minWidth, onClose }
align: 'start' | 'end', minWidth, onClose }
OVDrop.attach(button, items, opts) the button opens and closes it, with aria-expanded kept true to what is shown
OVDrop.attach(button, items, opts) the button opens and closes it, with aria-expanded kept true to what is shown
- OVDrop.pages(anchor, links, icon) a site's pages from its bar links, Home first, the present page marked
+ OVDrop.pages(anchor, links, icon, more) a site's pages from its bar links, Home first, the present page marked;
+ more: { label, onClick } ends it with a way out (the navbar's: every OpenVibe site)
OVDrop.close() close the one that is open */
OVDrop.close() close the one that is open */
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -132,7 +133,8 @@
@@ -132,7 +133,8 @@
* A site's pages, from the bar's links (the navbar calls this when the site's name is pressed): Home first, the
* A site's pages, from the bar's links (the navbar calls this when the site's name is pressed): Home first, the
* page you are on marked. links: [{ label, href, icon, hidden }]; icon(name) gives an icon's SVG text.
* page you are on marked. links: [{ label, href, icon, hidden }]; icon(name) gives an icon's SVG text.
*/
*/
- function pages(anchor, links, icon) {
+ const GRID = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"><rect x="4" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.6"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.6"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.6"/></svg>';
+ function pages(anchor, links, icon, more) {
const path = (h) => String(h).split(/[?#]/)[0].replace(/\/+$/, '') || '/';
const path = (h) => String(h).split(/[?#]/)[0].replace(/\/+$/, '') || '/';
const list = (links || []).filter((l) => l && l.label && l.href && !l.hidden && !/^https?:/i.test(l.href));
const list = (links || []).filter((l) => l && l.label && l.href && !l.hidden && !/^https?:/i.test(l.href));
const at = path(root.location.pathname);
const at = path(root.location.pathname);
@@ -141,6 +143,9 @@
@@ -141,6 +143,9 @@
const draw = (name) => { try { return typeof icon === 'function' ? icon(name) : ''; } catch { return ''; } };
const draw = (name) => { try { return typeof icon === 'function' ? icon(name) : ''; } catch { return ''; } };
const items = list.map((l, i) => ({ label: l.label, href: l.href, icon: draw(l.icon || 'fa-arrow-right'), current: i === best }));
const items = list.map((l, i) => ({ label: l.label, href: l.href, icon: draw(l.icon || 'fa-arrow-right'), current: i === best }));
if (!list.some((l) => path(l.href) === '/')) items.unshift({ label: 'Home', href: anchor.getAttribute('href') || '/', icon: draw('fa-house'), current: at === '/' });
if (!list.some((l) => path(l.href) === '/')) items.unshift({ label: 'Home', href: anchor.getAttribute('href') || '/', icon: draw('fa-house'), current: at === '/' });
+ // The way to every site (ask 338, the owner: "a button … to view all sites … past the list of pages … to the list
+ // of actual domains"): one row under a hairline.
+ if (more && typeof more.onClick === 'function') items.push(null, { label: more.label || 'All OpenVibe sites', icon: GRID, onClick: more.onClick });
return open(anchor, items, { label: `Pages on ${anchor.textContent.trim()}` });
return open(anchor, items, { label: `Pages on ${anchor.textContent.trim()}` });
}
}
root.OVDrop = { open, close: () => close(), attach, pages, get isOpen() { return !!cur; } };
root.OVDrop = { open, close: () => close(), attach, pages, get isOpen() { return !!cur; } };
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.199.0",
+ "version": "4.200.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.199.0",
+ "version": "4.200.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.199.0",
+ "version": "4.200.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": {
@@ -79,6 +79,7 @@
@@ -79,6 +79,7 @@
let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;
let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;
// A place of the Dock shown inside the menu (a phone, 312 step 2): which one, its mount, and AI's part.
// A place of the Dock shown inside the menu (a phone, 312 step 2): which one, its mount, and AI's part.
let door = null, doorEl = null, doorHost = null, doorSw = null, doorMount = null, aiPart = 'vibe';
let door = null, doorEl = null, doorHost = null, doorSw = null, doorMount = null, aiPart = 'vibe';
+ let actsEl = null, actsObs = null, actsTick = 0;
const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
function siteOf(url) {
function siteOf(url) {
@@ -191,6 +192,13 @@
@@ -191,6 +192,13 @@
st.textContent = `
st.textContent = `
.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
+/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */
+.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }
+.ovs-fly-part { position: absolute; display: grid; place-items: center; color: var(--ovk-text, var(--text-primary, #e8ecf4)); will-change: transform, opacity; }
+.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }
+.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
+.ovs-fly-part--count { width: auto !important; height: auto !important; }
+.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }
.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
@@ -275,6 +283,13 @@
@@ -275,6 +283,13 @@
.ovs-door { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.ovs-door { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.ovs-door[hidden] { display: none; }
.ovs-door[hidden] { display: none; }
.ovs-door-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.ovs-door-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
+/* A place's actions in the search pill (4.200.0); the panel's own row of them is hidden inside the menu. */
+.ovs-acts { display: flex; flex: none; gap: 2px; }
+.ovs-acts:empty, .ovs-door-host .ovdk-bar { display: none; }
+.ovs-act { width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+.ovs-act svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }
+.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }
+.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
.ovs-door-sw { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin: 8px 12px 4px; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
.ovs-door-sw { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin: 8px 12px 4px; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
.ovs-door-sw[hidden] { display: none; }
.ovs-door-sw[hidden] { display: none; }
@@ -451,7 +466,7 @@
@@ -451,7 +466,7 @@
el.setAttribute('role', 'dialog');
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'Search and every OpenVibe site');
el.setAttribute('aria-label', 'Search and every OpenVibe site');
- el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`
+ el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><div class="ovs-acts"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`
+ '<div class="ovs-tabs ovs-starttabs" hidden></div>'
+ '<div class="ovs-tabs ovs-starttabs" hidden></div>'
+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
@@ -459,13 +474,15 @@
@@ -459,13 +474,15 @@
ghost = el.querySelector('.ovs-ghost');
ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
listEl = el.querySelector('.ovs-list');
doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;
doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;
- doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', 'AI');
- doorSw.innerHTML = [['vibe', 'Vibe'], ['agent', 'Agents']].map(([id, label]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[id]}</span><span>${label}</span></button>`).join('');
+ doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', 'Vibe');
+ // Vibe holds two things (ask 335, the owner: "under Vibe is Chat and Agents"): your chats with it, and your agents.
+ doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</span><span>${label}</span></button>`).join('');
doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });
doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
doorEl.append(doorSw, doorHost);
doorEl.append(doorSw, doorHost);
listEl.after(doorEl);
listEl.after(doorEl);
tabsEl = el.querySelector('.ovs-tabs');
tabsEl = el.querySelector('.ovs-tabs');
+ actsEl = el.querySelector('.ovs-acts');
rtabsEl = el.querySelector('.ovs-rtabs');
rtabsEl = el.querySelector('.ovs-rtabs');
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
@@ -1035,6 +1052,32 @@
@@ -1035,6 +1052,32 @@
try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }
try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }
return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');
return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');
}
}
+ // A place's own actions (+ for a new chat, ↗ to open it in Chat) sit in the search pill, left of the close orb, so
+ // they cost no row (asks 335, 336: "an icon plus button that looks nice and integrated", "open in Chat … a button
+ // icon"). The Dock's panel draws them in its slim row; inside the menu that row is hidden and mirrored here, and
+ // each mirror presses the panel's own button (an address opens from the person's own tap).
+ function mirrorActs() {
+ actsTick = 0;
+ if (!actsEl) return;
+ actsEl.textContent = '';
+ if (!door || !doorHost) return;
+ doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib').forEach((orig) => {
+ const b = document.createElement('button'); b.type = 'button';
+ b.className = 'ovs-act' + (orig.classList.contains('is-new') ? ' is-new' : '');
+ const label = orig.getAttribute('aria-label') || ''; b.setAttribute('aria-label', label); b.title = label;
+ b.innerHTML = orig.innerHTML;
+ b.addEventListener('click', (e) => { e.stopPropagation(); orig.click(); });
+ actsEl.appendChild(b);
+ });
+ }
+ function watchActs(on) {
+ if (actsObs) { actsObs.disconnect(); actsObs = null; }
+ if (on && doorHost && typeof MutationObserver === 'function') {
+ actsObs = new MutationObserver(() => { if (!actsTick) actsTick = requestAnimationFrame(mirrorActs); });
+ actsObs.observe(doorHost, { childList: true, subtree: true });
+ }
+ mirrorActs();
+ }
/** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */
/** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */
function openDoor(id) {
function openDoor(id) {
if (!doorEl || !inSheet()) return;
if (!doorEl || !inSheet()) return;
@@ -1045,7 +1088,8 @@
@@ -1045,7 +1088,8 @@
listEl.hidden = true; doorEl.hidden = false; el.classList.add('ovs-in-door');
listEl.hidden = true; doorEl.hidden = false; el.classList.add('ovs-in-door');
try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: want }); else doorMount.show(want); }
try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: want }); else doorMount.show(want); }
catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }
catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }
- input.placeholder = id === 'chats' ? 'Search your messages' : `Search ${here()}, or ask Vibe`;
+ watchActs(true);
+ input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;
paintTabs();
paintTabs();
}
}
/** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
/** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
@@ -1053,6 +1097,7 @@
@@ -1053,6 +1097,7 @@
if (!door) return;
if (!door) return;
door = null;
door = null;
if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
+ watchActs(false);
if (doorEl) doorEl.hidden = true;
if (doorEl) doorEl.hidden = true;
if (listEl) listEl.hidden = false;
if (listEl) listEl.hidden = false;
if (el) el.classList.remove('ovs-in-door');
if (el) el.classList.remove('ovs-in-door');
@@ -1071,7 +1116,7 @@
@@ -1071,7 +1116,7 @@
try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
// One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and
// One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and
// AI are the Dock's; they are in this row, between Feed and You, where the bar can open the Dock for this person.
// AI are the Dock's; they are in this row, between Feed and You, where the bar can open the Dock for this person.
- const doors = typeof ctx.dock === 'function' ? [['chats', 'Messages'], ['vibe', 'AI']] : [];
+ const doors = typeof ctx.dock === 'function' ? [['chats', ctx.service === 'chat' ? 'Messages' : 'Chat'], ['vibe', 'Vibe']] : [];
const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
tabsEl.classList.toggle('has-doors', doors.length > 0);
tabsEl.classList.toggle('has-doors', doors.length > 0);
for (const [id, label, isDoor] of row) {
for (const [id, label, isDoor] of row) {
@@ -1110,6 +1155,7 @@
@@ -1110,6 +1155,7 @@
const BRIDGE_SVG = {
const BRIDGE_SVG = {
vibe: bsvg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
vibe: bsvg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
agent: bsvg('<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13v.01M14.5 13v.01M3 12.5v2M21 12.5v2"/>'),
agent: bsvg('<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13v.01M14.5 13v.01M3 12.5v2M21 12.5v2"/>'),
+ talk: bsvg('<path d="M4.6 11.6a7.4 6.4 0 1 1 3.3 5.4l-3.4 1.3 1-3.1a6 6 0 0 1-.9-3.6z"/><path d="M9 11.6h.01M12 11.6h.01M15 11.6h.01"/>'),
chats: bsvg('<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7L8 20v-3.5H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5z"/>'),
chats: bsvg('<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7L8 20v-3.5H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5z"/>'),
activity: bsvg('<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>'),
activity: bsvg('<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>'),
};
};
@@ -1503,11 +1549,82 @@
@@ -1503,11 +1549,82 @@
try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }
try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }
}
}
+ // ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the
+ // tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications
+ // tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's
+ // icon and turns into it on the way (its own picture fades out as the tab's fades in, along one short arc); the
+ // bell's count flies with it to the tab's count. Closing flies it back. Motion Off skips it; Reduced plays it short
+ // and straight (Calm reduces, only Off removes).
+ let flyFrom = null;
+ const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
+ function tabFace(id) {
+ const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
+ return b ? (b.querySelector('.ovs-tab-ic') || b) : null;
+ }
+ /** What the eye sees of a button: its picture or its glyph, without ids or handlers. */
+ function faceOf(node) {
+ const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;
+ const g = pic.cloneNode(true);
+ if (g.removeAttribute) g.removeAttribute('id');
+ if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
+ return g;
+ }
+ const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);
+ function fly(fromEl, toEl, { back = false } = {}) {
+ const level = motionLevel();
+ if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;
+ const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();
+ if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;
+ const short = level === 'reduced';
+ const D = short ? 180 : back ? 300 : 420, E = short ? 'ease-out' : 'cubic-bezier(.45,0,.2,1)';
+ const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');
+ const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };
+ const src = part(faceOf(fromEl), A), dst = part(faceOf(toEl), B), riders = [];
+ document.body.appendChild(layer);
+ const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;
+ const lift = short ? 0 : Math.min(36, Math.hypot(dx, dy) * .12);
+ const at = (k, sc) => `translate(${dx * k}px, ${dy * k - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;
+ const back1 = (k, sc) => `translate(${-dx * (1 - k)}px, ${-dy * (1 - k) - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;
+ src.animate([{ transform: 'none', opacity: 1 }, { transform: at(.5, (1 + s) / 2), opacity: .85, offset: .5 }, { transform: at(1, s), opacity: 0 }], { duration: D, easing: E, fill: 'forwards' });
+ const end = dst.animate([{ transform: back1(0, 1 / s), opacity: 0 }, { transform: back1(.5, (1 + 1 / s) / 2), opacity: .5, offset: .5 }, { transform: 'none', opacity: 1 }], { duration: D, easing: E, fill: 'forwards' });
+ // The count rides along (the bell's red count to the tab's, and back), when both are showing.
+ const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);
+ const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));
+ if (c1 && c2) {
+ const R1 = c1.getBoundingClientRect(), R2 = c2.getBoundingClientRect();
+ const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();
+ const box = part(pill, R1, ' ovs-fly-part--count');
+ const mx = R2.left - R1.left, my = R2.top - R1.top;
+ box.animate([{ transform: 'none' }, { transform: `translate(${mx / 2}px, ${my / 2 - lift}px)`, offset: .5 }, { transform: `translate(${mx}px, ${my}px)` }], { duration: D, easing: E, fill: 'forwards' });
+ c1.style.opacity = c2.style.opacity = '0';
+ riders.push(c1, c2);
+ }
+ const hidden = [fromEl, toEl, ...riders];
+ hidden.forEach((n) => { n.style.opacity = '0'; }); // opacity, not visibility: a button still takes a press while it flies
+ const done = () => {
+ hidden.forEach((n) => { n.style.opacity = ''; });
+ layer.remove();
+ const tabBtn = !back && toEl.closest && toEl.closest('.ovs-tab');
+ if (tabBtn && !short) tabBtn.animate([{ transform: 'scale(.92)' }, { transform: 'scale(1.03)', offset: .6 }, { transform: 'none' }], { duration: 200, easing: 'ease-out' });
+ };
+ end.finished.then(done, done);
+ }
+ /** After the menu has painted its tabs: the sheet's own entrance is finished at once (the flight is the motion). */
+ function flyIn() {
+ const from = flyFrom;
+ if (!from || !el) return;
+ const to = tabFace(door || tab);
+ if (!to) return;
+ try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }
+ fly(from, to);
+ }
+
function open(opts = {}) {
function open(opts = {}) {
homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
ctx = opts || {};
ctx = opts || {};
if (!el) build();
if (!el) build();
+ flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;
lastFocus = document.activeElement;
lastFocus = document.activeElement;
// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
// tab, whose second half is every site; "tools" is You); else this site.
// tab, whose second half is every site; "tools" is You); else this site.
@@ -1537,15 +1654,19 @@
@@ -1537,15 +1654,19 @@
if (orbEl) {
if (orbEl) {
orbEl.classList.remove('is-x');
orbEl.classList.remove('is-x');
if (phoneSheet()) {
if (phoneSheet()) {
- // From the button's centre to the pill's right end: the same orb, moving into the menu.
+ // From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
+ // button flies into its tab instead, the orb is simply there as the close X.
const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;
const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
- orbEl.classList.add('is-from');
- void orbEl.offsetWidth;
- requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });
+ if (flyFrom) orbEl.classList.add('is-x');
+ else {
+ orbEl.classList.add('is-from');
+ void orbEl.offsetWidth;
+ requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });
+ }
}
}
}
}
if (root.OVSurfaces) {
if (root.OVSurfaces) {
@@ -1557,6 +1678,7 @@
@@ -1557,6 +1678,7 @@
input.value = opts.q || '';
input.value = opts.q || '';
paintGhost();
paintGhost();
render(input.value);
render(input.value);
+ if (flyFrom && !input.value) requestAnimationFrame(flyIn);
// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
@@ -1566,17 +1688,23 @@
@@ -1566,17 +1688,23 @@
if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
- if (tab === 'tools' || tab === 'sites') setTimeout(() => { const t = listEl.querySelector(`[data-sec="${tab === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);
+ const jump = opts.tab === 'tools' || opts.tab === 'sites' ? opts.tab : '';
+ if (jump) setTimeout(() => { const t = listEl.querySelector(`[data-sec="${jump === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);
}
}
- let pushed = false, popping = false, surface = null;
- if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
+ // ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened
+ // again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).
+ let pushed = false, popping = false, surface = null, ownBack = false;
+ if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
function close({ navigating = false } = {}) {
function close({ navigating = false } = {}) {
+ // The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
+ const backTo = flyFrom; flyFrom = null;
+ if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true });
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
leaveDoor();
leaveDoor();
if (!el || closing) return;
if (!el || closing) return;
if (navigating) pushed = false;
if (navigating) pushed = false;
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
- if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) history.back(); } catch { /* */ } }
+ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
el.classList.add('is-closing');
el.classList.add('is-closing');
@@ -97,7 +97,7 @@ assert.ok(!vibe.includes('box.focus()') || /touch|coarse|pointer/.test(vibe), 't
@@ -97,7 +97,7 @@ assert.ok(!vibe.includes('box.focus()') || /touch|coarse|pointer/.test(vibe), 't
assert.ok(dock.includes("load('dock-runs.js'"), 'the Dock loads the Agents tab');
assert.ok(dock.includes("load('dock-runs.js'"), 'the Dock loads the Agents tab');
assert.ok(dock.includes("load('ai-marks-client.js'"), 'and the maker marks with it');
assert.ok(dock.includes("load('ai-marks-client.js'"), 'and the maker marks with it');
assert.ok(dock.includes('ovdk-runs-foot'), 'the Agents tab has its own foot');
assert.ok(dock.includes('ovdk-runs-foot'), 'the Agents tab has its own foot');
-assert.ok(dock.includes('AI chats'), "the Chats tab has the AI chats row");
+assert.ok(dock.includes("h('b', '', 'Vibe')"), 'the Chat tab has the Vibe row (your chats with Vibe)');
assert.ok(dock.includes('vibeOnList'), 'and it opens the Vibe tab on its list');
assert.ok(dock.includes('vibeOnList'), 'and it opens the Vibe tab on its list');
assert.ok(dock.includes('groupAvatar'), "a group's row shows faces, not a number sign");
assert.ok(dock.includes('groupAvatar'), "a group's row shows faces, not a number sign");
assert.ok(dock.includes('answered'), 'and when Vibe last answered');
assert.ok(dock.includes('answered'), 'and when Vibe last answered');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
-const DOCK_BROTLI_BUDGET = 20787; // 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
+const DOCK_BROTLI_BUDGET = 20940; // 20.45 KB with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -39,15 +39,24 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -39,15 +39,24 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places`);
assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places`);
await wait(350);
await wait(350);
const row = await page.evaluate(ROW);
const row = await page.evaluate(ROW);
- assert.deepEqual(row.map((t) => t.label + (t.chosen ? '*' : '') + (t.door ? `>${t.door}` : '')), ['Chat*', 'Feed', 'Messages>chats', 'AI>vibe', 'You'], `${name}: this site, Feed, Messages, AI, You, in that order`);
+ assert.deepEqual(row.map((t) => t.label + (t.chosen ? '*' : '') + (t.door ? `>${t.door}` : '')), ['Chat*', 'Feed', 'Messages>chats', 'Vibe>vibe', 'You'], `${name}: this site, Feed, Messages (Chat's own site keeps the word: its tab is Chat), Vibe, You, in that order`);
+ if (width <= 640) {
+ // On another site the door is called Chat (ask 336).
+ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
+ await page.evaluate(OPEN(true).replace("service: 'chat'", "service: 'design'"));
+ assert.ok(await until(`document.querySelectorAll('.ovs.is-open .ovs-starttabs .ovs-tab').length === 5`), `${name}: five places on another site`);
+ assert.deepEqual((await page.evaluate(ROW)).slice(2, 4).map((t) => t.label), ['Chat', 'Vibe'], `${name}: on another site, Chat and Vibe`);
+ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
+ await page.evaluate(OPEN(true)); await wait(350);
+ }
assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
assert.ok(row.every((t) => t.top === row[0].top), `${name}: one row (${row.map((t) => t.top).join(',')})`);
assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
assert.ok(row.every((t) => Math.abs(t.w - row[0].w) <= 1 && t.h >= 42), `${name}: the same width each, and tall enough to press (${row.map((t) => `${t.w}x${t.h}`).join(' ')})`);
assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
assert.ok(row.every((t) => t.role === 'tab') && row[row.length - 1].right <= width, `${name}: all on the screen`);
assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);
assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-bridge, .ovs-bridge-b').length`), 0, `${name}: no second row of buttons under the tabs`);
assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);
assert.equal(await page.evaluate(`document.querySelectorAll('.ovs-starttabs .ovs-tab svg, .ovs-starttabs .ovs-tab .ovs-tab-ic').length >= 4`), true, `${name}: each place has its icon`);
- // AI opens the Dock on Vibe; Messages on the conversations; neither becomes the chosen tab.
+ // Vibe opens the Dock on its chats; Messages on the conversations; neither becomes the chosen tab.
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
- assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI opens the Dock's Vibe`);
+ assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: Vibe opens the Dock's Vibe`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`);
assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);
assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`);
assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);
assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`);
@@ -66,10 +75,10 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -66,10 +75,10 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
if (width <= 640) {
if (width <= 640) {
- assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Vibe* Agents', chosen: 'AI' }, `${name}: AI opens inside the menu, on Vibe`);
+ assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`);
assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);
assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
- assert.equal((await inside()).sw, 'Vibe Agents*', `${name}: the switch goes to Agents`);
+ assert.equal((await inside()).sw, 'Chat Agents*', `${name}: the switch goes to Agents`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);
assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
@@ -77,10 +77,16 @@ const server = http.createServer((req, res) => {
@@ -77,10 +77,16 @@ const server = http.createServer((req, res) => {
// Work together: a new workspace room on OpenVibe.Chat (a team and Vibe, a work queue, a shared screen).
// Work together: a new workspace room on OpenVibe.Chat (a team and Vibe, a work queue, a shared screen).
assert.strictEqual(await p.evaluate(`document.querySelector('[data-ov-work]').href`), 'https://openvibe.chat/work');
assert.strictEqual(await p.evaluate(`document.querySelector('[data-ov-work]').href`), 'https://openvibe.chat/work');
+ // The menu loads on the first press (search.js): each press waits for the state it asked for, so a second
+ // press never lands before the first has opened the menu (2026-11-10: a press 20 ms after the first was
+ // swallowed while the script loaded, and the third press then closed the menu).
+ const settle = async (open) => { const end = Date.now() + 6000; while (Date.now() < end) { if (await p.evaluate(`!!document.querySelector('.ovs.is-open') === ${open}`)) return; await new Promise((r) => setTimeout(r, 40)); } };
// Nothing selected: the plain console.
// Nothing selected: the plain console.
await press();
await press();
assert.strictEqual(await askHref(), 'https://openvibe.chat/vibe');
assert.strictEqual(await askHref(), 'https://openvibe.chat/vibe');
+ await settle(true);
await press();
await press();
+ await settle(false);
// Text selected on the page, then a real press on the avatar: the selection is the ask (spaces folded).
// Text selected on the page, then a real press on the avatar: the selection is the ask (spaces folded).
await p.evaluate(`(() => { const r = document.createRange(); r.selectNodeContents(document.getElementById('text')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); })()`);
await p.evaluate(`(() => { const r = document.createRange(); r.selectNodeContents(document.getElementById('text')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); })()`);