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)

OP OpenVibers committed 2 days ago · 58934ccbc7 · 14 files, +253 −61

* 4.200.0: the button becomes its tab; Vibe holds Chat and Agents; every site from the site's name

- The bar's pressed button (the menu button, the picture) flies into its tab in the menu and back on close, the
  bell's count riding along; Reduced plays it short, Off skips it; hidden by opacity so a second press still lands.
- "AI" is Vibe, its switch Chat · Agents; the Vibe list's heading and large button become a + and an ↗ icon.
- Messages is Chat (openvibe.chat keeps "Messages"); an ↗ icon opens OpenVibe.Chat over conversations and in a
  thread; the footer link goes; the "AI chats" row is "Vibe".
- A place's icons sit in the search pill inside the menu (mirrored from the panel's own row).
- The site-name dropdown ends with "All OpenVibe sites", opening the menu scrolled to every site (the scroll had
  never run: the tab was read after "sites" had become "here").

* 4.200.0: the site's name highlighted with room and a spring (ask 342); README status and sibling revisions

The name that opens its pages draws its highlight behind it with room around it (no layout shift): faint on
hover, deeper with a hairline ring on open, coming up on a small spring, a give on press; Motion Off keeps it
still. The README's status line names v4.200.0 and SIBLING_REV is current (the two release checks that failed).

* 4.200.0: the name's highlight as a spread shadow (it reads whole at 360 px), within the bar's budget

An absolutely placed highlight counted as overflow in the name's clipping box, so at 360 px the site's word no
longer read whole; a spread shadow draws the same room without adding width (the name's 8 px clip margin lets it
show). The per-element Motion Off rule goes (the theme loader already removes every transition under Off), and
the bar is back under its 49.9 KB brotli budget.

* 4.200.0: changelog line for the name's highlight matches what shipped

* 4.200.0: a quick close and reopen keeps the menu open; the ask-Vibe test waits for each press

macOS CI failed navbar-ask-vibe once: the first press loads search.js, and a press 20 ms later was swallowed
while it loaded, so the third press closed the menu it expected to open (logged: two pointerdowns before the
first open). The test now waits for the state each press asked for (16 of 16 passing). And a real race found on
the way: closing steps back in history and that popstate, arriving late, closed a menu opened again in between;
the menu ignores the one step it took itself.

* 4.200.0: SIBLING_REV current after the last search.js change

* 4.200.0: two navbar comments shorter, so the bar is back under its budget

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3d29ea7..fa11fca 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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:
ddiff --git a/README.md b/README.md
iindex 1dd54e1..61517a9 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f717a9f..b6e7bb8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex cbe5aa2..980ad33 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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);
ddiff --git a/dock.js b/dock.js
iindex 2a8dbab..6979655 100644
---- a/dock.js
++++ b/dock.js
@@@ -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');
ddiff --git a/navbar.js b/navbar.js
iindex 5c7070e..5c26b34 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
ddiff --git a/ov-drop.js b/ov-drop.js
iindex be965c2..a4cc3d5 100644
---- a/ov-drop.js
++++ b/ov-drop.js
@@@ -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; } };
ddiff --git a/package-lock.json b/package-lock.json
iindex 8d64d1c..4a7609f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 8f1c911..246ff21 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex 113336c..4036077 100644
---- a/search.js
++++ b/search.js
@@@ -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 opens
if (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');
ddiff --git a/test/dock-runs.test.js b/test/dock-runs.test.js
iindex 7a12625..c0c79a9 100644
---- a/test/dock-runs.test.js
++++ b/test/dock-runs.test.js
@@@ -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('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');
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 32e226e..d5283d3 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -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 ──────────────────────────────────────────────
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
iindex 921c102..9cab081 100644
---- a/test/menu-places-chrome.test.js
++++ b/test/menu-places-chrome.test.js
@@@ -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`);
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')).ma
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);
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);
ddiff --git a/test/navbar-ask-vibe.test.js b/test/navbar-ask-vibe.test.js
iindex 8deb69d..a202b47 100644
---- a/test/navbar-ask-vibe.test.js
++++ b/test/navbar-ask-vibe.test.js
@@@ -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); })()`);