openvibers/openvibe.shared Commits
openvibe-shared 4.220.0: one UI, the floating Dock gone, the menu holds Chat and Vibe; Vibe redesigned (#495)
Changes
@@@ -3,7 +3,7 @@## Project overview`openvibe-shared` is the versioned UI and server-helpers package every OpenVibe site renders: the--OpenVibe Frame (navbar, footer, Dock, panels), themes and design tokens, glyphs and app icons, SEO,++OpenVibe Frame (navbar, the menu with its Chat and Vibe places, footer, panels), themes and design tokens, glyphs and app icons, SEO,legal pages, analytics, readiness/metrics, egress and the web runtime. It is a library (MIT), not aservice: it owns no database, port or process, and nothing is deployed from this repository.@@@ -1,5 +1,63 @@# Changelog++## 4.220.0 — 2026-11-12++++**One UI: the floating Dock is gone; the menu holds Chat and Vibe on every screen** (the owner, 11-12, with a phone++screenshot of the Dock crossed out: "there should only be the one unified UI … we're supposed to delete … that has the++old tab system", and the menu marked "the one unified"). And the Vibe place redesigned (the owner: "sleek … nice on++mobile … organized properly and sized well … without all these huge spaces … makes use of the full space … icons for++everything using OpenVibe.design … modular … reusable for different sites").++++- **Gone:**++ - the Dock's pill;++ - its floating card with the Vibe · Code · Agents · Chat · Activity tab bar, More, pin and Close;++ - corners, drag and resize;++ - the phone sheet that opened out of the menu button;++ - the window of its own (pop-out);++ - the You tab's "The Dock" row;++ - the Activity tab (the menu's You tab has the notifications; `OpenVibeNotifications.renderDock` is gone with it).++ `dock.js` is 703 lines, from 1,129.++- **The menu's Chat and Vibe open inside the menu on every screen.** A computer opened the floating Dock.++ - Vibe's Chat · Code · Agents switch has the timer as an icon at its end; it was a full row under the box.++ - On a computer the menu grows tall while a place is open (`min(820px, 86vh)`).++ - `OpenVibeSearch.open({ door })` and `OpenVibeSearch.door(id)` open the menu on a place, or move an open menu there.++- **`OpenVibeDock.open({ tab })`, `ask({ text })`, `share({ text, title })`, Alt+V and `OpenVibeNavbar.openDock(id)`**++ open the menu on that place (`opts.openMenu`, given by `navbar.js`); `close()` closes the menu. A place in Your places++ (Vibe, Agents) opens there too.++- **What the pill said is a status on the bar's menu button** (`opts.status`, painted by `navbar.js`):++ - a breathing accent dot while agents work;++ - an amber one when one needs you (the button then opens the menu on Agents);++ - a timer's minutes in a small label under the button.++ The tooltip and the button's name say it in words. Calm slows the breathing; Off stops it.++- **Vibe, redesigned:**++ - a header that says what it is: back, Vibe's mark, "Vibe" over "Your AI on every OpenVibe site", New chat and Open++ in Chat as icons;++ - a greeting at the top ("What can I do for you?"), and a new chat opens there;++ - six starters as full-width rows with their marks (two columns on a computer);++ - the composer's two-row box (the words over the whole width, then picture, Build it in Code, the mic and a round++ send);++ - result cards with their actions under the words;++ - long links wrap.++- **Every Vibe mark is the network's glyph** (`openvibe-shared/glyphs`, shown on openvibe.design/icons); so is the++ Dock's send. `test/dock-vibe-glyphs.test.js` keeps them equal.++- **Direct messages take the network's message actions** (openvibe.design rule 138, as Live's chat and `messages.js`):++ - Edit is a pencil beside your bubble on its empty side, on hover or focus, or a tap on a phone; it never covers words;++ - the box is the editor, under an "Editing your message" bar (Escape or × lets go); the browser's prompt is gone;++ - "edited" is one quiet word inside the bubble;++ - `dock-chat-edits.js` keeps only Chat's socket.++- `bottom.js`: the pill's stacking entries and `--ovb-composer` (which only the pill read) are gone.++- **One head, not two** (the owner, 11-12: "part of the one UI instead of having two different areas"): inside the menu a++ place has no header band of its own. An open conversation's + and ↗ move into the menu's search pill (as a list's++ already did); it keeps a slim way back and its name; Vibe's mark is the greeting's.++++Tests:++- Rewritten for the menu: `dock-chrome` and `dock-vibe-timer-chrome` (no pill or floating Dock ever; the status on++ the menu button; Agents, Chats, Send to, the agent view, Vibe, voice, the timer and Ask your agent through the menu),++ `dock-mount-chrome`, `dock-signin-chrome`, `menu-places-chrome` (Chat and Vibe inside the menu at 390 and 1280),++ `menu-home-screen-chrome`, `bottom-chrome` (no pill).++- New: `dock-vibe-glyphs`.++- Gone: `pill-composer-chrome` (the pill it kept above a chat's box is gone).++## 4.219.0 — 2026-11-12Every plain element styled by the theme (the owner, 11-12, with circled screenshots of openvibe.exchange: "make sure all@@@ -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.219.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.220.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.@@@ -626,26 +626,24 @@ there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Netwo`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens itfrom a site's own control.--**The Dock (4.51.0).** Your agents and your activity at the edge of every OpenVibe page (the Dock plan of 2026-10-30,--K-D1). For a signed-in person `navbar.js` loads `dock.js` from beside itself after the bell (serve it there; it is in--`files.js`), and it draws a pill in the Dock's corner: the mark, a word ("Needs you", "Working", "2 working") and a dot.--The pill shows only while an agent works, one needs you, or the person pinned it. A tap opens the Dock. On a desktop it--is a floating card that is not modal: drag its head to a corner, drag its free corner to resize it (320–720 × 360–900).--On a phone it is a sheet. It has two tabs. **Agents** is the Action Center's waiting and running items, approved or--declined in place (`OpenVibeActionCenter.mount(container, opts)`). **Activity** is the bell's list--(`OpenVibeNotifications.renderDock(container)`). Its state (open, tab, pinned, where it sits per device class) is the--Network's, so it follows the person from site to site:--- reads: `GET /api/v1/me/dock` (`network.dock-result@1`);--- writes: one debounced `PUT` (`network.dock-state@1`);--- this site's copy paints it at once;--- an open Dock reopens by itself on a desktop, never on a phone;--- a 404 from a Network without the route keeps it away for a day.----The account menu has a Dock tile. A site can call `OpenVibeNavbar.openDock('agent' | 'activity')`, pass `dock: false`--to leave it out, or pass `dockApiBase` for tests and staging. Desktop toasts stack above the pill (`--ov-dock-clear`).--**Chats** (4.52.0) is the person's conversations and DMs from OpenVibe.Chat's API--(`/api/dm/conversations`, read and answered in place, unread counts on the tab; `dockChatBase` overrides Chat's origin).--The agent's live screen joins in K-D3.++**The menu's Chat and Vibe (dock.js; 4.51.0, one UI since 4.220.0).** The OpenVibe menu holds the person's++conversations (**Chat**: OpenVibe.Chat's `/api/dm/conversations`, read and answered in place, Edit as a pencil at the++bubble's corner) and **Vibe** (Vibe's conversations, the person's coding tasks and their agents, by a Chat · Code ·++Agents switch, with a timer button at its end). The panels are `dock.js`'s, placed in the menu with++`OpenVibeDock.mountInto(host, { tab })` on every screen. There is no floating Dock, no pill and no window of its own++(the owner, 2026-11-12: "there should only be the one unified UI"). For a signed-in person `navbar.js` loads `dock.js`++from beside itself (it is in `files.js`); it reads the person's state from the Network so it follows them from site to++site:++- reads: `GET /api/v1/me/dock` (`network.dock-result@1`); writes: one debounced `PUT` (`network.dock-state@1`): the++ place last open, the open chat, the Vibe conversation and the timer;++- what needs saying is a status on the bar's menu button: a breathing dot while agents work, an amber one when one++ needs you (the button then opens on Agents), a timer's minutes in a small label under it;++- a 404 from a Network without the route keeps the status away for a day.++++`OpenVibeNavbar.openDock('chats' | 'vibe' | 'code' | 'agent')`, `OpenVibeDock.open({ tab })`, `ask({ text })` and++`share({ text, title })` open the menu there (`OpenVibeSearch.open({ door })` or `OpenVibeSearch.door(id)` when it is++open). `dock: false` leaves Chat and Vibe out of the menu; `dockApiBase` and `dockChatBase` override the Network and++Chat for tests and staging.**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every tenminutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.@@@ -872,10 +870,10 @@ New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,and on `setChip(id, value)` turns to that balance, counts the number andplays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site'ssingle counter that is not a balance stays an ordinary chip.-- The menu and the Dock (4.189): the menu button always opens this site's tab-- (no tab is remembered; the picture still asks for You); under the tabs a row-- opens the Dock on Vibe, Agents, Messages or Activity (`dock: false` leaves-- it out), and the Dock's head has a button back to the menu.++ The menu (4.189, one UI since 4.220): the menu button always opens this++ site's tab (no tab is remembered; the picture still asks for You); Chat and++ Vibe are places in the menu itself, on every screen (`dock: false` leaves++ them out).The account's things (4.188): the person's picture opens the OpenVibe menuon You, where the account's block is (who you are, add account, settings,sign out, your other accounts, this site's own items from addMenuItem,@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.219.0",++ "version": "4.220.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)",@@@ -8,21 +8,17 @@* number every bottom-docked part needs and writes it where CSS can use it:** --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;-- * the bar, the small player, the Dock's pill and the composer move up by it with `translate`++ * the bar, the small player and the composer move up by it with `translate`* (a paint, never layout).* --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.* --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).* --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.* --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.* --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.-- * --ovb-composer how far a page's own composer docked at the bottom reaches up (its top, plus 8 px), so the Dock's-- * pill stands above a chat's box instead of on its send button (2026-11-11); 0 when there is none.** One stacking order, written here only (the bottom of the screen, low to high):* 1. the app bar --ovb-z-bar 9990* 2. the small player --ovb-z-mini 9991-- * 3. the Dock's pill --ovb-z-pill 9995-- * 4. the Dock's card --ovb-z-dock 9996 (a phone's Dock sheet is 10010: it covers the navbar)* Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).** navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses@@@ -51,17 +47,15 @@--ovb-mini: 0px;--ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));--ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));-- --ovb-composer: 0px;--ovb-z-bar: 9990;--ovb-z-mini: 9991;-- --ovb-z-pill: 9995;-- --ovb-z-dock: 9996;}--.ovnav-appbar, .ovmini, .ovdk-pill { will-change: translate; }`;++.ovnav-appbar, .ovmini { will-change: translate; }`;(document.head || html).appendChild(st);}const px = (n) => `${Math.round(n * 100) / 100}px`;++ // A page's own composer docked at the bottom moves up with the toolbar like the bar does.const COMPOSERS = 'form[data-ovt-composer], [data-ov-bottom-dock]';/** The height of an element that is actually on screen (a bar hidden under the page is not). */@@@ -93,16 +87,6 @@}} catch { /* no matchMedia: no full-width small player to stack */ }html.style.setProperty('--ovb-mini', px(mini));-- // A page's composer at the bottom of the screen (a chat's box, the shared composer, or anything marked-- // data-ov-bottom-dock) that reaches the right-hand corner where the pill sits.-- let composer = 0;-- const visBottom = vv ? vv.offsetTop + vv.height : root.innerHeight;-- for (const f of document.querySelectorAll(COMPOSERS)) {-- if (!onScreen(f)) continue;-- const r = f.getBoundingClientRect();-- if (r.bottom >= visBottom - 48 && r.top < visBottom && r.right > root.innerWidth - 140) composer = Math.max(composer, visBottom - r.top + 8);-- }-- html.style.setProperty('--ovb-composer', px(composer));}const later = () => { if (!raf) raf = requestAnimationFrame(measure); };@@@ -1,20 +1,10 @@--/* Edit events for an open Dock DM. Loaded when the conversation opens. */++/* Edit events for an open Dock DM, over Chat's socket: another of my devices (or this one) edited a message, and the++ Dock's apply() redraws it. Loaded when the conversation opens. Editing itself is the Dock's own (4.220.0: the box is the++ editor, a pencil at the bubble's corner starts it; no browser prompt). */(function (root) {'use strict';-- root.OpenVibeDockChatEdits = function ({ id, list, messages, error, tokenOf, chatCall, chatBase, active, load, h }) {++ root.OpenVibeDockChatEdits = function ({ id, apply, tokenOf, chatBase, active }) {let socket = null, timer = null, stopped = false;-- function apply(m) {-- const old = messages.find((x) => Number(x.id) === Number(m && m.id));-- const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`);-- if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return;-- Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at });-- li.querySelector('.ovdk-text').textContent = String(m.message || '');-- let mark = li.querySelector('.ovdk-edited');-- if (!mark) { mark = h('small', 'ovdk-edited'); li.append(mark); }-- mark.textContent = `edited · ${String(m.edited_at).slice(11, 16)}`;-- const card = li.querySelector('.ovdk-card'); if (card) card.remove();-- if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && active() && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } });-- }async function connect() {if (stopped || !active() || !root.WebSocket) return;const token = await tokenOf();@@@ -22,19 +12,9 @@const ws = new root.WebSocket(chatBase().replace(/^http/, 'ws') + '/ws/chat');socket = ws;ws.onopen = () => ws.send(JSON.stringify({ type: 'join', token }));-- ws.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch { return; } if (frame.type === 'dm_edited' && String(frame.conversation_id) === id) apply(frame.message); };++ ws.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch { return; } if (frame.type === 'dm_edited' && String(frame.conversation_id) === id && active()) apply(frame.message); };ws.onclose = () => { if (socket === ws) socket = null; if (!stopped && active()) timer = setTimeout(connect, 3000); };}-- list.addEventListener('click', async (event) => {-- if (!event.target.classList.contains('ovdk-edit')) return;-- const li = event.target.closest('[data-id]');-- const old = li && messages.find((m) => Number(m.id) === Number(li.dataset.id));-- if (!old) return;-- const body = root.prompt('Edit message', old.message);-- if (body == null || body.trim() === old.message.trim()) return;-- try { const result = await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages/${encodeURIComponent(old.id)}`, { method: 'PATCH', body: JSON.stringify({ message: body }) }); apply(result.message); }-- catch (e) { error.textContent = e.message || 'Could not edit'; error.hidden = false; }-- });connect().catch(() => {});return { stop() { stopped = true; clearTimeout(timer); if (socket) { socket.onclose = null; socket.close(); socket = null; } } };};@@@ -9,9 +9,25 @@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"/>';++ // 4.220.0: the panel's marks are the network's glyphs (openvibe-shared/glyphs, shown on openvibe.design/icons; the owner,++ // 11-12: "actually has icons for everything using OpenVibe.design"). test/dock-vibe-glyphs.test.js keeps them equal.++ const V = {++ back: '<path d="M16.1 3.6l-8.4 8.4l8.4 8.4"/>',++ spark: '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',++ image: '<rect x="3.5" y="5.4" width="16.7" height="13" rx="2.8"/><circle cx="9.1" cy="10" r="1.5"/><path d="M20.3 15.6l-4.7-4.7l-7.4 7.4"/>',++ code: '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',++ send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>',++ bell: '<path d="M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3ZM10 18.7a2.1 2.1 0 0 0 4 0"/>',++ calendar: '<rect x="4" y="5.5" width="16" height="15" rx="2"/><path d="M4 10.5h16M9 3.5v4M15 3.5v4"/>',++ live: '<circle cx="12" cy="11.8" r="2"/><path d="M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9"/>',++ clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',++ phone: '<path d="M5.7 3.4h3.9l2 4.9l-2.5 1.5a10.8 10.8 0 0 0 4.9 4.9L15.6 12.3l4.9 2v3.9a2 2 0 0 1-2 2A15.8 15.8 0 0 1 3.8 5.4a2 2 0 0 1 2-2"/>',++ search: '<circle cx="10.6" cy="10.6" r="7"/><path d="M20.4 20.4l-4.5-4.5"/>',++ chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',++ plus: '<path d="M12 3.5v17M3.5 12h17"/>',++ mic: '<rect x="9.2" y="3.5" width="5.7" height="10.4" rx="2.8"/><path d="M5.4 11.1a6.6 6.6 0 0 0 13.2 0M12 17.7v2.8"/>',++ open: '<path d="M14 3.8h6.3v6.3M20.2 3.8l-9.4 9.4M18.2 14.2v4.2a2.1 2.1 0 0 1-2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1V7.9a2.1 2.1 0 0 1 2.1-2.1h4.2"/>',++ };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); }@@@ -30,11 +46,49 @@.ovdk-typing .ovdk-text { display: inline-flex; gap: 4px; align-items: center; padding: 10px 12px; }.ovdk-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted, #8b8b96); animation: ovdk-hop 1s ease-in-out infinite; }.ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }-- .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }-- .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }-- .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }-- .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }-- .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }++ /* 4.220.0 (the owner, 11-12: "sleek … organized … sized well … without huge spaces … uses the full space … icons for++ everything"): a header that says what Vibe is, a greeting at the top, the starters as rows with their marks, and the++ shared composer's two-row shape. */++ .ovdk-vibe-h { gap: 8px; padding: 6px 8px; min-height: 56px; box-sizing: border-box; }++ .ovdk-vibe-h .ovdk-vibe-back { width: 40px; height: 40px; min-width: 40px; min-height: 40px; border-radius: 12px; color: var(--text-secondary, #b6b6c0); }++ .ovdk-vibe-h .ovdk-vibe-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }++ .ovdk-vibe-back svg { width: 17px; height: 17px; } .ovdk-chip-go svg { width: 13px; height: 13px; }++ .ovdk-vibe-mark { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; color: #fff;++ background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #3b82f6) 90%, #a855f7), color-mix(in srgb, #a855f7 85%, var(--accent, #3b82f6))); box-shadow: 0 4px 14px color-mix(in srgb, #a855f7 28%, transparent); }++ .ovdk-vibe-mark svg { width: 19px; height: 19px; }++ .ovdk-vibe-mark.is-big { width: 46px; height: 46px; border-radius: 14px; }++ .ovdk-vibe-mark.is-big svg { width: 24px; height: 24px; }++ .ovdk-vibe-name { flex: 1; min-width: 0; display: grid; line-height: 1.2; }++ .ovdk-vibe-name b { font-size: 15px; }++ .ovdk-vibe-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #8b8b96); font-size: 12px; }++ .ovdk-vibe-h .ovdk-ib { margin-left: 0; width: 40px; height: 40px; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); }++ .ovdk-vibe-h .ovdk-ib:hover, .ovdk-vibe-h .ovdk-ib:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }++ .ovdk-vibe-h .ovdk-vibe-fresh { color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-vibe-start { list-style: none; display: grid; gap: 14px; padding: 14px 4px 6px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; text-align: left; }++ .ovdk-vibe-hello { display: flex; align-items: center; gap: 12px; padding: 0 4px; }++ .ovdk-vibe-hello-t { min-width: 0; display: grid; gap: 2px; }++ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 16.5px; letter-spacing: -.01em; }++ .ovdk-chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); gap: 6px; margin: 0; }++ .ovdk-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px 8px 8px; border-radius: 14px;++ border: 1px solid var(--border, rgba(255,255,255,.1)); background: color-mix(in srgb, var(--text-primary, #ececf1) 3%, transparent); color: var(--text-primary, #ececf1);++ font: inherit; font-size: 14px; text-align: left; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease, background-color var(--ov-dur-2, .2s) ease; }++ .ovdk-chip:hover, .ovdk-chip:focus-visible { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); outline: none; }++ .ovdk-chip-ic { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }++ .ovdk-chip-ic svg { width: 18px; height: 18px; }++ .ovdk-chip-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovdk-chip-go { display: grid; color: var(--text-muted, #8b8b96); }++ /* The box: the words on top over the full width, the tools and a round send under them. */++ .ovdk-send.ovdk-send--two { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 6px 6px 6px 8px; border-radius: 20px; }++ .ovdk-send--two textarea { padding: 8px 6px 4px; min-height: 40px; font-size: 15.5px; }++ .ovdk-send-tools { display: flex; align-items: center; gap: 2px; min-width: 0; }++ .ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }++ .ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; }++ .ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; }++ .ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; }++ .ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; }++ .ovdk-res-c { grid-template-columns: auto minmax(0, 1fr); }++ .ovdk-res-c .ovdk-res-a { grid-column: 2; }.ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }.ovdk-attach:hover { color: var(--text-primary,#ececf1); }.ovdk-mic:hover { color: var(--text-primary, #ececf1); }@@@ -74,7 +128,7 @@/** 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; };++ const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', V.open, '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") ──@@@ -91,7 +145,14 @@const TALK = Boolean(SR || MR);const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];const REC_MAX_MS = 30000;-- const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];++ const STARTERS = [++ ['bell', 'Remind me to stretch every 2 hours'],++ ['calendar', 'What is on today?'],++ ['live', 'Who is live right now?'],++ ['clock', 'Start a 25-minute focus timer'],++ ['phone', 'Remind me tomorrow at 9 to call Sam'],++ ['search', 'What is new on OpenVibe this week?'],++ ];function linkified(text) {const span = h('span', 'ovdk-text');String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {@@@ -155,7 +216,7 @@// 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(); });++ const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', V.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');scroll.append(list); panel.append(head, scroll);@@@ -189,14 +250,18 @@panel.textContent = '';const head = h('div', 'ovdk-thread-h ovdk-vibe-h');const title = h('b', '', 'Vibe');-- 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 back = iconButton('button', 'ovdk-vibe-back', V.back, 'Back to your chats'); back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });const away = awayLink();-- head.append(back, icon(G.vibe), title, away);++ const mark = h('span', 'ovdk-vibe-mark'); mark.innerHTML = svg(V.spark);++ const name = h('span', 'ovdk-vibe-name'); name.append(title, h('small', '', 'Your AI on every OpenVibe site'));++ const fresh = iconButton('button', 'ovdk-ib ovdk-vibe-fresh', V.plus, 'New chat');++ fresh.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });++ head.append(back, mark, name, fresh, away);const list = h('ol', 'ovdk-msgs'); list.setAttribute('aria-live', 'polite');const scroll = h('div', 'ovdk-scroll ovdk-thread'); scroll.append(list);const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');const form = h('form', 'ovdk-send');-- const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');++ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe anything'; box.setAttribute('aria-label', 'Message Vibe');const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));const err = h('p', 'ovdk-err'); err.hidden = true;const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'@@@ -207,7 +272,7 @@const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }};-- const attach = h('button', 'ovdk-attach', '+'); attach.type = 'button'; attach.setAttribute('aria-label', 'Attach a picture'); attach.title = 'Attach a picture';++ const attach = iconButton('button', 'ovdk-attach', V.image, 'Attach a picture');const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;attach.addEventListener('click', () => file.click());file.addEventListener('change', async () => {@@@ -222,21 +287,25 @@finally { attach.disabled = false; file.value = ''; }});// 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.-- const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');++ const build = iconButton('button', 'ovdk-attach ovdk-build', V.code, 'Build it in Code');build.addEventListener('click', () => {const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');const text = box.value.trim() || (last && last.text) || '';if (!text) { box.focus(); return; }build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));});-- form.append(attach, file, build);++ form.classList.add('ovdk-send--two');++ const tools = h('div', 'ovdk-send-tools');++ tools.append(attach, file, build);let mic = null;if (TALK) {mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');-- form.append(mic);++ tools.append(mic);}-- form.append(box, go);++ go.textContent = ''; go.innerHTML = svg(V.send);++ tools.append(go);++ form.append(box, tools);panel.append(head, scroll, err, form);const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };@@@ -295,13 +364,25 @@}if (first && !vibe.msgs.length) {const start = h('li', 'ovdk-vibe-start');-- start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));-- const chips = h('div', 'ovdk-chips');-- STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });-- start.append(chips);++ const hello = h('div', 'ovdk-vibe-hello');++ const big = h('span', 'ovdk-vibe-mark is-big'); big.innerHTML = svg(V.spark);++ const words = h('div', 'ovdk-vibe-hello-t');++ words.append(h('b', '', 'What can I do for you?'), h('span', '', TALK ? 'Ask anything, set reminders and timers, find what is live. Hold the mic to talk.' : 'Ask anything, set reminders and timers, find what is live.'));++ hello.append(big, words);++ const chips = h('div', 'ovdk-chips'); chips.setAttribute('role', 'list');++ STARTERS.forEach(([ic, s]) => {++ const c = h('button', 'ovdk-chip'); c.type = 'button'; c.setAttribute('role', 'listitem');++ const m = h('span', 'ovdk-chip-ic'); m.innerHTML = svg(V[ic]);++ const t = h('span', 'ovdk-chip-t', s);++ const go2 = h('span', 'ovdk-chip-go'); go2.innerHTML = svg(V.chevron);++ c.append(m, t, go2);++ c.addEventListener('click', () => send(s)); chips.append(c);++ });++ start.append(hello, chips);list.append(start);}-- if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;++ if (first && !vibe.msgs.length) scroll.scrollTop = 0;++ else if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;clearTimeout(vibe.timer);if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);}@@@ -382,7 +463,7 @@function mount(panel, c) {ctx = c || {};-- G = { mic: MIC, ...(ctx.icons || {}) };++ G = { mic: V.mic, ...(ctx.icons || {}) };const st = typeof ctx.styles === 'function' ? ctx.styles() : null;if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }@@@ -401,5 +482,5 @@ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },};}-- root.OpenVibeDockVibe = { mount };++ root.OpenVibeDockVibe = { mount, GLYPHS: V };})(typeof window !== 'undefined' ? window : globalThis);@@@ -1,53 +1,44 @@--// The Dock: navbar.js calls mount({ token, apiBase, sibling }) for the pill. open({ tab }) opens the floating card;--// mountInto(host, { tab }) places its panel body inside another sheet. Both use the same state, counts and loaders.++/**++ * openvibe-shared/dock.js — the panels behind the menu's Chat and Vibe tabs: your conversations (Chat), and Vibe, your++ * coding tasks and your agents (Vibe's three parts). 4.220.0 (the owner, 11-12: "there should only be the one unified++ * UI … delete … the old tab system"): the floating Dock — its pill, its card and tab bar, corners, the phone sheet and++ * the window of its own — is gone. The menu (search.js) places these panels with mountInto on every screen.++ *++ * OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out)++ * OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats')++ * OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu)++ * What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js++ * paints on the bar's menu button.++ */(function (root) {'use strict';if (typeof document === 'undefined' || root.OpenVibeDock) return;-- const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };-- const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;-- // Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the-- // corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.-- // The close button is the menu button, already crossed (navbar.js, root.OpenVibeOrb): it forms as the sheet opens.-- const ORBX = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(22, true) : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>');const G = {code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>',spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',-- // An upright pushpin whose lines never cross (owner, 11-09: the old one had "a line going through it").-- pin: '<path d="M7.5 3.5h9M10 3.5v5.3L6.5 13h11L14 8.8V3.5M12 13v7.5"/>',close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',-- send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',++ send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>', // 4.220.0: the registry's (openvibe-shared/glyphs), as Vibe's boxvibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',-- // Three filled dots on the exact centre line (a stroked h.01 path drew them a hair off centre: the owner, 11-09).-- more: '<g fill="currentColor" stroke="none"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></g>',clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',++ // 4.220.0: the registry's pencil (openvibe-shared/glyphs), for a message's Edit.++ pencil: '<path d="M11.9 19.6h8.4M16.1 4.2a2 2 0 0 1 2.8 2.8L7.2 18.7l-3.7 0.9l0.9-3.7Z"/>',};// 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], ['code', 'Code', G.code], ['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', 'code', 'agent', 'chats', 'activity'];-- const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };++ const TABS = [['vibe', 'Vibe', G.spark], ['code', 'Code', G.code], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat]];++ const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null };-- let el = null, bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null, dockSurface = null;-- // Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.-- let pipWin = null, popped = false, pipClosing = false, popOk = true;++ let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {};const mounts = {};-- const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };-- // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.-- const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';-- const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));const state = () => (doc && doc.state) || DEFAULT_STATE;const counts = () => (doc && doc.counts) || {};-- const kind = () => (phone() ? 'phone' : 'desktop');-- const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);// K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one// timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame@@@ -67,14 +58,14 @@}/** A token may be a string, a sync function or an async function (the extension refreshes it). */async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }-- const notify = () => { store(doc); paintPill(); };++ const notify = () => { store(doc); paintStatus(); };/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */function save(patch) {const st = state();-- const next = { ...st, ...patch, place: patch.place ? { ...st.place, ...patch.place } : st.place };++ const next = { ...st, ...patch };doc = { ...(doc || {}), state: next, counts: counts() };-- pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };++ pending = { ...pending, ...patch };notify();clearTimeout(saveT);saveT = setTimeout(flush, 700);@@@ -84,12 +75,12 @@if (!Object.keys(body).length) return;try {const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });-- if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }++ if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); }} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }}// 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': '7869d3d345b4', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': 'def2148254a0', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -106,99 +97,27 @@const st = document.createElement('style');st.id = 'ovdk-styles';st.textContent = `-- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))), var(--ovb-composer, 0px)); translate: 0 calc(-1 * var(--ovb-lift, 0px));-- display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);-- font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }-- .ovdk-pill[hidden] { display: none; }-- .ovdk-pill[data-corner$="left"] { right: auto; left: max(16px, env(safe-area-inset-left, 0px)); }-- .ovdk-pill[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }-- .ovdk-pill[data-tone="waiting"] { --t: var(--warning, #f5a524); }-- .ovdk-pill:hover { border-color: color-mix(in srgb, var(--t) 45%, var(--border, #333340)); }-- .ovdk-pill:active { scale: .96; }-- .ovdk-pill:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 3px; }-- .ovdk-m { position: relative; display: grid; place-items: center; width: 30px; height: 30px; }-- .ovdk-m::before, .ovdk-m::after { content: ''; position: absolute; top: -1px; right: -2px; width: 9px; height: 9px; border-radius: 50%; background: var(--t); }-- .ovdk-m::after { box-shadow: 0 0 0 2.5px var(--bg); }-- .ovdk-pill[data-tone="waiting"] .ovdk-m::before { animation: ovdk-ping 1.8s ease-out infinite; }-- .ovdk-pill[data-tone="idle"] .ovdk-m::before, .ovdk-pill[data-tone="idle"] .ovdk-m::after, .ovdk-t:empty { display: none; }-- .ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }-- @keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }-- @keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }-- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;-- right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));-- width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);-- background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));-- border-radius: 22px; box-shadow: 0 30px 80px -24px rgba(0,0,0,.62), 0 2px 10px -2px rgba(0,0,0,.25); font-family: inherit; transform-origin: 100% 100%; }-- .ovdk[hidden] { display: none; }-- .ovdk[data-corner$="left"] { right: auto; left: max(var(--ovdk-gap), env(safe-area-inset-left, 0px)); }-- .ovdk[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }-- .ovdk[data-corner="bottom-left"] { transform-origin: 0 100%; }-- .ovdk[data-corner="top-right"] { transform-origin: 100% 0; }-- .ovdk[data-corner="top-left"] { transform-origin: 0 0; }-- .ovdk.is-in { animation: ovdk-grow .3s cubic-bezier(.2,.9,.25,1.08); }-- .ovdk.is-moving { transition: none !important; box-shadow: 0 40px 90px -20px rgba(0,0,0,.7); cursor: grabbing; }-- @keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }-- @keyframes ovdk-rise { from { transform: translateY(100%); } }++ /* The bar's menu button carries the status: navbar.js paints .ovnav-st, which shows only once this file has loaded. */++ .ovnav-st { position: absolute; top: -4px; right: -6px; z-index: 1; display: inline-flex; align-items: center; justify-content: center; min-width: 10px; height: 18px; padding: 0 6px;++ box-sizing: border-box; border-radius: 999px; font: normal 750 10.5px/1 var(--font-sans, inherit); font-variant-numeric: tabular-nums; letter-spacing: .01em; white-space: nowrap;++ --st: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--st); box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19); pointer-events: none; }++ [data-ov-status="running"] > .ovnav-st, [data-ov-status="waiting"] > .ovnav-st { width: 10px; min-width: 10px; height: 10px; padding: 0; top: 1px; right: 1px; }++ [data-ov-status^="timer"] > .ovnav-st { top: auto; bottom: -8px; right: 50%; transform: translateX(50%); height: 15px; padding: 0 5px; font-size: 9.5px; }++ [data-ov-status="waiting"] > .ovnav-st { --st: var(--warning, #f5a524); }++ [data-ov-status="timer-done"] > .ovnav-st { --st: var(--success, #22c55e); color: #052e14; }++ [data-ov-status="running"] > .ovnav-st { animation: ovnav-st-breathe 1.8s ease-in-out infinite; }++ [data-ov-status="waiting"] > .ovnav-st { animation: ovnav-st-breathe 1.2s ease-in-out infinite; }++ @keyframes ovnav-st-breathe { 50% { box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19), 0 0 0 6px color-mix(in srgb, var(--st) 30%, transparent); } }++ html[data-ov-motion=calm] [data-ov-status] > .ovnav-st { animation-duration: 3.2s; }++ html[data-ov-motion=off] .ovnav-st { animation: none; }++ /* 4.220.0: the panels live in the menu only (the floating card, its pill and tab bar are gone). */++ .ovdk { position: relative; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; width: 100%; height: 100%; min-height: 0;++ color: var(--text-primary, #ececf1); font-family: inherit; container-type: inline-size; }.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }/* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */.ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; }-- .ovdk-head { position: relative; flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 8px 8px 10px; cursor: grab; touch-action: none; user-select: none; }-- .ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }-- .ovdk-tabs::-webkit-scrollbar { display: none; }-- .ovdk-tabs button { flex: none; }-- .ovdk-tabs button { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border: 0; border-radius: 999px; background: transparent;-- color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }-- .ovdk-tabs button svg { width: 17px; height: 17px; flex: none; }-- .ovdk-tabs button:hover { color: var(--text-primary, #ececf1); }-- .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }-- .ovdk-tabs button[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }-- .ovdk-tabs button:focus-visible, .ovdk-acts button:focus-visible, .ovdk-foot :focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 1px; }-- .ovdk-tabs .n { min-width: 18px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; font-size: 11px; font-weight: 800; line-height: 1.35; text-align: center; font-variant-numeric: tabular-nums;-- color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }-- .ovdk-tabs .n[data-tone="waiting"] { background: color-mix(in srgb, var(--warning, #f5a524) 34%, transparent); }-- .ovdk-tabs .n:empty { display: none; }-- .ovdk-acts { display: flex; gap: 2px; margin-left: auto; }-- .ovdk-acts button { width: 40px; height: 40px; border: 0; border-radius: 12px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }-- .ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }-- .ovdk-acts button svg { width: 19px; height: 19px; display: block; }-- .ovdk-acts .ovdk-tomenu span { display: none; }-- .ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }-- .ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }-- .ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }-- .ovdk-orbx:hover .ovmb, .ovdk-orbx:focus-visible .ovmb { transform: rotate(90deg); }-- .ovdk-orbx .ovmb { transition: transform .35s cubic-bezier(.2,1.3,.3,1); }-- .ovdk-orbx:active .ovmb { transform: rotate(90deg) scale(.8); }-- /* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */-- .ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }-- .ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }-- @keyframes ovdk-reveal { from { clip-path: circle(28px at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } }-- @keyframes ovdk-fold { from { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(20px at var(--ovdk-ox) var(--ovdk-oy)); opacity: 0; } }-- .ovdk.from-orb .ovdk-head { padding: 4px 14px 6px 8px; }-- .from-orb .ovdk-acts .ovdk-orbx { position: relative; width: 40px; height: 40px; margin: 2px; border-radius: 50%; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19));-- box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: background-color .3s, box-shadow .3s; }-- .from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }-- .from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }-- @keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }-- .from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }-- .from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }-- .ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }-- .from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }-- .ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }-- .ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;-- border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }-- .ovdk-more[hidden] { display: none; }-- .ovdk-more button { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary, #ececf1); font: inherit; text-align: left; cursor: pointer; }-- .ovdk-more button[hidden] { display: none; }-- .ovdk-more button:hover, .ovdk-more button:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); outline: none; }-- .ovdk-more button > svg { flex: none; width: 20px; height: 20px; color: var(--text-muted, #8b8b96); }-- .ovdk-more span { flex: 1; min-width: 0; display: grid; gap: 2px; }-- .ovdk-more b { font-size: 14px; font-weight: 650; }-- .ovdk-more small { font-size: 12px; color: var(--text-muted, #8b8b96); line-height: 1.35; }-- .ovdk-sw { flex: none; position: relative; width: 38px; height: 22px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.14)); transition: background .15s; }-- .ovdk-sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }-- .ovdk-more [aria-checked="true"] .ovdk-sw { background: var(--accent-strong, var(--accent, #2563eb)); }-- .ovdk-more [aria-checked="true"] .ovdk-sw::after { transform: translateX(16px); }-- .ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; border-top: 1px solid var(--border, rgba(255,255,255,.08)); }++ { transform: translateX(16px); }++ .ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }.ovdk-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }.ovdk-panel[hidden] { display: none; }.ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }@@@ -213,24 +132,6 @@.ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }@keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }-- .ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }-- .ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }-- .ovdk .ovdk-size::after { opacity: .75; }-- .ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }-- .ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }-- .ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }-- .ovdk[data-corner="top-right"] .ovdk-size::after { top: auto; bottom: 9px; border-top: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-bottom-left-radius: 3px; }-- .ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }-- .ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }-- @media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }-- .ovdk { container-type: inline-size; }-- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }-- /* A narrow Dock (Opus, 11-07: "Activity" was cut off at the strip's edge): the open tab keeps its icon and name,-- the others show their icon and count only, their names kept for screen readers. All four always fit. */-- @container (max-width: 480px) {-- .ovdk-tabs button:not([aria-selected="true"]) { padding: 0 10px; gap: 4px; }-- .ovdk-tabs button:not([aria-selected="true"]) span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }-- }.ovdk-muted { color: var(--text-muted, #8b8b96); }.ovdk-i { display: inline-grid; place-items: center; }.ovdk-i svg { width: 18px; height: 18px; }@@@ -278,270 +179,72 @@.ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }.ovdk-go:disabled { opacity: .55; cursor: default; }++ /* 4.220.0: a message's actions at its corner (openvibe.design rule 138; the owner, 11-12, on chat's Edit: "hidden++ until you tap"): on hover or focus on a computer, a tap on a phone; never under the words. "edited" is one++ quiet word in the bubble. Editing happens in the box, under a slim bar that says so. */++ .ovdk-msg.has-acts { position: relative; }++ /* Beside the bubble, on its empty side (a bubble is at most 84% wide), so the cluster never covers anyone's words. */++ .ovdk-acts { position: absolute; top: 50%; transform: translateY(-50%); right: calc(100% + 6px); z-index: 2; display: none; align-items: center; gap: 2px; padding: 2px; border-radius: 10px;++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #1b1d26); box-shadow: 0 6px 18px rgba(0,0,0,.28); }++ .ovdk-msg:not(.is-mine) > .ovdk-acts { right: auto; left: calc(100% + 6px); }++ .ovdk-msg.has-acts:hover > .ovdk-acts, .ovdk-msg.has-acts:focus-within > .ovdk-acts, .ovdk-msg.is-acting > .ovdk-acts { display: inline-flex; }++ .ovdk-act { width: 30px; height: 28px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++ .ovdk-act:hover, .ovdk-act:focus-visible { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); outline: none; }++ .ovdk-act .ovdk-i svg { width: 16px; height: 16px; }++ @media (hover: none) {++ .ovdk-msg.has-acts:hover:not(.is-acting):not(:focus-within) > .ovdk-acts { display: none; }++ .ovdk-act { width: 44px; height: 40px; border-radius: 9px; }++ .ovdk-act .ovdk-i svg { width: 18px; height: 18px; }++ }++ .ovdk-msg.is-acting .ovdk-text, .ovdk-msg.is-editing .ovdk-text { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }++ .ovdk-edited { margin-left: 6px; font-size: .74em; color: var(--text-muted, #8b8b96); white-space: nowrap; }++ .ovdk-msg.is-mine .ovdk-edited { color: inherit; opacity: .72; }++ .ovdk-edit-bar { flex: none; display: flex; align-items: center; gap: 8px; margin: 8px 10px -2px; padding: 2px 2px 2px 12px; min-height: 36px; border-radius: 12px;++ font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }++ .ovdk-edit-bar[hidden] { display: none; }++ .ovdk-edit-bar span:not(.ovdk-i) { flex: 1; min-width: 0; }++ .ovdk-edit-bar .ovdk-i svg { width: 15px; height: 15px; }++ .ovdk-edit-bar button { width: 36px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++ .ovdk-edit-bar button:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 8%, transparent); }++ .ovdk-send.is-editing { border-color: var(--accent, #3b82f6); }++ /* One send for every box in the Dock: round, as Vibe's. */++ .ovdk-send { border-radius: 22px; padding: 4px 4px 4px 14px; }++ .ovdk-send .ovdk-go { width: 40px; height: 40px; border-radius: 50%; }.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }.ovdk-err[hidden] { display: none; }.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }-- @media (max-width: 640px) {-- .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: var(--ov-kb, 0px); width: auto !important; height: auto !important; max-width: none; max-height: none;-- border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }-- .ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }-- .ovdk-head { cursor: default; }-- .ovdk-size { display: none; }-- .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }-- /* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */-- .ovdk-tabs button { height: 44px; }-- .ovdk-acts button { width: 44px; height: 44px; }-- /* Two rows (4.150.0, the owner: the strip was "cut off", "Activity" read "Ac"): the tools sit beside the grab-- handle; the four tabs share the full width below, each icon over its name, every one whole. */-- .ovdk-head { flex-wrap: nowrap; gap: 4px; padding: 6px 6px 6px 8px; }-- .ovdk-acts { flex: none; gap: 0; }-- .ovdk-acts button[data-act="pop"] { display: none !important; }-- .ovdk-tabs { order: 0; flex: 1 1 auto; min-width: 0; padding: 3px; border-radius: 16px; }-- .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { flex: 1 1 0; min-width: 0; height: 54px; padding: 4px 2px; flex-direction: column; justify-content: center; gap: 3px; border-radius: 12px; font-size: 11.5px; font-weight: 650; }-- .ovdk-tabs button span, .ovdk-tabs button:not([aria-selected="true"]) span { position: static; width: auto; height: auto; overflow: hidden; clip: auto; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }-- .ovdk-tabs button svg { width: 19px; height: 19px; }-- .ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }-- .ovdk-foot a, .ovdk-foot button { min-height: 44px; }-- /* Two rows, as the menu's head (4.189.0): the way back to the menu, More and Close; under them the four tabs,-- full width, in the look of the menu's row that leads here. */-- .ovdk-head, .ovdk.from-orb .ovdk-head { flex-wrap: wrap; gap: 6px 4px; padding: 6px 12px 8px; }-- .ovdk-acts { order: 0; flex: 1 0 100%; align-items: center; gap: 2px; }-- .ovdk-acts .ovdk-tomenu { width: auto; margin-right: auto; padding: 0 14px 0 10px; gap: 8px; display: inline-flex; align-items: center; border-radius: 999px; font: inherit; font-size: 13.5px; font-weight: 700;-- color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }-- .ovdk-acts .ovdk-tomenu span { display: inline; }-- .ovdk-tabs { order: 1; flex: 1 0 100%; gap: 4px; padding: 0; border-radius: 0; background: transparent; overflow: visible; }-- .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { height: 50px; gap: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }-- .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }-- .ovdk-tabs button:not([aria-selected="true"]) svg { color: var(--accent-light, var(--accent, #60a5fa)); }-- }-- @media (min-width: 641px) and (max-width: 1023px) {-- .ovdk, .ovdk[data-corner] { z-index: 10010; top: 0; bottom: 0; right: 0; left: auto; width: min(460px, calc(100vw - 48px)) !important; height: auto !important;-- max-width: none; max-height: none; border-radius: 22px 0 0 22px; border-right: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);-- box-shadow: 0 0 0 100vmax rgba(0,0,0,.4), -16px 0 40px rgba(0,0,0,.3); transform-origin: 100% 50%; }-- .ovdk-size { display: none; }-- .ovdk-head { cursor: default; }-- }-- html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }-- // In a window of its own the panel is the window: full bleed, no card, no phone sheet.-- .ovdk-pip .ovdk, .ovdk-pip .ovdk[data-corner] { left: 0 !important; top: 0 !important; right: 0 !important; bottom: 0 !important;-- width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important;-- border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }-- .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }-- .ovdk-pip .ovdk-head::before { display: none !important; }-- .ovdk-pip .ovdk-size { display: none !important; }++ @media (max-width: 640px) { .ovdk-foot a, .ovdk-foot button { min-height: 44px; } }`;document.head.appendChild(st);}++ /** The panels' body, once: the menu (search.js) places it with mountInto. */function build() {styles();-- el = document.createElement('section');-- el.className = 'ovdk';-- el.setAttribute('role', 'dialog');-- el.setAttribute('aria-label', 'Dock');-- el.hidden = true;-- el.innerHTML = `-- <header class="ovdk-head">-- <div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>-- <div class="ovdk-acts">-- <button type="button" data-act="menu" class="ovdk-tomenu" title="The OpenVibe menu" aria-label="The OpenVibe menu: every site, the feed, you">${root.OpenVibeOrb ? root.OpenVibeOrb.svg(20) : ''}<span>Menu</span></button>-- <button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>-- <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>-- </div>-- <div class="ovdk-more" role="menu" aria-label="More" hidden>-- <button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>-- <button type="button" role="menuitemcheckbox" aria-checked="false" data-act="pin">${svg(G.pin)}<span><b>Keep the Dock's button on every page</b><small>When off, it shows only while something works or needs you</small></span><i class="ovdk-sw" aria-hidden="true"></i></button>-- <button type="button" role="menuitem" data-act="pop" hidden>${svg(G.open)}<span><b>Open the Dock in a window of its own</b><small>Keep it beside whatever you are doing</small></span></button>-- </div>-- </header>-- <div class="ovdk-body">${TABS.map(([id]) => `<div class="ovdk-panel" role="tabpanel" id="ovdk-panel-${id}" aria-labelledby="ovdk-tab-${id}" data-panel="${id}" hidden></div>`).join('')}</div>-- <span class="ovdk-size" aria-hidden="true"></span>`;-- document.body.appendChild(el);-- bodyEl = el.querySelector('.ovdk-body');---- const tabs = [...el.querySelectorAll('[role="tab"]')];-- tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));-- el.querySelector('.ovdk-tabs').addEventListener('keydown', (e) => {-- const i = tabs.indexOf(document.activeElement); if (i < 0) return;-- const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;-- if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }-- });-- el.querySelector('[data-act="close"]').addEventListener('click', () => close());-- // The way back to the menu: this sheet folds, the menu opens out of the same button.-- { const m = el.querySelector('[data-act="menu"]'); if (m) { if (!root.OpenVibeNavbar || !root.OpenVibeOrb) m.hidden = true; m.addEventListener('click', () => { close(); setTimeout(() => { try { root.OpenVibeNavbar.openSwitcher(); } catch { /* no bar */ } }, 300); }); } }-- el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });-- el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });-- el.querySelector('[data-act="timer"]').addEventListener('click', () => { const a = el.querySelector('[data-act="more"]'); closeMore(); withTimer((t) => t.menu(a)); });-- const moreBtn = el.querySelector('[data-act="more"]'), moreMenu = el.querySelector('.ovdk-more');-- function closeMore() { moreMenu.hidden = true; moreBtn.setAttribute('aria-expanded', 'false'); }-- moreBtn.addEventListener('click', () => {-- const open = moreMenu.hidden;-- closeMore();-- const tm = el.querySelector('.ovdk-tmenu'); if (tm) tm.remove();-- if (open) { moreMenu.hidden = false; moreBtn.setAttribute('aria-expanded', 'true'); const first = moreMenu.querySelector('button:not([hidden])'); if (first) first.focus(); }-- });-- moreMenu.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b && b.dataset.act !== 'pin') closeMore(); });-- moreMenu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeMore(); moreBtn.focus(); } });-- // A pop-over (More, the timer) closes on a tap anywhere else, in the Dock or on the page (owner, 11-09: "clicked off-- // something else, and it didn't actually close").-- const awayFrom = (e) => {-- const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];-- if (!moreMenu.hidden && !path.includes(moreMenu) && !path.includes(moreBtn)) closeMore();-- const tm = el.querySelector('.ovdk-tmenu');-- if (tm && !path.includes(tm) && !path.includes(moreMenu) && !path.includes(moreBtn)) tm.remove();-- };-- el.addEventListener('pointerdown', awayFrom, true);-- document.addEventListener('pointerdown', (e) => { if (!el.contains(e.target)) awayFrom(e); }, true);-- el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });-- // A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a-- // chat) leaves its target detached, and must not read as outside.-- document.addEventListener('click', (e) => {-- if (!isOpen() || !phone()) return;-- // The tap that opened the sheet (a page's button, the share sheet's OpenVibe Chat, Ask your agent) is still on-- // its way up when the sheet opens: an event made before the sheet opened is never "a tap outside" (4.145.1).-- if (e.timeStamp && e.timeStamp <= openedAt) return;-- const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];-- if (path.includes(el) || (pill && path.includes(pill)) || (e.target && e.target.isConnected === false)) return;-- close();-- });-- root.addEventListener('resize', () => { if (isOpen()) layout(); });-- bindMove();-- bindSize();-- paintPop();-- }---- // ── Where it sits ────────────────────────────────────────-- function layout() {-- const p = place();-- el.dataset.corner = CORNERS.includes(p.corner) ? p.corner : 'bottom-right';-- if (phone()) { el.style.width = ''; el.style.height = ''; clear(0); return; }-- const w = clamp(p.width || DEF.width, W[0], Math.max(W[0], Math.min(W[1], root.innerWidth - 32)));-- const h = clamp(p.height || DEF.height, H[0], Math.max(H[0], Math.min(H[1], root.innerHeight - 96)));-- el.style.width = `${w}px`; el.style.height = `${h}px`;-- // Desktop toasts (bottom right) stack above the card when there is room, so neither hides the other.-- clear(el.dataset.corner === 'bottom-right' && root.innerHeight - h > 320 ? h + 12 : 0);-- }-- const clear = (px) => document.documentElement.style.setProperty('--ov-dock-clear', `${px}px`);---- /**-- * The head drags the card, the tabs too once the pointer has moved 6 px (a tap still picks the tab; pin and close-- * never drag). On release it snaps to the nearest corner (a FLIP, so it glides there).-- */-- function bindMove() {-- const head = el.querySelector('.ovdk-head');-- let s = null, swallow = false;-- // Armed on the press; the window hears the moves, so a quick drag that leaves the head before 6 px still counts.-- const move = (e) => {-- if (!s || e.pointerId !== s.id) return;-- s.dx = e.clientX - s.x; s.dy = e.clientY - s.y;-- if (!s.live) {-- if (Math.abs(s.dx) + Math.abs(s.dy) < 6) return;-- s.live = true;-- if (!phone()) el.classList.add('is-moving');-- }-- if (phone()) { if (e.pointerType !== 'mouse') el.style.transform = `translateY(${Math.max(0, s.dy)}px)`; return; }-- el.style.transform = `translate(${s.dx}px, ${s.dy}px)`;-- };-- const end = (e) => {-- if (!s || (e && e.pointerId !== s.id)) return;-- const { r, dx, dy, live } = s; s = null;-- root.removeEventListener('pointermove', move); root.removeEventListener('pointerup', end); root.removeEventListener('pointercancel', end);-- if (!live) return;-- swallow = true; setTimeout(() => { swallow = false; }, 0);-- el.classList.remove('is-moving');-- if (phone()) {-- el.style.transition = 'transform .2s ease';-- if (dy > 110) close(); else el.style.transform = '';-- setTimeout(() => { el.style.transition = ''; }, 220);-- return;-- }-- const cx = r.left + dx + r.width / 2, cy = r.top + dy + r.height / 2;-- const corner = `${cy < root.innerHeight / 2 ? 'top' : 'bottom'}-${cx < root.innerWidth / 2 ? 'left' : 'right'}`;-- const from = { x: r.left + dx, y: r.top + dy };-- el.style.transform = '';-- save({ place: { desktop: { ...place(), corner } } });-- layout();-- const to = el.getBoundingClientRect();-- if (reduced()) return;-- el.style.transform = `translate(${from.x - to.left}px, ${from.y - to.top}px)`;-- el.getBoundingClientRect();-- el.style.transition = 'transform .28s cubic-bezier(.2,.9,.25,1.05)';-- el.style.transform = '';-- setTimeout(() => { el.style.transition = ''; }, 300);-- };-- head.addEventListener('pointerdown', (e) => {-- if (e.button || e.target.closest('.ovdk-acts, .ovdk-more')) return;-- s = { x: e.clientX, y: e.clientY, r: el.getBoundingClientRect(), dx: 0, dy: 0, id: e.pointerId, live: false };-- root.addEventListener('pointermove', move); root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);-- });-- // A drag that began on a tab is a drag, not a pick.-- head.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); } }, true);++ bodyEl = document.createElement('div');++ bodyEl.className = 'ovdk-body';++ bodyEl.innerHTML = TABS.map(([id, label]) => `<div class="ovdk-panel" role="region" aria-label="${label}" id="ovdk-panel-${id}" data-panel="${id}" hidden></div>`).join('');}-- /** The free corner (the one away from the anchor) resizes the card. */-- function bindSize() {-- const grip = el.querySelector('.ovdk-size');-- let s = null;-- grip.addEventListener('pointerdown', (e) => {-- if (e.button || phone()) return;-- e.preventDefault();-- const r = el.getBoundingClientRect();-- s = { x: e.clientX, y: e.clientY, w: r.width, h: r.height, id: e.pointerId, c: el.dataset.corner };-- try { grip.setPointerCapture(e.pointerId); } catch { /* */ }-- });-- grip.addEventListener('pointermove', (e) => {-- if (!s || e.pointerId !== s.id) return;-- const dx = e.clientX - s.x, dy = e.clientY - s.y;-- const w = clamp(s.w + (s.c.endsWith('right') ? -dx : dx), W[0], Math.min(W[1], root.innerWidth - 32));-- const h = clamp(s.h + (s.c.startsWith('bottom') ? -dy : dy), H[0], Math.min(H[1], root.innerHeight - 96));-- el.style.width = `${Math.round(w)}px`; el.style.height = `${Math.round(h)}px`;-- s.nw = Math.round(w); s.nh = Math.round(h);-- });-- const end = (e) => {-- if (!s || (e && e.pointerId !== s.id)) return;-- const { nw, nh } = s; s = null;-- if (nw && nh) { save({ place: { desktop: { ...place(), width: nw, height: nh } } }); layout(); }-- };-- grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end);-- }// ── Tabs ─────────────────────────────────────────────────-- function badges() {-- if (!el) return;-- const c = counts();-- const agent = (c.waiting || 0) + (c.running || 0);-- const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };-- 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 ? `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');-- }-- function paintPin() {-- const b = el.querySelector('[data-act="pin"]'), on = !!state().pinned;-- b.setAttribute('aria-checked', String(on));-- b.title = on ? "On: the Dock's button stays on every page" : "Off: the Dock's button shows only while something works or needs you";-- }function setTab(id, remember) {if (id === 'agents') id = 'agent';if (inline && tab !== id) disposePanel();-- tab = TABS.some(([t]) => t === id) ? id : 'activity';++ tab = TABS.some(([t]) => t === id) ? id : 'vibe';if (tab !== 'chats') clearTimeout(chats.timer);if (tab !== 'vibe' && vibe.api) vibe.api.pause();-- el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });-- if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });++ if (remember && state().tab !== tab) save({ tab });fill(tab);if (inline && tab === 'chats') readUnread();}-- const panelActive = (id) => tab === id && (isOpen() || !!inline);++ const panelActive = (id) => tab === id && !!inline;function disposePanel() {panelEpoch++;clearTimeout(chats.timer); clearTimeout(chats.unreadT);if (vibe.api) { vibe.api.pause(); vibe.api = null; }for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; }-- if (root.OpenVibeNotifications && root.OpenVibeNotifications.unmountDock && bodyEl) root.OpenVibeNotifications.unmountDock(bodyEl.querySelector('[data-panel="activity"]'));chats.panel = null; chats.open = null; vibe.panel = null;if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; });}@@@ -556,7 +259,7 @@const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));if (epoch !== panelEpoch || !panelActive(id)) return;if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }-- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });return;}if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }@@@ -591,25 +294,18 @@});const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),-- styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),++ styles: () => document.getElementById('ovdk-styles'),foot: panel.querySelector('.ovdk-runs-foot'),-- onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },++ onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; notify(); },});mounts[id] = m;return;}-- const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));-- if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }-- if (!ok) return failed(panel, id);-- const N = root.OpenVibeNotifications;-- if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }-- panel.innerHTML = '';-- N.renderDock(panel);-- mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };++ delete mounts[id];}function failed(panel, id) {mounts[id] = null;-- panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;++ panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`;}// ── Chats (Dock plan K-D2) ───────────────────────────────@@@ -636,7 +332,7 @@}function readUnread() {clearTimeout(chats.unreadT);-- chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });++ chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; }).catch(() => { /* Chat away: no count */ });if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000);}const when = (iso) => {@@@ -677,7 +373,7 @@if (sharing) await sharing;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); } }));++ if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: inline, 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');@@@ -720,14 +416,37 @@}panel.append(scroll);}++ /** "edited", one quiet word after a message's words; when, in its title. */++ function editedMark(at) {++ const mark = h('small', 'ovdk-edited', 'edited');++ const d = new Date(String(at || '').replace(' ', 'T'));++ if (!Number.isNaN(d.getTime())) mark.title = `Edited ${d.toLocaleString()}`;++ return mark;++ }++ /** An edit arrived (mine, saved here, or another of my devices'): the words in place, marked, the card again. */++ function applyEdit(list, m) {++ const old = chats.msgs.find((x) => Number(x.id) === Number(m && m.id));++ const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`);++ if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return;++ Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at });++ const words = li.querySelector('.ovdk-text');++ words.textContent = String(m.message || ''); words.append(editedMark(m.edited_at));++ const card = li.querySelector('.ovdk-card'); if (card) card.remove();++ if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } });++ }function bubble(m, prev) {const mine = m.username === me();const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));li.dataset.id = String(m.id);-- li.append(h('span', 'ovdk-text', String(m.message || '')));-- if (m.edited_at) li.append(h('small', 'ovdk-edited', `edited · ${String(m.edited_at).slice(11, 16)}`));-- if (mine && canWriteChat()) { const edit = h('button', 'ovk-btn ovk-btn--quiet ovdk-edit', 'Edit'); edit.type = 'button'; li.append(edit); }++ const words = h('span', 'ovdk-text', String(m.message || ''));++ if (m.edited_at) words.append(editedMark(m.edited_at));++ li.append(words);++ if (mine && canWriteChat()) {++ const acts = h('span', 'ovdk-acts');++ const edit = h('button', 'ovdk-act ovdk-edit'); edit.type = 'button'; edit.setAttribute('aria-label', 'Edit message'); edit.setAttribute('data-ov-tip', 'Edit'); edit.append(icon(G.pencil));++ acts.append(edit); li.append(acts); li.classList.add('has-acts');++ }// A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows.if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); });li.title = new Date(m.created_at).toLocaleString();@@@ -758,16 +477,65 @@const err = h('p', 'ovdk-err'); err.hidden = true;form.append(box, go);panel.append(head, scroll, err);++ // Editing one of mine: the box holds its words, a slim bar over it says so, Escape or × lets go.++ const bar = h('div', 'ovdk-edit-bar'); bar.hidden = true;++ const stopX = h('button'); stopX.type = 'button'; stopX.setAttribute('aria-label', 'Stop editing'); stopX.setAttribute('data-ov-tip', 'Stop editing'); stopX.append(icon(G.close));++ bar.append(icon(G.pencil), h('span', '', 'Editing your message'), stopX);++ let editing = null;++ const stopEditing = () => {++ if (!editing) return;++ if (editing.li) editing.li.classList.remove('is-editing');++ editing = null; bar.hidden = true; form.classList.remove('is-editing');++ box.value = ''; box.dispatchEvent(new Event('input')); go.setAttribute('aria-label', 'Send');++ };++ const startEditing = (li) => {++ const old = li && chats.msgs.find((m) => Number(m.id) === Number(li.dataset.id));++ if (!old) return;++ if (editing && editing.li) editing.li.classList.remove('is-editing');++ list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting'));++ editing = { id: old.id, was: String(old.message || ''), li };++ li.classList.add('is-editing'); bar.hidden = false; form.classList.add('is-editing'); go.setAttribute('aria-label', 'Save');++ box.value = editing.was; box.dispatchEvent(new Event('input'));++ try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ }++ };++ stopX.addEventListener('click', () => { stopEditing(); box.focus(); });++ list.addEventListener('click', (e) => {++ const edit = e.target.closest && e.target.closest('.ovdk-edit');++ if (edit) { e.preventDefault(); startEditing(edit.closest('[data-id]')); return; }++ // A finger: a tap on my bubble shows its actions (a second tap, or a tap on another, puts them away).++ const li = e.target.closest && e.target.closest('.ovdk-msg.has-acts');++ if (!li || !(typeof matchMedia === 'function' && matchMedia('(hover: none)').matches) || (e.target.closest('a, button'))) return;++ const was = li.classList.contains('is-acting');++ list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting'));++ if (!was) li.classList.add('is-acting');++ });if (canWriteChat()) {-- panel.append(form);++ panel.append(bar, form);const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };box.addEventListener('input', fit);if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });-- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });++ box.addEventListener('keydown', (e) => {++ if (e.key === 'Escape' && editing) { e.preventDefault(); e.stopPropagation(); stopEditing(); return; }++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); }++ });form.addEventListener('submit', async (e) => {e.preventDefault();const text = box.value.trim();if (!text || chats.busy) return;++ if (editing) {++ const ed = editing;++ if (text === ed.was.trim()) { stopEditing(); return; }++ chats.busy = true; go.disabled = true; err.hidden = true;++ try {++ const out = await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', body: JSON.stringify({ message: text }) });++ if (out && out.message) applyEdit(list, out.message);++ if (editing === ed) stopEditing();++ } catch (x) {++ err.textContent = x.status === 429 ? 'Slow down a little, then save it again.' : x.status === 403 ? 'That message can no longer be edited.' : 'The edit did not save. Try again.';++ err.hidden = false;++ } finally { chats.busy = false; go.disabled = false; box.focus(); }++ return;++ }chats.busy = true; go.disabled = true; err.hidden = true;try {await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });@@@ -784,7 +552,7 @@list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(list, scroll, true);-- load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), list, messages: chats.msgs, error: err, tokenOf, chatCall, chatBase, active: () => chats.open === String(id) && panelActive('chats'), load, h }); });++ load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); });if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */@@@ -816,8 +584,9 @@document.addEventListener('keydown', (e) => {if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;e.preventDefault();-- const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };-- if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();++ const talk = (n = 0) => { if (vibe.api && vibe.api.talk && panelActive('vibe')) vibe.api.talk(); else if (n < 40) setTimeout(() => talk(n + 1), 100); };++ if (!panelActive('vibe')) open({ tab: 'vibe' });++ talk();});// ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──@@@ -825,288 +594,124 @@function withTimer(fn) {if (timerMod) return fn(timerMod);timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {-- if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline || el, repaint: () => paintPill() });++ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline, repaint: () => paintStatus() });return timerMod;});return timerLoading.then((m) => (m ? fn(m) : null));}-- // ── The pill ─────────────────────────────────────────────-- let pill = null, readT = 0, bound = false, openedAt = 0;++ // ── Status (4.220.0): what the pill used to say rides on the bar's menu button (navbar.js passes o.status) ──++ let readT = 0, bound = false, off = false;function store(v) {try { if (v === undefined) return JSON.parse(localStorage.getItem('ov-dock') || 'null'); localStorage.setItem('ov-dock', JSON.stringify(v)); } catch { /* */ }return null;}-- function paintPill() {-- if (!pill) return;++ /** An agent needs you (first), a timer's minutes, or agents working; null when there is nothing to say. */++ function status() {++ if (off || !doc) return null;const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;-- const corner = place().corner;-- // Popped out: the page keeps the pill, and it says where the Dock went.-- if (popped) {-- pill.hidden = false;-- pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';-- pill.dataset.tone = 'idle';-- pill.querySelector('.ovdk-t').textContent = 'In a window';-- pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');-- return;-- }-- // A running timer: its minutes, unless an agent needs the person (that comes first).if (st.timer && !timerMod) withTimer((m) => m.sync());const tm = timerMod && !w ? timerMod.pillText() : null;-- const show = !isOpen() && !!(w || r || st.pinned || tm);-- pill.hidden = !show;-- pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';-- pill.dataset.tone = w ? 'waiting' : tm ? tm.tone : r ? 'running' : 'idle';-- pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');-- pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : tm ? tm.text : r ? (r > 1 ? `${r} working` : 'Working') : '';-- pill.setAttribute('aria-label', tm ? tm.label : [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');-- // Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.-- if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);++ if (w) return { tone: 'waiting', text: 'Needs you', label: `${w} need${w > 1 ? '' : 's'} you`, part: 'agent' };++ if (tm) return { tone: tm.tone, text: tm.text, label: tm.label, part: 'vibe' };++ if (r) return { tone: 'running', text: r > 1 ? `${r} working` : 'Working', label: `${r} working`, part: 'agent' };++ return null;++ }++ /** The status on the bar's menu button, for a bar that asked (navbar.js passes o.status): a dot while agents work, an++ * amber one when one needs you, a timer's minutes in a small label under it; the tooltip and the name say it in words. */++ function paintBar(s) {++ document.querySelectorAll('.openvibe-navbar #openvibe-search-btn, .openvibe-navbar #openvibe-burger.ovnav-orb').forEach((b) => {++ let chip = b.querySelector('.ovnav-st');++ if (!s) { if (chip) chip.remove(); b.removeAttribute('data-ov-status'); if (b.dataset.ovLabel) { b.setAttribute('aria-label', b.dataset.ovLabel); b.setAttribute('data-ov-tip', b.dataset.ovTip || b.dataset.ovLabel); } return; }++ // An i, not a span: the old burger draws its spans as lines.++ if (!chip) { chip = document.createElement('i'); chip.className = 'ovnav-st'; chip.setAttribute('aria-hidden', 'true'); b.appendChild(chip); }++ chip.textContent = /^timer/.test(s.tone) ? (s.tone === 'timer-done' ? 'Done' : s.text) : '';++ b.setAttribute('data-ov-status', s.tone);++ if (!b.dataset.ovLabel) { b.dataset.ovLabel = b.getAttribute('aria-label') || 'Menu'; b.dataset.ovTip = b.getAttribute('data-ov-tip') || ''; }++ b.setAttribute('aria-label', `${b.dataset.ovLabel}: ${s.label}`);++ b.setAttribute('data-ov-tip', s.label);++ });++ }++ function paintStatus() {++ if (typeof o.status !== 'function') return;++ const s = status();++ try { o.status(s); } catch { /* the bar is optional */ }++ paintBar(s);}async function read() {clearTimeout(readT);-- if (!pill || document.hidden) return;++ if (off || !o.token || document.hidden) return;const t = await tokenOf();if (!t) return;try {const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });-- if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }++ if (r.status === 404) { off = true; store({ off: Date.now() + 864e5 }); paintStatus(); return; }const next = r.ok ? await r.json() : null;-- if (!next || !next.state || !pill) return;++ if (!next || !next.state) return;update(next);-- if (!isOpen() && !inline && next.state.open && !phone()) open();const c = next.counts || {};if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);} catch { /* offline: the copy stands */ }}-- /** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */++ /** A signed-in person's state and counts (navbar.js), or none (null: signed out, or the site turned it off). */function mount(opts) {if (!opts) {if (mountHandle) mountHandle.destroy();clearTimeout(readT);-- if (pill) { pill.remove(); pill = null; }-- if (isOpen()) close();-- doc = null;++ const was = o.status; o = {}; doc = null;++ if (typeof was === 'function') { try { was(null); } catch { /* */ } paintBar(null); }return;}o = { ...o, ...opts };styles();-- if (!pill) {-- const kept = store();-- if (kept && kept.off > Date.now()) return;-- if (kept && kept.state && !doc) doc = kept;-- pill = document.createElement('button');-- pill.type = 'button'; pill.id = 'ovdk-pill'; pill.className = 'ovdk-pill'; pill.hidden = true;-- pill.innerHTML = '<span class="ovdk-m"><span class="ov-mark" data-size="26"></span></span><span class="ovdk-t"></span>';-- pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });-- document.body.appendChild(pill);-- paintPill();-- }++ const kept = store();++ off = !!(kept && kept.off > Date.now());++ if (kept && kept.state && !doc) doc = kept;++ paintStatus();if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }read();}-- // ── Pop out (K-D4) ────────────────────────────────────────-- // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and-- // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks-- // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.-- const PIP_W = 360, PIP_H = 520;-- const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;-- // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).-- const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');---- function paintPop() {-- const b = el.querySelector('[data-act="pop"]');-- if (!b) return;-- b.hidden = !pip();-- const t = popped ? 'Bring the Dock back to the page' : 'Open the Dock in a window of its own';-- b.title = t; const name = b.querySelector('b'); if (name) name.textContent = t;-- }---- /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */-- function dress(win) {-- const d = win.document;-- if (!d.documentElement) d.appendChild(d.createElement('html'));-- if (!d.head) d.documentElement.appendChild(d.createElement('head'));-- if (!d.body) d.documentElement.appendChild(d.createElement('body'));-- d.title = 'OpenVibe Dock';-- for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ovda-styles', 'ov-mark-css']) {-- const s = document.getElementById(id);-- if (!s) continue;-- const c = d.createElement('style');-- c.id = id; c.textContent = s.textContent;-- d.head.appendChild(c);-- }-- const cs = getComputedStyle(document.documentElement);-- for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }-- const b = getComputedStyle(document.body);-- const s = d.body.style;-- s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;-- s.background = b.backgroundColor;-- d.documentElement.style.colorScheme = cs.colorScheme || '';-- d.documentElement.classList.add('ovdk-pip');-- }---- /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened-- * a window by itself would be a window nobody asked for. */-- async function popOut() {-- if (popped || !pip()) return;-- let win = null;-- try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }-- if (!win || !win.document) return;-- pipWin = win;-- popped = true;-- dress(win);-- win.document.body.appendChild(el);-- win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });-- paintPop();-- // Whether the Dock is in a window stays with this page: the window belongs to one page in one browser, closes-- // with it, and no other device could open it again (the browser wants a click), so the Network is not told.-- save({ open: true });-- paintPill();-- }---- /** The panel comes back to the page: the window was closed, or the pill was pressed. */-- function backToPage() {-- if (!popped) return;-- popped = false;-- const w = pipWin; pipWin = null;-- pipClosing = true;-- try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }-- pipClosing = false;-- if (el.parentNode !== document.body) document.body.appendChild(el);-- el.hidden = false;-- layout();-- paintPop();-- paintPill();-- }---- // ── Open and close ───────────────────────────────────────++ // ── Open: the menu, on one of its places ──// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.-- function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }-- function isOpen() { return !!(el && !el.hidden); }-- // A phone's sheet and the navbar's orb are one button (.from-orb).-- let orbLink = null, closing = 0;-- function navOrb() {-- if (!phone()) return null;-- const list = document.querySelectorAll('.openvibe-navbar .ovnav-orb');-- for (let i = 0; i < list.length; i++) { const r = list[i].getBoundingClientRect(); if (r.width && r.left > root.innerWidth / 2 && r.top < 120 && r.bottom > 0) return list[i]; }-- return null;-- }-- function fromOrb() {-- unlinkOrb();-- const nav = navOrb(), mine = el.querySelector('.ovdk-orbx'), mineSvg = mine && mine.querySelector('svg');-- if (!nav || !mineSvg || reduced() || popped) return;-- el.classList.add('from-orb'); // first: it sets the header's layout, and the close orb is measured where it will rest-- const a = (nav.querySelector('svg') || nav).getBoundingClientRect(), b = mineSvg.getBoundingClientRect(), box = el.getBoundingClientRect();-- if (!a.width || !b.width) { el.classList.remove('from-orb'); return; }-- const ax = a.left + a.width / 2, ay = a.top + a.height / 2, bx = b.left + b.width / 2, by = b.top + b.height / 2, s = a.width / b.width;-- el.style.setProperty('--ovdk-ox', `${Math.round(ax - box.left)}px`); el.style.setProperty('--ovdk-oy', `${Math.round(ay - box.top)}px`);-- mine.style.setProperty('--orb-dx', `${(ax - bx).toFixed(1)}px`); mine.style.setProperty('--orb-dy', `${(ay - by).toFixed(1)}px`); mine.style.setProperty('--orb-s', s.toFixed(3));-- nav.style.setProperty('--dock-dx', `${(bx - ax).toFixed(1)}px`); nav.style.setProperty('--dock-dy', `${(by - ay).toFixed(1)}px`); nav.style.setProperty('--dock-s', (1 / s).toFixed(3));-- mine.classList.add('is-set', 'is-from');-- orbLink = { nav, mine };-- void mine.offsetWidth; // the starting pose is put on, not animated into-- mine.classList.remove('is-set');-- void mine.offsetWidth;-- nav.style.transition = 'transform .42s cubic-bezier(.2, 1.1, .3, 1), color .3s';-- root.requestAnimationFrame(() => { if (!orbLink || orbLink.mine !== mine) return; mine.classList.remove('is-from'); nav.classList.add('is-dock'); });-- }-- function unlinkOrb() {-- if (closing) { clearTimeout(closing); closing = 0; }-- if (el) el.classList.remove('from-orb', 'is-out');-- if (!orbLink) return;-- orbLink.nav.classList.remove('is-dock'); orbLink.nav.style.transition = ''; orbLink.mine.classList.remove('is-from', 'is-set');-- orbLink = null;-- }++ function ask({ text = '' } = {}, n = 0) { if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }++ function isOpen() { return !!inline; }function share({ text = '', title = '' } = {}) {const t = String(text || '').trim().slice(0, 4000);if (!t) return;chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null;// A bar left from an earlier share would show the old words until the list reloads: it goes now.const stale = chats.panel && chats.panel.querySelector('.ovdk-share'); if (stale) stale.remove();++ if (panelActive('chats')) { chatList(); return; }open({ tab: 'chats' });}++ /** The menu opens on that place (navbar.js passes o.openMenu), and the menu mounts the panel with mountInto. */function open(opts = {}) {-- if (mountHandle) mountHandle.destroy();-- openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;-- o = { ...o, ...opts };-- if (opts.doc) doc = opts.doc;-- if (!el) build();-- lastFocus = document.activeElement;++ const { tab: want, doc: given, ...rest } = opts || {};++ o = { ...o, ...rest };++ if (given) doc = given;const c = counts();-- const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');-- layout();-- badges();-- paintPin();-- paintPop();-- el.hidden = false;-- el.classList.remove('is-in');-- fromOrb();-- void el.offsetWidth; el.classList.add('is-in');-- // On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.-- if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {-- const pill = document.getElementById('ovdk-pill');-- root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);-- }-- if (phone()) {-- el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');-- if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }-- } else { el.removeAttribute('aria-modal'); if (dockSurface) { dockSurface.destroy(); dockSurface = null; } }-- setTab(want, false);-- readUnread();-- if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();-- setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);-- }-- function close() {-- if (!isOpen()) return;-- // Fold back into the navbar's orb first, then go.-- if (orbLink && !closing && !popped && !reduced() && el.classList.contains('from-orb')) {-- el.classList.remove('is-in'); el.classList.add('is-out');-- orbLink.mine.classList.add('is-from'); orbLink.nav.classList.remove('is-dock');-- closing = setTimeout(() => { closing = 0; shut(); }, 280);-- return;-- }-- shut();-- }-- function shut(keepState = false) {-- if (!isOpen()) return;-- unlinkOrb();-- const wasPopped = popped;-- if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }-- const inside = el.contains(document.activeElement);-- el.hidden = true;-- if (dockSurface) { dockSurface.destroy(); dockSurface = null; }-- el.style.transform = '';-- el.removeAttribute('aria-modal');-- document.documentElement.classList.remove('ovdk-sheet-open');-- clear(0);-- clearTimeout(chats.timer); clearTimeout(chats.unreadT);-- if (!keepState) save({ open: false });-- if (inside) {-- const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);-- try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }-- }++ let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe');++ if (id === 'agents') id = 'agent';++ if (!TABS.some(([t]) => t === id)) id = 'vibe';++ if (inline && id === tab) return;++ if (typeof o.openMenu === 'function') o.openMenu(id);}-- /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */++ function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); }++ /** A fresh copy from the Network: counts, and anything changed elsewhere (the timer, the open chat). */function update(next) {if (!next || !next.state) return;doc = Object.keys(pending).length ? { ...next, state: state() } : next;store(doc);if (timerMod) timerMod.sync();-- paintPill();-- if (!el) return;-- badges();-- paintPin();-- if (isOpen()) layout();++ paintStatus();}let mountHandle = null;/** Place the same panel body under another sheet's head. The helper and its layout load on first use. */-- function mountInto(host, { tab: first = 'activity' } = {}) {++ function mountInto(host, { tab: first = 'vibe' } = {}) {if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');if (mountHandle) mountHandle.destroy();-- if (isOpen()) { clearTimeout(closing); closing = 0; shut(true); }let api = null, gone = false, wanted = first;const handle = {show(id) { if (gone) return; wanted = id; if (api) api.show(id); },@@@ -1115,15 +720,19 @@mountHandle = handle;load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {-- start(wrap) { if (!el) build(); inline = wrap; wrap.append(bodyEl); },-- show(id) { setTab(id, false); badges(); },-- end() { disposePanel(); inline = null; if (el) el.insertBefore(bodyEl, el.querySelector('.ovdk-size')); paintPill(); },++ start(wrap) { if (!bodyEl) build(); inline = wrap; wrap.append(bodyEl); },++ show(id) { setTab(id, true); },++ end() { disposePanel(); inline = null; if (bodyEl) bodyEl.remove(); paintStatus(); },});});return handle;}root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share,-- filterChats: (v) => { chats.filter = v.toLowerCase(); chatList(); },-- more: a => el.querySelector(`[data-act="${a}"]`).click(), canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };++ // The menu's search box filters the conversations. The menu redraws when its own loads arrive (and calls this with the++ // same words): an unchanged filter does nothing, so an open chat is never swapped for the list under the person.++ filterChats: (v) => { const f = String(v || '').toLowerCase(); if (f === chats.filter) return; chats.filter = f; chatList(); },++ // The timer's menu (the menu's Vibe tab has its button): presets, a focus round, your own.++ timer: (anchor) => withTimer((t) => t.menu(anchor || null)), more: (a) => { if (a === 'timer') withTimer((t) => t.menu(null)); },++ status, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };})(typeof window !== 'undefined' ? window : globalThis);@@@ -22,7 +22,7 @@ thing the person is looking at. `bottom.js` measures the difference and writes i| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts || `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |--`navbar.js` loads `bottom.js` on every page before the bar, the Dock and the small player draw. Every rule that uses++`navbar.js` loads `bottom.js` on every page before the bar and the small player draw. Every rule that usesthese variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the@@@ -38,10 +38,8 @@ The bottom of the screen, low to high (these are the only z-indexes that belong|---|---|---|| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) || the small player | `.ovmini` | `--ovb-z-mini` (9991) |--| the Dock's pill | `.ovdk-pill` | `--ovb-z-pill` (9995) |--| the Dock's card | `.ovdk` | `--ovb-z-dock` (9996) |--A phone's Dock sheet (10010) covers the navbar; sheets (10060), the notification panel (99999) and toasts/notices++Sheets (10060), the notification panel (99999) and toasts/notices(2147483000+) sit above all of these and do not belong to the bottom stack.## The page's end@@@ -66,7 +64,7 @@ while a shimmed `visualViewport` opens and closes the toolbar (the layout viewpoupdate reaches the visible viewport first), and fails when:- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;--- the small player is not 8 px above the bar, or the Dock's pill not 16 px above the small player;++- the small player is not 8 px above the bar;- any of the three overlaps another;- a gap jumps by more than 1 px between samples.@@@ -546,7 +546,7 @@ One control on every site: the menu button at the top of the page (or `/`, or Ctto be two panels doing the same job, the launcher's dots (every site) and the magnifier (search).**The menu button** (4.187.0; the owner, 2026-11-10: the Arena's "Search and apps"): a search bar over three apps,--drawn by `OpenVibeOrb.svg()` in the navbar, the menu's sheet and the Dock.++drawn by `OpenVibeOrb.svg()` in the navbar and the menu's sheet.- **Hovered:** its parts lift in turn.- **While the menu loads:** the apps breathe.- **While it is open:** it becomes the × that closes the menu, in the same place.@@@ -1,6 +1,6 @@# One conversation UI: `openvibe-shared/thread`--Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the Dock use the same parts,++Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the menu's Chat and Vibe use the same parts,so a conversation looks and behaves the same on every OpenVibe site.- **Server:** `require('openvibe-shared/thread')` builds the markup. It is complete without script and every string is@@@ -324,17 +324,17 @@/* While the menu loads: the apps breathe in turn. */.ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }/* Open: the bar is one stroke of the close mark, the three apps the other. */-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }-- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */.ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }@keyframes ovmb-form { from { transform: none; } }-- .ovnav-orb:not([aria-expanded="true"]):not(.is-dock) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }++ .ovnav-orb:not([aria-expanded="true"]) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }.ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; }@keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } }@keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }@@@ -602,7 +602,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '68900e53dead', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'c1f8f8dfab77', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -1217,7 +1217,7 @@// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').burger.addEventListener('click', (e) => {e.stopPropagation();-- if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch('', 'here', burger); return; }++ if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch('', 'here', burger); return; }const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);});drawer.addEventListener('click', (e) => {@@@ -1352,12 +1352,18 @@const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);}-- // ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;-- // for a signed-in person it loads alongside notifications. A Network without the route yet (dock.js-- // keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).++ // ── The menu's Chat and Vibe (dock.js's panels; 4.220.0: no floating Dock). For a signed-in person dock.js reads their++ // state (a Network without the route is not asked again for a day) and paints its status on the menu button.const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');-- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling });-- const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';++ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling,++ status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });++ let _menuStatus = null;++ /** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */++ function openMenuOn(id) {++ if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;++ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];++ openSearch('', 'here', orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, id);++ }function loadDock(then) {if (root.OpenVibeDock) return then();let sc = document.getElementById('ov-dock-loader');@@@ -1427,7 +1433,7 @@// Back from the next page (the browser's page cache): the bar is gone.root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); });}-- // The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.++ // The menu on a place ('chats' | 'vibe' | 'code' | 'agent'); opts.task marks an agent's step and brings it into view.function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.function askAgent(text) {@@@ -2213,7 +2219,6 @@bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' },!isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null,!isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,-- bell && _config.dock !== false ? { id: 'ov-dock', label: 'The Dock: your agents and activity on every page', short: 'Dock', drawn: DOCK_GLYPH, mark: 'data-ov-dock' } : null,admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,!isAnon ? { id: 'ov-wallet', label: 'Wallet', drawn: navIcon('fa-wallet'), href: 'https://openvibe.network/my#wallet' } : null,!isAnon && u.username ? { id: 'ov-profile', label: 'Profile', drawn: navIcon('fa-id-card'), href: `https://openvibe.network/@${encodeURIComponent(u.username)}` } : null,@@@ -2260,7 +2265,6 @@nav.appendChild(dropdown);_accountEl = dropdown;bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));-- dropdown.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('[data-ov-dock]')) return; dropdown.classList.remove('open'); openDock(); });bindChips(dropdown);if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });bindDisplayRows(dropdown);@@@ -2367,7 +2371,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, section) {++ function openSearch(q, tab, fromEl, section, door) {// 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.@@@ -2381,14 +2385,14 @@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, from: fromEl || null, section,++ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,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); },look: bindLook,-- dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => { if (root.OpenVibeSearch) root.OpenVibeSearch.close(); openDock(t); } : null,++ dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => openDock(t) : null,dockMore: (action) => { if (root.OpenVibeDock && root.OpenVibeDock.more) root.OpenVibeDock.more(action); },-- ask: (text) => { if (root.OpenVibeSearch) root.OpenVibeSearch.close(); loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); },++ ask: (text) => { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); },me: _accountEl ? { add: _config.accounts === false ? '' : addAccountHref(), out: () => { const b = _accountEl.querySelector('#openvibe-logout-btn'); if (b) b.click(); }, own: _own } : null,onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');@@@ -2404,6 +2408,7 @@// Every site: the menu on its first tab, scrolled to the sites, out of the menu button on screen.function openSwitcher() {if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }++ if (_menuStatus && _menuStatus.tone === 'waiting') { openMenuOn('agent'); return; }const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);}@@@ -2471,7 +2476,8 @@function hookSearch(nav) {const b = nav.querySelector('#openvibe-search-btn');if (b) {-- b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch(); });++ // An agent that needs the person opens the menu on Agents (the Dock's pill did, 4.220.0).++ b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch(); });for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });if (menuOpen()) b.setAttribute('aria-expanded', 'true');// One ripple a tab, a moment after the first paint: here is the menu.@@@ -8,7 +8,6 @@// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })// OpenVibeNotifications.createBell(mountEl)// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox--// OpenVibeNotifications.renderDock(containerEl) // the Dock's Activity tab (dock.js): the same list, no frame//// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)@@@ -131,7 +130,7 @@/* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;-- right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ov-dock-clear, 0px)); width: min(380px, calc(100vw - 32px));++ right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px))); width: min(380px, calc(100vw - 32px));font-family: inherit; transition: opacity .18s ease; }.openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }.openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }@@@ -1481,15 +1480,6 @@reload(v);return v;},-- /** The Dock's Activity tab: the bell's list in the Dock's container (no frame, no title, no close). */-- renderDock(container) {-- injectStyles();-- if (!container) return null;-- container.classList.add('openvibe-notif-dock');-- const v = buildView(container, { mode: 'dock' });-- reload(v);-- return v;-- },/** The You tab's preview (4.211.0): the newest three, no tools; Show all opens the whole list in place. */renderMenuNotifications(container, onCount) {injectStyles();@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.219.0",++ "version": "4.220.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.219.0",++ "version": "4.220.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.219.0",++ "version": "4.220.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -314,6 +314,15 @@/* 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; }++/* One UI (4.220.0; the owner, 11-12: "part of the one UI instead of having two different areas"): inside the menu a place has++ no head of its own. A conversation keeps a slim way back and its name; its actions are the search pill's; its mark is the++ greeting's; no band, no border. */++.ovs-door-host .ovdk-thread-h { min-height: 40px; padding: 2px 12px 2px 6px; gap: 4px; border-bottom: 0; }++.ovs-door-host .ovdk-thread-h > .ovdk-ib, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-mark, .ovs-door-host .ovdk-vibe-name small { display: none; }++.ovs-door-host .ovdk-thread-h .ovdk-back, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-back { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }++.ovs-door-host .ovdk-thread-h b, .ovs-door-host .ovdk-vibe-name b { font-size: 14px; font-weight: 700; color: var(--text-secondary, #b4c0d4); }++.ovs-door-host .ovdk-vibe-start { padding-top: 2px; }++.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); }@@@ -321,6 +330,13 @@.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }.ovs-door-sw { flex: none; display: grid; grid-template-columns: repeat(3, minmax(0, 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-top { flex: none; display: flex; align-items: center; gap: 6px; margin: 8px 12px 4px; }++.ovs-door-top:not(:has(> :not([hidden]))) { display: none; }++.ovs-door-top > .ovs-door-sw { flex: 1 1 auto; min-width: 0; margin: 0; }++.ovs-door-top > .ovs-ai-timer { flex: none; width: 50px; min-height: 50px; margin: 0; padding: 0; justify-content: center; border-radius: 14px; }++.ovs-door-top > .ovs-ai-timer span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.ovs-door-top > .ovs-ai-timer svg { width: 20px; height: 20px; }++@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { display: none; } }.ovs-door-sw button { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s; }.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }@@@ -391,13 +407,6 @@.ovs-ai-timer[hidden] { display: none; }.ovs-ai-timer:hover, .ovs-ai-timer:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--text-primary, #e6edf7); }.ovs-ai-timer svg { flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }--.ovs-dockyou-b { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; padding: 0 14px; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }--.ovs-dockyou-b::after { content: ""; width: 7px; height: 7px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); transition: transform .2s; }--.ovs-dockyou-b[aria-expanded="true"]::after { transform: rotate(225deg); }--.ovs-dock-you { display: grid; gap: 2px; padding: 0 6px 8px; }--.ovs-dock-you[hidden] { display: none; }--.ovs-dockyou-i { display: flex; align-items: center; min-height: 44px; padding: 0 12px; border: 0; border-radius: 11px; background: none; color: var(--text-primary, #e6edf7); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer; }--.ovs-dockyou-i:hover, .ovs-dockyou-i:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }@@@ -532,12 +541,14 @@doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['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';-- const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;-- timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });-- doorEl.append(doorSw, doorHost, timerButton);++ const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;++ timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });++ // Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.++ const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw, timerButton);++ doorEl.append(doorTop, doorHost);listEl.after(doorEl);doorHost.addEventListener('openvibe:open-vibe', (e) => {-- if (door !== 'chats' || !inSheet()) return;++ if (door !== 'chats' || !canDoor()) return;e.preventDefault();openDoor('vibe');});@@@ -596,7 +607,7 @@const it = a && items[Number(a.getAttribute('data-i'))];if (it && !input.value.trim()) beacon(it, a);if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {-- e.preventDefault(); close(); try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } return;++ e.preventDefault(); if (!showDoor(it.dock)) { try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } } return;}if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }@@@ -627,8 +638,8 @@e.preventDefault();const q = input.value.trim();if (tab === 'feed' && homeLib() && !door) { render(q); return; }-- if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }-- if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }++ if (door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }++ if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }const it = items[active >= 0 ? active : 0];// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).if (q && !moved && view !== 'results' && !exact(it, q)) { showResults(q); return; }@@@ -703,18 +714,7 @@items = [];listEl.textContent = '';if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }-- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'dock-row', 'notifications'].includes(g.as)).forEach((g) => {-- if (g.as === 'dock-row') {-- const li = document.createElement('li'); li.setAttribute('role', 'presentation');-- const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-dockyou-b'; b.setAttribute('aria-expanded', 'false'); b.textContent = 'The Dock';-- const host = document.createElement('div'); host.className = 'ovs-dock-you'; host.hidden = true;-- [['timer', 'Start a timer'], ['pin', 'Keep the Dock button on every page'], ['pop', 'Open the Dock in its own window']].forEach(([action, label]) => {-- const item = document.createElement('button'); item.type = 'button'; item.className = 'ovs-dockyou-i'; item.textContent = label;-- item.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore(action); } catch { /* Dock optional */ } }); host.appendChild(item);-- });-- b.addEventListener('click', () => { host.hidden = !host.hidden; b.setAttribute('aria-expanded', String(!host.hidden)); });-- li.append(b, host); listEl.appendChild(li); return;-- }++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'notifications'].includes(g.as)).forEach((g) => {if (g.as === 'notifications') {const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'notifications');const host = document.createElement('div'); host.className = 'ovs-notifications-host'; li.appendChild(host);@@@ -1140,11 +1140,8 @@* live or a count of what is new since you last looked, and You with your picture and an online dot.*//** A phone's menu with the Dock to hand: its places open inside the menu, under the menu's own head. */-- function inSheet() {-- let phone = false;-- try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }-- return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');-- }++ /** Chat and Vibe open inside the menu, on every screen (4.220.0: the floating Dock is gone). */++ function canDoor() { return !!(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@@@ -1154,7 +1151,8 @@if (!actsEl) return;actsEl.textContent = '';if (!door || !doorHost) return;-- doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib').forEach((orig) => {++ // A list's actions and an open conversation's (+ for a new chat, ↗ to open it in Chat): one head, the menu's (4.220.0).++ doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib, .ovdk-thread-h > .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;@@@ -1171,9 +1169,17 @@}mirrorActs();}-- /** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */++ /** A place by the Dock's name ('chats', or Vibe's parts 'vibe' | 'code' | 'agent'), in the open menu. */++ function showDoor(id) {++ if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;++ const want = id === 'agents' ? 'agent' : id;++ if (['vibe', 'code', 'agent'].includes(want)) aiPart = want;++ openDoor(want === 'chats' ? 'chats' : 'vibe');++ return door != null;++ }++ /** Show a place in the menu: Messages, or AI (Vibe, Code or your agents, by the switch). */function openDoor(id) {-- if (!doorEl || !inSheet()) return;++ if (!doorEl || !canDoor()) return;door = id;const want = id === 'chats' ? 'chats' : aiPart;doorSw.hidden = id !== 'vibe';@@@ -1223,10 +1229,9 @@// A place of the Dock. On a phone it opens here, inside the menu, and is then the chosen tab; on a computer// it opens the floating Dock beside the page (it is kept beside the work there).b.setAttribute('data-tab', id); b.setAttribute('data-dock', id); b.setAttribute('aria-selected', door === id ? 'true' : 'false');-- if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);const t = document.createElement('span'); t.textContent = label; b.appendChild(t);-- b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } close(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });++ b.addEventListener('click', (e) => { e.stopPropagation(); if (canDoor()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* not loaded yet: it opens the menu here when it is */ } });tabsEl.appendChild(b);continue;}@@@ -1438,7 +1443,6 @@return groups;}if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });-- if (inSheet()) groups.push({ title: '', as: 'dock-row', items: [] });const yours = mine && mine.services.some((x) => x.count > 0);// Without their own history, developer services are not places to visit: they come up here once used.const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));@@@ -1503,8 +1507,8 @@function render(q) {q = String(q || '').trim();el.classList.toggle('ovs-typing', !!q);-- if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }-- if (door === 'vibe' && inSheet()) return;++ if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }++ if (door === 'vibe') return;if (tab === 'feed' && homeLib()) {tabsEl.hidden = false;input.placeholder = 'Search the feed';@@@ -1862,6 +1866,7 @@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 (opts.door) showDoor(opts.door);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);}@@@ -1906,7 +1911,7 @@document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });-- const api = { open, close, suggest, siteOf, registerCard, _state: () => ({ tab, from: ctx.from }) };++ const api = { open, close, door: (id) => showDoor(id), suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };root.OpenVibeSearch = api;if (typeof module === 'object' && module.exports) module.exports = api;})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -8,7 +8,8 @@// bottom: 0 is left behind);// • the page scrolls up and down through four positions and the toolbar opens and closes;// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px--// above the bar and the Dock's pill 16 px above the small player, and none of them overlap;++// above the bar, and they do not overlap (4.220.0: the Dock's pill is gone, the menu holds its places; no pill++// may come back);// • the gaps do not jump by more than 1 px between samples.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('node:assert/strict');@@@ -90,17 +91,16 @@ const MEASURE = `(() => {if (!r.width || !r.height) return null;return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };};-- const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = box('#ovdk-pill');++ const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = document.querySelector('#ovdk-pill, .ovdk-pill');const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));return {visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),-- bar, mini, pill,++ bar, mini, pill: !!pill,gaps: {bar: bar && px(visibleBottom - bar.b),mini: (bar && mini) ? px(bar.t - mini.b) : null,-- pill: (mini && pill) ? px(mini.t - pill.b) : null,},-- overlap: { barMini: area(bar, mini), barPill: area(bar, pill), miniPill: area(mini, pill) },++ overlap: { barMini: area(bar, mini) },};})()`;@@@ -121,8 +121,8 @@ const SCROLLS = [0, 900, 1800, 500];await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };-- assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)') && !!document.querySelector('#ovdk-pill') && !document.querySelector('#ovdk-pill').hidden`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);-- await wait(450); // the small player's entrance and the Dock's first read++ assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)')`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);++ await wait(450); // the small player's entrance and the Dock's first read (status only, no pill)const samples = [];for (const [i, y] of SCROLLS.entries()) {for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {@@@ -148,28 +148,22 @@ const SCROLLS = [0, 900, 1800, 500];const at = `${m.y}px/${m.toolbar}`;check(!!m.bar, `${at}: the app bar is on the page`);check(!!m.mini, `${at}: the small player is on the page`);-- check(!!m.pill, `${at}: the Dock's pill is on the page`);-- if (!m.bar || !m.mini || !m.pill) continue;++ check(!m.pill, `${at}: no Dock pill (the menu holds its places)`);++ if (!m.bar || !m.mini) continue;check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);-- check(Math.abs(m.overlap.barPill) === 0, `${at}: the bar and the Dock overlap by ${m.overlap.barPill}px²`);-- check(Math.abs(m.overlap.miniPill) === 0, `${at}: the small player and the Dock overlap by ${m.overlap.miniPill}px²`);check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);-- check(Math.abs(m.gaps.pill - 16) <= 1, `${at}: the Dock sits ${m.gaps.pill}px above the small player (16)`);}const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);-- check(spread('pill') <= 1, `${width}x${height}: the Dock jumps ${spread('pill')}px between samples`);const barGaps = samples.map((m) => m.gaps.bar);const miniGaps = samples.map((m) => m.gaps.mini);-- const pillGaps = samples.map((m) => m.gaps.pill);report.push({size: `${width}x${height}`, samples: samples.length,bar: [Math.min(...barGaps), Math.max(...barGaps)],mini: [Math.min(...miniGaps), Math.max(...miniGaps)],-- pill: [Math.min(...pillGaps), Math.max(...pillGaps)],-- overlap: Math.max(...samples.map((m) => m.overlap.barMini + m.overlap.barPill + m.overlap.miniPill)),++ overlap: Math.max(...samples.map((m) => m.overlap.barMini)),});if (failures.length) {failed++;@@@ -179,8 +173,8 @@ const SCROLLS = [0, 900, 1800, 500];} finally { await page.close(); }}} finally { await browser.close(); }-- for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, pill ${r.pill[0]}..${r.pill[1]}px, overlap ${r.overlap}px²`);++ for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, overlap ${r.overlap}px²`);if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }-- console.log('bottom-chrome: the bar, the small player and the Dock hold the bottom at every size and toolbar state');++ console.log('bottom-chrome: the bar and the small player hold the bottom at every size and toolbar state, and no Dock pill is drawn');server.close();})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });@@@ -1,23 +1,19 @@'use strict';--// The Dock (dock.js, the pill in navbar.js; Dock plan 2026-10-30 K-D1) in a real headless Chrome, against a local--// stand-in for the Network's GET/PUT /api/v1/me/dock, /api/v1/me/actions and the notification routes:--// • the pill shows only while an agent works, one needs you, or it is pinned; it names both, in its corner;--// • a 404 (Network without the route yet) keeps it away, and signed out there is none;--// • a tap opens the card on Agents when something needs you (waiting first, approve in place), Activity is the--// bell's list, Escape closes and gives the focus back to the pill;--// • every change reaches the Network as one debounced PUT; on a desktop the head drags the card to a corner and the--// free corner resizes it inside 320-720 × 360-900; an open Dock reopens by itself on a desktop, never on a phone;--// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;--// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs--// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone--// never reads as a tap outside the sheet;--// • Vibe (L48 V2): the person's Vibe from Chat's /api/vibe, starters before the first message, a question posted and--// Vibe's answer arriving in place (an https link as a link), and the Vibe tab kept in the Network's state;--// • the timer (L48 R2): the header's menu starts one, the pill counts it down when the Dock is closed, its end--// clears it and says so, and a focus round's end writes its break from that same end (any page, the same result);--// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with--// its tabs and its styles, the agent view's stream and styles included, the page keeps the pill saying where the--// Dock went, and closing the window brings the panel back; the Network is told.++// The menu's Chat and Vibe places (dock.js's panels; Dock plan 2026-10-30 K-D1, and 4.220.0: the owner, 11-12, "there++// should only be the one unified UI … delete … the old tab system") in a real headless Chrome, against a local stand-in++// for the Network's GET/PUT /api/v1/me/dock, /api/v1/me/actions, Chat's DM and Vibe APIs and Codes' agent view:++// • no floating Dock and no pill, ever: what the pill said is a status on the bar's menu button (agents working, one++// needs you, a timer's minutes), and a 404 (Network without the route yet) or a signed-out page says nothing;++// • the menu button opens on Agents when something needs you (waiting first, approve in place); Escape closes it;++// every change reaches the Network as one debounced PUT;++// • Chats (K-D2): the person's conversations with their unread counts, a thread opened in place (theirs and mine), Edit++// as a pencil at the bubble's corner with the box as the editor, Enter sends, read, the open chat is the state;++// • Send to (4.144.0), the agent view (K-D3), and nothing off a 320 px screen;++// • Vibe (L48 V2): starters before the first message, a question and Vibe's answer in place, voice without the++// browser's speech recognition, and the Vibe place kept in the Network's state;++// • the timer (L48 R2): the Vibe place's timer button starts one, the menu button counts it down, its end says so++// and clears it, and a focus round's end writes its break from that same end;++// • Ask your agent: a page's [data-ov-ask] opens the menu on Vibe with the question in the box, unsent.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('assert');const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)@@@ -229,36 +225,16 @@ async function until(p, expr, what, ms = 15000) { // a limit on a poll: a loadawait wait(80);}}--const PILL = `(() => { const p = document.getElementById('ovdk-pill'); if (!p || p.hidden) return null; const b = p.getBoundingClientRect();-- return { text: p.innerText.trim(), label: p.getAttribute('aria-label'), corner: p.dataset.corner, tone: p.dataset.tone, l: b.left, r: b.right, t: b.top, bt: b.bottom,-- clear: getComputedStyle(document.documentElement).getPropertyValue('--ov-dock-clear').trim() }; })()`;--const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hidden) return null; const b = c.getBoundingClientRect();-- return { corner: c.dataset.corner, w: Math.round(b.width), h: Math.round(b.height), l: b.left, t: b.top, tab: (c.querySelector('[role=tab][aria-selected=true]') || {}).dataset?.tab,-- groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;++// 4.220.0: what the Dock's pill said is the menu button's status; the panels are the menu's places.++const STATUS = `(() => { const b = [...document.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')].find((x) => x.getBoundingClientRect().width > 0);++ if (!b || !b.getAttribute('data-ov-status')) return null; const c = b.querySelector('.ovnav-st');++ return { tone: b.getAttribute('data-ov-status'), label: b.getAttribute('data-ov-tip'), text: c ? c.textContent : '' }; })()`;++const PLACE = `(() => { const c = document.querySelector('.ovs.is-open .ovs-door-host .ovdk'); if (!c) return null; const panel = c.querySelector('[data-panel]:not([hidden])');++ return { tab: panel && panel.dataset.panel, groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length }; })()`;++const OPEN = (tab) => `(window.OpenVibeDock.open({ tab: '${tab}' }), true)`;++const MENU_BUTTON = "[...document.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')].find((x) => x.getBoundingClientRect().width > 0).click()";++const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host'))";--// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the--// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.--const PIP = `(() => {-- const f = document.createElement('iframe');-- f.id = 'pip-stub';-- f.style.cssText = 'position:fixed;right:16px;bottom:16px;width:360px;height:520px;border:0;z-index:99999;background:#0d0d12';-- document.body.appendChild(f);-- const w = f.contentWindow;-- const shut = () => { try { w.dispatchEvent(new w.Event('pagehide')); } catch (e) { } try { f.remove(); } catch (e) { } };-- w.close = shut;-- const dp = { requestWindow: async (o) => { window.__pip.opts = o || null; return w; } };-- window.__pip = { win: w, frame: f, close: shut, dp };-- // Chromium has its own Document Picture-in-Picture getter; an own, writable value stands in for it in the test.-- Object.defineProperty(window, 'documentPictureInPicture', { value: dp, writable: true, configurable: true });-- return true;--})()`;----async function drag(p, from, to) {-- await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from[0], y: from[1] });-- await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: from[0], y: from[1], button: 'left', buttons: 1, clickCount: 1 });-- for (let i = 1; i <= 8; i++) await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from[0] + (to[0] - from[0]) * i / 8, y: from[1] + (to[1] - from[1]) * i / 8, button: 'left', buttons: 1 });-- await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: to[0], y: to[1], button: 'left', buttons: 0, clickCount: 1 });--}(async () => {await new Promise((ok) => server.listen(0, '127.0.0.1', ok));@@@ -299,12 +275,12 @@ async function drag(p, from, to) {await shot(p, 'overview');// The Vibe tab: the list of the person's conversations.-- await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");++ await p.evaluate(OPEN('vibe'));await until(p, "document.querySelectorAll('.ovdk-vibe-list button').length >= 3", 'the Vibe list');await shot(p, 'vibe');// The Chats tab: the AI chats row among the DMs.-- await p.evaluate("document.querySelector('#ovdk-tab-chats').click()");++ await p.evaluate(OPEN('chats'));await until(p, "!!document.querySelector('.ovdk-chat-b b')", 'the Chats rows');await shot(p, 'chats');await p.close();@@@ -314,55 +290,41 @@ async function drag(p, from, to) {// Two halves (each well under the runner's 60 s; the whole file took about 65 s and timed out on macOS): this file// runs the first (DOCK_CHROME_PART 1, the default), test/dock-vibe-timer-chrome.test.js the second, 0 both.if (PART !== 2) {-- // ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──++ // ── Nothing runs, nothing waits: the menu button says nothing. Agents working: a breathing dot on it. No pill. ──{scenario = { counts: { running: 0, waiting: 0, unread: 3 } };const p = await fresh(390);await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!window.OpenVibeDock", 'the panels beside the navbar');await wait(700);-- await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)'));-- scenario.state = { ...defaultState(), pinned: true };++ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'nothing to say while nothing runs or waits (unread alone is the bell\'s)'));++ await eventually(async () => assert.strictEqual(await p.evaluate(NO_FLOATING), true, 'no pill and no floating Dock'));++ scenario.counts = { running: 2, waiting: 0, unread: 0 };await p.goto(`${base}/page`, { maxMs: 5000 });-- const pin = await until(p, PILL, 'the pinned pill');-- assert.deepStrictEqual([pin.text, pin.tone, pin.label], ['', 'idle', 'Open the Dock'], 'pinned and idle: the mark alone');++ const st = await until(p, STATUS, 'the status on the menu button');++ assert.deepStrictEqual([st.tone, st.label, st.text], ['running', '2 working', ''], 'agents working: a dot, named in the tooltip');await p.close();}-- // ── A phone: the pill says what matters, the sheet opens on Agents, Escape closes it. ──++ // ── A phone: the menu button says what matters and opens on Agents; approve in place; Escape closes it. ──{scenario = { counts: { running: 2, waiting: 1, unread: 2 } };puts.length = 0; decided.length = 0;const p = await fresh(390, 780);await p.goto(`${base}/page`, { maxMs: 5000 });-- const pill = await until(p, PILL, 'the pill');-- assert.deepStrictEqual([pill.text, pill.label, pill.corner, pill.tone], ['Needs you', '1 needs you, 2 working', 'bottom-right', 'waiting']);-- assert.ok(pill.r <= 390 - 16 + 0.5 && pill.bt <= 780 - 16 + 0.5, `the pill keeps its 16 px from the edges (${pill.r}, ${pill.bt})`);-- assert.strictEqual(pill.clear, '0px', 'phones: the toasts are at the top, nothing to clear');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- const card = await until(p, `(() => { const c = ${CARD}; return c && c.items ? c : null; })()`, 'the Agents tab');-- assert.deepStrictEqual([card.tab, card.groups, card.items], ['agent', ['waiting', 'running'], 3], 'what needs you first, then what runs');-- await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished rising-- const placed = await p.evaluate(CARD);-- assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);-- await eventually(async () => assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open'));++ const st = await until(p, STATUS, 'the status');++ assert.deepStrictEqual([st.tone, st.label], ['waiting', '1 needs you'], 'what needs you comes first');++ await p.evaluate(MENU_BUTTON);++ const place = await until(p, `(() => { const c = ${PLACE}; return c && c.items ? c : null; })()`, 'the menu, open on Agents');++ assert.deepStrictEqual([place.tab, place.groups, place.items], ['agent', ['waiting', 'running'], 3], 'what needs you first, then what runs');++ assert.strictEqual(await p.evaluate(NO_FLOATING), true, 'inside the menu, nothing floating');await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');-- await p.evaluate("document.getElementById('ovdk-tab-activity').click()");-- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');-- await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");-- await until(p, `!(${CARD})`, 'Escape closes');-- await eventually(async () => assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill'));-- await wait(900);-- const all = Object.assign({}, ...puts);-- assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);++ await p.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))");++ await until(p, "!document.querySelector('.ovs.is-open')", 'Escape closes the menu');++ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'agent'), `the Network heard the place (${JSON.stringify(puts)})`));assert.ok(puts.length <= 3, `changes go as debounced PUTs, not one per click (${puts.length})`);-- // The Network says it was left open: a phone does not throw a sheet over the next page.-- scenario.state = { ...scenario.state, open: true };-- await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, PILL, 'the pill again');-- await wait(400);-- await eventually(async () => assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked'));await p.close();}@@@ -372,31 +334,38 @@ async function drag(p, from, to) {puts.length = 0; said.length = 0; reads.length = 0;const p = await fresh(390, 780);await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, PILL, 'the pill');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, "!!document.getElementById('ovdk-tab-chats')", 'the Chats tab');-- await until(p, "document.querySelector('[data-n=chats]').textContent === '3'", 'the unread count from Chat');-- await p.evaluate("document.getElementById('ovdk-tab-chats').click()");++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(OPEN('chats'));const rows = await until(p, "(() => { const r = [...document.querySelectorAll('.ovdk .ovdk-chat')]; return r.length ? r.map((b) => b.getAttribute('aria-label') + ' | ' + b.querySelector('.ovdk-chat-p').textContent) : null; })()", 'the conversations');assert.deepStrictEqual(rows, ['Kai Moreno, 2 unread | see the shots?', 'Design crit | You: Shipped it'], 'named for the other person (or the group), unread first, mine as "You:"');await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');++ // The menu redraws when its own loads arrive and hands its (empty) search words on again: the open chat stays.++ await p.evaluate("OpenVibeDock.filterChats(''), true");++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovdk-msg').length"), 2, 'a menu redraw never swaps the open chat for the list'));const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');-- await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");-- await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true));-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet'));++ // 4.220.0: Edit is a pencil at the bubble's corner, hidden until hover, focus or a tap (rule 138); the box is++ // the editor (no browser prompt), and "edited" is one quiet word inside the bubble.++ assert.strictEqual(await p.evaluate("getComputedStyle(document.querySelector('.ovdk .ovdk-acts')).display"), 'none', 'the pencil waits for a tap');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-edit').getAttribute('aria-label')"), 'Edit message');++ await p.evaluate("(() => { window.prompt = () => { window.__prompted = true; return null; }; document.querySelector('.ovdk .ovdk-edit').click(); })()");++ await until(p, "!document.querySelector('.ovdk .ovdk-edit-bar').hidden && document.querySelector('.ovdk-send textarea').value === '<b>on it</b>'", 'the box holds the words, under its editing bar');++ assert.strictEqual(await p.evaluate("!!window.__prompted"), false, 'never the browser prompt');++ await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'updated by me'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");++ await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').firstChild.textContent === 'updated by me'", 'the edited message');++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text .ovdk-edited').textContent"), 'edited', 'one quiet word, inside the bubble'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-edit-bar').hidden && document.querySelector('.ovdk-send textarea').value === ''"), true, 'saved: the bar goes and the box is empty'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs.is-open .ovdk')"), true, 'the thread stays in the menu'));await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');assert.ok(reads.includes('11'), 'the thread is marked read as it is shown');-- await wait(900);-- assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`);++ await eventually(async () => assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`));await p.evaluate("document.querySelector('.ovdk-back').click()");await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'back to the list');await p.close();@@@ -408,7 +377,7 @@ async function drag(p, from, to) {said.length = 0; started.length = 0;const p = await fresh(390, 780);await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, PILL, 'the pill');++ await until(p, "!!window.OpenVibeDock", 'the panels');const TEXT = 'Friday film https://openvibe.party/p/friday?k=abc';const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);await share('Friday film');@@@ -431,17 +400,15 @@ async function drag(p, from, to) {await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');assert.deepStrictEqual(said, [], 'still nothing sent');-- // On a phone, a page button that sends something opens the sheet and it stays open (the opening tap is not-- // "a tap outside"); a later tap outside still closes it.-- await p.evaluate("(() => { OpenVibeDock.close(); const b = document.createElement('button'); b.id = 'pagesend'; b.textContent = 'Send it'; b.addEventListener('click', () => OpenVibeDock.share({ text: 'From a page button' })); document.body.prepend(b); })()");++ // A page button that sends something opens the menu on Chat, and the opening tap does not close it.++ await p.evaluate("(() => { OpenVibeDock.close(); const b = document.createElement('button'); b.id = 'pagesend'; b.textContent = 'Send it'; b.addEventListener('click', () => OpenVibeDock.share({ text: 'From a page button' })); document.body.appendChild(b); })()");++ await until(p, "!document.querySelector('.ovs.is-open')", 'closed first');await p.evaluate("document.getElementById('pagesend').click()");-- await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');++ await until(p, "!!document.querySelector('.ovs.is-open .ovdk .ovdk-share')", 'the bar, opened by a tap on the page');await wait(300);-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet'));-- await p.evaluate("document.body.click()");-- // A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment-- // after the tap, not in the same instant.-- await until(p, "!document.querySelector('.ovdk:not([hidden])')", 'a later tap outside closes it');++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs.is-open')"), true, 'the opening tap does not close the menu'));++ await p.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))");++ await until(p, "!document.querySelector('.ovs.is-open')", 'Escape closes it');await share();await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');// Cancel: back to the list, nothing waiting.@@@ -469,9 +436,9 @@ async function drag(p, from, to) {// The embed is Live's own page; the harness blocks its address so the test never reaches it.const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780, block: ['https://openvibe.live/*'] });await p.goto(`${base}/page`, { maxMs: 8000 });-- await until(p, PILL, 'the pill');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab');++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(OPEN('agent'));++ await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place');// The stream: Live's embed, the task, the numbers.const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');@@@ -501,9 +468,9 @@ async function drag(p, from, to) {// No stream at all: the view hides itself and the tab is just the list.agentView = null;await p.goto(`${base}/page`, { maxMs: 8000 });-- await until(p, PILL, 'the pill again');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');++ await until(p, "!!window.OpenVibeDock", 'the panels again');++ await p.evaluate(OPEN('agent'));++ await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place again');await wait(400);await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));@@@ -515,138 +482,28 @@ async function drag(p, from, to) {scenario = { counts: { running: 1, waiting: 0, unread: 0 } };const p = await fresh(320, 640);await p.goto(`${base}/page`, { maxMs: 5000 });-- const pill = await until(p, PILL, 'the pill at 320');-- assert.strictEqual(pill.text, 'Working');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, CARD, 'the sheet at 320');++ const st = await until(p, STATUS, 'the status at 320');++ assert.strictEqual(st.tone, 'running');++ await p.evaluate(OPEN('agent'));++ await until(p, PLACE, 'the menu on Agents at 320');await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));await p.close();}---- // ── A desktop: the card at the corner, dragged and resized; an open Dock reopens by itself; toasts clear it. ──-- {-- scenario = { counts: { running: 2, waiting: 1, unread: 2 } };-- puts.length = 0;-- const p = await fresh(1280, 860);-- await p.goto(`${base}/page`, { maxMs: 5000 });-- const pill = await until(p, PILL, 'the desktop pill');-- assert.strictEqual(pill.clear, '58px', 'desktop toasts stack above the pill in their corner');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, `(() => { const c = ${CARD}; return c && c.items ? c : null; })()`, 'the desktop card');-- await wait(400); // past the grow-in (a transform), so the box is the card's own-- let card = await p.evaluate(CARD);-- assert.deepStrictEqual([card.corner, card.w, card.h], ['bottom-right', 420, 640], 'a 420 × 640 card in the pill\'s corner');-- // Drag the head from its middle to near the top left: it snaps to that corner.-- await drag(p, [card.l + 120, card.t + 26], [180, 90]); // from the Activity tab: a drag, not a tap-- card = await until(p, `(() => { const c = ${CARD}; return c && c.corner === 'top-left' ? c : null; })()`, 'the card snaps to the top left');-- assert.strictEqual(card.tab, 'agent', 'a drag that began on a tab does not pick it');-- await wait(400);-- card = await p.evaluate(CARD);-- assert.ok(card.l <= 17 && card.t >= 60 && card.t < 80, `it sits in the top-left corner, under the bar (${card.l}, ${card.t})`);-- // The free corner (bottom right, now) resizes it, inside the limits.-- await drag(p, [card.l + card.w - 8, card.t + card.h - 8], [card.l + card.w + 2000, card.t + card.h - 400]);-- card = await p.evaluate(CARD);-- assert.deepStrictEqual([card.w, card.h], [720, 360], 'it stops at 720 wide and 360 tall');-- await wait(900);-- const place = Object.assign({}, ...puts.filter((b) => b.place).map((b) => b.place.desktop));-- assert.deepStrictEqual([place.corner, place.width, place.height], ['top-left', 720, 360], `the Network keeps where and how big (${JSON.stringify(puts)})`);-- // The next page restores it open on a desktop, where it was left.-- await p.goto(`${base}/page`, { maxMs: 5000 });-- card = await until(p, `(() => { const c = ${CARD}; return c && c.corner === 'top-left' ? c : null; })()`, 'the Dock reopens where it was');-- assert.deepStrictEqual([card.w, card.h], [720, 360], 'the same size');-- await p.close();}---- // ── Pop out (K-D4): the button only where the browser can open a window; the panel goes there and comes back. ──-- {-- scenario = { counts: { running: 1, waiting: 0, unread: 0 } };-- puts.length = 0;-- const p = await fresh(1280, 860);-- await p.goto(`${base}/page`, { maxMs: 5000 });-- // Where the browser cannot open a window of its own, there is nothing to press.-- await p.evaluate("(() => { Object.defineProperty(window, 'documentPictureInPicture', { value: undefined, writable: true, configurable: true }); return true; })()");-- await until(p, PILL, 'the pill');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, CARD, 'the card');-- const POP = "document.querySelector('.ovdk [data-act=pop]')";-- await eventually(async () => assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button'));-- await eventually(async () => assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own'));-- // The browser can: the button appears.-- await p.evaluate(PIP);-- await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");-- await until(p, PILL, 'the pill again');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, `(() => { const b = ${POP}; return b && !b.hidden ? true : null; })()`, 'the pop-out button');-- // The panel moves into the window: the same DOM, the same tabs, the styles come with it.-- await p.evaluate(`${POP}.click()`);-- await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more'));-- await eventually(async () => assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)'));-- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it"));-- await eventually(async () => assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520'));-- const inWindow = await until(p, PILL, 'the pill stays');-- assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');-- // The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.-- for (let i = 0; i < 50 && !puts.some((b) => b.open === true); i++) await wait(100);-- assert.ok(puts.some((b) => b.open === true), `the Network is told the Dock is open (${JSON.stringify(puts)})`);-- assert.ok(!puts.some((b) => 'popped' in b), `and never where its window is: that stays with this page (${JSON.stringify(puts)})`);-- // The window is closed: the panel comes back to the page.-- await p.evaluate("window.__pip.close()");-- await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');-- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window'));-- await wait(900);-- assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);-- await p.close();-- }---- // ── Pop out with the agent view (K-D3): the stream goes into the window too, and its styles follow the-- // document the view is actually in, not the page behind it. ──-- {-- scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true, tab: 'activity' } };-- agentView = {-- id: AGENT_ID, state: 'live', title: 'Fix the flaky upload test',-- task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },-- curtain: { up: false }, viewers: 1, worked_seconds: 300, visibility: 'private',-- page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,-- embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test`, last_frame_url: null,-- };-- const p = await fresh(1280, 860);-- await p.goto(`${base}/page`, { maxMs: 8000 });-- await until(p, PILL, 'the pinned pill');-- await p.evaluate(PIP);-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, CARD, 'the Dock on Activity');-- await p.evaluate("document.querySelector('.ovdk [data-act=pop]').click()");-- await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the Dock in the window');-- // The Agents tab opens for the first time in the window: the stream and its style belong there.-- await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");-- const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');-- assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');-- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window'));-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it'));-- // Back on the page, the same view is still there.-- await p.evaluate("window.__pip.close()");-- await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url));-- await p.close();-- }---- }-- if (PART === 1) { console.log('dock-chrome (1 of 2): the pill only when it matters, a phone\'s sheet, Chats, Send to, the agent view, 320 px clean, a desktop\'s card, pop-out'); return; }++ if (PART === 1) { console.log('dock-chrome (1 of 2): no pill and no floating Dock, the status on the menu button, Agents in the menu, Chats, Send to, the agent view, 320 px clean'); return; }// ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the Network keeps the tab. ──{scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };puts.length = 0;const p = await fresh(1280, 860);await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, PILL, 'the pinned pill');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, CARD, 'the card');-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab'));-- await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(OPEN('vibe'));++ await until(p, "document.querySelector('.ovs.is-open [data-dock=\"vibe\"]') && document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected') === 'true'", 'the menu on Vibe');await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");-- await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');++ await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 6", 'the starters before the first message, each with its icon');++ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip')].filter((c) => c.querySelector('.ovdk-chip-ic svg')).length"), 6, 'every starter has its icon');await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');@@@ -667,8 +524,7 @@ async function drag(p, from, to) {await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));-- await wait(900);-- assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);++ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`));await p.close();}@@@ -689,8 +545,8 @@ async function drag(p, from, to) {stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }};` });await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, PILL, 'the pinned pill');-- await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(OPEN('vibe'));await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');@@@ -728,9 +584,10 @@ async function drag(p, from, to) {puts.length = 0;const p = await fresh(1280, 860);await p.goto(`${base}/page`, { maxMs: 5000 });-- await p.evaluate("OpenVibeDock.open({ tab: 'activity' })");-- await until(p, CARD, 'the card');-- await p.evaluate("document.querySelector('.ovdk [data-act=timer]').click()");++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(OPEN('vibe'));++ await until(p, PLACE, 'the menu on Vibe');++ await p.evaluate("document.querySelector('.ovs .ovs-ai-timer').click()");await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');@@@ -738,8 +595,8 @@ async function drag(p, from, to) {await wait(900);const started = puts.find((b) => b.timer);assert.ok(started && started.timer.kind === 'timer' && Date.parse(started.timer.ends_at) - Date.parse(started.timer.started_at) === 300000, `the Network keeps the timer (${JSON.stringify(puts)})`);-- await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");-- const pill = await until(p, `(() => { const q = ${PILL}; return q && /^\\d+:\\d\\d$/.test(q.text) ? q : null; })()`, 'the pill counts it down');++ await p.evaluate("OpenVibeSearch.close()");++ const pill = await until(p, `(() => { const q = ${STATUS}; return q && /^\\d+:\\d\\d$/.test(q.text) ? q : null; })()`, 'the menu button counts it down');assert.strictEqual(pill.tone, 'timer');assert.match(pill.label, /Timer: \d+:\d\d left/);// Another page's view: a timer two seconds from its end.@@@ -747,7 +604,7 @@ async function drag(p, from, to) {scenario.state = { ...defaultState(), timer: soon(1500) };puts.length = 0;await p.goto(`${base}/page`, { maxMs: 5000 });-- const up = await until(p, `(() => { const q = ${PILL}; return q && q.text === "Time's up" ? q : null; })()`, 'the end, said', 8000);++ const up = await until(p, `(() => { const q = ${STATUS}; return q && q.tone === 'timer-done' && q.text === 'Done' ? q : null; })()`, 'the end, said', 8000);assert.match(up.label, /time's up/i);await wait(900);assert.ok(puts.some((b) => 'timer' in b && b.timer === null), `a plain timer clears itself at its end (${JSON.stringify(puts)})`);@@@ -756,7 +613,7 @@ async function drag(p, from, to) {scenario.state = { ...defaultState(), timer: { kind: 'focus', label: 'Write', started_at: new Date(end - 25 * 60000).toISOString(), ends_at: new Date(end).toISOString(), round: 1, rounds: 2, focus_min: 25, break_min: 5 } };puts.length = 0;await p.goto(`${base}/page`, { maxMs: 5000 });-- await until(p, `(() => { const q = ${PILL}; return q && q.tone === 'timer' ? q : null; })()`, 'the focus round in the pill');++ await until(p, `(() => { const q = ${STATUS}; return q && q.tone === 'timer' ? q : null; })()`, 'the focus round on the menu button');await wait(2600);const brk = puts.map((b) => b.timer).find((t) => t && t.kind === 'break');assert.ok(brk, `the break follows the focus round (${JSON.stringify(puts)})`);@@@ -766,20 +623,21 @@ async function drag(p, from, to) {await p.close();}-- // ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──++ // ── Network without the route yet (404): no status, and the site stops asking for a day. Signed out: none. ──{scenario = { missing: true };const p = await fresh(1280);await p.goto(`${base}/page`, { maxMs: 5000 });await wait(700);-- await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route'));++ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'nothing on the menu button without the route'));const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');await p.evaluate("localStorage.removeItem('ov-dock')");scenario = { counts: { running: 1, waiting: 0, unread: 0 } };await p.goto(`${base}/page?in=0`, { maxMs: 5000 });await wait(600);-- await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all'));++ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'signed out: no status'));++ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#ovdk-pill, .ovdk-pill')"), false, 'and no pill'));await p.close();}// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──@@@ -790,15 +648,15 @@ async function drag(p, from, to) {await p.goto(`${base}/page`, { maxMs: 5000 });await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");-- await until(p, CARD, 'the Dock opens');++ await until(p, PLACE, 'the menu opens');const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab'));++ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected')"), 'true', 'on Vibe'));await wait(400);assert.strictEqual(asked.length, before, 'nothing is sent for the person');await p.close();}-- console.log('dock-chrome: the pill only when it matters, Vibe, Ask your agent, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');++ console.log('dock-chrome: Vibe, voice, the glyphs, the timer on the menu button, a Network without the route, Ask your agent');} finally {await browser.close();server.close();@@@ -1,5 +1,7 @@'use strict';// mountInto uses the Dock's actual panel siblings against local service stand-ins. Also checks six cleanup laps.++// 4.220.0 (the owner, 11-12: "one unified UI"): the panels live only where the menu places them. No pill, no floating++// card: open() asks the menu (opts.openMenu) and the counts reach the bar's menu button (opts.status).const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -18,7 +20,7 @@ const page = `<!doctype html><html><head><meta name="viewport" content="width=dehtml,body{margin:0;height:100%;font:14px system-ui;background:#161820;color:#eee}#host{width:100%;height:600px;overflow:hidden}main{height:100px}</style><script src="/dock.js"></script></head><body><main>Local Dock mount test</main><div id="host"></div>-- <script>OpenVibeDock.mount({token:()=> 'local-token',apiBase:location.origin,chatBase:location.origin,actorBase:location.origin,agentBase:location.origin,sibling:(name)=>'/'+name});</script></body></html>`;++ <script>window.__status=[];window.__menu=[];OpenVibeDock.mount({token:()=> 'local-token',apiBase:location.origin,chatBase:location.origin,actorBase:location.origin,agentBase:location.origin,sibling:(name)=>'/'+name,status:(s)=>window.__status.push(s),openMenu:(id)=>window.__menu.push(id),closeMenu:()=>window.__menu.push('close')});</script></body></html>`;const server = http.createServer((req, res) => {const u = new URL(req.url, 'http://local');const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };@@@ -55,14 +57,15 @@ async function until(p, expression, ms = 6000) {const context = await browser.newContext();const p = await h.openPage(browser, context, { width, height: 800, probe: true });await p.goto(base, { maxMs: 5000 });-- await until(p, '!!window.OpenVibeDock && !!document.getElementById("ovdk-pill")');++ await until(p, '!!window.OpenVibeDock && window.__status.length > 0');++ assert.strictEqual(await p.evaluate('!!document.querySelector("#ovdk-pill, .ovdk-pill, body > .ovdk")'), false, `${width}px: no pill and no floating card`);await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');await until(p, '!!document.querySelector("#host .ovdk-chat")');-- await until(p, 'document.querySelector("#ovdk-tab-chats .n").textContent === "3"');await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 0, waiting: 1}})');-- assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-tab-agent .n").textContent'), '1', `${width}px: mounted panel shares unread counts`);-- assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-pill .ovdk-t").textContent'), 'Needs you', `${width}px: pill uses the same counts`);-- for (const [tab, marker] of [['chats', '.ovdk-chat'], ['vibe', '.ovdk-vibe-new'], ['agent', '.ovdk-runs-foot'], ['activity', '.openvibe-nv-head']]) {++ assert.deepStrictEqual(await p.evaluate('window.__status[window.__status.length - 1]'), { tone: 'waiting', text: 'Needs you', label: '1 needs you', part: 'agent' }, `${width}px: what the pill said goes to the menu button`);++ await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 2, waiting: 0}})');++ assert.strictEqual(await p.evaluate('window.__status[window.__status.length - 1].text'), '2 working', `${width}px: agents working`);++ for (const [tab, marker] of [['chats', '.ovdk-chat'], ['vibe', '.ovdk-vibe-new'], ['agent', '.ovdk-runs-foot']]) {await p.evaluate(`mount.show('${tab}')`);await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden]) ${marker}')`);const result = await p.evaluate(`(() => { const host=document.getElementById('host'); const panel=host.querySelector('[data-panel="${tab}"]');@@@ -82,20 +85,21 @@ async function until(p, expression, ms = 6000) {assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: destroy empties host`);await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"vibe"})');await until(p, '!!document.querySelector("#host [data-panel=vibe]")');-- await p.evaluate('OpenVibeDock.open({tab:"activity"})');-- assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: floating Dock takes ownership`);-- assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), true, `${width}px: floating Dock opens`);-- await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');-- await until(p, '!!document.querySelector("#host [data-panel=chats]")');-- assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), false, `${width}px: mount closes floating Dock`);++ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), true, `${width}px: open while the menu holds a panel`);++ await p.evaluate('OpenVibeDock.open({tab:"chats"})');++ assert.deepStrictEqual(await p.evaluate('window.__menu.slice(-1)'), ['chats'], `${width}px: open() asks the menu for that place`);++ assert.strictEqual(await p.evaluate('!!document.querySelector("#ovdk-pill, .ovdk-pill, body > .ovdk")'), false, `${width}px: and draws nothing of its own`);++ await p.evaluate('OpenVibeDock.close()');++ assert.deepStrictEqual(await p.evaluate('window.__menu.slice(-1)'), ['close'], `${width}px: close() closes the menu`);await p.evaluate('mount.destroy()');++ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), false, `${width}px: closed once the menu hands the panel back`);if (width === 390) {// The probe and forced GC use the same measures as boost-navbar-chrome's lifecycle check.const samples = [];for (let lap = 0; lap < 6; lap++) {await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');await until(p, '!!document.querySelector("#host .ovdk-chat")');-- for (const tab of ['vibe', 'agent', 'activity', 'chats']) {++ for (const tab of ['vibe', 'agent', 'chats']) {await p.evaluate(`mount.show('${tab}')`);await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden])')`);}@@@ -110,6 +114,6 @@ async function until(p, expression, ms = 6000) {assert.ok(b.nodes-a.nodes <= 25 && b.listeners-a.listeners <= 3 && b.doc-a.doc <= 0 && b.win-a.win <= 0, `no node or listener growth: ${JSON.stringify(samples)}`);}}-- console.log('dock-mount-chrome: four panels, both widths, exclusive ownership, six leak laps passed');++ console.log('dock-mount-chrome: three panels in the menu, both widths, status to the menu button, open() through the menu, six leak laps passed');} finally { await browser.close().catch(() => {}); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }})().catch((e) => { console.error(e); process.exit(1); });@@@ -101,6 +101,8 @@ assert.ok(dock.includes("h('b', '', 'Vibe')"), 'the Chat tab has the Vibe row (yassert.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');--assert.ok(dock.includes('ovdk-size::after') || dock.includes('.ovdk-size'), 'the Dock has a grip to resize it with');++// 4.220.0: the floating Dock is gone (the owner, 11-12: "one unified UI"): no pill, no card, no grip; its panels live in the menu.++assert.ok(!dock.includes('ovdk-pill') && !dock.includes('.ovdk-size') && !dock.includes('popOut'), 'no floating Dock: no pill, no resize grip, no window of its own');++assert.ok(dock.includes('mountInto') && dock.includes('o.openMenu'), 'the panels are placed in the menu, and open() opens the menu there');console.log("dock v2: the run rows, the overview, the Vibe list and the Chats row");@@@ -1,7 +1,7 @@'use strict';// The Dock with the extension's read-only scopes and an async token (2026-10-30): every request uses the// token the function returns at call time; the Agents tab shows Details but no approve/decline; the Chats--// tab reads but has no composer; the Activity tab loads through the same token function.++// tab reads but has no composer. 4.220.0: the panels live in the menu (mountInto), not in a floating Dock with a pill.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('assert');const fs = require('fs');@@@ -77,23 +77,20 @@ async function until(p, expr, what, ms = 6000) {try {await p.goto(`${base}/page`, { maxMs: 5000 });await until(p, 'window.__calls > 0', 'the first dock read with the async token');-- await until(p, "!!document.getElementById('ovdk-pill')", 'the pill');++ assert.strictEqual(await p.evaluate("!!document.querySelector('#ovdk-pill, .ovdk-pill')"), false, 'no pill: the menu holds the panels');assert.ok(seenAuth.length && seenAuth.every((a) => a === 'Bearer tok_async'), 'every request used the token function result');-- await p.evaluate("document.getElementById('ovdk-pill').click()");-- await until(p, "!!document.querySelector('.ovdk [data-panel=agent]')", 'the Dock opens');-- await p.evaluate("document.getElementById('ovdk-tab-agent').click()");++ await p.evaluate("(() => { const host = document.createElement('div'); host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column'; document.body.append(host); window.__dock = OpenVibeDock.mountInto(host, { tab: 'agent' }); })()");++ await until(p, "!!document.querySelector('.ovdk [data-panel=agent]:not([hidden])')", 'the Agents panel, placed as the menu places it');await until(p, "document.querySelectorAll('.ovdk .ovac-item').length === 1", 'the action item');assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovac-item [data-do=approve], .ovdk .ovac-item [data-do=decline]').length"), 0, 'no approve/decline with activity only');assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovac-item a[href]')"), true, 'Details stays');-- await p.evaluate("document.getElementById('ovdk-tab-chats').click()");++ await p.evaluate("window.__dock.show('chats')");await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 1", 'the chat list');await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 1", 'the thread');assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-send')"), false, 'no composer with chat.read only');assert.match(await p.evaluate("document.querySelector('.ovdk [data-panel=chats]').innerText"), /Reading only/);-- await p.evaluate("document.getElementById('ovdk-tab-activity').click()");-- await until(p, "document.querySelectorAll('.ovdk .openvibe-notif-item').length === 1", 'the activity list');-- assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each tab');++ assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each panel');assert.ok(seenAuth.every((a) => a === 'Bearer tok_async'), JSON.stringify([...new Set(seenAuth)]));console.log('dock signin chrome: all checks passed');} finally {nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++// The Dock's Vibe tab draws the network's glyphs, not marks of its own (Shared 4.220.0; the owner, 11-12: "actually has++// icons for everything using OpenVibe.design"): every mark in its set is the registry's drawing of the same name, so a++// glyph redrawn on openvibe.design/icons is redrawn here when the file is rebuilt, and this test says which is stale.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const glyphs = require('../glyphs');++++const src = fs.readFileSync(path.join(__dirname, '..', 'dock-vibe.js'), 'utf8');++// A document just real enough for the file to define itself.++const sandbox = { document: { createElement: () => ({}), currentScript: null, getElementById: () => null }, console };++sandbox.window = sandbox;++vm.runInNewContext(src, sandbox, { filename: 'dock-vibe.js' });++const set = sandbox.OpenVibeDockVibe && sandbox.OpenVibeDockVibe.GLYPHS;++assert.ok(set && typeof set === 'object', 'dock-vibe.js exposes its marks as OpenVibeDockVibe.GLYPHS');++const names = Object.keys(set);++assert.ok(names.length >= 12, `the panel's marks (${names.length})`);++names.forEach((name) => {++ assert.ok(glyphs.path(name), `${name} is a glyph in the registry (openvibe-shared/glyphs)`);++ assert.strictEqual(set[name], glyphs.path(name), `${name} is drawn as the registry draws it`);++});++// Every V.<name> the file uses is in the set (a typo would draw nothing).++const used = [...new Set([...src.matchAll(/\bV\.([a-z]+)\b/g)].map((m) => m[1]))];++used.forEach((name) => assert.ok(names.includes(name), `V.${name} is in the set`));++// Each starter's mark too.++const starters = [...src.matchAll(/\['([a-z]+)', '[^']+'\]/g)].map((m) => m[1]);++assert.ok(starters.length >= 4, 'the starters');++starters.forEach((name) => assert.ok(names.includes(name), `the starter mark ${name} is in the set`));++console.log(`dock-vibe-glyphs: ${names.length} marks, all the registry's; ${used.length} used, ${starters.length} starters`);@@@ -246,32 +246,19 @@ const server = http.createServer((req, res) => {assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');-- // ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,-- // 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button-- // that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes-- // the same move underneath; closing runs it back, then the sheet is gone. With motion off there is no move.++ // ── 4.220.0 (the owner, 11-12: "one unified UI"): the Dock's open() opens the menu on that place; there is no++ // floating Dock, no pill and no sheet of its own, and close() closes the menu. ──await s.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs-orb') || { click() {} }).click(), true`);await new Promise((ok) => setTimeout(ok, 700));-- assert.ok(await until(s, `!!(window.OpenVibeDock && window.OpenVibeDock.open)`), 'the Dock is loaded beside the navbar');-- const start = await s.evaluate(`(() => { const c = (e) => { const r = e.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };-- const nav = [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].find((b) => b.getBoundingClientRect().width > 0);-- const before = c(nav.querySelector('svg'));-- window.OpenVibeDock.open({ tab: 'activity' });-- const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx');-- return { before, at: c(mine.querySelector('svg')), fromOrb: dock.classList.contains('from-orb'), isFrom: mine.classList.contains('is-from') }; })()`);-- assert.ok(start.fromOrb && start.isFrom, 'the sheet opens from the orb');-- assert.ok(Math.abs(start.at[0] - start.before[0]) <= 1.5 && Math.abs(start.at[1] - start.before[1]) <= 1.5, `the close orb starts on the navbar's orb (${JSON.stringify(start)})`);-- await new Promise((ok) => setTimeout(ok, 900));-- const open = await s.evaluate(`(() => { const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx'), sv = mine.querySelector('svg'), r = sv.getBoundingClientRect(), b = mine.getBoundingClientRect();-- return { settled: getComputedStyle(sv).transform === 'none' && !mine.classList.contains('is-from'), centred: Math.abs((r.left + r.width / 2) - (b.left + b.width / 2)) <= 1, navIsDock: !!document.querySelector('.openvibe-navbar .ovnav-orb.is-dock'), opacity: getComputedStyle(dock).opacity }; })()`);-- assert.ok(open.settled && open.centred, `it settles into its place in the header (${JSON.stringify(open)})`);-- assert.ok(open.navIsDock && open.opacity === '1', 'the navbar\'s orb made the same move underneath');-- const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);-- assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);-- assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');-- await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was'));-- const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);-- assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');++ assert.ok(await until(s, `!!(window.OpenVibeDock && window.OpenVibeDock.open)`), 'the Dock\'s panels are loaded beside the navbar');++ await s.evaluate(`window.OpenVibeDock.open({ tab: 'chats' }), true`);++ assert.ok(await until(s, `!!document.querySelector('.ovs.is-open.ovs-in-door') && document.querySelector('.ovs [data-dock="chats"]').getAttribute('aria-selected') === 'true'`), 'open() opens the menu on Chat');++ assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk')`), 'the panel is inside the menu');++ assert.strictEqual(await s.evaluate(`[...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host')) && !document.querySelector('#ovdk-pill, .ovdk-pill')`), true, 'no floating Dock and no pill');++ await s.evaluate(`window.OpenVibeDock.open({ tab: 'agent' }), true`);++ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && document.querySelector('.ovs-door-sw [data-part="agent"]').getAttribute('aria-selected') === 'true'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Agents');++ await s.evaluate(`window.OpenVibeDock.close(), true`);++ assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');await s.close();if (process.env.OV_CAPTURE_MENU) {for (const width of [390, 1280]) {@@@ -282,7 +269,7 @@ const server = http.createServer((req, res) => {const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);-- if (width === 1280) assert.ok(await until(shotPage, `!document.querySelector('.ovs').classList.contains('is-open')`), 'desktop Chat closes the menu so the Dock is visible');++ assert.ok(await until(shotPage, `document.querySelector('.ovs').classList.contains('is-open')`), 'Chat opens inside the menu on every screen');assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));@@@ -69,27 +69,15 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);-- if (width <= 640) {-- await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);-- await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`));-- await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);-- await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`));-- }++ // 4.220.0: no "The Dock" row in You (the floating Dock is gone; the timer has its button on the Vibe tab).++ await eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('.ovs-dockyou-b, .ovs-dock-you')`), false, `${name}: no Dock row in You`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);-- if (width > 640) {-- // A computer opens the floating Dock beside the menu.-- await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);-- await eventually(async () => 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 eventually(async () => assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`));-- await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`));-- }await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);await page.evaluate('OpenVibeSearch.close()'); await wait(300);-- // On a phone, with the Dock able to lend its panels (OpenVibeDock.mountInto, 312 step 2): Messages and AI open-- // INSIDE the menu, under its own head, and become the chosen place; AI has a Vibe · Agents switch; Feed and-- // closing hand the panel back. A computer keeps the floating Dock.++ // With the Dock able to lend its panels (OpenVibeDock.mountInto): Messages and AI open INSIDE the menu, under its++ // own head, and become the chosen place; AI has a Chat · Code · Agents switch; Feed and closing hand the panel back.++ // 4.220.0: on every screen (a computer opened a floating Dock; the owner, 11-12: "one unified UI").await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, mountInto(host, o) { window.log.push('mount:' + o.tab); host.innerHTML = '<div class="stub"></div>';return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } }`);await page.evaluate(OPEN(true)); await wait(350);@@@ -97,7 +85,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).masw: document.querySelector('.ovs-door-sw') && !document.querySelector('.ovs-door-sw').hidden ? Array.from(document.querySelectorAll('.ovs-door-sw [data-part]')).map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')).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);-- if (width <= 640) {++ {await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);@@@ -121,10 +109,6 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);await page.evaluate('OpenVibeSearch.close()'); await wait(300);await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));-- } else {-- await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`));-- await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`));-- await page.evaluate('OpenVibeSearch.close()'); await wait(300);}await page.evaluate('delete window.OpenVibeDock');// Opened from a button: the sheet grows out of that button's centre, wherever the sheet itself was placed.ddeleted file mode 100644@@@ -1,70 +0,0 @@--'use strict';--// The Dock's pill and a page's own composer at the bottom of a phone (2026-11-11: on openvibe.chat the "Needs you" pill--// sat on the composer's send button). A real headless Chrome at 390×844: a chat-like page with a composer fixed to the--// bottom, bottom.js and the Dock. The pill stands above the composer (its bottom edge at least 8 px above the box's top);--// when the composer grows (a second line) the pill moves up with it; when the composer goes, the pill comes back down.--// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.--const assert = require('node:assert/strict');--const fs = require('node:fs');--const os = require('node:os');--const path = require('node:path');--const http = require('node:http');--const h = require('../browser-harness');----if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {-- console.log('pill-composer-chrome: skipped (no Chrome; set CHROME_BIN)');-- process.exit(0);--}--const ROOT = path.join(__dirname, '..');--const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title>--<style>body{margin:0;background:#0b1120;color:#e6edf7;font:15px system-ui}main{padding:20px 16px 120px}--form{position:fixed;left:12px;right:12px;bottom:12px;display:flex;gap:8px;padding:10px;border-radius:24px;background:#1b2436}--textarea{flex:1;min-height:40px;resize:none;background:none;border:0;color:inherit;font:inherit}button{width:44px;height:44px;border-radius:50%}</style></head>--<body><main><h1>Room</h1>${'<p>A message in the room.</p>'.repeat(30)}</main>--<form data-ovt-composer class="ovt-composer"><textarea data-ovt-text rows="1"></textarea><button type="submit" id="send">↑</button></form>--<script src="/bottom.js"></script><script src="/dock.js"></script>--<script>OpenVibeDock.mount({ apiBase: location.origin, token: 'tok', user: { id: 7, username: 'ada', display_name: 'Ada' } });</script></body></html>`;--const server = http.createServer((req, res) => {-- const u = new URL(req.url, 'http://x');-- if (u.pathname === '/bottom.js' || u.pathname === '/dock.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }-- if (u.pathname === '/api/v1/me/dock') {-- res.writeHead(200, { 'content-type': 'application/json' });-- return res.end(JSON.stringify({ state: { open: false, tab: 'activity', pinned: true, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right' } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 1, unread: 0 }, sources_partial: false }));-- }-- res.writeHead(200, { 'content-type': 'text/html' });-- res.end(PAGE);--});--(async () => {-- await new Promise((ok) => server.listen(0, '127.0.0.1', ok));-- const base = `http://127.0.0.1:${server.address().port}`;-- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pill-composer-'));-- const browser = await h.launch({ tmpDir });-- const wait = (ms) => new Promise((r) => setTimeout(r, ms));-- try {-- const context = await browser.newContext();-- const pg = await h.openPage(browser, context, { width: 390, height: 844 });-- await pg.goto(`${base}/`, { maxMs: 5000 });-- let pill = false;-- for (let i = 0; i < 40 && !pill; i++) { await wait(100); pill = await pg.evaluate(`(() => { const p = document.querySelector('.ovdk-pill'); return !!p && p.getBoundingClientRect().height > 0; })()`); }-- if (!pill) { console.log('pill-composer-chrome: the Dock drew no pill on this page (nothing to check)'); return; }-- const gap = async () => pg.evaluate(`(() => { const p = document.querySelector('.ovdk-pill').getBoundingClientRect(); const f = document.querySelector('form').getBoundingClientRect(); return Math.round(f.top - p.bottom); })()`);-- await wait(400);-- const first = await gap();-- assert.ok(first >= 8, `the pill stands above the composer (gap ${first} px)`);-- // A taller composer (a second line): the pill moves up with it.-- await pg.evaluate(`document.querySelector('textarea').style.minHeight = '96px'`);-- await wait(500);-- assert.ok(await gap() >= 8, 'and stays above it when the box grows');-- // No composer: the pill comes back to its own place.-- const before = await pg.evaluate(`document.querySelector('.ovdk-pill').getBoundingClientRect().bottom`);-- await pg.evaluate(`document.querySelector('form').remove()`);-- await wait(500);-- const after = await pg.evaluate(`document.querySelector('.ovdk-pill').getBoundingClientRect().bottom`);-- assert.ok(after > before + 40, `without a composer it comes back down (${Math.round(before)} → ${Math.round(after)})`);-- console.log('pill-composer-chrome: the pill stands above a bottom composer, follows it as it grows, and comes back without one');-- } finally {-- await browser.close();-- server.close();-- fs.rmSync(tmpDir, { recursive: true, force: true });-- }--})().catch((e) => { console.error(e); process.exit(1); });
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
## Project overview
## Project overview
`openvibe-shared` is the versioned UI and server-helpers package every OpenVibe site renders: the
`openvibe-shared` is the versioned UI and server-helpers package every OpenVibe site renders: the
-OpenVibe Frame (navbar, footer, Dock, panels), themes and design tokens, glyphs and app icons, SEO,
+OpenVibe Frame (navbar, the menu with its Chat and Vibe places, footer, panels), themes and design tokens, glyphs and app icons, SEO,
legal pages, analytics, readiness/metrics, egress and the web runtime. It is a library (MIT), not a
legal pages, analytics, readiness/metrics, egress and the web runtime. It is a library (MIT), not a
service: it owns no database, port or process, and nothing is deployed from this repository.
service: it owns no database, port or process, and nothing is deployed from this repository.
@@ -1,5 +1,63 @@
@@ -1,5 +1,63 @@
# Changelog
# Changelog
+## 4.220.0 — 2026-11-12
+
+**One UI: the floating Dock is gone; the menu holds Chat and Vibe on every screen** (the owner, 11-12, with a phone
+screenshot of the Dock crossed out: "there should only be the one unified UI … we're supposed to delete … that has the
+old tab system", and the menu marked "the one unified"). And the Vibe place redesigned (the owner: "sleek … nice on
+mobile … organized properly and sized well … without all these huge spaces … makes use of the full space … icons for
+everything using OpenVibe.design … modular … reusable for different sites").
+
+- **Gone:**
+ - the Dock's pill;
+ - its floating card with the Vibe · Code · Agents · Chat · Activity tab bar, More, pin and Close;
+ - corners, drag and resize;
+ - the phone sheet that opened out of the menu button;
+ - the window of its own (pop-out);
+ - the You tab's "The Dock" row;
+ - the Activity tab (the menu's You tab has the notifications; `OpenVibeNotifications.renderDock` is gone with it).
+ `dock.js` is 703 lines, from 1,129.
+- **The menu's Chat and Vibe open inside the menu on every screen.** A computer opened the floating Dock.
+ - Vibe's Chat · Code · Agents switch has the timer as an icon at its end; it was a full row under the box.
+ - On a computer the menu grows tall while a place is open (`min(820px, 86vh)`).
+ - `OpenVibeSearch.open({ door })` and `OpenVibeSearch.door(id)` open the menu on a place, or move an open menu there.
+- **`OpenVibeDock.open({ tab })`, `ask({ text })`, `share({ text, title })`, Alt+V and `OpenVibeNavbar.openDock(id)`**
+ open the menu on that place (`opts.openMenu`, given by `navbar.js`); `close()` closes the menu. A place in Your places
+ (Vibe, Agents) opens there too.
+- **What the pill said is a status on the bar's menu button** (`opts.status`, painted by `navbar.js`):
+ - a breathing accent dot while agents work;
+ - an amber one when one needs you (the button then opens the menu on Agents);
+ - a timer's minutes in a small label under the button.
+ The tooltip and the button's name say it in words. Calm slows the breathing; Off stops it.
+- **Vibe, redesigned:**
+ - a header that says what it is: back, Vibe's mark, "Vibe" over "Your AI on every OpenVibe site", New chat and Open
+ in Chat as icons;
+ - a greeting at the top ("What can I do for you?"), and a new chat opens there;
+ - six starters as full-width rows with their marks (two columns on a computer);
+ - the composer's two-row box (the words over the whole width, then picture, Build it in Code, the mic and a round
+ send);
+ - result cards with their actions under the words;
+ - long links wrap.
+- **Every Vibe mark is the network's glyph** (`openvibe-shared/glyphs`, shown on openvibe.design/icons); so is the
+ Dock's send. `test/dock-vibe-glyphs.test.js` keeps them equal.
+- **Direct messages take the network's message actions** (openvibe.design rule 138, as Live's chat and `messages.js`):
+ - Edit is a pencil beside your bubble on its empty side, on hover or focus, or a tap on a phone; it never covers words;
+ - the box is the editor, under an "Editing your message" bar (Escape or × lets go); the browser's prompt is gone;
+ - "edited" is one quiet word inside the bubble;
+ - `dock-chat-edits.js` keeps only Chat's socket.
+- `bottom.js`: the pill's stacking entries and `--ovb-composer` (which only the pill read) are gone.
+- **One head, not two** (the owner, 11-12: "part of the one UI instead of having two different areas"): inside the menu a
+ place has no header band of its own. An open conversation's + and ↗ move into the menu's search pill (as a list's
+ already did); it keeps a slim way back and its name; Vibe's mark is the greeting's.
+
+Tests:
+- Rewritten for the menu: `dock-chrome` and `dock-vibe-timer-chrome` (no pill or floating Dock ever; the status on
+ the menu button; Agents, Chats, Send to, the agent view, Vibe, voice, the timer and Ask your agent through the menu),
+ `dock-mount-chrome`, `dock-signin-chrome`, `menu-places-chrome` (Chat and Vibe inside the menu at 390 and 1280),
+ `menu-home-screen-chrome`, `bottom-chrome` (no pill).
+- New: `dock-vibe-glyphs`.
+- Gone: `pill-composer-chrome` (the pill it kept above a chat's box is gone).
+
## 4.219.0 — 2026-11-12
## 4.219.0 — 2026-11-12
Every plain element styled by the theme (the owner, 11-12, with circled screenshots of openvibe.exchange: "make sure all
Every plain element styled by the theme (the owner, 11-12, with circled screenshots of openvibe.exchange: "make sure all
@@ -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.219.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.220.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.
@@ -626,26 +626,24 @@ there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Netwo
@@ -626,26 +626,24 @@ there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Netwo
`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens it
`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens it
from a site's own control.
from a site's own control.
-**The Dock (4.51.0).** Your agents and your activity at the edge of every OpenVibe page (the Dock plan of 2026-10-30,
-K-D1). For a signed-in person `navbar.js` loads `dock.js` from beside itself after the bell (serve it there; it is in
-`files.js`), and it draws a pill in the Dock's corner: the mark, a word ("Needs you", "Working", "2 working") and a dot.
-The pill shows only while an agent works, one needs you, or the person pinned it. A tap opens the Dock. On a desktop it
-is a floating card that is not modal: drag its head to a corner, drag its free corner to resize it (320–720 × 360–900).
-On a phone it is a sheet. It has two tabs. **Agents** is the Action Center's waiting and running items, approved or
-declined in place (`OpenVibeActionCenter.mount(container, opts)`). **Activity** is the bell's list
-(`OpenVibeNotifications.renderDock(container)`). Its state (open, tab, pinned, where it sits per device class) is the
-Network's, so it follows the person from site to site:
-- reads: `GET /api/v1/me/dock` (`network.dock-result@1`);
-- writes: one debounced `PUT` (`network.dock-state@1`);
-- this site's copy paints it at once;
-- an open Dock reopens by itself on a desktop, never on a phone;
-- a 404 from a Network without the route keeps it away for a day.
-
-The account menu has a Dock tile. A site can call `OpenVibeNavbar.openDock('agent' | 'activity')`, pass `dock: false`
-to leave it out, or pass `dockApiBase` for tests and staging. Desktop toasts stack above the pill (`--ov-dock-clear`).
-**Chats** (4.52.0) is the person's conversations and DMs from OpenVibe.Chat's API
-(`/api/dm/conversations`, read and answered in place, unread counts on the tab; `dockChatBase` overrides Chat's origin).
-The agent's live screen joins in K-D3.
+**The menu's Chat and Vibe (dock.js; 4.51.0, one UI since 4.220.0).** The OpenVibe menu holds the person's
+conversations (**Chat**: OpenVibe.Chat's `/api/dm/conversations`, read and answered in place, Edit as a pencil at the
+bubble's corner) and **Vibe** (Vibe's conversations, the person's coding tasks and their agents, by a Chat · Code ·
+Agents switch, with a timer button at its end). The panels are `dock.js`'s, placed in the menu with
+`OpenVibeDock.mountInto(host, { tab })` on every screen. There is no floating Dock, no pill and no window of its own
+(the owner, 2026-11-12: "there should only be the one unified UI"). For a signed-in person `navbar.js` loads `dock.js`
+from beside itself (it is in `files.js`); it reads the person's state from the Network so it follows them from site to
+site:
+- reads: `GET /api/v1/me/dock` (`network.dock-result@1`); writes: one debounced `PUT` (`network.dock-state@1`): the
+ place last open, the open chat, the Vibe conversation and the timer;
+- what needs saying is a status on the bar's menu button: a breathing dot while agents work, an amber one when one
+ needs you (the button then opens on Agents), a timer's minutes in a small label under it;
+- a 404 from a Network without the route keeps the status away for a day.
+
+`OpenVibeNavbar.openDock('chats' | 'vibe' | 'code' | 'agent')`, `OpenVibeDock.open({ tab })`, `ask({ text })` and
+`share({ text, title })` open the menu there (`OpenVibeSearch.open({ door })` or `OpenVibeSearch.door(id)` when it is
+open). `dock: false` leaves Chat and Vibe out of the menu; `dockApiBase` and `dockChatBase` override the Network and
+Chat for tests and staging.
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
@@ -872,10 +870,10 @@ New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,
@@ -872,10 +870,10 @@ New in 4.190: `ov-drop.js` is the one dropdown (`OVDrop.open(anchor, items,
and on `setChip(id, value)` turns to that balance, counts the number and
and on `setChip(id, value)` turns to that balance, counts the number and
plays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site's
plays the gain or spend effect (`OpenVibePurse.play(id, delta)`). A site's
single counter that is not a balance stays an ordinary chip.
single counter that is not a balance stays an ordinary chip.
- The menu and the Dock (4.189): the menu button always opens this site's tab
- (no tab is remembered; the picture still asks for You); under the tabs a row
- opens the Dock on Vibe, Agents, Messages or Activity (`dock: false` leaves
- it out), and the Dock's head has a button back to the menu.
+ The menu (4.189, one UI since 4.220): the menu button always opens this
+ site's tab (no tab is remembered; the picture still asks for You); Chat and
+ Vibe are places in the menu itself, on every screen (`dock: false` leaves
+ them out).
The account's things (4.188): the person's picture opens the OpenVibe menu
The account's things (4.188): the person's picture opens the OpenVibe menu
on You, where the account's block is (who you are, add account, settings,
on You, where the account's block is (who you are, add account, settings,
sign out, your other accounts, this site's own items from addMenuItem,
sign out, your other accounts, this site's own items from addMenuItem,
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.219.0",
+ "version": "4.220.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)",
@@ -8,21 +8,17 @@
@@ -8,21 +8,17 @@
* number every bottom-docked part needs and writes it where CSS can use it:
* number every bottom-docked part needs and writes it where CSS can use it:
*
*
* --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;
* --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;
- * the bar, the small player, the Dock's pill and the composer move up by it with `translate`
+ * the bar, the small player and the composer move up by it with `translate`
* (a paint, never layout).
* (a paint, never layout).
* --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.
* --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.
* --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).
* --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).
* --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.
* --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.
* --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.
* --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.
* --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.
* --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.
- * --ovb-composer how far a page's own composer docked at the bottom reaches up (its top, plus 8 px), so the Dock's
- * pill stands above a chat's box instead of on its send button (2026-11-11); 0 when there is none.
*
*
* One stacking order, written here only (the bottom of the screen, low to high):
* One stacking order, written here only (the bottom of the screen, low to high):
* 1. the app bar --ovb-z-bar 9990
* 1. the app bar --ovb-z-bar 9990
* 2. the small player --ovb-z-mini 9991
* 2. the small player --ovb-z-mini 9991
- * 3. the Dock's pill --ovb-z-pill 9995
- * 4. the Dock's card --ovb-z-dock 9996 (a phone's Dock sheet is 10010: it covers the navbar)
* Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).
* Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).
*
*
* navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses
* navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses
@@ -51,17 +47,15 @@
@@ -51,17 +47,15 @@
--ovb-mini: 0px;
--ovb-mini: 0px;
--ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));
--ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));
--ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));
--ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));
- --ovb-composer: 0px;
--ovb-z-bar: 9990;
--ovb-z-bar: 9990;
--ovb-z-mini: 9991;
--ovb-z-mini: 9991;
- --ovb-z-pill: 9995;
- --ovb-z-dock: 9996;
}
}
-.ovnav-appbar, .ovmini, .ovdk-pill { will-change: translate; }`;
+.ovnav-appbar, .ovmini { will-change: translate; }`;
(document.head || html).appendChild(st);
(document.head || html).appendChild(st);
}
}
const px = (n) => `${Math.round(n * 100) / 100}px`;
const px = (n) => `${Math.round(n * 100) / 100}px`;
+ // A page's own composer docked at the bottom moves up with the toolbar like the bar does.
const COMPOSERS = 'form[data-ovt-composer], [data-ov-bottom-dock]';
const COMPOSERS = 'form[data-ovt-composer], [data-ov-bottom-dock]';
/** The height of an element that is actually on screen (a bar hidden under the page is not). */
/** The height of an element that is actually on screen (a bar hidden under the page is not). */
@@ -93,16 +87,6 @@
@@ -93,16 +87,6 @@
}
}
} catch { /* no matchMedia: no full-width small player to stack */ }
} catch { /* no matchMedia: no full-width small player to stack */ }
html.style.setProperty('--ovb-mini', px(mini));
html.style.setProperty('--ovb-mini', px(mini));
- // A page's composer at the bottom of the screen (a chat's box, the shared composer, or anything marked
- // data-ov-bottom-dock) that reaches the right-hand corner where the pill sits.
- let composer = 0;
- const visBottom = vv ? vv.offsetTop + vv.height : root.innerHeight;
- for (const f of document.querySelectorAll(COMPOSERS)) {
- if (!onScreen(f)) continue;
- const r = f.getBoundingClientRect();
- if (r.bottom >= visBottom - 48 && r.top < visBottom && r.right > root.innerWidth - 140) composer = Math.max(composer, visBottom - r.top + 8);
- }
- html.style.setProperty('--ovb-composer', px(composer));
}
}
const later = () => { if (!raf) raf = requestAnimationFrame(measure); };
const later = () => { if (!raf) raf = requestAnimationFrame(measure); };
@@ -1,20 +1,10 @@
@@ -1,20 +1,10 @@
-/* Edit events for an open Dock DM. Loaded when the conversation opens. */
+/* Edit events for an open Dock DM, over Chat's socket: another of my devices (or this one) edited a message, and the
+ Dock's apply() redraws it. Loaded when the conversation opens. Editing itself is the Dock's own (4.220.0: the box is the
+ editor, a pencil at the bubble's corner starts it; no browser prompt). */
(function (root) {
(function (root) {
'use strict';
'use strict';
- root.OpenVibeDockChatEdits = function ({ id, list, messages, error, tokenOf, chatCall, chatBase, active, load, h }) {
+ root.OpenVibeDockChatEdits = function ({ id, apply, tokenOf, chatBase, active }) {
let socket = null, timer = null, stopped = false;
let socket = null, timer = null, stopped = false;
- function apply(m) {
- const old = messages.find((x) => Number(x.id) === Number(m && m.id));
- const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`);
- if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return;
- Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at });
- li.querySelector('.ovdk-text').textContent = String(m.message || '');
- let mark = li.querySelector('.ovdk-edited');
- if (!mark) { mark = h('small', 'ovdk-edited'); li.append(mark); }
- mark.textContent = `edited · ${String(m.edited_at).slice(11, 16)}`;
- const card = li.querySelector('.ovdk-card'); if (card) card.remove();
- if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && active() && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } });
- }
async function connect() {
async function connect() {
if (stopped || !active() || !root.WebSocket) return;
if (stopped || !active() || !root.WebSocket) return;
const token = await tokenOf();
const token = await tokenOf();
@@ -22,19 +12,9 @@
@@ -22,19 +12,9 @@
const ws = new root.WebSocket(chatBase().replace(/^http/, 'ws') + '/ws/chat');
const ws = new root.WebSocket(chatBase().replace(/^http/, 'ws') + '/ws/chat');
socket = ws;
socket = ws;
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', token }));
ws.onopen = () => ws.send(JSON.stringify({ type: 'join', token }));
- ws.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch { return; } if (frame.type === 'dm_edited' && String(frame.conversation_id) === id) apply(frame.message); };
+ ws.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch { return; } if (frame.type === 'dm_edited' && String(frame.conversation_id) === id && active()) apply(frame.message); };
ws.onclose = () => { if (socket === ws) socket = null; if (!stopped && active()) timer = setTimeout(connect, 3000); };
ws.onclose = () => { if (socket === ws) socket = null; if (!stopped && active()) timer = setTimeout(connect, 3000); };
}
}
- list.addEventListener('click', async (event) => {
- if (!event.target.classList.contains('ovdk-edit')) return;
- const li = event.target.closest('[data-id]');
- const old = li && messages.find((m) => Number(m.id) === Number(li.dataset.id));
- if (!old) return;
- const body = root.prompt('Edit message', old.message);
- if (body == null || body.trim() === old.message.trim()) return;
- try { const result = await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages/${encodeURIComponent(old.id)}`, { method: 'PATCH', body: JSON.stringify({ message: body }) }); apply(result.message); }
- catch (e) { error.textContent = e.message || 'Could not edit'; error.hidden = false; }
- });
connect().catch(() => {});
connect().catch(() => {});
return { stop() { stopped = true; clearTimeout(timer); if (socket) { socket.onclose = null; socket.close(); socket = null; } } };
return { stop() { stopped = true; clearTimeout(timer); if (socket) { socket.onclose = null; socket.close(); socket = null; } } };
};
};
@@ -9,9 +9,25 @@
@@ -9,9 +9,25 @@
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"/>';
+ // 4.220.0: the panel's marks are the network's glyphs (openvibe-shared/glyphs, shown on openvibe.design/icons; the owner,
+ // 11-12: "actually has icons for everything using OpenVibe.design"). test/dock-vibe-glyphs.test.js keeps them equal.
+ const V = {
+ back: '<path d="M16.1 3.6l-8.4 8.4l8.4 8.4"/>',
+ spark: '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',
+ image: '<rect x="3.5" y="5.4" width="16.7" height="13" rx="2.8"/><circle cx="9.1" cy="10" r="1.5"/><path d="M20.3 15.6l-4.7-4.7l-7.4 7.4"/>',
+ code: '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',
+ send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>',
+ bell: '<path d="M6 14.6V9.5a6.1 6.1 0 0 1 12.2 0v5.1l1.5 2.1h-15.3ZM10 18.7a2.1 2.1 0 0 0 4 0"/>',
+ calendar: '<rect x="4" y="5.5" width="16" height="15" rx="2"/><path d="M4 10.5h16M9 3.5v4M15 3.5v4"/>',
+ live: '<circle cx="12" cy="11.8" r="2"/><path d="M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9"/>',
+ clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
+ phone: '<path d="M5.7 3.4h3.9l2 4.9l-2.5 1.5a10.8 10.8 0 0 0 4.9 4.9L15.6 12.3l4.9 2v3.9a2 2 0 0 1-2 2A15.8 15.8 0 0 1 3.8 5.4a2 2 0 0 1 2-2"/>',
+ search: '<circle cx="10.6" cy="10.6" r="7"/><path d="M20.4 20.4l-4.5-4.5"/>',
+ chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>',
+ plus: '<path d="M12 3.5v17M3.5 12h17"/>',
+ mic: '<rect x="9.2" y="3.5" width="5.7" height="10.4" rx="2.8"/><path d="M5.4 11.1a6.6 6.6 0 0 0 13.2 0M12 17.7v2.8"/>',
+ open: '<path d="M14 3.8h6.3v6.3M20.2 3.8l-9.4 9.4M18.2 14.2v4.2a2.1 2.1 0 0 1-2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1V7.9a2.1 2.1 0 0 1 2.1-2.1h4.2"/>',
+ };
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 { 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-ib.is-new:hover, .ovdk-ib.is-new:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
@@ -30,11 +46,49 @@
@@ -30,11 +46,49 @@
.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; }
.ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }
.ovdk-typing i:nth-child(2) { animation-delay: .15s; } .ovdk-typing i:nth-child(3) { animation-delay: .3s; }
- .ovdk-vibe-start { list-style: none; display: grid; gap: 6px; padding: 18px 6px 8px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
- .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 15.5px; }
- .ovdk-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 8px; }
- .ovdk-chip { padding: 7px 12px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); font: inherit; font-size: 12.5px; cursor: pointer; }
- .ovdk-chip:hover { border-color: var(--accent, #3b82f6); }
+ /* 4.220.0 (the owner, 11-12: "sleek … organized … sized well … without huge spaces … uses the full space … icons for
+ everything"): a header that says what Vibe is, a greeting at the top, the starters as rows with their marks, and the
+ shared composer's two-row shape. */
+ .ovdk-vibe-h { gap: 8px; padding: 6px 8px; min-height: 56px; box-sizing: border-box; }
+ .ovdk-vibe-h .ovdk-vibe-back { width: 40px; height: 40px; min-width: 40px; min-height: 40px; border-radius: 12px; color: var(--text-secondary, #b6b6c0); }
+ .ovdk-vibe-h .ovdk-vibe-back:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
+ .ovdk-vibe-back svg { width: 17px; height: 17px; } .ovdk-chip-go svg { width: 13px; height: 13px; }
+ .ovdk-vibe-mark { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; color: #fff;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #3b82f6) 90%, #a855f7), color-mix(in srgb, #a855f7 85%, var(--accent, #3b82f6))); box-shadow: 0 4px 14px color-mix(in srgb, #a855f7 28%, transparent); }
+ .ovdk-vibe-mark svg { width: 19px; height: 19px; }
+ .ovdk-vibe-mark.is-big { width: 46px; height: 46px; border-radius: 14px; }
+ .ovdk-vibe-mark.is-big svg { width: 24px; height: 24px; }
+ .ovdk-vibe-name { flex: 1; min-width: 0; display: grid; line-height: 1.2; }
+ .ovdk-vibe-name b { font-size: 15px; }
+ .ovdk-vibe-name small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #8b8b96); font-size: 12px; }
+ .ovdk-vibe-h .ovdk-ib { margin-left: 0; width: 40px; height: 40px; border-radius: 12px; background: transparent; color: var(--text-secondary, #b6b6c0); }
+ .ovdk-vibe-h .ovdk-ib:hover, .ovdk-vibe-h .ovdk-ib:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
+ .ovdk-vibe-h .ovdk-vibe-fresh { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-vibe-start { list-style: none; display: grid; gap: 14px; padding: 14px 4px 6px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; text-align: left; }
+ .ovdk-vibe-hello { display: flex; align-items: center; gap: 12px; padding: 0 4px; }
+ .ovdk-vibe-hello-t { min-width: 0; display: grid; gap: 2px; }
+ .ovdk-vibe-start b { color: var(--text-primary, #ececf1); font-size: 16.5px; letter-spacing: -.01em; }
+ .ovdk-chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); gap: 6px; margin: 0; }
+ .ovdk-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px 8px 8px; border-radius: 14px;
+ border: 1px solid var(--border, rgba(255,255,255,.1)); background: color-mix(in srgb, var(--text-primary, #ececf1) 3%, transparent); color: var(--text-primary, #ececf1);
+ font: inherit; font-size: 14px; text-align: left; cursor: pointer; transition: border-color var(--ov-dur-2, .2s) ease, background-color var(--ov-dur-2, .2s) ease; }
+ .ovdk-chip:hover, .ovdk-chip:focus-visible { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); outline: none; }
+ .ovdk-chip-ic { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
+ .ovdk-chip-ic svg { width: 18px; height: 18px; }
+ .ovdk-chip-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovdk-chip-go { display: grid; color: var(--text-muted, #8b8b96); }
+ /* The box: the words on top over the full width, the tools and a round send under them. */
+ .ovdk-send.ovdk-send--two { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 6px 6px 6px 8px; border-radius: 20px; }
+ .ovdk-send--two textarea { padding: 8px 6px 4px; min-height: 40px; font-size: 15.5px; }
+ .ovdk-send-tools { display: flex; align-items: center; gap: 2px; min-width: 0; }
+ .ovdk-send-tools .ovdk-attach, .ovdk-send-tools .ovdk-mic { width: 40px; height: 40px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-send-tools .ovdk-attach:hover, .ovdk-send-tools .ovdk-mic:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
+ .ovdk-send-tools .ovdk-attach svg, .ovdk-send-tools .ovdk-mic svg { width: 20px; height: 20px; }
+ .ovdk-send-tools .ovdk-go { margin-left: auto; width: 40px; height: 40px; border-radius: 50%; }
+ .ovdk-send-tools .ovdk-go svg { width: 18px; height: 18px; stroke-width: 2.2; }
+ .ovdk-msg.is-vibe .ovdk-text a { word-break: normal; overflow-wrap: anywhere; }
+ .ovdk-res-c { grid-template-columns: auto minmax(0, 1fr); }
+ .ovdk-res-c .ovdk-res-a { grid-column: 2; }
.ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
.ovdk-mic, .ovdk-attach { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center; background: transparent; color: var(--text-muted, #8b8b96); touch-action: none; }
.ovdk-attach:hover { color: var(--text-primary,#ececf1); }
.ovdk-attach:hover { color: var(--text-primary,#ececf1); }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
.ovdk-mic:hover { color: var(--text-primary, #ececf1); }
@@ -74,7 +128,7 @@
@@ -74,7 +128,7 @@
/** A round icon button with its name as label and tooltip. */
/** 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; };
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). */
/** 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; };
+ const awayLink = () => { const a = iconButton('a', 'ovdk-ib ovdk-away', V.open, '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") ──
@@ -91,7 +145,14 @@
@@ -91,7 +145,14 @@
const TALK = Boolean(SR || MR);
const TALK = Boolean(SR || MR);
const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];
const REC_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', 'audio/ogg'];
const REC_MAX_MS = 30000;
const REC_MAX_MS = 30000;
- const STARTERS = ['Remind me to stretch every 2 hours', 'What is on today?', 'Who is live right now?', 'Remind me tomorrow at 9 to call Sam'];
+ const STARTERS = [
+ ['bell', 'Remind me to stretch every 2 hours'],
+ ['calendar', 'What is on today?'],
+ ['live', 'Who is live right now?'],
+ ['clock', 'Start a 25-minute focus timer'],
+ ['phone', 'Remind me tomorrow at 9 to call Sam'],
+ ['search', 'What is new on OpenVibe this week?'],
+ ];
function linkified(text) {
function linkified(text) {
const span = h('span', 'ovdk-text');
const span = h('span', 'ovdk-text');
String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {
String(text || '').split(/(https:\/\/[^\s<>"')]+)/).forEach((part, i) => {
@@ -155,7 +216,7 @@
@@ -155,7 +216,7 @@
// No heading (ask 335, the owner: "remove the text … new conversation … an icon plus button … open in Chat a
// 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.
// little button icon"): one slim row of two icon buttons over your chats.
const head = h('div', 'ovdk-bar');
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(); });
+ const newer = iconButton('button', 'ovdk-ib is-new ovdk-vibe-new', V.plus, 'New chat'); newer.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
head.append(newer, awayLink());
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');
scroll.append(list); panel.append(head, scroll);
scroll.append(list); panel.append(head, scroll);
@@ -189,14 +250,18 @@
@@ -189,14 +250,18 @@
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 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 back = iconButton('button', 'ovdk-vibe-back', V.back, 'Back to your chats'); back.addEventListener('click', () => { ctx.save({ vibe: null }); openList(); });
const away = awayLink();
const away = awayLink();
- head.append(back, icon(G.vibe), title, away);
+ const mark = h('span', 'ovdk-vibe-mark'); mark.innerHTML = svg(V.spark);
+ const name = h('span', 'ovdk-vibe-name'); name.append(title, h('small', '', 'Your AI on every OpenVibe site'));
+ const fresh = iconButton('button', 'ovdk-ib ovdk-vibe-fresh', V.plus, 'New chat');
+ fresh.addEventListener('click', () => { vibe.fresh = true; vibe.chat = null; ctx.save({ vibe: null }); openVibe(); });
+ head.append(back, mark, name, fresh, 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);
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
const typing = h('li', 'ovdk-msg is-vibe ovdk-typing'); typing.innerHTML = '<span class="ovdk-text"><i></i><i></i><i></i></span>'; typing.setAttribute('aria-label', 'Vibe is writing');
const form = h('form', 'ovdk-send');
const form = h('form', 'ovdk-send');
- const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe'; box.setAttribute('aria-label', 'Message Vibe');
+ const box = h('textarea'); box.rows = 1; box.maxLength = 8000; box.placeholder = TALK ? 'Ask Vibe, or hold the mic' : 'Ask Vibe anything'; box.setAttribute('aria-label', 'Message Vibe');
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'
const reason = (e) => e && e.status === 401 ? 'Your sign in expired. Sign in again to continue.'
@@ -207,7 +272,7 @@
@@ -207,7 +272,7 @@
const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;
const showFailure = (e, retry) => { err.textContent = `Vibe did not answer: ${reason(e)} `; err.hidden = false;
if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }
if (retry) { const b = h('button', '', 'Retry'); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; try { await retry(); } catch (x) { showFailure(x, retry); } finally { b.disabled = false; } }); err.append(b); }
};
};
- const attach = h('button', 'ovdk-attach', '+'); attach.type = 'button'; attach.setAttribute('aria-label', 'Attach a picture'); attach.title = 'Attach a picture';
+ const attach = iconButton('button', 'ovdk-attach', V.image, 'Attach a picture');
const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;
const file = h('input'); file.type = 'file'; file.accept = 'image/*'; file.hidden = true;
attach.addEventListener('click', () => file.click());
attach.addEventListener('click', () => file.click());
file.addEventListener('change', async () => {
file.addEventListener('change', async () => {
@@ -222,21 +287,25 @@
@@ -222,21 +287,25 @@
finally { attach.disabled = false; file.value = ''; }
finally { attach.disabled = false; file.value = ''; }
});
});
// 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.
// 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.
- const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');
+ const build = iconButton('button', 'ovdk-attach ovdk-build', V.code, 'Build it in Code');
build.addEventListener('click', () => {
build.addEventListener('click', () => {
const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');
const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');
const text = box.value.trim() || (last && last.text) || '';
const text = box.value.trim() || (last && last.text) || '';
if (!text) { box.focus(); return; }
if (!text) { box.focus(); return; }
build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
});
});
- form.append(attach, file, build);
+ form.classList.add('ovdk-send--two');
+ const tools = h('div', 'ovdk-send-tools');
+ tools.append(attach, file, build);
let mic = null;
let mic = null;
if (TALK) {
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
mic.setAttribute('aria-label', 'Talk to Vibe: hold, or press Alt+V'); mic.title = 'Hold to talk (Alt+V)'; mic.setAttribute('aria-pressed', 'false');
- form.append(mic);
+ tools.append(mic);
}
}
- form.append(box, go);
+ go.textContent = ''; go.innerHTML = svg(V.send);
+ tools.append(go);
+ form.append(box, tools);
panel.append(head, scroll, err, form);
panel.append(head, scroll, err, form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };
vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };
@@ -295,13 +364,25 @@
@@ -295,13 +364,25 @@
}
}
if (first && !vibe.msgs.length) {
if (first && !vibe.msgs.length) {
const start = h('li', 'ovdk-vibe-start');
const start = h('li', 'ovdk-vibe-start');
- start.append(h('b', '', 'Ask Vibe anything'), h('span', '', TALK ? 'Type, or hold the mic and say it. It sets reminders, finds what is live, and answers from the whole network.' : 'It sets reminders, finds what is live, and answers from the whole network.'));
- const chips = h('div', 'ovdk-chips');
- STARTERS.forEach((s) => { const c = h('button', 'ovdk-chip', s); c.type = 'button'; c.addEventListener('click', () => send(s)); chips.append(c); });
- start.append(chips);
+ const hello = h('div', 'ovdk-vibe-hello');
+ const big = h('span', 'ovdk-vibe-mark is-big'); big.innerHTML = svg(V.spark);
+ const words = h('div', 'ovdk-vibe-hello-t');
+ words.append(h('b', '', 'What can I do for you?'), h('span', '', TALK ? 'Ask anything, set reminders and timers, find what is live. Hold the mic to talk.' : 'Ask anything, set reminders and timers, find what is live.'));
+ hello.append(big, words);
+ const chips = h('div', 'ovdk-chips'); chips.setAttribute('role', 'list');
+ STARTERS.forEach(([ic, s]) => {
+ const c = h('button', 'ovdk-chip'); c.type = 'button'; c.setAttribute('role', 'listitem');
+ const m = h('span', 'ovdk-chip-ic'); m.innerHTML = svg(V[ic]);
+ const t = h('span', 'ovdk-chip-t', s);
+ const go2 = h('span', 'ovdk-chip-go'); go2.innerHTML = svg(V.chevron);
+ c.append(m, t, go2);
+ c.addEventListener('click', () => send(s)); chips.append(c);
+ });
+ start.append(hello, chips);
list.append(start);
list.append(start);
}
}
- if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;
+ if (first && !vibe.msgs.length) scroll.scrollTop = 0;
+ else if (first || atEnd || answered) scroll.scrollTop = scroll.scrollHeight;
clearTimeout(vibe.timer);
clearTimeout(vibe.timer);
if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);
if (ctx.active()) vibe.timer = setTimeout(() => pull(), vibe.waitUntil ? 1500 : 15000);
}
}
@@ -382,7 +463,7 @@
@@ -382,7 +463,7 @@
function mount(panel, c) {
function mount(panel, c) {
ctx = c || {};
ctx = c || {};
- G = { mic: MIC, ...(ctx.icons || {}) };
+ G = { mic: V.mic, ...(ctx.icons || {}) };
const st = typeof ctx.styles === 'function' ? ctx.styles() : null;
const st = typeof ctx.styles === 'function' ? ctx.styles() : null;
if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
if (st && !st.textContent.includes('.ovdk-vibe-start')) st.textContent += CSS;
else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
else if (!st && !document.getElementById('ovdk-vibe-styles')) { const s = document.createElement('style'); s.id = 'ovdk-vibe-styles'; s.textContent = CSS; document.head.appendChild(s); }
@@ -401,5 +482,5 @@
@@ -401,5 +482,5 @@
ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
};
};
}
}
- root.OpenVibeDockVibe = { mount };
+ root.OpenVibeDockVibe = { mount, GLYPHS: V };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,53 +1,44 @@
@@ -1,53 +1,44 @@
-// The Dock: navbar.js calls mount({ token, apiBase, sibling }) for the pill. open({ tab }) opens the floating card;
-// mountInto(host, { tab }) places its panel body inside another sheet. Both use the same state, counts and loaders.
+/**
+ * openvibe-shared/dock.js — the panels behind the menu's Chat and Vibe tabs: your conversations (Chat), and Vibe, your
+ * coding tasks and your agents (Vibe's three parts). 4.220.0 (the owner, 11-12: "there should only be the one unified
+ * UI … delete … the old tab system"): the floating Dock — its pill, its card and tab bar, corners, the phone sheet and
+ * the window of its own — is gone. The menu (search.js) places these panels with mountInto on every screen.
+ *
+ * OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out)
+ * OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats')
+ * OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu)
+ * What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js
+ * paints on the bar's menu button.
+ */
(function (root) {
(function (root) {
'use strict';
'use strict';
if (typeof document === 'undefined' || root.OpenVibeDock) return;
if (typeof document === 'undefined' || root.OpenVibeDock) return;
- const W = [320, 720], H = [360, 900], DEF = { width: 420, height: 640 };
- const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
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>`;
- // Close is the OpenVibe menu's orb in its × form (11-09, the owner: "replace this close button with our own… the
- // corner button… its close form… nice animations"): nine dots that fold into an × as the Dock opens.
- // The close button is the menu button, already crossed (navbar.js, root.OpenVibeOrb): it forms as the sheet opens.
- const ORBX = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(22, true) : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>');
const G = {
const G = {
code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>',
code: '<path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
bell: '<path d="M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 2h-14z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
- // An upright pushpin whose lines never cross (owner, 11-09: the old one had "a line going through it").
- pin: '<path d="M7.5 3.5h9M10 3.5v5.3L6.5 13h11L14 8.8V3.5M12 13v7.5"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
open: '<path d="M13.5 5.5h5v5M18.5 5.5l-8 8M16.5 13.5v4a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
chat: '<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5z"/>',
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
back: '<path d="M14.5 6.5L9 12l5.5 5.5"/>',
- send: '<path d="M4.5 12h11M11 6.5l5.5 5.5-5.5 5.5"/>',
+ send: '<path d="M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3"/>', // 4.220.0: the registry's (openvibe-shared/glyphs), as Vibe's box
vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
vibe: '<path d="M12 4.5a7.5 7.5 0 0 1 6.4 11.4L19.5 19.5l-3.6-1.1A7.5 7.5 0 1 1 12 4.5z"/><path d="M9 11.5h.01M12 11.5h.01M15 11.5h.01"/>',
- // Three filled dots on the exact centre line (a stroked h.01 path drew them a hair off centre: the owner, 11-09).
- more: '<g fill="currentColor" stroke="none"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></g>',
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
clock: '<circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/>',
+ // 4.220.0: the registry's pencil (openvibe-shared/glyphs), for a message's Edit.
+ pencil: '<path d="M11.9 19.6h8.4M16.1 4.2a2 2 0 0 1 2.8 2.8L7.2 18.7l-3.7 0.9l0.9-3.7Z"/>',
};
};
// 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], ['code', 'Code', G.code], ['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', 'code', 'agent', 'chats', 'activity'];
- const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
+ const TABS = [['vibe', 'Vibe', G.spark], ['code', 'Code', G.code], ['agent', 'Agents', ROBOT], ['chats', 'Chat', G.chat]];
+ const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null };
- let el = null, bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null, dockSurface = null;
- // Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.
- let pipWin = null, popped = false, pipClosing = false, popOk = true;
+ let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {};
const mounts = {};
const mounts = {};
- const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
- // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
- const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
- const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const state = () => (doc && doc.state) || DEFAULT_STATE;
const state = () => (doc && doc.state) || DEFAULT_STATE;
const counts = () => (doc && doc.counts) || {};
const counts = () => (doc && doc.counts) || {};
- const kind = () => (phone() ? 'phone' : 'desktop');
- const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
// K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one
// K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one
// timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame
// timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame
@@ -67,14 +58,14 @@
@@ -67,14 +58,14 @@
}
}
/** A token may be a string, a sync function or an async function (the extension refreshes it). */
/** A token may be a string, a sync function or an async function (the extension refreshes it). */
async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }
async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }
- const notify = () => { store(doc); paintPill(); };
+ const notify = () => { store(doc); paintStatus(); };
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
function save(patch) {
function save(patch) {
const st = state();
const st = state();
- const next = { ...st, ...patch, place: patch.place ? { ...st.place, ...patch.place } : st.place };
+ const next = { ...st, ...patch };
doc = { ...(doc || {}), state: next, counts: counts() };
doc = { ...(doc || {}), state: next, counts: counts() };
- pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };
+ pending = { ...pending, ...patch };
notify();
notify();
clearTimeout(saveT);
clearTimeout(saveT);
saveT = setTimeout(flush, 700);
saveT = setTimeout(flush, 700);
@@ -84,12 +75,12 @@
@@ -84,12 +75,12 @@
if (!Object.keys(body).length) return;
if (!Object.keys(body).length) return;
try {
try {
const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
- if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
+ if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
}
}
// 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': '7869d3d345b4', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': 'def2148254a0', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
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);
@@ -106,99 +97,27 @@
@@ -106,99 +97,27 @@
const st = document.createElement('style');
const st = document.createElement('style');
st.id = 'ovdk-styles';
st.id = 'ovdk-styles';
st.textContent = `
st.textContent = `
- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))), var(--ovb-composer, 0px)); translate: 0 calc(-1 * var(--ovb-lift, 0px));
- display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);
- font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }
- .ovdk-pill[hidden] { display: none; }
- .ovdk-pill[data-corner$="left"] { right: auto; left: max(16px, env(safe-area-inset-left, 0px)); }
- .ovdk-pill[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }
- .ovdk-pill[data-tone="waiting"] { --t: var(--warning, #f5a524); }
- .ovdk-pill:hover { border-color: color-mix(in srgb, var(--t) 45%, var(--border, #333340)); }
- .ovdk-pill:active { scale: .96; }
- .ovdk-pill:focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 3px; }
- .ovdk-m { position: relative; display: grid; place-items: center; width: 30px; height: 30px; }
- .ovdk-m::before, .ovdk-m::after { content: ''; position: absolute; top: -1px; right: -2px; width: 9px; height: 9px; border-radius: 50%; background: var(--t); }
- .ovdk-m::after { box-shadow: 0 0 0 2.5px var(--bg); }
- .ovdk-pill[data-tone="waiting"] .ovdk-m::before { animation: ovdk-ping 1.8s ease-out infinite; }
- .ovdk-pill[data-tone="idle"] .ovdk-m::before, .ovdk-pill[data-tone="idle"] .ovdk-m::after, .ovdk-t:empty { display: none; }
- .ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }
- @keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }
- @keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }
- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
- right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
- width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
- background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
- border-radius: 22px; box-shadow: 0 30px 80px -24px rgba(0,0,0,.62), 0 2px 10px -2px rgba(0,0,0,.25); font-family: inherit; transform-origin: 100% 100%; }
- .ovdk[hidden] { display: none; }
- .ovdk[data-corner$="left"] { right: auto; left: max(var(--ovdk-gap), env(safe-area-inset-left, 0px)); }
- .ovdk[data-corner^="top"] { bottom: auto; top: calc(66px + env(safe-area-inset-top, 0px)); }
- .ovdk[data-corner="bottom-left"] { transform-origin: 0 100%; }
- .ovdk[data-corner="top-right"] { transform-origin: 100% 0; }
- .ovdk[data-corner="top-left"] { transform-origin: 0 0; }
- .ovdk.is-in { animation: ovdk-grow .3s cubic-bezier(.2,.9,.25,1.08); }
- .ovdk.is-moving { transition: none !important; box-shadow: 0 40px 90px -20px rgba(0,0,0,.7); cursor: grabbing; }
- @keyframes ovdk-grow { from { opacity: 0; transform: scale(.82); } }
- @keyframes ovdk-rise { from { transform: translateY(100%); } }
+ /* The bar's menu button carries the status: navbar.js paints .ovnav-st, which shows only once this file has loaded. */
+ .ovnav-st { position: absolute; top: -4px; right: -6px; z-index: 1; display: inline-flex; align-items: center; justify-content: center; min-width: 10px; height: 18px; padding: 0 6px;
+ box-sizing: border-box; border-radius: 999px; font: normal 750 10.5px/1 var(--font-sans, inherit); font-variant-numeric: tabular-nums; letter-spacing: .01em; white-space: nowrap;
+ --st: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--st); box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19); pointer-events: none; }
+ [data-ov-status="running"] > .ovnav-st, [data-ov-status="waiting"] > .ovnav-st { width: 10px; min-width: 10px; height: 10px; padding: 0; top: 1px; right: 1px; }
+ [data-ov-status^="timer"] > .ovnav-st { top: auto; bottom: -8px; right: 50%; transform: translateX(50%); height: 15px; padding: 0 5px; font-size: 9.5px; }
+ [data-ov-status="waiting"] > .ovnav-st { --st: var(--warning, #f5a524); }
+ [data-ov-status="timer-done"] > .ovnav-st { --st: var(--success, #22c55e); color: #052e14; }
+ [data-ov-status="running"] > .ovnav-st { animation: ovnav-st-breathe 1.8s ease-in-out infinite; }
+ [data-ov-status="waiting"] > .ovnav-st { animation: ovnav-st-breathe 1.2s ease-in-out infinite; }
+ @keyframes ovnav-st-breathe { 50% { box-shadow: 0 0 0 2px var(--bg-primary, #0b0f19), 0 0 0 6px color-mix(in srgb, var(--st) 30%, transparent); } }
+ html[data-ov-motion=calm] [data-ov-status] > .ovnav-st { animation-duration: 3.2s; }
+ html[data-ov-motion=off] .ovnav-st { animation: none; }
+ /* 4.220.0: the panels live in the menu only (the floating card, its pill and tab bar are gone). */
+ .ovdk { position: relative; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; width: 100%; height: 100%; min-height: 0;
+ color: var(--text-primary, #ececf1); font-family: inherit; container-type: inline-size; }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */
/* The menu button's drawing (navbar.js) has its own fills: the line style above is not its. */
.ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; }
.ovdk svg.ovmb { fill: currentColor; stroke: none; stroke-width: 0; }
- .ovdk-head { position: relative; flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 8px 8px 10px; cursor: grab; touch-action: none; user-select: none; }
- .ovdk-tabs { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #ececf1) 6%, transparent); min-width: 0; flex: 0 1 auto; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
- .ovdk-tabs::-webkit-scrollbar { display: none; }
- .ovdk-tabs button { flex: none; }
- .ovdk-tabs button { position: relative; display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 14px 0 11px; border: 0; border-radius: 999px; background: transparent;
- color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13.5px; font-weight: 700; cursor: pointer; white-space: nowrap; transition: background .15s, color .15s; }
- .ovdk-tabs button svg { width: 17px; height: 17px; flex: none; }
- .ovdk-tabs button:hover { color: var(--text-primary, #ececf1); }
- .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
- .ovdk-tabs button[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
- .ovdk-tabs button:focus-visible, .ovdk-acts button:focus-visible, .ovdk-foot :focus-visible { outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 1px; }
- .ovdk-tabs .n { min-width: 18px; padding: 1px 6px; box-sizing: border-box; border-radius: 999px; font-size: 11px; font-weight: 800; line-height: 1.35; text-align: center; font-variant-numeric: tabular-nums;
- color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
- .ovdk-tabs .n[data-tone="waiting"] { background: color-mix(in srgb, var(--warning, #f5a524) 34%, transparent); }
- .ovdk-tabs .n:empty { display: none; }
- .ovdk-acts { display: flex; gap: 2px; margin-left: auto; }
- .ovdk-acts button { width: 40px; height: 40px; border: 0; border-radius: 12px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
- .ovdk-acts button:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); color: var(--text-primary, #ececf1); }
- .ovdk-acts button svg { width: 19px; height: 19px; display: block; }
- .ovdk-acts .ovdk-tomenu span { display: none; }
- .ovdk-orbx svg { width: 22px !important; height: 22px !important; overflow: visible; }
- .ovdk-orbx { --ovo-a: var(--accent-light, var(--accent, #60a5fa)); }
- .ovdk.is-in .ovdk-orbx .ovmb :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
- .ovdk-orbx:hover .ovmb, .ovdk-orbx:focus-visible .ovmb { transform: rotate(90deg); }
- .ovdk-orbx .ovmb { transition: transform .35s cubic-bezier(.2,1.3,.3,1); }
- .ovdk-orbx:active .ovmb { transform: rotate(90deg) scale(.8); }
- /* A phone's sheet opens out of the navbar's orb as the menu's does (4.186.0; see CHANGELOG): one move, one close button. */
- .ovdk.from-orb.is-in { animation: ovdk-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
- .ovdk.from-orb.is-out { animation: ovdk-fold .28s cubic-bezier(.5,0,.75,.2) both; pointer-events: none; }
- @keyframes ovdk-reveal { from { clip-path: circle(28px at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } }
- @keyframes ovdk-fold { from { clip-path: circle(150% at var(--ovdk-ox) var(--ovdk-oy)); } to { clip-path: circle(20px at var(--ovdk-ox) var(--ovdk-oy)); opacity: 0; } }
- .ovdk.from-orb .ovdk-head { padding: 4px 14px 6px 8px; }
- .from-orb .ovdk-acts .ovdk-orbx { position: relative; width: 40px; height: 40px; margin: 2px; border-radius: 50%; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19));
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); transition: background-color .3s, box-shadow .3s; }
- .from-orb .ovdk-acts .ovdk-orbx.is-from { background: transparent; box-shadow: none; }
- .from-orb.is-in .ovdk-orbx::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent-light, #60a5fa); pointer-events: none; animation: ovdk-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
- @keyframes ovdk-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
- .from-orb .ovdk-orbx { color: var(--text-primary, #ececf1); }
- .from-orb .ovdk-orbx svg { transition: transform .42s cubic-bezier(.2, 1.1, .3, 1); }
- .ovdk-orbx.is-set, .ovdk-orbx.is-set * { transition: none !important; }
- .from-orb .is-from svg { transform: translate(var(--orb-dx, 0), var(--orb-dy, 0)) scale(var(--orb-s, 1)); }
- .ovnav-orb.is-dock { transform: translate(var(--dock-dx, 0), var(--dock-dy, 0)) scale(var(--dock-s, 1)) !important; color: var(--text-primary, #e6edf7) !important; }
- .ovdk-more { position: absolute; z-index: 6; right: 8px; top: calc(100% - 4px); width: min(320px, calc(100% - 16px)); display: grid; gap: 2px; padding: 6px; border-radius: 16px;
- border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); box-shadow: 0 18px 40px -12px rgba(0,0,0,.55); cursor: default; }
- .ovdk-more[hidden] { display: none; }
- .ovdk-more button { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 8px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary, #ececf1); font: inherit; text-align: left; cursor: pointer; }
- .ovdk-more button[hidden] { display: none; }
- .ovdk-more button:hover, .ovdk-more button:focus-visible { background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); outline: none; }
- .ovdk-more button > svg { flex: none; width: 20px; height: 20px; color: var(--text-muted, #8b8b96); }
- .ovdk-more span { flex: 1; min-width: 0; display: grid; gap: 2px; }
- .ovdk-more b { font-size: 14px; font-weight: 650; }
- .ovdk-more small { font-size: 12px; color: var(--text-muted, #8b8b96); line-height: 1.35; }
- .ovdk-sw { flex: none; position: relative; width: 38px; height: 22px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.14)); transition: background .15s; }
- .ovdk-sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
- .ovdk-more [aria-checked="true"] .ovdk-sw { background: var(--accent-strong, var(--accent, #2563eb)); }
- .ovdk-more [aria-checked="true"] .ovdk-sw::after { transform: translateX(16px); }
- .ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; border-top: 1px solid var(--border, rgba(255,255,255,.08)); }
+ { transform: translateX(16px); }
+ .ovdk-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.ovdk-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.ovdk-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.ovdk-panel[hidden] { display: none; }
.ovdk-panel[hidden] { display: none; }
.ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.ovdk-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
@@ -213,24 +132,6 @@
@@ -213,24 +132,6 @@
.ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }
.ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }
@keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
@keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
- .ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }
- .ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }
- .ovdk .ovdk-size::after { opacity: .75; }
- .ovdk[data-corner="bottom-left"] .ovdk-size { left: auto; right: 0; cursor: nesw-resize; }
- .ovdk[data-corner="bottom-left"] .ovdk-size::after { left: auto; right: 9px; border-left: 0; border-right: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-top-right-radius: 3px; }
- .ovdk[data-corner="top-right"] .ovdk-size { top: auto; bottom: 0; cursor: nesw-resize; }
- .ovdk[data-corner="top-right"] .ovdk-size::after { top: auto; bottom: 9px; border-top: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 0; border-bottom-left-radius: 3px; }
- .ovdk[data-corner="top-left"] .ovdk-size { top: auto; bottom: 0; left: auto; right: 0; }
- .ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
- @media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
- .ovdk { container-type: inline-size; }
- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
- /* A narrow Dock (Opus, 11-07: "Activity" was cut off at the strip's edge): the open tab keeps its icon and name,
- the others show their icon and count only, their names kept for screen readers. All four always fit. */
- @container (max-width: 480px) {
- .ovdk-tabs button:not([aria-selected="true"]) { padding: 0 10px; gap: 4px; }
- .ovdk-tabs button:not([aria-selected="true"]) span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
- }
.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; }
@@ -278,270 +179,72 @@
@@ -278,270 +179,72 @@
.ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
.ovdk-go { width: 44px; height: 44px; flex: none; border: 0; border-radius: 12px; cursor: pointer; display: grid; place-items: center;
color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
color: var(--on-accent-strong, var(--on-accent, #fff)); background: var(--accent-strong, var(--accent, #3472d8)); }
.ovdk-go:disabled { opacity: .55; cursor: default; }
.ovdk-go:disabled { opacity: .55; cursor: default; }
+ /* 4.220.0: a message's actions at its corner (openvibe.design rule 138; the owner, 11-12, on chat's Edit: "hidden
+ until you tap"): on hover or focus on a computer, a tap on a phone; never under the words. "edited" is one
+ quiet word in the bubble. Editing happens in the box, under a slim bar that says so. */
+ .ovdk-msg.has-acts { position: relative; }
+ /* Beside the bubble, on its empty side (a bubble is at most 84% wide), so the cluster never covers anyone's words. */
+ .ovdk-acts { position: absolute; top: 50%; transform: translateY(-50%); right: calc(100% + 6px); z-index: 2; display: none; align-items: center; gap: 2px; padding: 2px; border-radius: 10px;
+ border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #1b1d26); box-shadow: 0 6px 18px rgba(0,0,0,.28); }
+ .ovdk-msg:not(.is-mine) > .ovdk-acts { right: auto; left: calc(100% + 6px); }
+ .ovdk-msg.has-acts:hover > .ovdk-acts, .ovdk-msg.has-acts:focus-within > .ovdk-acts, .ovdk-msg.is-acting > .ovdk-acts { display: inline-flex; }
+ .ovdk-act { width: 30px; height: 28px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+ .ovdk-act:hover, .ovdk-act:focus-visible { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); outline: none; }
+ .ovdk-act .ovdk-i svg { width: 16px; height: 16px; }
+ @media (hover: none) {
+ .ovdk-msg.has-acts:hover:not(.is-acting):not(:focus-within) > .ovdk-acts { display: none; }
+ .ovdk-act { width: 44px; height: 40px; border-radius: 9px; }
+ .ovdk-act .ovdk-i svg { width: 18px; height: 18px; }
+ }
+ .ovdk-msg.is-acting .ovdk-text, .ovdk-msg.is-editing .ovdk-text { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
+ .ovdk-edited { margin-left: 6px; font-size: .74em; color: var(--text-muted, #8b8b96); white-space: nowrap; }
+ .ovdk-msg.is-mine .ovdk-edited { color: inherit; opacity: .72; }
+ .ovdk-edit-bar { flex: none; display: flex; align-items: center; gap: 8px; margin: 8px 10px -2px; padding: 2px 2px 2px 12px; min-height: 36px; border-radius: 12px;
+ font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
+ .ovdk-edit-bar[hidden] { display: none; }
+ .ovdk-edit-bar span:not(.ovdk-i) { flex: 1; min-width: 0; }
+ .ovdk-edit-bar .ovdk-i svg { width: 15px; height: 15px; }
+ .ovdk-edit-bar button { width: 36px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+ .ovdk-edit-bar button:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 8%, transparent); }
+ .ovdk-send.is-editing { border-color: var(--accent, #3b82f6); }
+ /* One send for every box in the Dock: round, as Vibe's. */
+ .ovdk-send { border-radius: 22px; padding: 4px 4px 4px 14px; }
+ .ovdk-send .ovdk-go { width: 40px; height: 40px; border-radius: 50%; }
.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
.ovdk-err { margin: 0 14px; font-size: 12.5px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
.ovdk-err[hidden] { display: none; }
.ovdk-err[hidden] { display: none; }
.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
.ovdk-none b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
- @media (max-width: 640px) {
- .ovdk, .ovdk[data-corner] { z-index: 10010; left: 0; right: 0; top: 0; bottom: var(--ov-kb, 0px); width: auto !important; height: auto !important; max-width: none; max-height: none;
- border: 0; border-radius: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: none; transform-origin: 50% 100%; }
- .ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }
- .ovdk-head { cursor: default; }
- .ovdk-size { display: none; }
- .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
- /* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */
- .ovdk-tabs button { height: 44px; }
- .ovdk-acts button { width: 44px; height: 44px; }
- /* Two rows (4.150.0, the owner: the strip was "cut off", "Activity" read "Ac"): the tools sit beside the grab
- handle; the four tabs share the full width below, each icon over its name, every one whole. */
- .ovdk-head { flex-wrap: nowrap; gap: 4px; padding: 6px 6px 6px 8px; }
- .ovdk-acts { flex: none; gap: 0; }
- .ovdk-acts button[data-act="pop"] { display: none !important; }
- .ovdk-tabs { order: 0; flex: 1 1 auto; min-width: 0; padding: 3px; border-radius: 16px; }
- .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { flex: 1 1 0; min-width: 0; height: 54px; padding: 4px 2px; flex-direction: column; justify-content: center; gap: 3px; border-radius: 12px; font-size: 11.5px; font-weight: 650; }
- .ovdk-tabs button span, .ovdk-tabs button:not([aria-selected="true"]) span { position: static; width: auto; height: auto; overflow: hidden; clip: auto; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
- .ovdk-tabs button svg { width: 19px; height: 19px; }
- .ovdk-tabs .n { position: absolute; top: 4px; left: calc(50% + 6px); min-width: 17px; padding: 1px 5px; font-size: 10.5px; }
- .ovdk-foot a, .ovdk-foot button { min-height: 44px; }
- /* Two rows, as the menu's head (4.189.0): the way back to the menu, More and Close; under them the four tabs,
- full width, in the look of the menu's row that leads here. */
- .ovdk-head, .ovdk.from-orb .ovdk-head { flex-wrap: wrap; gap: 6px 4px; padding: 6px 12px 8px; }
- .ovdk-acts { order: 0; flex: 1 0 100%; align-items: center; gap: 2px; }
- .ovdk-acts .ovdk-tomenu { width: auto; margin-right: auto; padding: 0 14px 0 10px; gap: 8px; display: inline-flex; align-items: center; border-radius: 999px; font: inherit; font-size: 13.5px; font-weight: 700;
- color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
- .ovdk-acts .ovdk-tomenu span { display: inline; }
- .ovdk-tabs { order: 1; flex: 1 0 100%; gap: 4px; padding: 0; border-radius: 0; background: transparent; overflow: visible; }
- .ovdk-tabs button, .ovdk-tabs button:not([aria-selected="true"]) { height: 50px; gap: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); }
- .ovdk-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
- .ovdk-tabs button:not([aria-selected="true"]) svg { color: var(--accent-light, var(--accent, #60a5fa)); }
- }
- @media (min-width: 641px) and (max-width: 1023px) {
- .ovdk, .ovdk[data-corner] { z-index: 10010; top: 0; bottom: 0; right: 0; left: auto; width: min(460px, calc(100vw - 48px)) !important; height: auto !important;
- max-width: none; max-height: none; border-radius: 22px 0 0 22px; border-right: 0; padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
- box-shadow: 0 0 0 100vmax rgba(0,0,0,.4), -16px 0 40px rgba(0,0,0,.3); transform-origin: 100% 50%; }
- .ovdk-size { display: none; }
- .ovdk-head { cursor: default; }
- }
- html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
- // In a window of its own the panel is the window: full bleed, no card, no phone sheet.
- .ovdk-pip .ovdk, .ovdk-pip .ovdk[data-corner] { left: 0 !important; top: 0 !important; right: 0 !important; bottom: 0 !important;
- width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important;
- border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }
- .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }
- .ovdk-pip .ovdk-head::before { display: none !important; }
- .ovdk-pip .ovdk-size { display: none !important; }
+ @media (max-width: 640px) { .ovdk-foot a, .ovdk-foot button { min-height: 44px; } }
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
+ /** The panels' body, once: the menu (search.js) places it with mountInto. */
function build() {
function build() {
styles();
styles();
- el = document.createElement('section');
- el.className = 'ovdk';
- el.setAttribute('role', 'dialog');
- el.setAttribute('aria-label', 'Dock');
- el.hidden = true;
- el.innerHTML = `
- <header class="ovdk-head">
- <div class="ovdk-tabs" role="tablist" aria-label="Dock">${TABS.map(([id, label, g]) => `<button type="button" role="tab" id="ovdk-tab-${id}" aria-controls="ovdk-panel-${id}" data-tab="${id}" aria-selected="false" tabindex="-1">${svg(g)}<span>${label}</span><b class="n" data-n="${id}"></b></button>`).join('')}</div>
- <div class="ovdk-acts">
- <button type="button" data-act="menu" class="ovdk-tomenu" title="The OpenVibe menu" aria-label="The OpenVibe menu: every site, the feed, you">${root.OpenVibeOrb ? root.OpenVibeOrb.svg(20) : ''}<span>Menu</span></button>
- <button type="button" data-act="more" aria-haspopup="true" aria-expanded="false" title="More" aria-label="More: a timer, the Dock's button">${svg(G.more)}</button>
- <button type="button" data-act="close" class="ovdk-orbx" title="Close" aria-label="Close the Dock">${ORBX()}</button>
- </div>
- <div class="ovdk-more" role="menu" aria-label="More" hidden>
- <button type="button" role="menuitem" data-act="timer">${svg(G.clock)}<span><b>Start a timer</b><small>A countdown or a focus round, on the Dock's button</small></span></button>
- <button type="button" role="menuitemcheckbox" aria-checked="false" data-act="pin">${svg(G.pin)}<span><b>Keep the Dock's button on every page</b><small>When off, it shows only while something works or needs you</small></span><i class="ovdk-sw" aria-hidden="true"></i></button>
- <button type="button" role="menuitem" data-act="pop" hidden>${svg(G.open)}<span><b>Open the Dock in a window of its own</b><small>Keep it beside whatever you are doing</small></span></button>
- </div>
- </header>
- <div class="ovdk-body">${TABS.map(([id]) => `<div class="ovdk-panel" role="tabpanel" id="ovdk-panel-${id}" aria-labelledby="ovdk-tab-${id}" data-panel="${id}" hidden></div>`).join('')}</div>
- <span class="ovdk-size" aria-hidden="true"></span>`;
- document.body.appendChild(el);
- bodyEl = el.querySelector('.ovdk-body');
-
- const tabs = [...el.querySelectorAll('[role="tab"]')];
- tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
- el.querySelector('.ovdk-tabs').addEventListener('keydown', (e) => {
- const i = tabs.indexOf(document.activeElement); if (i < 0) return;
- const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
- if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
- });
- el.querySelector('[data-act="close"]').addEventListener('click', () => close());
- // The way back to the menu: this sheet folds, the menu opens out of the same button.
- { const m = el.querySelector('[data-act="menu"]'); if (m) { if (!root.OpenVibeNavbar || !root.OpenVibeOrb) m.hidden = true; m.addEventListener('click', () => { close(); setTimeout(() => { try { root.OpenVibeNavbar.openSwitcher(); } catch { /* no bar */ } }, 300); }); } }
- el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
- el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
- el.querySelector('[data-act="timer"]').addEventListener('click', () => { const a = el.querySelector('[data-act="more"]'); closeMore(); withTimer((t) => t.menu(a)); });
- const moreBtn = el.querySelector('[data-act="more"]'), moreMenu = el.querySelector('.ovdk-more');
- function closeMore() { moreMenu.hidden = true; moreBtn.setAttribute('aria-expanded', 'false'); }
- moreBtn.addEventListener('click', () => {
- const open = moreMenu.hidden;
- closeMore();
- const tm = el.querySelector('.ovdk-tmenu'); if (tm) tm.remove();
- if (open) { moreMenu.hidden = false; moreBtn.setAttribute('aria-expanded', 'true'); const first = moreMenu.querySelector('button:not([hidden])'); if (first) first.focus(); }
- });
- moreMenu.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b && b.dataset.act !== 'pin') closeMore(); });
- moreMenu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeMore(); moreBtn.focus(); } });
- // A pop-over (More, the timer) closes on a tap anywhere else, in the Dock or on the page (owner, 11-09: "clicked off
- // something else, and it didn't actually close").
- const awayFrom = (e) => {
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
- if (!moreMenu.hidden && !path.includes(moreMenu) && !path.includes(moreBtn)) closeMore();
- const tm = el.querySelector('.ovdk-tmenu');
- if (tm && !path.includes(tm) && !path.includes(moreMenu) && !path.includes(moreBtn)) tm.remove();
- };
- el.addEventListener('pointerdown', awayFrom, true);
- document.addEventListener('pointerdown', (e) => { if (!el.contains(e.target)) awayFrom(e); }, true);
- el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
- // A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
- // chat) leaves its target detached, and must not read as outside.
- document.addEventListener('click', (e) => {
- if (!isOpen() || !phone()) return;
- // The tap that opened the sheet (a page's button, the share sheet's OpenVibe Chat, Ask your agent) is still on
- // its way up when the sheet opens: an event made before the sheet opened is never "a tap outside" (4.145.1).
- if (e.timeStamp && e.timeStamp <= openedAt) return;
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
- if (path.includes(el) || (pill && path.includes(pill)) || (e.target && e.target.isConnected === false)) return;
- close();
- });
- root.addEventListener('resize', () => { if (isOpen()) layout(); });
- bindMove();
- bindSize();
- paintPop();
- }
-
- // ── Where it sits ────────────────────────────────────────
- function layout() {
- const p = place();
- el.dataset.corner = CORNERS.includes(p.corner) ? p.corner : 'bottom-right';
- if (phone()) { el.style.width = ''; el.style.height = ''; clear(0); return; }
- const w = clamp(p.width || DEF.width, W[0], Math.max(W[0], Math.min(W[1], root.innerWidth - 32)));
- const h = clamp(p.height || DEF.height, H[0], Math.max(H[0], Math.min(H[1], root.innerHeight - 96)));
- el.style.width = `${w}px`; el.style.height = `${h}px`;
- // Desktop toasts (bottom right) stack above the card when there is room, so neither hides the other.
- clear(el.dataset.corner === 'bottom-right' && root.innerHeight - h > 320 ? h + 12 : 0);
- }
- const clear = (px) => document.documentElement.style.setProperty('--ov-dock-clear', `${px}px`);
-
- /**
- * The head drags the card, the tabs too once the pointer has moved 6 px (a tap still picks the tab; pin and close
- * never drag). On release it snaps to the nearest corner (a FLIP, so it glides there).
- */
- function bindMove() {
- const head = el.querySelector('.ovdk-head');
- let s = null, swallow = false;
- // Armed on the press; the window hears the moves, so a quick drag that leaves the head before 6 px still counts.
- const move = (e) => {
- if (!s || e.pointerId !== s.id) return;
- s.dx = e.clientX - s.x; s.dy = e.clientY - s.y;
- if (!s.live) {
- if (Math.abs(s.dx) + Math.abs(s.dy) < 6) return;
- s.live = true;
- if (!phone()) el.classList.add('is-moving');
- }
- if (phone()) { if (e.pointerType !== 'mouse') el.style.transform = `translateY(${Math.max(0, s.dy)}px)`; return; }
- el.style.transform = `translate(${s.dx}px, ${s.dy}px)`;
- };
- const end = (e) => {
- if (!s || (e && e.pointerId !== s.id)) return;
- const { r, dx, dy, live } = s; s = null;
- root.removeEventListener('pointermove', move); root.removeEventListener('pointerup', end); root.removeEventListener('pointercancel', end);
- if (!live) return;
- swallow = true; setTimeout(() => { swallow = false; }, 0);
- el.classList.remove('is-moving');
- if (phone()) {
- el.style.transition = 'transform .2s ease';
- if (dy > 110) close(); else el.style.transform = '';
- setTimeout(() => { el.style.transition = ''; }, 220);
- return;
- }
- const cx = r.left + dx + r.width / 2, cy = r.top + dy + r.height / 2;
- const corner = `${cy < root.innerHeight / 2 ? 'top' : 'bottom'}-${cx < root.innerWidth / 2 ? 'left' : 'right'}`;
- const from = { x: r.left + dx, y: r.top + dy };
- el.style.transform = '';
- save({ place: { desktop: { ...place(), corner } } });
- layout();
- const to = el.getBoundingClientRect();
- if (reduced()) return;
- el.style.transform = `translate(${from.x - to.left}px, ${from.y - to.top}px)`;
- el.getBoundingClientRect();
- el.style.transition = 'transform .28s cubic-bezier(.2,.9,.25,1.05)';
- el.style.transform = '';
- setTimeout(() => { el.style.transition = ''; }, 300);
- };
- head.addEventListener('pointerdown', (e) => {
- if (e.button || e.target.closest('.ovdk-acts, .ovdk-more')) return;
- s = { x: e.clientX, y: e.clientY, r: el.getBoundingClientRect(), dx: 0, dy: 0, id: e.pointerId, live: false };
- root.addEventListener('pointermove', move); root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
- });
- // A drag that began on a tab is a drag, not a pick.
- head.addEventListener('click', (e) => { if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); } }, true);
+ bodyEl = document.createElement('div');
+ bodyEl.className = 'ovdk-body';
+ bodyEl.innerHTML = TABS.map(([id, label]) => `<div class="ovdk-panel" role="region" aria-label="${label}" id="ovdk-panel-${id}" data-panel="${id}" hidden></div>`).join('');
}
}
- /** The free corner (the one away from the anchor) resizes the card. */
- function bindSize() {
- const grip = el.querySelector('.ovdk-size');
- let s = null;
- grip.addEventListener('pointerdown', (e) => {
- if (e.button || phone()) return;
- e.preventDefault();
- const r = el.getBoundingClientRect();
- s = { x: e.clientX, y: e.clientY, w: r.width, h: r.height, id: e.pointerId, c: el.dataset.corner };
- try { grip.setPointerCapture(e.pointerId); } catch { /* */ }
- });
- grip.addEventListener('pointermove', (e) => {
- if (!s || e.pointerId !== s.id) return;
- const dx = e.clientX - s.x, dy = e.clientY - s.y;
- const w = clamp(s.w + (s.c.endsWith('right') ? -dx : dx), W[0], Math.min(W[1], root.innerWidth - 32));
- const h = clamp(s.h + (s.c.startsWith('bottom') ? -dy : dy), H[0], Math.min(H[1], root.innerHeight - 96));
- el.style.width = `${Math.round(w)}px`; el.style.height = `${Math.round(h)}px`;
- s.nw = Math.round(w); s.nh = Math.round(h);
- });
- const end = (e) => {
- if (!s || (e && e.pointerId !== s.id)) return;
- const { nw, nh } = s; s = null;
- if (nw && nh) { save({ place: { desktop: { ...place(), width: nw, height: nh } } }); layout(); }
- };
- grip.addEventListener('pointerup', end); grip.addEventListener('pointercancel', end);
- }
// ── Tabs ─────────────────────────────────────────────────
// ── Tabs ─────────────────────────────────────────────────
- function badges() {
- if (!el) return;
- const c = counts();
- const agent = (c.waiting || 0) + (c.running || 0);
- const n = (k, v, tone) => { const b = el.querySelector(`[data-n="${k}"]`); b.textContent = v ? (v > 99 ? '99+' : String(v)) : ''; b.dataset.tone = tone || ''; };
- 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 ? `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');
- }
- function paintPin() {
- const b = el.querySelector('[data-act="pin"]'), on = !!state().pinned;
- b.setAttribute('aria-checked', String(on));
- b.title = on ? "On: the Dock's button stays on every page" : "Off: the Dock's button shows only while something works or needs you";
- }
function setTab(id, remember) {
function setTab(id, remember) {
if (id === 'agents') id = 'agent';
if (id === 'agents') id = 'agent';
if (inline && tab !== id) disposePanel();
if (inline && tab !== id) disposePanel();
- tab = TABS.some(([t]) => t === id) ? id : 'activity';
+ tab = TABS.some(([t]) => t === id) ? id : 'vibe';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'vibe' && vibe.api) vibe.api.pause();
if (tab !== 'vibe' && vibe.api) vibe.api.pause();
- el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
- if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
+ if (remember && state().tab !== tab) save({ tab });
fill(tab);
fill(tab);
if (inline && tab === 'chats') readUnread();
if (inline && tab === 'chats') readUnread();
}
}
- const panelActive = (id) => tab === id && (isOpen() || !!inline);
+ const panelActive = (id) => tab === id && !!inline;
function disposePanel() {
function disposePanel() {
panelEpoch++;
panelEpoch++;
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
if (vibe.api) { vibe.api.pause(); vibe.api = null; }
if (vibe.api) { vibe.api.pause(); vibe.api = null; }
for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; }
for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; }
- if (root.OpenVibeNotifications && root.OpenVibeNotifications.unmountDock && bodyEl) root.OpenVibeNotifications.unmountDock(bodyEl.querySelector('[data-panel="activity"]'));
chats.panel = null; chats.open = null; vibe.panel = null;
chats.panel = null; chats.open = null; vibe.panel = null;
if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; });
if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; });
}
}
@@ -556,7 +259,7 @@
@@ -556,7 +259,7 @@
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
if (epoch !== panelEpoch || !panelActive(id)) return;
if (epoch !== panelEpoch || !panelActive(id)) return;
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
+ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
return;
}
}
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
@@ -591,25 +294,18 @@
@@ -591,25 +294,18 @@
});
});
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
- styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),
+ styles: () => document.getElementById('ovdk-styles'),
foot: panel.querySelector('.ovdk-runs-foot'),
foot: panel.querySelector('.ovdk-runs-foot'),
- onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
+ onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; notify(); },
});
});
mounts[id] = m;
mounts[id] = m;
return;
return;
}
}
- const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
- if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
- if (!ok) return failed(panel, id);
- const N = root.OpenVibeNotifications;
- if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }
- panel.innerHTML = '';
- N.renderDock(panel);
- mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
+ delete mounts[id];
}
}
function failed(panel, id) {
function failed(panel, id) {
mounts[id] = null;
mounts[id] = null;
- panel.innerHTML = `<div class="ovdk-none">That part of the Dock did not load. Check your connection, then open it again.</div>`;
+ panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`;
}
}
// ── Chats (Dock plan K-D2) ───────────────────────────────
// ── Chats (Dock plan K-D2) ───────────────────────────────
@@ -636,7 +332,7 @@
@@ -636,7 +332,7 @@
}
}
function readUnread() {
function readUnread() {
clearTimeout(chats.unreadT);
clearTimeout(chats.unreadT);
- chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });
+ chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; }).catch(() => { /* Chat away: no count */ });
if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000);
if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000);
}
}
const when = (iso) => {
const when = (iso) => {
@@ -677,7 +373,7 @@
@@ -677,7 +373,7 @@
if (sharing) await sharing;
if (sharing) await sharing;
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: inline, 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).
// 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 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');
@@ -720,14 +416,37 @@
@@ -720,14 +416,37 @@
}
}
panel.append(scroll);
panel.append(scroll);
}
}
+ /** "edited", one quiet word after a message's words; when, in its title. */
+ function editedMark(at) {
+ const mark = h('small', 'ovdk-edited', 'edited');
+ const d = new Date(String(at || '').replace(' ', 'T'));
+ if (!Number.isNaN(d.getTime())) mark.title = `Edited ${d.toLocaleString()}`;
+ return mark;
+ }
+ /** An edit arrived (mine, saved here, or another of my devices'): the words in place, marked, the card again. */
+ function applyEdit(list, m) {
+ const old = chats.msgs.find((x) => Number(x.id) === Number(m && m.id));
+ const li = old && list.querySelector(`[data-id="${Number(m.id)}"]`);
+ if (!li || !m.edited_at || (old.edited_at && old.edited_at >= m.edited_at)) return;
+ Object.assign(old, { message: m.message, card: m.card, edited_at: m.edited_at });
+ const words = li.querySelector('.ovdk-text');
+ words.textContent = String(m.message || ''); words.append(editedMark(m.edited_at));
+ const card = li.querySelector('.ovdk-card'); if (card) card.remove();
+ if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { if (ok && li.isConnected) { const c = root.OpenVibeDockCard(m.card, h); if (c) li.append(c); } });
+ }
function bubble(m, prev) {
function bubble(m, prev) {
const mine = m.username === me();
const mine = m.username === me();
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
const li = h('li', `ovdk-msg${mine ? ' is-mine' : ''}${prev && prev.username === m.username ? ' is-cont' : ''}`);
if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
if (!mine && !(prev && prev.username === m.username)) li.append(h('span', 'ovdk-who', m.display_name || m.username || ''));
li.dataset.id = String(m.id);
li.dataset.id = String(m.id);
- li.append(h('span', 'ovdk-text', String(m.message || '')));
- if (m.edited_at) li.append(h('small', 'ovdk-edited', `edited · ${String(m.edited_at).slice(11, 16)}`));
- if (mine && canWriteChat()) { const edit = h('button', 'ovk-btn ovk-btn--quiet ovdk-edit', 'Edit'); edit.type = 'button'; li.append(edit); }
+ const words = h('span', 'ovdk-text', String(m.message || ''));
+ if (m.edited_at) words.append(editedMark(m.edited_at));
+ li.append(words);
+ if (mine && canWriteChat()) {
+ const acts = h('span', 'ovdk-acts');
+ const edit = h('button', 'ovdk-act ovdk-edit'); edit.type = 'button'; edit.setAttribute('aria-label', 'Edit message'); edit.setAttribute('data-ov-tip', 'Edit'); edit.append(icon(G.pencil));
+ acts.append(edit); li.append(acts); li.classList.add('has-acts');
+ }
// A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows.
// A known OpenVibe link's card (Chat's link cards): dock-card.js draws it, loaded the first time one shows.
if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); });
if (m.card) load('dock-card.js', () => !!root.OpenVibeDockCard).then((ok) => { const c = ok && root.OpenVibeDockCard(m.card, h); if (c) li.append(c); });
li.title = new Date(m.created_at).toLocaleString();
li.title = new Date(m.created_at).toLocaleString();
@@ -758,16 +477,65 @@
@@ -758,16 +477,65 @@
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
form.append(box, go);
form.append(box, go);
panel.append(head, scroll, err);
panel.append(head, scroll, err);
+ // Editing one of mine: the box holds its words, a slim bar over it says so, Escape or × lets go.
+ const bar = h('div', 'ovdk-edit-bar'); bar.hidden = true;
+ const stopX = h('button'); stopX.type = 'button'; stopX.setAttribute('aria-label', 'Stop editing'); stopX.setAttribute('data-ov-tip', 'Stop editing'); stopX.append(icon(G.close));
+ bar.append(icon(G.pencil), h('span', '', 'Editing your message'), stopX);
+ let editing = null;
+ const stopEditing = () => {
+ if (!editing) return;
+ if (editing.li) editing.li.classList.remove('is-editing');
+ editing = null; bar.hidden = true; form.classList.remove('is-editing');
+ box.value = ''; box.dispatchEvent(new Event('input')); go.setAttribute('aria-label', 'Send');
+ };
+ const startEditing = (li) => {
+ const old = li && chats.msgs.find((m) => Number(m.id) === Number(li.dataset.id));
+ if (!old) return;
+ if (editing && editing.li) editing.li.classList.remove('is-editing');
+ list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting'));
+ editing = { id: old.id, was: String(old.message || ''), li };
+ li.classList.add('is-editing'); bar.hidden = false; form.classList.add('is-editing'); go.setAttribute('aria-label', 'Save');
+ box.value = editing.was; box.dispatchEvent(new Event('input'));
+ try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ }
+ };
+ stopX.addEventListener('click', () => { stopEditing(); box.focus(); });
+ list.addEventListener('click', (e) => {
+ const edit = e.target.closest && e.target.closest('.ovdk-edit');
+ if (edit) { e.preventDefault(); startEditing(edit.closest('[data-id]')); return; }
+ // A finger: a tap on my bubble shows its actions (a second tap, or a tap on another, puts them away).
+ const li = e.target.closest && e.target.closest('.ovdk-msg.has-acts');
+ if (!li || !(typeof matchMedia === 'function' && matchMedia('(hover: none)').matches) || (e.target.closest('a, button'))) return;
+ const was = li.classList.contains('is-acting');
+ list.querySelectorAll('.is-acting').forEach((x) => x.classList.remove('is-acting'));
+ if (!was) li.classList.add('is-acting');
+ });
if (canWriteChat()) {
if (canWriteChat()) {
- panel.append(form);
+ panel.append(bar, form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
box.addEventListener('input', fit);
box.addEventListener('input', fit);
if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });
if (draft && root.OpenVibeDockShare) root.OpenVibeDockShare.ready({ draft, h, panel, form, box, fit });
- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
+ box.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && editing) { e.preventDefault(); e.stopPropagation(); stopEditing(); return; }
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); }
+ });
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
const text = box.value.trim();
const text = box.value.trim();
if (!text || chats.busy) return;
if (!text || chats.busy) return;
+ if (editing) {
+ const ed = editing;
+ if (text === ed.was.trim()) { stopEditing(); return; }
+ chats.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ const out = await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages/${encodeURIComponent(ed.id)}`, { method: 'PATCH', body: JSON.stringify({ message: text }) });
+ if (out && out.message) applyEdit(list, out.message);
+ if (editing === ed) stopEditing();
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'Slow down a little, then save it again.' : x.status === 403 ? 'That message can no longer be edited.' : 'The edit did not save. Try again.';
+ err.hidden = false;
+ } finally { chats.busy = false; go.disabled = false; box.focus(); }
+ return;
+ }
chats.busy = true; go.disabled = true; err.hidden = true;
chats.busy = true; go.disabled = true; err.hidden = true;
try {
try {
await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
@@ -784,7 +552,7 @@
@@ -784,7 +552,7 @@
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
await pull(list, scroll, true);
- load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), list, messages: chats.msgs, error: err, tokenOf, chatCall, chatBase, active: () => chats.open === String(id) && panelActive('chats'), load, h }); });
+ load('dock-chat-edits.js', () => !!root.OpenVibeDockChatEdits).then((ok) => { if (ok && chats.open === String(id)) chats.edits = root.OpenVibeDockChatEdits({ id: String(id), apply: (m) => applyEdit(list, m), tokenOf, chatBase, active: () => chats.open === String(id) && panelActive('chats') }); });
if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
if (canWriteChat() && !(typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)) setTimeout(() => { try { box.focus({ preventScroll: true }); if (draft) box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } }, 0);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
@@ -816,8 +584,9 @@
@@ -816,8 +584,9 @@
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
if (!e.altKey || e.ctrlKey || e.metaKey || String(e.key).toLowerCase() !== 'v' || !canListen() || !o || typeof o.token !== 'function') return;
e.preventDefault();
e.preventDefault();
- const talk = () => { if (vibe.api && vibe.api.talk) vibe.api.talk(); };
- if (!isOpen() || tab !== 'vibe') { open({ tab: 'vibe' }); setTimeout(talk, 600); } else talk();
+ const talk = (n = 0) => { if (vibe.api && vibe.api.talk && panelActive('vibe')) vibe.api.talk(); else if (n < 40) setTimeout(() => talk(n + 1), 100); };
+ if (!panelActive('vibe')) open({ tab: 'vibe' });
+ talk();
});
});
// ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──
// ── The timer (plan L48 R2): dock-timer.js, loaded when the state carries one or the person opens its menu ──
@@ -825,288 +594,124 @@
@@ -825,288 +594,124 @@
function withTimer(fn) {
function withTimer(fn) {
if (timerMod) return fn(timerMod);
if (timerMod) return fn(timerMod);
timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {
timerLoading = timerLoading || load('dock-timer.js', () => !!(root.OpenVibeDockTimer && root.OpenVibeDockTimer.attach)).then((ok) => {
- if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline || el, repaint: () => paintPill() });
+ if (ok && !timerMod) timerMod = root.OpenVibeDockTimer.attach({ state, save, host: () => inline, repaint: () => paintStatus() });
return timerMod;
return timerMod;
});
});
return timerLoading.then((m) => (m ? fn(m) : null));
return timerLoading.then((m) => (m ? fn(m) : null));
}
}
- // ── The pill ─────────────────────────────────────────────
- let pill = null, readT = 0, bound = false, openedAt = 0;
+ // ── Status (4.220.0): what the pill used to say rides on the bar's menu button (navbar.js passes o.status) ──
+ let readT = 0, bound = false, off = false;
function store(v) {
function store(v) {
try { if (v === undefined) return JSON.parse(localStorage.getItem('ov-dock') || 'null'); localStorage.setItem('ov-dock', JSON.stringify(v)); } catch { /* */ }
try { if (v === undefined) return JSON.parse(localStorage.getItem('ov-dock') || 'null'); localStorage.setItem('ov-dock', JSON.stringify(v)); } catch { /* */ }
return null;
return null;
}
}
- function paintPill() {
- if (!pill) return;
+ /** An agent needs you (first), a timer's minutes, or agents working; null when there is nothing to say. */
+ function status() {
+ if (off || !doc) return null;
const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
- const corner = place().corner;
- // Popped out: the page keeps the pill, and it says where the Dock went.
- if (popped) {
- pill.hidden = false;
- pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
- pill.dataset.tone = 'idle';
- pill.querySelector('.ovdk-t').textContent = 'In a window';
- pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
- return;
- }
- // A running timer: its minutes, unless an agent needs the person (that comes first).
if (st.timer && !timerMod) withTimer((m) => m.sync());
if (st.timer && !timerMod) withTimer((m) => m.sync());
const tm = timerMod && !w ? timerMod.pillText() : null;
const tm = timerMod && !w ? timerMod.pillText() : null;
- const show = !isOpen() && !!(w || r || st.pinned || tm);
- pill.hidden = !show;
- pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
- pill.dataset.tone = w ? 'waiting' : tm ? tm.tone : r ? 'running' : 'idle';
- pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');
- pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : tm ? tm.text : r ? (r > 1 ? `${r} working` : 'Working') : '';
- pill.setAttribute('aria-label', tm ? tm.label : [w ? `${w} need${w > 1 ? '' : 's'} you` : '', r ? `${r} working` : ''].filter(Boolean).join(', ') || 'Open the Dock');
- // Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
- if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
+ if (w) return { tone: 'waiting', text: 'Needs you', label: `${w} need${w > 1 ? '' : 's'} you`, part: 'agent' };
+ if (tm) return { tone: tm.tone, text: tm.text, label: tm.label, part: 'vibe' };
+ if (r) return { tone: 'running', text: r > 1 ? `${r} working` : 'Working', label: `${r} working`, part: 'agent' };
+ return null;
+ }
+ /** The status on the bar's menu button, for a bar that asked (navbar.js passes o.status): a dot while agents work, an
+ * amber one when one needs you, a timer's minutes in a small label under it; the tooltip and the name say it in words. */
+ function paintBar(s) {
+ document.querySelectorAll('.openvibe-navbar #openvibe-search-btn, .openvibe-navbar #openvibe-burger.ovnav-orb').forEach((b) => {
+ let chip = b.querySelector('.ovnav-st');
+ if (!s) { if (chip) chip.remove(); b.removeAttribute('data-ov-status'); if (b.dataset.ovLabel) { b.setAttribute('aria-label', b.dataset.ovLabel); b.setAttribute('data-ov-tip', b.dataset.ovTip || b.dataset.ovLabel); } return; }
+ // An i, not a span: the old burger draws its spans as lines.
+ if (!chip) { chip = document.createElement('i'); chip.className = 'ovnav-st'; chip.setAttribute('aria-hidden', 'true'); b.appendChild(chip); }
+ chip.textContent = /^timer/.test(s.tone) ? (s.tone === 'timer-done' ? 'Done' : s.text) : '';
+ b.setAttribute('data-ov-status', s.tone);
+ if (!b.dataset.ovLabel) { b.dataset.ovLabel = b.getAttribute('aria-label') || 'Menu'; b.dataset.ovTip = b.getAttribute('data-ov-tip') || ''; }
+ b.setAttribute('aria-label', `${b.dataset.ovLabel}: ${s.label}`);
+ b.setAttribute('data-ov-tip', s.label);
+ });
+ }
+ function paintStatus() {
+ if (typeof o.status !== 'function') return;
+ const s = status();
+ try { o.status(s); } catch { /* the bar is optional */ }
+ paintBar(s);
}
}
async function read() {
async function read() {
clearTimeout(readT);
clearTimeout(readT);
- if (!pill || document.hidden) return;
+ if (off || !o.token || document.hidden) return;
const t = await tokenOf();
const t = await tokenOf();
if (!t) return;
if (!t) return;
try {
try {
const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
- if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }
+ if (r.status === 404) { off = true; store({ off: Date.now() + 864e5 }); paintStatus(); return; }
const next = r.ok ? await r.json() : null;
const next = r.ok ? await r.json() : null;
- if (!next || !next.state || !pill) return;
+ if (!next || !next.state) return;
update(next);
update(next);
- if (!isOpen() && !inline && next.state.open && !phone()) open();
const c = next.counts || {};
const c = next.counts || {};
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
} catch { /* offline: the copy stands */ }
}
}
- /** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
+ /** A signed-in person's state and counts (navbar.js), or none (null: signed out, or the site turned it off). */
function mount(opts) {
function mount(opts) {
if (!opts) {
if (!opts) {
if (mountHandle) mountHandle.destroy();
if (mountHandle) mountHandle.destroy();
clearTimeout(readT);
clearTimeout(readT);
- if (pill) { pill.remove(); pill = null; }
- if (isOpen()) close();
- doc = null;
+ const was = o.status; o = {}; doc = null;
+ if (typeof was === 'function') { try { was(null); } catch { /* */ } paintBar(null); }
return;
return;
}
}
o = { ...o, ...opts };
o = { ...o, ...opts };
styles();
styles();
- if (!pill) {
- const kept = store();
- if (kept && kept.off > Date.now()) return;
- if (kept && kept.state && !doc) doc = kept;
- pill = document.createElement('button');
- pill.type = 'button'; pill.id = 'ovdk-pill'; pill.className = 'ovdk-pill'; pill.hidden = true;
- pill.innerHTML = '<span class="ovdk-m"><span class="ov-mark" data-size="26"></span></span><span class="ovdk-t"></span>';
- pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });
- document.body.appendChild(pill);
- paintPill();
- }
+ const kept = store();
+ off = !!(kept && kept.off > Date.now());
+ if (kept && kept.state && !doc) doc = kept;
+ paintStatus();
if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }
if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }
read();
read();
}
}
- // ── Pop out (K-D4) ────────────────────────────────────────
- // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and
- // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks
- // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.
- const PIP_W = 360, PIP_H = 520;
- const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;
- // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).
- const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');
-
- function paintPop() {
- const b = el.querySelector('[data-act="pop"]');
- if (!b) return;
- b.hidden = !pip();
- const t = popped ? 'Bring the Dock back to the page' : 'Open the Dock in a window of its own';
- b.title = t; const name = b.querySelector('b'); if (name) name.textContent = t;
- }
-
- /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */
- function dress(win) {
- const d = win.document;
- if (!d.documentElement) d.appendChild(d.createElement('html'));
- if (!d.head) d.documentElement.appendChild(d.createElement('head'));
- if (!d.body) d.documentElement.appendChild(d.createElement('body'));
- d.title = 'OpenVibe Dock';
- for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ovda-styles', 'ov-mark-css']) {
- const s = document.getElementById(id);
- if (!s) continue;
- const c = d.createElement('style');
- c.id = id; c.textContent = s.textContent;
- d.head.appendChild(c);
- }
- const cs = getComputedStyle(document.documentElement);
- for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }
- const b = getComputedStyle(document.body);
- const s = d.body.style;
- s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;
- s.background = b.backgroundColor;
- d.documentElement.style.colorScheme = cs.colorScheme || '';
- d.documentElement.classList.add('ovdk-pip');
- }
-
- /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened
- * a window by itself would be a window nobody asked for. */
- async function popOut() {
- if (popped || !pip()) return;
- let win = null;
- try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }
- if (!win || !win.document) return;
- pipWin = win;
- popped = true;
- dress(win);
- win.document.body.appendChild(el);
- win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });
- paintPop();
- // Whether the Dock is in a window stays with this page: the window belongs to one page in one browser, closes
- // with it, and no other device could open it again (the browser wants a click), so the Network is not told.
- save({ open: true });
- paintPill();
- }
-
- /** The panel comes back to the page: the window was closed, or the pill was pressed. */
- function backToPage() {
- if (!popped) return;
- popped = false;
- const w = pipWin; pipWin = null;
- pipClosing = true;
- try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }
- pipClosing = false;
- if (el.parentNode !== document.body) document.body.appendChild(el);
- el.hidden = false;
- layout();
- paintPop();
- paintPill();
- }
-
- // ── Open and close ───────────────────────────────────────
+ // ── Open: the menu, on one of its places ──
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
- function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
- function isOpen() { return !!(el && !el.hidden); }
- // A phone's sheet and the navbar's orb are one button (.from-orb).
- let orbLink = null, closing = 0;
- function navOrb() {
- if (!phone()) return null;
- const list = document.querySelectorAll('.openvibe-navbar .ovnav-orb');
- for (let i = 0; i < list.length; i++) { const r = list[i].getBoundingClientRect(); if (r.width && r.left > root.innerWidth / 2 && r.top < 120 && r.bottom > 0) return list[i]; }
- return null;
- }
- function fromOrb() {
- unlinkOrb();
- const nav = navOrb(), mine = el.querySelector('.ovdk-orbx'), mineSvg = mine && mine.querySelector('svg');
- if (!nav || !mineSvg || reduced() || popped) return;
- el.classList.add('from-orb'); // first: it sets the header's layout, and the close orb is measured where it will rest
- const a = (nav.querySelector('svg') || nav).getBoundingClientRect(), b = mineSvg.getBoundingClientRect(), box = el.getBoundingClientRect();
- if (!a.width || !b.width) { el.classList.remove('from-orb'); return; }
- const ax = a.left + a.width / 2, ay = a.top + a.height / 2, bx = b.left + b.width / 2, by = b.top + b.height / 2, s = a.width / b.width;
- el.style.setProperty('--ovdk-ox', `${Math.round(ax - box.left)}px`); el.style.setProperty('--ovdk-oy', `${Math.round(ay - box.top)}px`);
- mine.style.setProperty('--orb-dx', `${(ax - bx).toFixed(1)}px`); mine.style.setProperty('--orb-dy', `${(ay - by).toFixed(1)}px`); mine.style.setProperty('--orb-s', s.toFixed(3));
- nav.style.setProperty('--dock-dx', `${(bx - ax).toFixed(1)}px`); nav.style.setProperty('--dock-dy', `${(by - ay).toFixed(1)}px`); nav.style.setProperty('--dock-s', (1 / s).toFixed(3));
- mine.classList.add('is-set', 'is-from');
- orbLink = { nav, mine };
- void mine.offsetWidth; // the starting pose is put on, not animated into
- mine.classList.remove('is-set');
- void mine.offsetWidth;
- nav.style.transition = 'transform .42s cubic-bezier(.2, 1.1, .3, 1), color .3s';
- root.requestAnimationFrame(() => { if (!orbLink || orbLink.mine !== mine) return; mine.classList.remove('is-from'); nav.classList.add('is-dock'); });
- }
- function unlinkOrb() {
- if (closing) { clearTimeout(closing); closing = 0; }
- if (el) el.classList.remove('from-orb', 'is-out');
- if (!orbLink) return;
- orbLink.nav.classList.remove('is-dock'); orbLink.nav.style.transition = ''; orbLink.mine.classList.remove('is-from', 'is-set');
- orbLink = null;
- }
+ function ask({ text = '' } = {}, n = 0) { if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
+ function isOpen() { return !!inline; }
function share({ text = '', title = '' } = {}) {
function share({ text = '', title = '' } = {}) {
const t = String(text || '').trim().slice(0, 4000);
const t = String(text || '').trim().slice(0, 4000);
if (!t) return;
if (!t) return;
chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null;
chats.draft = { text: t, title: String(title || '').trim().slice(0, 140) }; chats.open = null;
// A bar left from an earlier share would show the old words until the list reloads: it goes now.
// A bar left from an earlier share would show the old words until the list reloads: it goes now.
const stale = chats.panel && chats.panel.querySelector('.ovdk-share'); if (stale) stale.remove();
const stale = chats.panel && chats.panel.querySelector('.ovdk-share'); if (stale) stale.remove();
+ if (panelActive('chats')) { chatList(); return; }
open({ tab: 'chats' });
open({ tab: 'chats' });
}
}
+ /** The menu opens on that place (navbar.js passes o.openMenu), and the menu mounts the panel with mountInto. */
function open(opts = {}) {
function open(opts = {}) {
- if (mountHandle) mountHandle.destroy();
- openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
- o = { ...o, ...opts };
- if (opts.doc) doc = opts.doc;
- if (!el) build();
- lastFocus = document.activeElement;
+ const { tab: want, doc: given, ...rest } = opts || {};
+ o = { ...o, ...rest };
+ if (given) doc = given;
const c = counts();
const c = counts();
- const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');
- layout();
- badges();
- paintPin();
- paintPop();
- el.hidden = false;
- el.classList.remove('is-in');
- fromOrb();
- void el.offsetWidth; el.classList.add('is-in');
- // On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
- if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {
- const pill = document.getElementById('ovdk-pill');
- root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);
- }
- if (phone()) {
- el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
- if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }
- } else { el.removeAttribute('aria-modal'); if (dockSurface) { dockSurface.destroy(); dockSurface = null; } }
- setTab(want, false);
- readUnread();
- if (!state().open || (state().tab !== tab && SAVED_TABS.includes(tab))) save({ open: true, ...(SAVED_TABS.includes(tab) ? { tab } : {}) }); else notify();
- setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
- }
- function close() {
- if (!isOpen()) return;
- // Fold back into the navbar's orb first, then go.
- if (orbLink && !closing && !popped && !reduced() && el.classList.contains('from-orb')) {
- el.classList.remove('is-in'); el.classList.add('is-out');
- orbLink.mine.classList.add('is-from'); orbLink.nav.classList.remove('is-dock');
- closing = setTimeout(() => { closing = 0; shut(); }, 280);
- return;
- }
- shut();
- }
- function shut(keepState = false) {
- if (!isOpen()) return;
- unlinkOrb();
- const wasPopped = popped;
- if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }
- const inside = el.contains(document.activeElement);
- el.hidden = true;
- if (dockSurface) { dockSurface.destroy(); dockSurface = null; }
- el.style.transform = '';
- el.removeAttribute('aria-modal');
- document.documentElement.classList.remove('ovdk-sheet-open');
- clear(0);
- clearTimeout(chats.timer); clearTimeout(chats.unreadT);
- if (!keepState) save({ open: false });
- if (inside) {
- const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
- try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
- }
+ let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe');
+ if (id === 'agents') id = 'agent';
+ if (!TABS.some(([t]) => t === id)) id = 'vibe';
+ if (inline && id === tab) return;
+ if (typeof o.openMenu === 'function') o.openMenu(id);
}
}
- /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */
+ function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); }
+ /** A fresh copy from the Network: counts, and anything changed elsewhere (the timer, the open chat). */
function update(next) {
function update(next) {
if (!next || !next.state) return;
if (!next || !next.state) return;
doc = Object.keys(pending).length ? { ...next, state: state() } : next;
doc = Object.keys(pending).length ? { ...next, state: state() } : next;
store(doc);
store(doc);
if (timerMod) timerMod.sync();
if (timerMod) timerMod.sync();
- paintPill();
- if (!el) return;
- badges();
- paintPin();
- if (isOpen()) layout();
+ paintStatus();
}
}
let mountHandle = null;
let mountHandle = null;
/** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
/** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
- function mountInto(host, { tab: first = 'activity' } = {}) {
+ function mountInto(host, { tab: first = 'vibe' } = {}) {
if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
if (mountHandle) mountHandle.destroy();
if (mountHandle) mountHandle.destroy();
- if (isOpen()) { clearTimeout(closing); closing = 0; shut(true); }
let api = null, gone = false, wanted = first;
let api = null, gone = false, wanted = first;
const handle = {
const handle = {
show(id) { if (gone) return; wanted = id; if (api) api.show(id); },
show(id) { if (gone) return; wanted = id; if (api) api.show(id); },
@@ -1115,15 +720,19 @@
@@ -1115,15 +720,19 @@
mountHandle = handle;
mountHandle = handle;
load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
- start(wrap) { if (!el) build(); inline = wrap; wrap.append(bodyEl); },
- show(id) { setTab(id, false); badges(); },
- end() { disposePanel(); inline = null; if (el) el.insertBefore(bodyEl, el.querySelector('.ovdk-size')); paintPill(); },
+ start(wrap) { if (!bodyEl) build(); inline = wrap; wrap.append(bodyEl); },
+ show(id) { setTab(id, true); },
+ end() { disposePanel(); inline = null; if (bodyEl) bodyEl.remove(); paintStatus(); },
});
});
});
});
return handle;
return handle;
}
}
root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share,
root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share,
- filterChats: (v) => { chats.filter = v.toLowerCase(); chatList(); },
- more: a => el.querySelector(`[data-act="${a}"]`).click(), canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
+ // The menu's search box filters the conversations. The menu redraws when its own loads arrive (and calls this with the
+ // same words): an unchanged filter does nothing, so an open chat is never swapped for the list under the person.
+ filterChats: (v) => { const f = String(v || '').toLowerCase(); if (f === chats.filter) return; chats.filter = f; chatList(); },
+ // The timer's menu (the menu's Vibe tab has its button): presets, a focus round, your own.
+ timer: (anchor) => withTimer((t) => t.menu(anchor || null)), more: (a) => { if (a === 'timer') withTimer((t) => t.menu(null)); },
+ status, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -22,7 +22,7 @@ thing the person is looking at. `bottom.js` measures the difference and writes i
@@ -22,7 +22,7 @@ thing the person is looking at. `bottom.js` measures the difference and writes i
| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts |
| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts |
| `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |
| `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |
-`navbar.js` loads `bottom.js` on every page before the bar, the Dock and the small player draw. Every rule that uses
+`navbar.js` loads `bottom.js` on every page before the bar and the small player draw. Every rule that uses
these variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.
these variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.
The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the
The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the
@@ -38,10 +38,8 @@ The bottom of the screen, low to high (these are the only z-indexes that belong
@@ -38,10 +38,8 @@ The bottom of the screen, low to high (these are the only z-indexes that belong
|---|---|---|
|---|---|---|
| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) |
| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) |
| the small player | `.ovmini` | `--ovb-z-mini` (9991) |
| the small player | `.ovmini` | `--ovb-z-mini` (9991) |
-| the Dock's pill | `.ovdk-pill` | `--ovb-z-pill` (9995) |
-| the Dock's card | `.ovdk` | `--ovb-z-dock` (9996) |
-A phone's Dock sheet (10010) covers the navbar; sheets (10060), the notification panel (99999) and toasts/notices
+Sheets (10060), the notification panel (99999) and toasts/notices
(2147483000+) sit above all of these and do not belong to the bottom stack.
(2147483000+) sit above all of these and do not belong to the bottom stack.
## The page's end
## The page's end
@@ -66,7 +64,7 @@ while a shimmed `visualViewport` opens and closes the toolbar (the layout viewpo
@@ -66,7 +64,7 @@ while a shimmed `visualViewport` opens and closes the toolbar (the layout viewpo
update reaches the visible viewport first), and fails when:
update reaches the visible viewport first), and fails when:
- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;
- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;
-- the small player is not 8 px above the bar, or the Dock's pill not 16 px above the small player;
+- the small player is not 8 px above the bar;
- any of the three overlaps another;
- any of the three overlaps another;
- a gap jumps by more than 1 px between samples.
- a gap jumps by more than 1 px between samples.
@@ -546,7 +546,7 @@ One control on every site: the menu button at the top of the page (or `/`, or Ct
@@ -546,7 +546,7 @@ One control on every site: the menu button at the top of the page (or `/`, or Ct
to be two panels doing the same job, the launcher's dots (every site) and the magnifier (search).
to be two panels doing the same job, the launcher's dots (every site) and the magnifier (search).
**The menu button** (4.187.0; the owner, 2026-11-10: the Arena's "Search and apps"): a search bar over three apps,
**The menu button** (4.187.0; the owner, 2026-11-10: the Arena's "Search and apps"): a search bar over three apps,
-drawn by `OpenVibeOrb.svg()` in the navbar, the menu's sheet and the Dock.
+drawn by `OpenVibeOrb.svg()` in the navbar and the menu's sheet.
- **Hovered:** its parts lift in turn.
- **Hovered:** its parts lift in turn.
- **While the menu loads:** the apps breathe.
- **While the menu loads:** the apps breathe.
- **While it is open:** it becomes the × that closes the menu, in the same place.
- **While it is open:** it becomes the × that closes the menu, in the same place.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# One conversation UI: `openvibe-shared/thread`
# One conversation UI: `openvibe-shared/thread`
-Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the Dock use the same parts,
+Plan L55 C4. Actor's chat proved the shape (L55 C1–C3). Vibe (openvibe.chat), Codes and the menu's Chat and Vibe use the same parts,
so a conversation looks and behaves the same on every OpenVibe site.
so a conversation looks and behaves the same on every OpenVibe site.
- **Server:** `require('openvibe-shared/thread')` builds the markup. It is complete without script and every string is
- **Server:** `require('openvibe-shared/thread')` builds the markup. It is complete without script and every string is
@@ -324,17 +324,17 @@
@@ -324,17 +324,17 @@
/* While the menu loads: the apps breathe in turn. */
/* While the menu loads: the apps breathe in turn. */
.ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }
.ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }
/* Open: the bar is one stroke of the close mark, the three apps the other. */
/* Open: the bar is one stroke of the close mark, the three apps the other. */
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }
- :is(.ovnav-orb[aria-expanded="true"], .ovnav-orb.is-dock, .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }
+ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }
/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
.ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
.ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
@keyframes ovmb-form { from { transform: none; } }
@keyframes ovmb-form { from { transform: none; } }
- .ovnav-orb:not([aria-expanded="true"]):not(.is-dock) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }
+ .ovnav-orb:not([aria-expanded="true"]) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }
.ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; }
.ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; }
@keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } }
@keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } }
@keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }
@keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }
@@ -602,7 +602,7 @@
@@ -602,7 +602,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '68900e53dead', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'c1f8f8dfab77', 'dock.js': '328a715e0ecd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '1dd1ca512625', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -1217,7 +1217,7 @@
@@ -1217,7 +1217,7 @@
// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
// site that turns the menu off (search: false) or asks for it (drawer: 'legacy').
burger.addEventListener('click', (e) => {
burger.addEventListener('click', (e) => {
e.stopPropagation();
e.stopPropagation();
- if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch('', 'here', burger); return; }
+ if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch('', 'here', burger); return; }
const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);
const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer);
});
});
drawer.addEventListener('click', (e) => {
drawer.addEventListener('click', (e) => {
@@ -1352,12 +1352,18 @@
@@ -1352,12 +1352,18 @@
const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
}
- // ── The Dock (Dock plan K-D1): your agents and activity at the screen's edge. dock.js draws its pill and the Dock;
- // for a signed-in person it loads alongside notifications. A Network without the route yet (dock.js
- // keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).
+ // ── The menu's Chat and Vibe (dock.js's panels; 4.220.0: no floating Dock). For a signed-in person dock.js reads their
+ // state (a Network without the route is not asked again for a day) and paints its status on the menu button.
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling });
- const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';
+ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling,
+ status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });
+ let _menuStatus = null;
+ /** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */
+ function openMenuOn(id) {
+ if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;
+ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
+ openSearch('', 'here', orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, id);
+ }
function loadDock(then) {
function loadDock(then) {
if (root.OpenVibeDock) return then();
if (root.OpenVibeDock) return then();
let sc = document.getElementById('ov-dock-loader');
let sc = document.getElementById('ov-dock-loader');
@@ -1427,7 +1433,7 @@
@@ -1427,7 +1433,7 @@
// Back from the next page (the browser's page cache): the bar is gone.
// Back from the next page (the browser's page cache): the bar is gone.
root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); });
root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); });
}
}
- // The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
+ // The menu on a place ('chats' | 'vibe' | 'code' | 'agent'); opts.task marks an agent's step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
function askAgent(text) {
function askAgent(text) {
@@ -2213,7 +2219,6 @@
@@ -2213,7 +2219,6 @@
bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' },
bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' },
!isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null,
!isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null,
!isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,
!isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,
- bell && _config.dock !== false ? { id: 'ov-dock', label: 'The Dock: your agents and activity on every page', short: 'Dock', drawn: DOCK_GLYPH, mark: 'data-ov-dock' } : null,
admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,
admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,
!isAnon ? { id: 'ov-wallet', label: 'Wallet', drawn: navIcon('fa-wallet'), href: 'https://openvibe.network/my#wallet' } : null,
!isAnon ? { id: 'ov-wallet', label: 'Wallet', drawn: navIcon('fa-wallet'), href: 'https://openvibe.network/my#wallet' } : null,
!isAnon && u.username ? { id: 'ov-profile', label: 'Profile', drawn: navIcon('fa-id-card'), href: `https://openvibe.network/@${encodeURIComponent(u.username)}` } : null,
!isAnon && u.username ? { id: 'ov-profile', label: 'Profile', drawn: navIcon('fa-id-card'), href: `https://openvibe.network/@${encodeURIComponent(u.username)}` } : null,
@@ -2260,7 +2265,6 @@
@@ -2260,7 +2265,6 @@
nav.appendChild(dropdown);
nav.appendChild(dropdown);
_accountEl = dropdown;
_accountEl = dropdown;
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
- dropdown.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('[data-ov-dock]')) return; dropdown.classList.remove('open'); openDock(); });
bindChips(dropdown);
bindChips(dropdown);
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
bindDisplayRows(dropdown);
@@ -2367,7 +2371,7 @@
@@ -2367,7 +2371,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, section) {
+ function openSearch(q, tab, fromEl, section, door) {
// 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.
@@ -2381,14 +2385,14 @@
@@ -2381,14 +2385,14 @@
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, from: fromEl || null, section,
+ root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
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); },
look: bindLook,
look: bindLook,
- dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => { if (root.OpenVibeSearch) root.OpenVibeSearch.close(); openDock(t); } : null,
+ dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => openDock(t) : null,
dockMore: (action) => { if (root.OpenVibeDock && root.OpenVibeDock.more) root.OpenVibeDock.more(action); },
dockMore: (action) => { if (root.OpenVibeDock && root.OpenVibeDock.more) root.OpenVibeDock.more(action); },
- ask: (text) => { if (root.OpenVibeSearch) root.OpenVibeSearch.close(); loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); },
+ ask: (text) => { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); },
me: _accountEl ? { add: _config.accounts === false ? '' : addAccountHref(), out: () => { const b = _accountEl.querySelector('#openvibe-logout-btn'); if (b) b.click(); }, own: _own } : null,
me: _accountEl ? { add: _config.accounts === false ? '' : addAccountHref(), out: () => { const b = _accountEl.querySelector('#openvibe-logout-btn'); if (b) b.click(); }, own: _own } : null,
onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');
const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true');
@@ -2404,6 +2408,7 @@
@@ -2404,6 +2408,7 @@
// Every site: the menu on its first tab, scrolled to the sites, out of the menu button on screen.
// Every site: the menu on its first tab, scrolled to the sites, out of the menu button on screen.
function openSwitcher() {
function openSwitcher() {
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; }
+ if (_menuStatus && _menuStatus.tone === 'waiting') { openMenuOn('agent'); return; }
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);
openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null);
}
}
@@ -2471,7 +2476,8 @@
@@ -2471,7 +2476,8 @@
function hookSearch(nav) {
function hookSearch(nav) {
const b = nav.querySelector('#openvibe-search-btn');
const b = nav.querySelector('#openvibe-search-btn');
if (b) {
if (b) {
- b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else openSearch(); });
+ // An agent that needs the person opens the menu on Agents (the Dock's pill did, 4.220.0).
+ b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch(); });
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true });
if (menuOpen()) b.setAttribute('aria-expanded', 'true');
if (menuOpen()) b.setAttribute('aria-expanded', 'true');
// One ripple a tab, a moment after the first paint: here is the menu.
// One ripple a tab, a moment after the first paint: here is the menu.
@@ -8,7 +8,6 @@
@@ -8,7 +8,6 @@
// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
// OpenVibeNotifications.init({ token, apiBase, swPath, onAction })
// OpenVibeNotifications.createBell(mountEl)
// OpenVibeNotifications.createBell(mountEl)
// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
// OpenVibeNotifications.renderInbox(containerEl) // full-page inbox
-// OpenVibeNotifications.renderDock(containerEl) // the Dock's Activity tab (dock.js): the same list, no frame
//
//
// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since
// Reads: GET /api/notifications?limit&offset&unread_only&tab&q&since
// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)
// GET /api/notifications/unread-count, /unread-by-tab (the counts on the bell's tabs)
@@ -131,7 +130,7 @@
@@ -131,7 +130,7 @@
/* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
/* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
- right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ov-dock-clear, 0px)); width: min(380px, calc(100vw - 32px));
+ right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px))); width: min(380px, calc(100vw - 32px));
font-family: inherit; transition: opacity .18s ease; }
font-family: inherit; transition: opacity .18s ease; }
.openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }
.openvibe-toast-container.ovn-held { opacity: 0; visibility: hidden; }
.openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }
.openvibe-toast-container.ovn-phone { top: var(--ovn-top, 64px); bottom: auto; left: max(8px, env(safe-area-inset-left, 0px)); right: max(8px, env(safe-area-inset-right, 0px)); width: auto; }
@@ -1481,15 +1480,6 @@
@@ -1481,15 +1480,6 @@
reload(v);
reload(v);
return v;
return v;
},
},
- /** The Dock's Activity tab: the bell's list in the Dock's container (no frame, no title, no close). */
- renderDock(container) {
- injectStyles();
- if (!container) return null;
- container.classList.add('openvibe-notif-dock');
- const v = buildView(container, { mode: 'dock' });
- reload(v);
- return v;
- },
/** The You tab's preview (4.211.0): the newest three, no tools; Show all opens the whole list in place. */
/** The You tab's preview (4.211.0): the newest three, no tools; Show all opens the whole list in place. */
renderMenuNotifications(container, onCount) {
renderMenuNotifications(container, onCount) {
injectStyles();
injectStyles();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.219.0",
+ "version": "4.220.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.219.0",
+ "version": "4.220.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.219.0",
+ "version": "4.220.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": {
@@ -314,6 +314,15 @@
@@ -314,6 +314,15 @@
/* A place's actions in the search pill (4.200.0); the panel's own row of them is hidden inside the menu. */
/* 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 { display: flex; flex: none; gap: 2px; }
.ovs-acts:empty, .ovs-door-host .ovdk-bar { display: none; }
.ovs-acts:empty, .ovs-door-host .ovdk-bar { display: none; }
+/* One UI (4.220.0; the owner, 11-12: "part of the one UI instead of having two different areas"): inside the menu a place has
+ no head of its own. A conversation keeps a slim way back and its name; its actions are the search pill's; its mark is the
+ greeting's; no band, no border. */
+.ovs-door-host .ovdk-thread-h { min-height: 40px; padding: 2px 12px 2px 6px; gap: 4px; border-bottom: 0; }
+.ovs-door-host .ovdk-thread-h > .ovdk-ib, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-mark, .ovs-door-host .ovdk-vibe-name small { display: none; }
+.ovs-door-host .ovdk-thread-h .ovdk-back, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-back { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
+.ovs-door-host .ovdk-thread-h b, .ovs-door-host .ovdk-vibe-name b { font-size: 14px; font-weight: 700; color: var(--text-secondary, #b4c0d4); }
+.ovs-door-host .ovdk-vibe-start { padding-top: 2px; }
+
.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 { 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 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:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }
@@ -321,6 +330,13 @@
@@ -321,6 +330,13 @@
.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: repeat(3, minmax(0, 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: repeat(3, minmax(0, 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; }
+.ovs-door-top { flex: none; display: flex; align-items: center; gap: 6px; margin: 8px 12px 4px; }
+.ovs-door-top:not(:has(> :not([hidden]))) { display: none; }
+.ovs-door-top > .ovs-door-sw { flex: 1 1 auto; min-width: 0; margin: 0; }
+.ovs-door-top > .ovs-ai-timer { flex: none; width: 50px; min-height: 50px; margin: 0; padding: 0; justify-content: center; border-radius: 14px; }
+.ovs-door-top > .ovs-ai-timer span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.ovs-door-top > .ovs-ai-timer svg { width: 20px; height: 20px; }
+@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { display: none; } }
.ovs-door-sw button { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s; }
.ovs-door-sw button { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s; }
.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
@@ -391,13 +407,6 @@
@@ -391,13 +407,6 @@
.ovs-ai-timer[hidden] { display: none; }
.ovs-ai-timer[hidden] { display: none; }
.ovs-ai-timer:hover, .ovs-ai-timer:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--text-primary, #e6edf7); }
.ovs-ai-timer:hover, .ovs-ai-timer:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--text-primary, #e6edf7); }
.ovs-ai-timer svg { flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-ai-timer svg { flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
-.ovs-dockyou-b { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; padding: 0 14px; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
-.ovs-dockyou-b::after { content: ""; width: 7px; height: 7px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); transition: transform .2s; }
-.ovs-dockyou-b[aria-expanded="true"]::after { transform: rotate(225deg); }
-.ovs-dock-you { display: grid; gap: 2px; padding: 0 6px 8px; }
-.ovs-dock-you[hidden] { display: none; }
-.ovs-dockyou-i { display: flex; align-items: center; min-height: 44px; padding: 0 12px; border: 0; border-radius: 11px; background: none; color: var(--text-primary, #e6edf7); font: inherit; font-size: 14.5px; text-align: left; cursor: pointer; }
-.ovs-dockyou-i:hover, .ovs-dockyou-i:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
@@ -532,12 +541,14 @@
@@ -532,12 +541,14 @@
doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['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.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['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';
- const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;
- timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });
- doorEl.append(doorSw, doorHost, timerButton);
+ const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;
+ timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });
+ // Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.
+ const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw, timerButton);
+ doorEl.append(doorTop, doorHost);
listEl.after(doorEl);
listEl.after(doorEl);
doorHost.addEventListener('openvibe:open-vibe', (e) => {
doorHost.addEventListener('openvibe:open-vibe', (e) => {
- if (door !== 'chats' || !inSheet()) return;
+ if (door !== 'chats' || !canDoor()) return;
e.preventDefault();
e.preventDefault();
openDoor('vibe');
openDoor('vibe');
});
});
@@ -596,7 +607,7 @@
@@ -596,7 +607,7 @@
const it = a && items[Number(a.getAttribute('data-i'))];
const it = a && items[Number(a.getAttribute('data-i'))];
if (it && !input.value.trim()) beacon(it, a);
if (it && !input.value.trim()) beacon(it, a);
if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {
if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {
- e.preventDefault(); close(); try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } return;
+ e.preventDefault(); if (!showDoor(it.dock)) { try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } } return;
}
}
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
@@ -627,8 +638,8 @@
@@ -627,8 +638,8 @@
e.preventDefault();
e.preventDefault();
const q = input.value.trim();
const q = input.value.trim();
if (tab === 'feed' && homeLib() && !door) { render(q); return; }
if (tab === 'feed' && homeLib() && !door) { render(q); return; }
- if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
- if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
+ if (door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
+ if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
const it = items[active >= 0 ? active : 0];
const it = items[active >= 0 ? active : 0];
// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
if (q && !moved && view !== 'results' && !exact(it, q)) { showResults(q); return; }
if (q && !moved && view !== 'results' && !exact(it, q)) { showResults(q); return; }
@@ -703,18 +714,7 @@
@@ -703,18 +714,7 @@
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'dock-row', 'notifications'].includes(g.as)).forEach((g) => {
- if (g.as === 'dock-row') {
- const li = document.createElement('li'); li.setAttribute('role', 'presentation');
- const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-dockyou-b'; b.setAttribute('aria-expanded', 'false'); b.textContent = 'The Dock';
- const host = document.createElement('div'); host.className = 'ovs-dock-you'; host.hidden = true;
- [['timer', 'Start a timer'], ['pin', 'Keep the Dock button on every page'], ['pop', 'Open the Dock in its own window']].forEach(([action, label]) => {
- const item = document.createElement('button'); item.type = 'button'; item.className = 'ovs-dockyou-i'; item.textContent = label;
- item.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore(action); } catch { /* Dock optional */ } }); host.appendChild(item);
- });
- b.addEventListener('click', () => { host.hidden = !host.hidden; b.setAttribute('aria-expanded', String(!host.hidden)); });
- li.append(b, host); listEl.appendChild(li); return;
- }
+ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'notifications'].includes(g.as)).forEach((g) => {
if (g.as === 'notifications') {
if (g.as === 'notifications') {
const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'notifications');
const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'notifications');
const host = document.createElement('div'); host.className = 'ovs-notifications-host'; li.appendChild(host);
const host = document.createElement('div'); host.className = 'ovs-notifications-host'; li.appendChild(host);
@@ -1140,11 +1140,8 @@
@@ -1140,11 +1140,8 @@
* live or a count of what is new since you last looked, and You with your picture and an online dot.
* live or a count of what is new since you last looked, and You with your picture and an online dot.
*/
*/
/** A phone's menu with the Dock to hand: its places open inside the menu, under the menu's own head. */
/** A phone's menu with the Dock to hand: its places open inside the menu, under the menu's own head. */
- function inSheet() {
- let phone = false;
- try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }
- return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');
- }
+ /** Chat and Vibe open inside the menu, on every screen (4.220.0: the floating Dock is gone). */
+ function canDoor() { return !!(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
// 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
// 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
// icon"). The Dock's panel draws them in its slim row; inside the menu that row is hidden and mirrored here, and
@@ -1154,7 +1151,8 @@
@@ -1154,7 +1151,8 @@
if (!actsEl) return;
if (!actsEl) return;
actsEl.textContent = '';
actsEl.textContent = '';
if (!door || !doorHost) return;
if (!door || !doorHost) return;
- doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib').forEach((orig) => {
+ // A list's actions and an open conversation's (+ for a new chat, ↗ to open it in Chat): one head, the menu's (4.220.0).
+ doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib, .ovdk-thread-h > .ovdk-ib').forEach((orig) => {
const b = document.createElement('button'); b.type = 'button';
const b = document.createElement('button'); b.type = 'button';
b.className = 'ovs-act' + (orig.classList.contains('is-new') ? ' is-new' : '');
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;
const label = orig.getAttribute('aria-label') || ''; b.setAttribute('aria-label', label); b.title = label;
@@ -1171,9 +1169,17 @@
@@ -1171,9 +1169,17 @@
}
}
mirrorActs();
mirrorActs();
}
}
- /** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */
+ /** A place by the Dock's name ('chats', or Vibe's parts 'vibe' | 'code' | 'agent'), in the open menu. */
+ function showDoor(id) {
+ if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;
+ const want = id === 'agents' ? 'agent' : id;
+ if (['vibe', 'code', 'agent'].includes(want)) aiPart = want;
+ openDoor(want === 'chats' ? 'chats' : 'vibe');
+ return door != null;
+ }
+ /** Show a place in the menu: Messages, or AI (Vibe, Code or your agents, by the switch). */
function openDoor(id) {
function openDoor(id) {
- if (!doorEl || !inSheet()) return;
+ if (!doorEl || !canDoor()) return;
door = id;
door = id;
const want = id === 'chats' ? 'chats' : aiPart;
const want = id === 'chats' ? 'chats' : aiPart;
doorSw.hidden = id !== 'vibe';
doorSw.hidden = id !== 'vibe';
@@ -1223,10 +1229,9 @@
@@ -1223,10 +1229,9 @@
// A place of the Dock. On a phone it opens here, inside the menu, and is then the chosen tab; on a computer
// A place of the Dock. On a phone it opens here, inside the menu, and is then the chosen tab; on a computer
// it opens the floating Dock beside the page (it is kept beside the work there).
// it opens the floating Dock beside the page (it is kept beside the work there).
b.setAttribute('data-tab', id); b.setAttribute('data-dock', id); b.setAttribute('aria-selected', door === id ? 'true' : 'false');
b.setAttribute('data-tab', id); b.setAttribute('data-dock', id); b.setAttribute('aria-selected', door === id ? 'true' : 'false');
- if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
- b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } close(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });
+ b.addEventListener('click', (e) => { e.stopPropagation(); if (canDoor()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* not loaded yet: it opens the menu here when it is */ } });
tabsEl.appendChild(b);
tabsEl.appendChild(b);
continue;
continue;
}
}
@@ -1438,7 +1443,6 @@
@@ -1438,7 +1443,6 @@
return groups;
return groups;
}
}
if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
- if (inSheet()) groups.push({ title: '', as: 'dock-row', items: [] });
const yours = mine && mine.services.some((x) => x.count > 0);
const yours = mine && mine.services.some((x) => x.count > 0);
// Without their own history, developer services are not places to visit: they come up here once used.
// Without their own history, developer services are not places to visit: they come up here once used.
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
@@ -1503,8 +1507,8 @@
@@ -1503,8 +1507,8 @@
function render(q) {
function render(q) {
q = String(q || '').trim();
q = String(q || '').trim();
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
- if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
- if (door === 'vibe' && inSheet()) return;
+ if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
+ if (door === 'vibe') return;
if (tab === 'feed' && homeLib()) {
if (tab === 'feed' && homeLib()) {
tabsEl.hidden = false;
tabsEl.hidden = false;
input.placeholder = 'Search the feed';
input.placeholder = 'Search the feed';
@@ -1862,6 +1866,7 @@
@@ -1862,6 +1866,7 @@
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 (opts.door) showDoor(opts.door);
const jump = opts.tab === 'tools' || opts.tab === 'sites' ? opts.tab : '';
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);
if (jump) setTimeout(() => { const t = listEl.querySelector(`[data-sec="${jump === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);
}
}
@@ -1906,7 +1911,7 @@
@@ -1906,7 +1911,7 @@
document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
- const api = { open, close, suggest, siteOf, registerCard, _state: () => ({ tab, from: ctx.from }) };
+ const api = { open, close, door: (id) => showDoor(id), suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };
root.OpenVibeSearch = api;
root.OpenVibeSearch = api;
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
// bottom: 0 is left behind);
// bottom: 0 is left behind);
// • the page scrolls up and down through four positions and the toolbar opens and closes;
// • the page scrolls up and down through four positions and the toolbar opens and closes;
// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px
// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px
-// above the bar and the Dock's pill 16 px above the small player, and none of them overlap;
+// above the bar, and they do not overlap (4.220.0: the Dock's pill is gone, the menu holds its places; no pill
+// may come back);
// • the gaps do not jump by more than 1 px between samples.
// • the gaps do not jump by more than 1 px between samples.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
@@ -90,17 +91,16 @@ const MEASURE = `(() => {
@@ -90,17 +91,16 @@ const MEASURE = `(() => {
if (!r.width || !r.height) return null;
if (!r.width || !r.height) return null;
return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };
return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };
};
};
- const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = box('#ovdk-pill');
+ const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = document.querySelector('#ovdk-pill, .ovdk-pill');
const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));
const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));
return {
return {
visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),
visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),
- bar, mini, pill,
+ bar, mini, pill: !!pill,
gaps: {
gaps: {
bar: bar && px(visibleBottom - bar.b),
bar: bar && px(visibleBottom - bar.b),
mini: (bar && mini) ? px(bar.t - mini.b) : null,
mini: (bar && mini) ? px(bar.t - mini.b) : null,
- pill: (mini && pill) ? px(mini.t - pill.b) : null,
},
},
- overlap: { barMini: area(bar, mini), barPill: area(bar, pill), miniPill: area(mini, pill) },
+ overlap: { barMini: area(bar, mini) },
};
};
})()`;
})()`;
@@ -121,8 +121,8 @@ const SCROLLS = [0, 900, 1800, 500];
@@ -121,8 +121,8 @@ const SCROLLS = [0, 900, 1800, 500];
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });
await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });
await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });
const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };
const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };
- assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)') && !!document.querySelector('#ovdk-pill') && !document.querySelector('#ovdk-pill').hidden`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);
- await wait(450); // the small player's entrance and the Dock's first read
+ assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)')`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);
+ await wait(450); // the small player's entrance and the Dock's first read (status only, no pill)
const samples = [];
const samples = [];
for (const [i, y] of SCROLLS.entries()) {
for (const [i, y] of SCROLLS.entries()) {
for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {
for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {
@@ -148,28 +148,22 @@ const SCROLLS = [0, 900, 1800, 500];
@@ -148,28 +148,22 @@ const SCROLLS = [0, 900, 1800, 500];
const at = `${m.y}px/${m.toolbar}`;
const at = `${m.y}px/${m.toolbar}`;
check(!!m.bar, `${at}: the app bar is on the page`);
check(!!m.bar, `${at}: the app bar is on the page`);
check(!!m.mini, `${at}: the small player is on the page`);
check(!!m.mini, `${at}: the small player is on the page`);
- check(!!m.pill, `${at}: the Dock's pill is on the page`);
- if (!m.bar || !m.mini || !m.pill) continue;
+ check(!m.pill, `${at}: no Dock pill (the menu holds its places)`);
+ if (!m.bar || !m.mini) continue;
check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);
check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);
check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);
check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);
- check(Math.abs(m.overlap.barPill) === 0, `${at}: the bar and the Dock overlap by ${m.overlap.barPill}px²`);
- check(Math.abs(m.overlap.miniPill) === 0, `${at}: the small player and the Dock overlap by ${m.overlap.miniPill}px²`);
check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);
check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);
- check(Math.abs(m.gaps.pill - 16) <= 1, `${at}: the Dock sits ${m.gaps.pill}px above the small player (16)`);
}
}
const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };
const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };
check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);
check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);
check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);
check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);
- check(spread('pill') <= 1, `${width}x${height}: the Dock jumps ${spread('pill')}px between samples`);
const barGaps = samples.map((m) => m.gaps.bar);
const barGaps = samples.map((m) => m.gaps.bar);
const miniGaps = samples.map((m) => m.gaps.mini);
const miniGaps = samples.map((m) => m.gaps.mini);
- const pillGaps = samples.map((m) => m.gaps.pill);
report.push({
report.push({
size: `${width}x${height}`, samples: samples.length,
size: `${width}x${height}`, samples: samples.length,
bar: [Math.min(...barGaps), Math.max(...barGaps)],
bar: [Math.min(...barGaps), Math.max(...barGaps)],
mini: [Math.min(...miniGaps), Math.max(...miniGaps)],
mini: [Math.min(...miniGaps), Math.max(...miniGaps)],
- pill: [Math.min(...pillGaps), Math.max(...pillGaps)],
- overlap: Math.max(...samples.map((m) => m.overlap.barMini + m.overlap.barPill + m.overlap.miniPill)),
+ overlap: Math.max(...samples.map((m) => m.overlap.barMini)),
});
});
if (failures.length) {
if (failures.length) {
failed++;
failed++;
@@ -179,8 +173,8 @@ const SCROLLS = [0, 900, 1800, 500];
@@ -179,8 +173,8 @@ const SCROLLS = [0, 900, 1800, 500];
} finally { await page.close(); }
} finally { await page.close(); }
}
}
} finally { await browser.close(); }
} finally { await browser.close(); }
- for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, pill ${r.pill[0]}..${r.pill[1]}px, overlap ${r.overlap}px²`);
+ for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, overlap ${r.overlap}px²`);
if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }
if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }
- console.log('bottom-chrome: the bar, the small player and the Dock hold the bottom at every size and toolbar state');
+ console.log('bottom-chrome: the bar and the small player hold the bottom at every size and toolbar state, and no Dock pill is drawn');
server.close();
server.close();
})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });
})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });
@@ -1,23 +1,19 @@
@@ -1,23 +1,19 @@
'use strict';
'use strict';
-// The Dock (dock.js, the pill in navbar.js; Dock plan 2026-10-30 K-D1) in a real headless Chrome, against a local
-// stand-in for the Network's GET/PUT /api/v1/me/dock, /api/v1/me/actions and the notification routes:
-// • the pill shows only while an agent works, one needs you, or it is pinned; it names both, in its corner;
-// • a 404 (Network without the route yet) keeps it away, and signed out there is none;
-// • a tap opens the card on Agents when something needs you (waiting first, approve in place), Activity is the
-// bell's list, Escape closes and gives the focus back to the pill;
-// • every change reaches the Network as one debounced PUT; on a desktop the head drags the card to a corner and the
-// free corner resizes it inside 320-720 × 360-900; an open Dock reopens by itself on a desktop, never on a phone;
-// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;
-// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
-// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
-// never reads as a tap outside the sheet;
-// • Vibe (L48 V2): the person's Vibe from Chat's /api/vibe, starters before the first message, a question posted and
-// Vibe's answer arriving in place (an https link as a link), and the Vibe tab kept in the Network's state;
-// • the timer (L48 R2): the header's menu starts one, the pill counts it down when the Dock is closed, its end
-// clears it and says so, and a focus round's end writes its break from that same end (any page, the same result);
-// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with
-// its tabs and its styles, the agent view's stream and styles included, the page keeps the pill saying where the
-// Dock went, and closing the window brings the panel back; the Network is told.
+// The menu's Chat and Vibe places (dock.js's panels; Dock plan 2026-10-30 K-D1, and 4.220.0: the owner, 11-12, "there
+// should only be the one unified UI … delete … the old tab system") in a real headless Chrome, against a local stand-in
+// for the Network's GET/PUT /api/v1/me/dock, /api/v1/me/actions, Chat's DM and Vibe APIs and Codes' agent view:
+// • no floating Dock and no pill, ever: what the pill said is a status on the bar's menu button (agents working, one
+// needs you, a timer's minutes), and a 404 (Network without the route yet) or a signed-out page says nothing;
+// • the menu button opens on Agents when something needs you (waiting first, approve in place); Escape closes it;
+// every change reaches the Network as one debounced PUT;
+// • Chats (K-D2): the person's conversations with their unread counts, a thread opened in place (theirs and mine), Edit
+// as a pencil at the bubble's corner with the box as the editor, Enter sends, read, the open chat is the state;
+// • Send to (4.144.0), the agent view (K-D3), and nothing off a 320 px screen;
+// • Vibe (L48 V2): starters before the first message, a question and Vibe's answer in place, voice without the
+// browser's speech recognition, and the Vibe place kept in the Network's state;
+// • the timer (L48 R2): the Vibe place's timer button starts one, the menu button counts it down, its end says so
+// and clears it, and a focus round's end writes its break from that same end;
+// • Ask your agent: a page's [data-ov-ask] opens the menu on Vibe with the question in the box, unsent.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
@@ -229,36 +225,16 @@ async function until(p, expr, what, ms = 15000) { // a limit on a poll: a load
@@ -229,36 +225,16 @@ async function until(p, expr, what, ms = 15000) { // a limit on a poll: a load
await wait(80);
await wait(80);
}
}
}
}
-const PILL = `(() => { const p = document.getElementById('ovdk-pill'); if (!p || p.hidden) return null; const b = p.getBoundingClientRect();
- return { text: p.innerText.trim(), label: p.getAttribute('aria-label'), corner: p.dataset.corner, tone: p.dataset.tone, l: b.left, r: b.right, t: b.top, bt: b.bottom,
- clear: getComputedStyle(document.documentElement).getPropertyValue('--ov-dock-clear').trim() }; })()`;
-const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hidden) return null; const b = c.getBoundingClientRect();
- return { corner: c.dataset.corner, w: Math.round(b.width), h: Math.round(b.height), l: b.left, t: b.top, tab: (c.querySelector('[role=tab][aria-selected=true]') || {}).dataset?.tab,
- groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;
+// 4.220.0: what the Dock's pill said is the menu button's status; the panels are the menu's places.
+const STATUS = `(() => { const b = [...document.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')].find((x) => x.getBoundingClientRect().width > 0);
+ if (!b || !b.getAttribute('data-ov-status')) return null; const c = b.querySelector('.ovnav-st');
+ return { tone: b.getAttribute('data-ov-status'), label: b.getAttribute('data-ov-tip'), text: c ? c.textContent : '' }; })()`;
+const PLACE = `(() => { const c = document.querySelector('.ovs.is-open .ovs-door-host .ovdk'); if (!c) return null; const panel = c.querySelector('[data-panel]:not([hidden])');
+ return { tab: panel && panel.dataset.panel, groups: [...new Set([...c.querySelectorAll('.ovdk-run[data-bucket]')].map((g) => g.dataset.bucket))], items: c.querySelectorAll('.ovac-item').length }; })()`;
+const OPEN = (tab) => `(window.OpenVibeDock.open({ tab: '${tab}' }), true)`;
+const MENU_BUTTON = "[...document.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')].find((x) => x.getBoundingClientRect().width > 0).click()";
+const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host'))";
-// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the
-// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.
-const PIP = `(() => {
- const f = document.createElement('iframe');
- f.id = 'pip-stub';
- f.style.cssText = 'position:fixed;right:16px;bottom:16px;width:360px;height:520px;border:0;z-index:99999;background:#0d0d12';
- document.body.appendChild(f);
- const w = f.contentWindow;
- const shut = () => { try { w.dispatchEvent(new w.Event('pagehide')); } catch (e) { } try { f.remove(); } catch (e) { } };
- w.close = shut;
- const dp = { requestWindow: async (o) => { window.__pip.opts = o || null; return w; } };
- window.__pip = { win: w, frame: f, close: shut, dp };
- // Chromium has its own Document Picture-in-Picture getter; an own, writable value stands in for it in the test.
- Object.defineProperty(window, 'documentPictureInPicture', { value: dp, writable: true, configurable: true });
- return true;
-})()`;
-
-async function drag(p, from, to) {
- await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from[0], y: from[1] });
- await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: from[0], y: from[1], button: 'left', buttons: 1, clickCount: 1 });
- for (let i = 1; i <= 8; i++) await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from[0] + (to[0] - from[0]) * i / 8, y: from[1] + (to[1] - from[1]) * i / 8, button: 'left', buttons: 1 });
- await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: to[0], y: to[1], button: 'left', buttons: 0, clickCount: 1 });
-}
(async () => {
(async () => {
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
@@ -299,12 +275,12 @@ async function drag(p, from, to) {
@@ -299,12 +275,12 @@ async function drag(p, from, to) {
await shot(p, 'overview');
await shot(p, 'overview');
// The Vibe tab: the list of the person's conversations.
// The Vibe tab: the list of the person's conversations.
- await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
+ await p.evaluate(OPEN('vibe'));
await until(p, "document.querySelectorAll('.ovdk-vibe-list button').length >= 3", 'the Vibe list');
await until(p, "document.querySelectorAll('.ovdk-vibe-list button').length >= 3", 'the Vibe list');
await shot(p, 'vibe');
await shot(p, 'vibe');
// The Chats tab: the AI chats row among the DMs.
// The Chats tab: the AI chats row among the DMs.
- await p.evaluate("document.querySelector('#ovdk-tab-chats').click()");
+ await p.evaluate(OPEN('chats'));
await until(p, "!!document.querySelector('.ovdk-chat-b b')", 'the Chats rows');
await until(p, "!!document.querySelector('.ovdk-chat-b b')", 'the Chats rows');
await shot(p, 'chats');
await shot(p, 'chats');
await p.close();
await p.close();
@@ -314,55 +290,41 @@ async function drag(p, from, to) {
@@ -314,55 +290,41 @@ async function drag(p, from, to) {
// Two halves (each well under the runner's 60 s; the whole file took about 65 s and timed out on macOS): this file
// Two halves (each well under the runner's 60 s; the whole file took about 65 s and timed out on macOS): this file
// runs the first (DOCK_CHROME_PART 1, the default), test/dock-vibe-timer-chrome.test.js the second, 0 both.
// runs the first (DOCK_CHROME_PART 1, the default), test/dock-vibe-timer-chrome.test.js the second, 0 both.
if (PART !== 2) {
if (PART !== 2) {
- // ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──
+ // ── Nothing runs, nothing waits: the menu button says nothing. Agents working: a breathing dot on it. No pill. ──
{
{
scenario = { counts: { running: 0, waiting: 0, unread: 3 } };
scenario = { counts: { running: 0, waiting: 0, unread: 3 } };
const p = await fresh(390);
const p = await fresh(390);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!window.OpenVibeDock", 'the panels beside the navbar');
await wait(700);
await wait(700);
- await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill while nothing runs or waits (unread alone is the bell\'s)'));
- scenario.state = { ...defaultState(), pinned: true };
+ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'nothing to say while nothing runs or waits (unread alone is the bell\'s)'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(NO_FLOATING), true, 'no pill and no floating Dock'));
+ scenario.counts = { running: 2, waiting: 0, unread: 0 };
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- const pin = await until(p, PILL, 'the pinned pill');
- assert.deepStrictEqual([pin.text, pin.tone, pin.label], ['', 'idle', 'Open the Dock'], 'pinned and idle: the mark alone');
+ const st = await until(p, STATUS, 'the status on the menu button');
+ assert.deepStrictEqual([st.tone, st.label, st.text], ['running', '2 working', ''], 'agents working: a dot, named in the tooltip');
await p.close();
await p.close();
}
}
- // ── A phone: the pill says what matters, the sheet opens on Agents, Escape closes it. ──
+ // ── A phone: the menu button says what matters and opens on Agents; approve in place; Escape closes it. ──
{
{
scenario = { counts: { running: 2, waiting: 1, unread: 2 } };
scenario = { counts: { running: 2, waiting: 1, unread: 2 } };
puts.length = 0; decided.length = 0;
puts.length = 0; decided.length = 0;
const p = await fresh(390, 780);
const p = await fresh(390, 780);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- const pill = await until(p, PILL, 'the pill');
- assert.deepStrictEqual([pill.text, pill.label, pill.corner, pill.tone], ['Needs you', '1 needs you, 2 working', 'bottom-right', 'waiting']);
- assert.ok(pill.r <= 390 - 16 + 0.5 && pill.bt <= 780 - 16 + 0.5, `the pill keeps its 16 px from the edges (${pill.r}, ${pill.bt})`);
- assert.strictEqual(pill.clear, '0px', 'phones: the toasts are at the top, nothing to clear');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- const card = await until(p, `(() => { const c = ${CARD}; return c && c.items ? c : null; })()`, 'the Agents tab');
- assert.deepStrictEqual([card.tab, card.groups, card.items], ['agent', ['waiting', 'running'], 3], 'what needs you first, then what runs');
- await new Promise((ok) => setTimeout(ok, 450)); // the sheet has finished rising
- const placed = await p.evaluate(CARD);
- assert.ok(placed.w === 390 && placed.t === 0 && placed.h === 780, `the whole phone, over the bar (owner, 11-09: "all the way to the top"; ${placed.t}, ${placed.h})`);
- await eventually(async () => assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open'));
+ const st = await until(p, STATUS, 'the status');
+ assert.deepStrictEqual([st.tone, st.label], ['waiting', '1 needs you'], 'what needs you comes first');
+ await p.evaluate(MENU_BUTTON);
+ const place = await until(p, `(() => { const c = ${PLACE}; return c && c.items ? c : null; })()`, 'the menu, open on Agents');
+ assert.deepStrictEqual([place.tab, place.groups, place.items], ['agent', ['waiting', 'running'], 3], 'what needs you first, then what runs');
+ assert.strictEqual(await p.evaluate(NO_FLOATING), true, 'inside the menu, nothing floating');
await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
- await p.evaluate("document.getElementById('ovdk-tab-activity').click()");
- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'activity' && c.notes === 2; })()`, 'the Activity tab');
- await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').focus(); document.querySelector('.ovdk').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");
- await until(p, `!(${CARD})`, 'Escape closes');
- await eventually(async () => assert.strictEqual(await p.evaluate('document.activeElement && document.activeElement.id'), 'ovdk-pill', 'the focus goes back to the pill'));
- await wait(900);
- const all = Object.assign({}, ...puts);
- assert.deepStrictEqual([all.open, all.tab], [false, 'activity'], `the Network heard where the Dock was left (${JSON.stringify(puts)})`);
+ await p.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))");
+ await until(p, "!document.querySelector('.ovs.is-open')", 'Escape closes the menu');
+ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'agent'), `the Network heard the place (${JSON.stringify(puts)})`));
assert.ok(puts.length <= 3, `changes go as debounced PUTs, not one per click (${puts.length})`);
assert.ok(puts.length <= 3, `changes go as debounced PUTs, not one per click (${puts.length})`);
- // The Network says it was left open: a phone does not throw a sheet over the next page.
- scenario.state = { ...scenario.state, open: true };
- await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, PILL, 'the pill again');
- await wait(400);
- await eventually(async () => assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked'));
await p.close();
await p.close();
}
}
@@ -372,31 +334,38 @@ async function drag(p, from, to) {
@@ -372,31 +334,38 @@ async function drag(p, from, to) {
puts.length = 0; said.length = 0; reads.length = 0;
puts.length = 0; said.length = 0; reads.length = 0;
const p = await fresh(390, 780);
const p = await fresh(390, 780);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, PILL, 'the pill');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, "!!document.getElementById('ovdk-tab-chats')", 'the Chats tab');
- await until(p, "document.querySelector('[data-n=chats]').textContent === '3'", 'the unread count from Chat');
- await p.evaluate("document.getElementById('ovdk-tab-chats').click()");
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(OPEN('chats'));
const rows = await until(p, "(() => { const r = [...document.querySelectorAll('.ovdk .ovdk-chat')]; return r.length ? r.map((b) => b.getAttribute('aria-label') + ' | ' + b.querySelector('.ovdk-chat-p').textContent) : null; })()", 'the conversations');
const rows = await until(p, "(() => { const r = [...document.querySelectorAll('.ovdk .ovdk-chat')]; return r.length ? r.map((b) => b.getAttribute('aria-label') + ' | ' + b.querySelector('.ovdk-chat-p').textContent) : null; })()", 'the conversations');
assert.deepStrictEqual(rows, ['Kai Moreno, 2 unread | see the shots?', 'Design crit | You: Shipped it'], 'named for the other person (or the group), unread first, mine as "You:"');
assert.deepStrictEqual(rows, ['Kai Moreno, 2 unread | see the shots?', 'Design crit | You: Shipped it'], 'named for the other person (or the group), unread first, mine as "You:"');
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');
const thread = await until(p, "(() => { const m = [...document.querySelectorAll('.ovdk .ovdk-msg')]; return m.length === 2 ? m.map((x) => (x.classList.contains('is-mine') ? 'me: ' : 'them: ') + x.querySelector('.ovdk-text').textContent) : null; })()", 'the thread');
assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');
assert.deepStrictEqual(thread, ['them: see the shots?', 'me: <b>on it</b>'], 'theirs and mine, and what people wrote is text, never markup');
+ // The menu redraws when its own loads arrive and hands its (empty) search words on again: the open chat stays.
+ await p.evaluate("OpenVibeDock.filterChats(''), true");
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovdk-msg').length"), 2, 'a menu redraw never swaps the open chat for the list'));
const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
const cards = await until(p, "(() => { const c = [...document.querySelectorAll('.ovdk .ovdk-msg .ovdk-card')]; return c.length === 2 ? c.map((x) => { const t = x.querySelector('.ovdk-card-t'), g = x.querySelector('.ovdk-card-go'), i = x.querySelector('img'); return [t.tagName, t.textContent, t.getAttribute('href'), i ? i.getAttribute('src') : null, g ? g.textContent + ' ' + g.getAttribute('href') : null]; }) : null; })()", 'the link cards');
assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
assert.deepStrictEqual(cards[0], ['A', 'The studio tour', 'https://openvibe.video/v/42', 'https://openvibe.media/t/abc?w=320', 'Watch together https://openvibe.party/new?media=https%3A%2F%2Fopenvibe.video%2Fv%2F42'], 'a card: its picture, its title as a link, its one move');
assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
assert.deepStrictEqual(cards[1], ['B', '<img src=x>', null, null, null], 'a card is data: no script link, no picture from elsewhere, no script button, the title as text');
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('script[src*=\"dock-card.js\"]').length"), 1, 'dock-card.js is loaded once, when a card first shows'));
await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');
await until(p, "!!document.querySelector('.ovdk .ovdk-edit')", 'the edit action');
- await p.evaluate("(() => { window.prompt = () => 'updated by me'; document.querySelector('.ovdk .ovdk-edit').click(); })()");
- await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').textContent === 'updated by me'", 'the edited message');
- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-edited').textContent.includes('edited')"), true));
- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'opening a chat on a phone is not a tap outside the sheet'));
+ // 4.220.0: Edit is a pencil at the bubble's corner, hidden until hover, focus or a tap (rule 138); the box is
+ // the editor (no browser prompt), and "edited" is one quiet word inside the bubble.
+ assert.strictEqual(await p.evaluate("getComputedStyle(document.querySelector('.ovdk .ovdk-acts')).display"), 'none', 'the pencil waits for a tap');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-edit').getAttribute('aria-label')"), 'Edit message');
+ await p.evaluate("(() => { window.prompt = () => { window.__prompted = true; return null; }; document.querySelector('.ovdk .ovdk-edit').click(); })()");
+ await until(p, "!document.querySelector('.ovdk .ovdk-edit-bar').hidden && document.querySelector('.ovdk-send textarea').value === '<b>on it</b>'", 'the box holds the words, under its editing bar');
+ assert.strictEqual(await p.evaluate("!!window.__prompted"), false, 'never the browser prompt');
+ await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'updated by me'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
+ await until(p, "document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text').firstChild.textContent === 'updated by me'", 'the edited message');
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-msg.is-mine .ovdk-text .ovdk-edited').textContent"), 'edited', 'one quiet word, inside the bubble'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-edit-bar').hidden && document.querySelector('.ovdk-send textarea').value === ''"), true, 'saved: the bar goes and the box is empty'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs.is-open .ovdk')"), true, 'the thread stays in the menu'));
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await p.evaluate("(() => { const b = document.querySelector('.ovdk-send textarea'); b.value = 'Looks good'; b.dispatchEvent(new Event('input')); b.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); })()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 3", 'the sent message in the thread');
assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
assert.deepStrictEqual(said, ['Looks good'], 'Enter sends it to Chat');
assert.ok(reads.includes('11'), 'the thread is marked read as it is shown');
assert.ok(reads.includes('11'), 'the thread is marked read as it is shown');
- await wait(900);
- assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`);
+ await eventually(async () => assert.ok(puts.some((b) => b.chat === '11'), `the open chat is the Dock's state (${JSON.stringify(puts)})`));
await p.evaluate("document.querySelector('.ovdk-back').click()");
await p.evaluate("document.querySelector('.ovdk-back').click()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'back to the list');
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 2", 'back to the list');
await p.close();
await p.close();
@@ -408,7 +377,7 @@ async function drag(p, from, to) {
@@ -408,7 +377,7 @@ async function drag(p, from, to) {
said.length = 0; started.length = 0;
said.length = 0; started.length = 0;
const p = await fresh(390, 780);
const p = await fresh(390, 780);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, PILL, 'the pill');
+ await until(p, "!!window.OpenVibeDock", 'the panels');
const TEXT = 'Friday film https://openvibe.party/p/friday?k=abc';
const TEXT = 'Friday film https://openvibe.party/p/friday?k=abc';
const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
const share = (title) => p.evaluate(`OpenVibeDock.share({ text: ${JSON.stringify(TEXT)}${title ? `, title: ${JSON.stringify(title)}` : ''} })`);
await share('Friday film');
await share('Friday film');
@@ -431,17 +400,15 @@ async function drag(p, from, to) {
@@ -431,17 +400,15 @@ async function drag(p, from, to) {
await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
await until(p, `document.querySelector('.ovdk-send textarea') && document.querySelector('.ovdk-send textarea').value === ${JSON.stringify(TEXT)}`, 'a new conversation with the message in the box');
assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
assert.deepStrictEqual(started, [[4]], 'the conversation is started with Mia, by her id');
assert.deepStrictEqual(said, [], 'still nothing sent');
assert.deepStrictEqual(said, [], 'still nothing sent');
- // On a phone, a page button that sends something opens the sheet and it stays open (the opening tap is not
- // "a tap outside"); a later tap outside still closes it.
- await p.evaluate("(() => { OpenVibeDock.close(); const b = document.createElement('button'); b.id = 'pagesend'; b.textContent = 'Send it'; b.addEventListener('click', () => OpenVibeDock.share({ text: 'From a page button' })); document.body.prepend(b); })()");
+ // A page button that sends something opens the menu on Chat, and the opening tap does not close it.
+ await p.evaluate("(() => { OpenVibeDock.close(); const b = document.createElement('button'); b.id = 'pagesend'; b.textContent = 'Send it'; b.addEventListener('click', () => OpenVibeDock.share({ text: 'From a page button' })); document.body.appendChild(b); })()");
+ await until(p, "!document.querySelector('.ovs.is-open')", 'closed first');
await p.evaluate("document.getElementById('pagesend').click()");
await p.evaluate("document.getElementById('pagesend').click()");
- await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar, opened by a tap on the page');
+ await until(p, "!!document.querySelector('.ovs.is-open .ovdk .ovdk-share')", 'the bar, opened by a tap on the page');
await wait(300);
await wait(300);
- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk:not([hidden])')"), true, 'the opening tap does not close the sheet'));
- await p.evaluate("document.body.click()");
- // A phone's sheet folds back into the navbar's orb before it goes (4.185.0: 240 ms), so it is gone a moment
- // after the tap, not in the same instant.
- await until(p, "!document.querySelector('.ovdk:not([hidden])')", 'a later tap outside closes it');
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs.is-open')"), true, 'the opening tap does not close the menu'));
+ await p.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))");
+ await until(p, "!document.querySelector('.ovs.is-open')", 'Escape closes it');
await share();
await share();
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
await until(p, "!!document.querySelector('.ovdk .ovdk-share')", 'the bar again');
// Cancel: back to the list, nothing waiting.
// Cancel: back to the list, nothing waiting.
@@ -469,9 +436,9 @@ async function drag(p, from, to) {
@@ -469,9 +436,9 @@ async function drag(p, from, to) {
// The embed is Live's own page; the harness blocks its address so the test never reaches it.
// The embed is Live's own page; the harness blocks its address so the test never reaches it.
const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780, block: ['https://openvibe.live/*'] });
const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780, block: ['https://openvibe.live/*'] });
await p.goto(`${base}/page`, { maxMs: 8000 });
await p.goto(`${base}/page`, { maxMs: 8000 });
- await until(p, PILL, 'the pill');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab');
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(OPEN('agent'));
+ await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place');
// The stream: Live's embed, the task, the numbers.
// The stream: Live's embed, the task, the numbers.
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
@@ -501,9 +468,9 @@ async function drag(p, from, to) {
@@ -501,9 +468,9 @@ async function drag(p, from, to) {
// No stream at all: the view hides itself and the tab is just the list.
// No stream at all: the view hides itself and the tab is just the list.
agentView = null;
agentView = null;
await p.goto(`${base}/page`, { maxMs: 8000 });
await p.goto(`${base}/page`, { maxMs: 8000 });
- await until(p, PILL, 'the pill again');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');
+ await until(p, "!!window.OpenVibeDock", 'the panels again');
+ await p.evaluate(OPEN('agent'));
+ await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place again');
await wait(400);
await wait(400);
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));
@@ -515,138 +482,28 @@ async function drag(p, from, to) {
@@ -515,138 +482,28 @@ async function drag(p, from, to) {
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
const p = await fresh(320, 640);
const p = await fresh(320, 640);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- const pill = await until(p, PILL, 'the pill at 320');
- assert.strictEqual(pill.text, 'Working');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, CARD, 'the sheet at 320');
+ const st = await until(p, STATUS, 'the status at 320');
+ assert.strictEqual(st.tone, 'running');
+ await p.evaluate(OPEN('agent'));
+ await until(p, PLACE, 'the menu on Agents at 320');
await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));
await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));
await p.close();
await p.close();
}
}
-
- // ── A desktop: the card at the corner, dragged and resized; an open Dock reopens by itself; toasts clear it. ──
- {
- scenario = { counts: { running: 2, waiting: 1, unread: 2 } };
- puts.length = 0;
- const p = await fresh(1280, 860);
- await p.goto(`${base}/page`, { maxMs: 5000 });
- const pill = await until(p, PILL, 'the desktop pill');
- assert.strictEqual(pill.clear, '58px', 'desktop toasts stack above the pill in their corner');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, `(() => { const c = ${CARD}; return c && c.items ? c : null; })()`, 'the desktop card');
- await wait(400); // past the grow-in (a transform), so the box is the card's own
- let card = await p.evaluate(CARD);
- assert.deepStrictEqual([card.corner, card.w, card.h], ['bottom-right', 420, 640], 'a 420 × 640 card in the pill\'s corner');
- // Drag the head from its middle to near the top left: it snaps to that corner.
- await drag(p, [card.l + 120, card.t + 26], [180, 90]); // from the Activity tab: a drag, not a tap
- card = await until(p, `(() => { const c = ${CARD}; return c && c.corner === 'top-left' ? c : null; })()`, 'the card snaps to the top left');
- assert.strictEqual(card.tab, 'agent', 'a drag that began on a tab does not pick it');
- await wait(400);
- card = await p.evaluate(CARD);
- assert.ok(card.l <= 17 && card.t >= 60 && card.t < 80, `it sits in the top-left corner, under the bar (${card.l}, ${card.t})`);
- // The free corner (bottom right, now) resizes it, inside the limits.
- await drag(p, [card.l + card.w - 8, card.t + card.h - 8], [card.l + card.w + 2000, card.t + card.h - 400]);
- card = await p.evaluate(CARD);
- assert.deepStrictEqual([card.w, card.h], [720, 360], 'it stops at 720 wide and 360 tall');
- await wait(900);
- const place = Object.assign({}, ...puts.filter((b) => b.place).map((b) => b.place.desktop));
- assert.deepStrictEqual([place.corner, place.width, place.height], ['top-left', 720, 360], `the Network keeps where and how big (${JSON.stringify(puts)})`);
- // The next page restores it open on a desktop, where it was left.
- await p.goto(`${base}/page`, { maxMs: 5000 });
- card = await until(p, `(() => { const c = ${CARD}; return c && c.corner === 'top-left' ? c : null; })()`, 'the Dock reopens where it was');
- assert.deepStrictEqual([card.w, card.h], [720, 360], 'the same size');
- await p.close();
}
}
-
- // ── Pop out (K-D4): the button only where the browser can open a window; the panel goes there and comes back. ──
- {
- scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
- puts.length = 0;
- const p = await fresh(1280, 860);
- await p.goto(`${base}/page`, { maxMs: 5000 });
- // Where the browser cannot open a window of its own, there is nothing to press.
- await p.evaluate("(() => { Object.defineProperty(window, 'documentPictureInPicture', { value: undefined, writable: true, configurable: true }); return true; })()");
- await until(p, PILL, 'the pill');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, CARD, 'the card');
- const POP = "document.querySelector('.ovdk [data-act=pop]')";
- await eventually(async () => assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button'));
- await eventually(async () => assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own'));
- // The browser can: the button appears.
- await p.evaluate(PIP);
- await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
- await until(p, PILL, 'the pill again');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, `(() => { const b = ${POP}; return b && !b.hidden ? true : null; })()`, 'the pop-out button');
- // The panel moves into the window: the same DOM, the same tabs, the styles come with it.
- await p.evaluate(`${POP}.click()`);
- await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more'));
- await eventually(async () => assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 5, 'the same five tabs (Vibe, Code, Agents, Chat, Activity)'));
- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it"));
- await eventually(async () => assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520'));
- const inWindow = await until(p, PILL, 'the pill stays');
- assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');
- // The state write is debounced; a slow runner (macOS CI) needs more than a fixed wait.
- for (let i = 0; i < 50 && !puts.some((b) => b.open === true); i++) await wait(100);
- assert.ok(puts.some((b) => b.open === true), `the Network is told the Dock is open (${JSON.stringify(puts)})`);
- assert.ok(!puts.some((b) => 'popped' in b), `and never where its window is: that stays with this page (${JSON.stringify(puts)})`);
- // The window is closed: the panel comes back to the page.
- await p.evaluate("window.__pip.close()");
- await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');
- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window'));
- await wait(900);
- assert.ok(!puts.some((b) => 'popped' in b), `nor that it is back (${JSON.stringify(puts)})`);
- await p.close();
- }
-
- // ── Pop out with the agent view (K-D3): the stream goes into the window too, and its styles follow the
- // document the view is actually in, not the page behind it. ──
- {
- scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true, tab: 'activity' } };
- agentView = {
- id: AGENT_ID, state: 'live', title: 'Fix the flaky upload test',
- task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },
- curtain: { up: false }, viewers: 1, worked_seconds: 300, visibility: 'private',
- page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
- embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test`, last_frame_url: null,
- };
- const p = await fresh(1280, 860);
- await p.goto(`${base}/page`, { maxMs: 8000 });
- await until(p, PILL, 'the pinned pill');
- await p.evaluate(PIP);
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, CARD, 'the Dock on Activity');
- await p.evaluate("document.querySelector('.ovdk [data-act=pop]').click()");
- await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the Dock in the window');
- // The Agents tab opens for the first time in the window: the stream and its style belong there.
- await p.evaluate("window.__pip.win.document.querySelector('#ovdk-tab-agent').click()");
- const src = await until(p, "(() => { const w = window.__pip.win; const f = w.document.querySelector('.ovda iframe'); return f && f.getAttribute('src'); })()", 'the stream in the window');
- assert.strictEqual(src, agentView.embed_url, 'the same ticket and stream, in the window');
- await eventually(async () => assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovda-styles')"), true, 'the agent view keeps its styles in the window'));
- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.getElementById('ovda-styles')"), false, 'and does not put them on the page behind it'));
- // Back on the page, the same view is still there.
- await p.evaluate("window.__pip.close()");
- await until(p, "!!document.querySelector('.ovdk .ovda iframe')", 'the stream back on the page');
- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda iframe').getAttribute('src')"), agentView.embed_url));
- await p.close();
- }
-
- }
- if (PART === 1) { console.log('dock-chrome (1 of 2): the pill only when it matters, a phone\'s sheet, Chats, Send to, the agent view, 320 px clean, a desktop\'s card, pop-out'); return; }
+ if (PART === 1) { console.log('dock-chrome (1 of 2): no pill and no floating Dock, the status on the menu button, Agents in the menu, Chats, Send to, the agent view, 320 px clean'); return; }
// ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the Network keeps the tab. ──
// ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the Network keeps the tab. ──
{
{
scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };
scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };
puts.length = 0;
puts.length = 0;
const p = await fresh(1280, 860);
const p = await fresh(1280, 860);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, PILL, 'the pinned pill');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, CARD, 'the card');
- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab]').dataset.tab"), 'vibe', 'Vibe is the first tab'));
- await p.evaluate("document.querySelector('#ovdk-tab-vibe').click()");
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(OPEN('vibe'));
+ await until(p, "document.querySelector('.ovs.is-open [data-dock=\"vibe\"]') && document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected') === 'true'", 'the menu on Vibe');
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
- await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 4", 'the starters before the first message');
+ await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 6", 'the starters before the first message, each with its icon');
+ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip')].filter((c) => c.querySelector('.ovdk-chip-ic svg')).length"), 6, 'every starter has its icon');
await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');
assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');
@@ -667,8 +524,7 @@ async function drag(p, from, to) {
@@ -667,8 +524,7 @@ async function drag(p, from, to) {
await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));
await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));
await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));
await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));
- await wait(900);
- assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`);
+ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`));
await p.close();
await p.close();
}
}
@@ -689,8 +545,8 @@ async function drag(p, from, to) {
@@ -689,8 +545,8 @@ async function drag(p, from, to) {
stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }
stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }
};` });
};` });
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, PILL, 'the pinned pill');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(OPEN('vibe'));
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
@@ -728,9 +584,10 @@ async function drag(p, from, to) {
@@ -728,9 +584,10 @@ async function drag(p, from, to) {
puts.length = 0;
puts.length = 0;
const p = await fresh(1280, 860);
const p = await fresh(1280, 860);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await p.evaluate("OpenVibeDock.open({ tab: 'activity' })");
- await until(p, CARD, 'the card');
- await p.evaluate("document.querySelector('.ovdk [data-act=timer]').click()");
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(OPEN('vibe'));
+ await until(p, PLACE, 'the menu on Vibe');
+ await p.evaluate("document.querySelector('.ovs .ovs-ai-timer').click()");
await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await until(p, "!!document.querySelector('.ovdk .ovdk-tmenu')", 'the timer menu');
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-tmenu [data-min=\"5\"]').click()");
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
const strip = await until(p, "(() => { const s = document.querySelector('.ovdk .ovdk-timer'); return s ? s.innerText : null; })()", 'the timer strip in the Dock');
@@ -738,8 +595,8 @@ async function drag(p, from, to) {
@@ -738,8 +595,8 @@ async function drag(p, from, to) {
await wait(900);
await wait(900);
const started = puts.find((b) => b.timer);
const started = puts.find((b) => b.timer);
assert.ok(started && started.timer.kind === 'timer' && Date.parse(started.timer.ends_at) - Date.parse(started.timer.started_at) === 300000, `the Network keeps the timer (${JSON.stringify(puts)})`);
assert.ok(started && started.timer.kind === 'timer' && Date.parse(started.timer.ends_at) - Date.parse(started.timer.started_at) === 300000, `the Network keeps the timer (${JSON.stringify(puts)})`);
- await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
- const pill = await until(p, `(() => { const q = ${PILL}; return q && /^\\d+:\\d\\d$/.test(q.text) ? q : null; })()`, 'the pill counts it down');
+ await p.evaluate("OpenVibeSearch.close()");
+ const pill = await until(p, `(() => { const q = ${STATUS}; return q && /^\\d+:\\d\\d$/.test(q.text) ? q : null; })()`, 'the menu button counts it down');
assert.strictEqual(pill.tone, 'timer');
assert.strictEqual(pill.tone, 'timer');
assert.match(pill.label, /Timer: \d+:\d\d left/);
assert.match(pill.label, /Timer: \d+:\d\d left/);
// Another page's view: a timer two seconds from its end.
// Another page's view: a timer two seconds from its end.
@@ -747,7 +604,7 @@ async function drag(p, from, to) {
@@ -747,7 +604,7 @@ async function drag(p, from, to) {
scenario.state = { ...defaultState(), timer: soon(1500) };
scenario.state = { ...defaultState(), timer: soon(1500) };
puts.length = 0;
puts.length = 0;
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- const up = await until(p, `(() => { const q = ${PILL}; return q && q.text === "Time's up" ? q : null; })()`, 'the end, said', 8000);
+ const up = await until(p, `(() => { const q = ${STATUS}; return q && q.tone === 'timer-done' && q.text === 'Done' ? q : null; })()`, 'the end, said', 8000);
assert.match(up.label, /time's up/i);
assert.match(up.label, /time's up/i);
await wait(900);
await wait(900);
assert.ok(puts.some((b) => 'timer' in b && b.timer === null), `a plain timer clears itself at its end (${JSON.stringify(puts)})`);
assert.ok(puts.some((b) => 'timer' in b && b.timer === null), `a plain timer clears itself at its end (${JSON.stringify(puts)})`);
@@ -756,7 +613,7 @@ async function drag(p, from, to) {
@@ -756,7 +613,7 @@ async function drag(p, from, to) {
scenario.state = { ...defaultState(), timer: { kind: 'focus', label: 'Write', started_at: new Date(end - 25 * 60000).toISOString(), ends_at: new Date(end).toISOString(), round: 1, rounds: 2, focus_min: 25, break_min: 5 } };
scenario.state = { ...defaultState(), timer: { kind: 'focus', label: 'Write', started_at: new Date(end - 25 * 60000).toISOString(), ends_at: new Date(end).toISOString(), round: 1, rounds: 2, focus_min: 25, break_min: 5 } };
puts.length = 0;
puts.length = 0;
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
- await until(p, `(() => { const q = ${PILL}; return q && q.tone === 'timer' ? q : null; })()`, 'the focus round in the pill');
+ await until(p, `(() => { const q = ${STATUS}; return q && q.tone === 'timer' ? q : null; })()`, 'the focus round on the menu button');
await wait(2600);
await wait(2600);
const brk = puts.map((b) => b.timer).find((t) => t && t.kind === 'break');
const brk = puts.map((b) => b.timer).find((t) => t && t.kind === 'break');
assert.ok(brk, `the break follows the focus round (${JSON.stringify(puts)})`);
assert.ok(brk, `the break follows the focus round (${JSON.stringify(puts)})`);
@@ -766,20 +623,21 @@ async function drag(p, from, to) {
@@ -766,20 +623,21 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
- // ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
+ // ── Network without the route yet (404): no status, and the site stops asking for a day. Signed out: none. ──
{
{
scenario = { missing: true };
scenario = { missing: true };
const p = await fresh(1280);
const p = await fresh(1280);
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await wait(700);
await wait(700);
- await eventually(async () => assert.strictEqual(await p.evaluate(PILL), null, 'no pill without the route'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'nothing on the menu button without the route'));
const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");
const off = await p.evaluate("JSON.parse(localStorage.getItem('ov-dock') || 'null')");
assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');
assert.ok(off && off.off > Date.now() + 23 * 3600e3, 'and it waits a day before asking again');
await p.evaluate("localStorage.removeItem('ov-dock')");
await p.evaluate("localStorage.removeItem('ov-dock')");
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
await p.goto(`${base}/page?in=0`, { maxMs: 5000 });
await p.goto(`${base}/page?in=0`, { maxMs: 5000 });
await wait(600);
await wait(600);
- await eventually(async () => assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all'));
+ await eventually(async () => assert.strictEqual(await p.evaluate(STATUS), null, 'signed out: no status'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('#ovdk-pill, .ovdk-pill')"), false, 'and no pill'));
await p.close();
await p.close();
}
}
// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
// ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
@@ -790,15 +648,15 @@ async function drag(p, from, to) {
@@ -790,15 +648,15 @@ async function drag(p, from, to) {
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');
await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');
await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");
await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");
- await until(p, CARD, 'the Dock opens');
+ await until(p, PLACE, 'the menu opens');
const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab'));
+ await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected')"), 'true', 'on Vibe'));
await wait(400);
await wait(400);
assert.strictEqual(asked.length, before, 'nothing is sent for the person');
assert.strictEqual(asked.length, before, 'nothing is sent for the person');
await p.close();
await p.close();
}
}
- console.log('dock-chrome: the pill only when it matters, Vibe, Ask your agent, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
+ console.log('dock-chrome: Vibe, voice, the glyphs, the timer on the menu button, a Network without the route, Ask your agent');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
@@ -1,5 +1,7 @@
@@ -1,5 +1,7 @@
'use strict';
'use strict';
// mountInto uses the Dock's actual panel siblings against local service stand-ins. Also checks six cleanup laps.
// mountInto uses the Dock's actual panel siblings against local service stand-ins. Also checks six cleanup laps.
+// 4.220.0 (the owner, 11-12: "one unified UI"): the panels live only where the menu places them. No pill, no floating
+// card: open() asks the menu (opts.openMenu) and the counts reach the bar's menu button (opts.status).
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -18,7 +20,7 @@ const page = `<!doctype html><html><head><meta name="viewport" content="width=de
@@ -18,7 +20,7 @@ const page = `<!doctype html><html><head><meta name="viewport" content="width=de
html,body{margin:0;height:100%;font:14px system-ui;background:#161820;color:#eee}
html,body{margin:0;height:100%;font:14px system-ui;background:#161820;color:#eee}
#host{width:100%;height:600px;overflow:hidden}main{height:100px}
#host{width:100%;height:600px;overflow:hidden}main{height:100px}
</style><script src="/dock.js"></script></head><body><main>Local Dock mount test</main><div id="host"></div>
</style><script src="/dock.js"></script></head><body><main>Local Dock mount test</main><div id="host"></div>
- <script>OpenVibeDock.mount({token:()=> 'local-token',apiBase:location.origin,chatBase:location.origin,actorBase:location.origin,agentBase:location.origin,sibling:(name)=>'/'+name});</script></body></html>`;
+ <script>window.__status=[];window.__menu=[];OpenVibeDock.mount({token:()=> 'local-token',apiBase:location.origin,chatBase:location.origin,actorBase:location.origin,agentBase:location.origin,sibling:(name)=>'/'+name,status:(s)=>window.__status.push(s),openMenu:(id)=>window.__menu.push(id),closeMenu:()=>window.__menu.push('close')});</script></body></html>`;
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://local');
const u = new URL(req.url, 'http://local');
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
@@ -55,14 +57,15 @@ async function until(p, expression, ms = 6000) {
@@ -55,14 +57,15 @@ async function until(p, expression, ms = 6000) {
const context = await browser.newContext();
const context = await browser.newContext();
const p = await h.openPage(browser, context, { width, height: 800, probe: true });
const p = await h.openPage(browser, context, { width, height: 800, probe: true });
await p.goto(base, { maxMs: 5000 });
await p.goto(base, { maxMs: 5000 });
- await until(p, '!!window.OpenVibeDock && !!document.getElementById("ovdk-pill")');
+ await until(p, '!!window.OpenVibeDock && window.__status.length > 0');
+ assert.strictEqual(await p.evaluate('!!document.querySelector("#ovdk-pill, .ovdk-pill, body > .ovdk")'), false, `${width}px: no pill and no floating card`);
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
await until(p, '!!document.querySelector("#host .ovdk-chat")');
await until(p, '!!document.querySelector("#host .ovdk-chat")');
- await until(p, 'document.querySelector("#ovdk-tab-chats .n").textContent === "3"');
await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 0, waiting: 1}})');
await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 0, waiting: 1}})');
- assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-tab-agent .n").textContent'), '1', `${width}px: mounted panel shares unread counts`);
- assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-pill .ovdk-t").textContent'), 'Needs you', `${width}px: pill uses the same counts`);
- for (const [tab, marker] of [['chats', '.ovdk-chat'], ['vibe', '.ovdk-vibe-new'], ['agent', '.ovdk-runs-foot'], ['activity', '.openvibe-nv-head']]) {
+ assert.deepStrictEqual(await p.evaluate('window.__status[window.__status.length - 1]'), { tone: 'waiting', text: 'Needs you', label: '1 needs you', part: 'agent' }, `${width}px: what the pill said goes to the menu button`);
+ await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 2, waiting: 0}})');
+ assert.strictEqual(await p.evaluate('window.__status[window.__status.length - 1].text'), '2 working', `${width}px: agents working`);
+ for (const [tab, marker] of [['chats', '.ovdk-chat'], ['vibe', '.ovdk-vibe-new'], ['agent', '.ovdk-runs-foot']]) {
await p.evaluate(`mount.show('${tab}')`);
await p.evaluate(`mount.show('${tab}')`);
await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden]) ${marker}')`);
await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden]) ${marker}')`);
const result = await p.evaluate(`(() => { const host=document.getElementById('host'); const panel=host.querySelector('[data-panel="${tab}"]');
const result = await p.evaluate(`(() => { const host=document.getElementById('host'); const panel=host.querySelector('[data-panel="${tab}"]');
@@ -82,20 +85,21 @@ async function until(p, expression, ms = 6000) {
@@ -82,20 +85,21 @@ async function until(p, expression, ms = 6000) {
assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: destroy empties host`);
assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: destroy empties host`);
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"vibe"})');
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"vibe"})');
await until(p, '!!document.querySelector("#host [data-panel=vibe]")');
await until(p, '!!document.querySelector("#host [data-panel=vibe]")');
- await p.evaluate('OpenVibeDock.open({tab:"activity"})');
- assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: floating Dock takes ownership`);
- assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), true, `${width}px: floating Dock opens`);
- await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
- await until(p, '!!document.querySelector("#host [data-panel=chats]")');
- assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), false, `${width}px: mount closes floating Dock`);
+ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), true, `${width}px: open while the menu holds a panel`);
+ await p.evaluate('OpenVibeDock.open({tab:"chats"})');
+ assert.deepStrictEqual(await p.evaluate('window.__menu.slice(-1)'), ['chats'], `${width}px: open() asks the menu for that place`);
+ assert.strictEqual(await p.evaluate('!!document.querySelector("#ovdk-pill, .ovdk-pill, body > .ovdk")'), false, `${width}px: and draws nothing of its own`);
+ await p.evaluate('OpenVibeDock.close()');
+ assert.deepStrictEqual(await p.evaluate('window.__menu.slice(-1)'), ['close'], `${width}px: close() closes the menu`);
await p.evaluate('mount.destroy()');
await p.evaluate('mount.destroy()');
+ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), false, `${width}px: closed once the menu hands the panel back`);
if (width === 390) {
if (width === 390) {
// The probe and forced GC use the same measures as boost-navbar-chrome's lifecycle check.
// The probe and forced GC use the same measures as boost-navbar-chrome's lifecycle check.
const samples = [];
const samples = [];
for (let lap = 0; lap < 6; lap++) {
for (let lap = 0; lap < 6; lap++) {
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
await until(p, '!!document.querySelector("#host .ovdk-chat")');
await until(p, '!!document.querySelector("#host .ovdk-chat")');
- for (const tab of ['vibe', 'agent', 'activity', 'chats']) {
+ for (const tab of ['vibe', 'agent', 'chats']) {
await p.evaluate(`mount.show('${tab}')`);
await p.evaluate(`mount.show('${tab}')`);
await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden])')`);
await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden])')`);
}
}
@@ -110,6 +114,6 @@ async function until(p, expression, ms = 6000) {
@@ -110,6 +114,6 @@ async function until(p, expression, ms = 6000) {
assert.ok(b.nodes-a.nodes <= 25 && b.listeners-a.listeners <= 3 && b.doc-a.doc <= 0 && b.win-a.win <= 0, `no node or listener growth: ${JSON.stringify(samples)}`);
assert.ok(b.nodes-a.nodes <= 25 && b.listeners-a.listeners <= 3 && b.doc-a.doc <= 0 && b.win-a.win <= 0, `no node or listener growth: ${JSON.stringify(samples)}`);
}
}
}
}
- console.log('dock-mount-chrome: four panels, both widths, exclusive ownership, six leak laps passed');
+ console.log('dock-mount-chrome: three panels in the menu, both widths, status to the menu button, open() through the menu, six leak laps passed');
} finally { await browser.close().catch(() => {}); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
} finally { await browser.close().catch(() => {}); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -101,6 +101,8 @@ assert.ok(dock.includes("h('b', '', 'Vibe')"), 'the Chat tab has the Vibe row (y
@@ -101,6 +101,8 @@ assert.ok(dock.includes("h('b', '', 'Vibe')"), 'the Chat tab has the Vibe row (y
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');
-assert.ok(dock.includes('ovdk-size::after') || dock.includes('.ovdk-size'), 'the Dock has a grip to resize it with');
+// 4.220.0: the floating Dock is gone (the owner, 11-12: "one unified UI"): no pill, no card, no grip; its panels live in the menu.
+assert.ok(!dock.includes('ovdk-pill') && !dock.includes('.ovdk-size') && !dock.includes('popOut'), 'no floating Dock: no pill, no resize grip, no window of its own');
+assert.ok(dock.includes('mountInto') && dock.includes('o.openMenu'), 'the panels are placed in the menu, and open() opens the menu there');
console.log("dock v2: the run rows, the overview, the Vibe list and the Chats row");
console.log("dock v2: the run rows, the overview, the Vibe list and the Chats row");
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The Dock with the extension's read-only scopes and an async token (2026-10-30): every request uses the
// The Dock with the extension's read-only scopes and an async token (2026-10-30): every request uses the
// token the function returns at call time; the Agents tab shows Details but no approve/decline; the Chats
// token the function returns at call time; the Agents tab shows Details but no approve/decline; the Chats
-// tab reads but has no composer; the Activity tab loads through the same token function.
+// tab reads but has no composer. 4.220.0: the panels live in the menu (mountInto), not in a floating Dock with a pill.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -77,23 +77,20 @@ async function until(p, expr, what, ms = 6000) {
@@ -77,23 +77,20 @@ async function until(p, expr, what, ms = 6000) {
try {
try {
await p.goto(`${base}/page`, { maxMs: 5000 });
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, 'window.__calls > 0', 'the first dock read with the async token');
await until(p, 'window.__calls > 0', 'the first dock read with the async token');
- await until(p, "!!document.getElementById('ovdk-pill')", 'the pill');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('#ovdk-pill, .ovdk-pill')"), false, 'no pill: the menu holds the panels');
assert.ok(seenAuth.length && seenAuth.every((a) => a === 'Bearer tok_async'), 'every request used the token function result');
assert.ok(seenAuth.length && seenAuth.every((a) => a === 'Bearer tok_async'), 'every request used the token function result');
- await p.evaluate("document.getElementById('ovdk-pill').click()");
- await until(p, "!!document.querySelector('.ovdk [data-panel=agent]')", 'the Dock opens');
- await p.evaluate("document.getElementById('ovdk-tab-agent').click()");
+ await p.evaluate("(() => { const host = document.createElement('div'); host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column'; document.body.append(host); window.__dock = OpenVibeDock.mountInto(host, { tab: 'agent' }); })()");
+ await until(p, "!!document.querySelector('.ovdk [data-panel=agent]:not([hidden])')", 'the Agents panel, placed as the menu places it');
await until(p, "document.querySelectorAll('.ovdk .ovac-item').length === 1", 'the action item');
await until(p, "document.querySelectorAll('.ovdk .ovac-item').length === 1", 'the action item');
assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovac-item [data-do=approve], .ovdk .ovac-item [data-do=decline]').length"), 0, 'no approve/decline with activity only');
assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovdk .ovac-item [data-do=approve], .ovdk .ovac-item [data-do=decline]').length"), 0, 'no approve/decline with activity only');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovac-item a[href]')"), true, 'Details stays');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovac-item a[href]')"), true, 'Details stays');
- await p.evaluate("document.getElementById('ovdk-tab-chats').click()");
+ await p.evaluate("window.__dock.show('chats')");
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 1", 'the chat list');
await until(p, "document.querySelectorAll('.ovdk .ovdk-chat').length === 1", 'the chat list');
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await p.evaluate("document.querySelector('.ovdk .ovdk-chat').click()");
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 1", 'the thread');
await until(p, "document.querySelectorAll('.ovdk .ovdk-msg').length === 1", 'the thread');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-send')"), false, 'no composer with chat.read only');
assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-send')"), false, 'no composer with chat.read only');
assert.match(await p.evaluate("document.querySelector('.ovdk [data-panel=chats]').innerText"), /Reading only/);
assert.match(await p.evaluate("document.querySelector('.ovdk [data-panel=chats]').innerText"), /Reading only/);
- await p.evaluate("document.getElementById('ovdk-tab-activity').click()");
- await until(p, "document.querySelectorAll('.ovdk .openvibe-notif-item').length === 1", 'the activity list');
- assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each tab');
+ assert.ok((await p.evaluate('window.__calls')) >= 3, 'the async token was resolved for each panel');
assert.ok(seenAuth.every((a) => a === 'Bearer tok_async'), JSON.stringify([...new Set(seenAuth)]));
assert.ok(seenAuth.every((a) => a === 'Bearer tok_async'), JSON.stringify([...new Set(seenAuth)]));
console.log('dock signin chrome: all checks passed');
console.log('dock signin chrome: all checks passed');
} finally {
} finally {
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+// The Dock's Vibe tab draws the network's glyphs, not marks of its own (Shared 4.220.0; the owner, 11-12: "actually has
+// icons for everything using OpenVibe.design"): every mark in its set is the registry's drawing of the same name, so a
+// glyph redrawn on openvibe.design/icons is redrawn here when the file is rebuilt, and this test says which is stale.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const glyphs = require('../glyphs');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'dock-vibe.js'), 'utf8');
+// A document just real enough for the file to define itself.
+const sandbox = { document: { createElement: () => ({}), currentScript: null, getElementById: () => null }, console };
+sandbox.window = sandbox;
+vm.runInNewContext(src, sandbox, { filename: 'dock-vibe.js' });
+const set = sandbox.OpenVibeDockVibe && sandbox.OpenVibeDockVibe.GLYPHS;
+assert.ok(set && typeof set === 'object', 'dock-vibe.js exposes its marks as OpenVibeDockVibe.GLYPHS');
+const names = Object.keys(set);
+assert.ok(names.length >= 12, `the panel's marks (${names.length})`);
+names.forEach((name) => {
+ assert.ok(glyphs.path(name), `${name} is a glyph in the registry (openvibe-shared/glyphs)`);
+ assert.strictEqual(set[name], glyphs.path(name), `${name} is drawn as the registry draws it`);
+});
+// Every V.<name> the file uses is in the set (a typo would draw nothing).
+const used = [...new Set([...src.matchAll(/\bV\.([a-z]+)\b/g)].map((m) => m[1]))];
+used.forEach((name) => assert.ok(names.includes(name), `V.${name} is in the set`));
+// Each starter's mark too.
+const starters = [...src.matchAll(/\['([a-z]+)', '[^']+'\]/g)].map((m) => m[1]);
+assert.ok(starters.length >= 4, 'the starters');
+starters.forEach((name) => assert.ok(names.includes(name), `the starter mark ${name} is in the set`));
+console.log(`dock-vibe-glyphs: ${names.length} marks, all the registry's; ${used.length} used, ${starters.length} starters`);
@@ -246,32 +246,19 @@ const server = http.createServer((req, res) => {
@@ -246,32 +246,19 @@ const server = http.createServer((req, res) => {
assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');
assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');
await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);
await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);
assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');
assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');
- // ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,
- // 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button
- // that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes
- // the same move underneath; closing runs it back, then the sheet is gone. With motion off there is no move.
+ // ── 4.220.0 (the owner, 11-12: "one unified UI"): the Dock's open() opens the menu on that place; there is no
+ // floating Dock, no pill and no sheet of its own, and close() closes the menu. ──
await s.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs-orb') || { click() {} }).click(), true`);
await s.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs-orb') || { click() {} }).click(), true`);
await new Promise((ok) => setTimeout(ok, 700));
await new Promise((ok) => setTimeout(ok, 700));
- assert.ok(await until(s, `!!(window.OpenVibeDock && window.OpenVibeDock.open)`), 'the Dock is loaded beside the navbar');
- const start = await s.evaluate(`(() => { const c = (e) => { const r = e.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
- const nav = [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].find((b) => b.getBoundingClientRect().width > 0);
- const before = c(nav.querySelector('svg'));
- window.OpenVibeDock.open({ tab: 'activity' });
- const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx');
- return { before, at: c(mine.querySelector('svg')), fromOrb: dock.classList.contains('from-orb'), isFrom: mine.classList.contains('is-from') }; })()`);
- assert.ok(start.fromOrb && start.isFrom, 'the sheet opens from the orb');
- assert.ok(Math.abs(start.at[0] - start.before[0]) <= 1.5 && Math.abs(start.at[1] - start.before[1]) <= 1.5, `the close orb starts on the navbar's orb (${JSON.stringify(start)})`);
- await new Promise((ok) => setTimeout(ok, 900));
- const open = await s.evaluate(`(() => { const dock = document.querySelector('.ovdk'), mine = dock.querySelector('.ovdk-orbx'), sv = mine.querySelector('svg'), r = sv.getBoundingClientRect(), b = mine.getBoundingClientRect();
- return { settled: getComputedStyle(sv).transform === 'none' && !mine.classList.contains('is-from'), centred: Math.abs((r.left + r.width / 2) - (b.left + b.width / 2)) <= 1, navIsDock: !!document.querySelector('.openvibe-navbar .ovnav-orb.is-dock'), opacity: getComputedStyle(dock).opacity }; })()`);
- assert.ok(open.settled && open.centred, `it settles into its place in the header (${JSON.stringify(open)})`);
- assert.ok(open.navIsDock && open.opacity === '1', 'the navbar\'s orb made the same move underneath');
- const closing = await s.evaluate(`(() => { window.OpenVibeDock.close(); const dock = document.querySelector('.ovdk'); return { hidden: dock.hidden, out: dock.classList.contains('is-out'), back: dock.querySelector('.ovdk-orbx').classList.contains('is-from'), navIsDock: !!document.querySelector('.ovnav-orb.is-dock') }; })()`);
- assert.ok(!closing.hidden && closing.out && closing.back && !closing.navIsDock, `closing folds it back into the orb first (${JSON.stringify(closing)})`);
- assert.ok(await until(s, `document.querySelector('.ovdk').hidden`), 'then the sheet is gone');
- await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('.ovdk').classList.contains('from-orb') && !document.querySelector('.ovnav-orb.is-dock') && [...document.querySelectorAll('.openvibe-navbar .ovnav-orb')].every((b) => !b.style.transition)`), 'and the navbar\'s orb is as it was'));
- const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
- assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
+ assert.ok(await until(s, `!!(window.OpenVibeDock && window.OpenVibeDock.open)`), 'the Dock\'s panels are loaded beside the navbar');
+ await s.evaluate(`window.OpenVibeDock.open({ tab: 'chats' }), true`);
+ assert.ok(await until(s, `!!document.querySelector('.ovs.is-open.ovs-in-door') && document.querySelector('.ovs [data-dock="chats"]').getAttribute('aria-selected') === 'true'`), 'open() opens the menu on Chat');
+ assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk')`), 'the panel is inside the menu');
+ assert.strictEqual(await s.evaluate(`[...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host')) && !document.querySelector('#ovdk-pill, .ovdk-pill')`), true, 'no floating Dock and no pill');
+ await s.evaluate(`window.OpenVibeDock.open({ tab: 'agent' }), true`);
+ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && document.querySelector('.ovs-door-sw [data-part="agent"]').getAttribute('aria-selected') === 'true'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Agents');
+ await s.evaluate(`window.OpenVibeDock.close(), true`);
+ assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');
await s.close();
await s.close();
if (process.env.OV_CAPTURE_MENU) {
if (process.env.OV_CAPTURE_MENU) {
for (const width of [390, 1280]) {
for (const width of [390, 1280]) {
@@ -282,7 +269,7 @@ const server = http.createServer((req, res) => {
@@ -282,7 +269,7 @@ const server = http.createServer((req, res) => {
const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));
fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));
await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
- if (width === 1280) assert.ok(await until(shotPage, `!document.querySelector('.ovs').classList.contains('is-open')`), 'desktop Chat closes the menu so the Dock is visible');
+ assert.ok(await until(shotPage, `document.querySelector('.ovs').classList.contains('is-open')`), 'Chat opens inside the menu on every screen');
assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');
assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');
const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));
fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));
@@ -69,27 +69,15 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -69,27 +69,15 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);
await page.evaluate(`document.querySelector('.ovs-list [data-sec="notifications"] .ovs-nhead-more').click()`);
await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));
await eventually(async () => assert.equal(await page.evaluate('window.notificationsExpanded'), true, `${name}: notifications expand in place`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click()`);
- if (width <= 640) {
- await page.evaluate(`document.querySelector('.ovs-list > li > .ovs-dockyou-b').click()`);
- await eventually(async () => assert.deepEqual(await page.evaluate(`Array.from(document.querySelectorAll('.ovs-dock-you button')).map((b) => b.textContent)`), ['Start a timer', 'Keep the Dock button on every page', 'Open the Dock in its own window'], `${name}: The Dock row opens its three actions in place`));
- await page.evaluate(`document.querySelector('.ovs-dock-you button').click()`);
- await eventually(async () => assert.equal(await page.evaluate('window.timerCalled'), 'timer', `${name}: You Dock timer action`));
- }
+ // 4.220.0: no "The Dock" row in You (the floating Dock is gone; the timer has its button on the Vibe tab).
+ await eventually(async () => assert.equal(await page.evaluate(`!!document.querySelector('.ovs-dockyou-b, .ovs-dock-you')`), false, `${name}: no Dock row in You`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="here"]').click()`);
- if (width > 640) {
- // A computer opens the floating Dock beside the menu.
- await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
- await eventually(async () => 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 eventually(async () => assert.equal(await page.evaluate('window.went'), 'chats', `${name}: Messages opens the Dock's conversations`));
- await eventually(async () => assert.deepEqual((await page.evaluate(ROW)).filter((t) => t.chosen).map((t) => t.label), ['Chat'], `${name}: the chosen tab is still this site's`));
- }
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
- // On a phone, with the Dock able to lend its panels (OpenVibeDock.mountInto, 312 step 2): Messages and AI open
- // INSIDE the menu, under its own head, and become the chosen place; AI has a Vibe · Agents switch; Feed and
- // closing hand the panel back. A computer keeps the floating Dock.
+ // With the Dock able to lend its panels (OpenVibeDock.mountInto): Messages and AI open INSIDE the menu, under its
+ // own head, and become the chosen place; AI has a Chat · Code · Agents switch; Feed and closing hand the panel back.
+ // 4.220.0: on every screen (a computer opened a floating Dock; the owner, 11-12: "one unified UI").
await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, mountInto(host, o) { window.log.push('mount:' + o.tab); host.innerHTML = '<div class="stub"></div>';
await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, mountInto(host, o) { window.log.push('mount:' + o.tab); host.innerHTML = '<div class="stub"></div>';
return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } }`);
return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } }`);
await page.evaluate(OPEN(true)); await wait(350);
await page.evaluate(OPEN(true)); await wait(350);
@@ -97,7 +85,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -97,7 +85,7 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
sw: document.querySelector('.ovs-door-sw') && !document.querySelector('.ovs-door-sw').hidden ? Array.from(document.querySelectorAll('.ovs-door-sw [data-part]')).map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')).join(' ') : '',
sw: document.querySelector('.ovs-door-sw') && !document.querySelector('.ovs-door-sw').hidden ? Array.from(document.querySelectorAll('.ovs-door-sw [data-part]')).map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')).join(' ') : '',
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) {
+ {
await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));
await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));
await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
@@ -121,10 +109,6 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -121,10 +109,6 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));
await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));
- } else {
- await eventually(async () => assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`));
- await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`));
- await page.evaluate('OpenVibeSearch.close()'); await wait(300);
}
}
await page.evaluate('delete window.OpenVibeDock');
await page.evaluate('delete window.OpenVibeDock');
// Opened from a button: the sheet grows out of that button's centre, wherever the sheet itself was placed.
// Opened from a button: the sheet grows out of that button's centre, wherever the sheet itself was placed.
deleted file mode 100644
deleted file mode 100644
@@ -1,70 +0,0 @@
@@ -1,70 +0,0 @@
-'use strict';
-// The Dock's pill and a page's own composer at the bottom of a phone (2026-11-11: on openvibe.chat the "Needs you" pill
-// sat on the composer's send button). A real headless Chrome at 390×844: a chat-like page with a composer fixed to the
-// bottom, bottom.js and the Dock. The pill stands above the composer (its bottom edge at least 8 px above the box's top);
-// when the composer grows (a second line) the pill moves up with it; when the composer goes, the pill comes back down.
-// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const os = require('node:os');
-const path = require('node:path');
-const http = require('node:http');
-const h = require('../browser-harness');
-
-if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
- console.log('pill-composer-chrome: skipped (no Chrome; set CHROME_BIN)');
- process.exit(0);
-}
-const ROOT = path.join(__dirname, '..');
-const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Chat</title>
-<style>body{margin:0;background:#0b1120;color:#e6edf7;font:15px system-ui}main{padding:20px 16px 120px}
-form{position:fixed;left:12px;right:12px;bottom:12px;display:flex;gap:8px;padding:10px;border-radius:24px;background:#1b2436}
-textarea{flex:1;min-height:40px;resize:none;background:none;border:0;color:inherit;font:inherit}button{width:44px;height:44px;border-radius:50%}</style></head>
-<body><main><h1>Room</h1>${'<p>A message in the room.</p>'.repeat(30)}</main>
-<form data-ovt-composer class="ovt-composer"><textarea data-ovt-text rows="1"></textarea><button type="submit" id="send">↑</button></form>
-<script src="/bottom.js"></script><script src="/dock.js"></script>
-<script>OpenVibeDock.mount({ apiBase: location.origin, token: 'tok', user: { id: 7, username: 'ada', display_name: 'Ada' } });</script></body></html>`;
-const server = http.createServer((req, res) => {
- const u = new URL(req.url, 'http://x');
- if (u.pathname === '/bottom.js' || u.pathname === '/dock.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
- if (u.pathname === '/api/v1/me/dock') {
- res.writeHead(200, { 'content-type': 'application/json' });
- return res.end(JSON.stringify({ state: { open: false, tab: 'activity', pinned: true, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right' } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 1, unread: 0 }, sources_partial: false }));
- }
- res.writeHead(200, { 'content-type': 'text/html' });
- res.end(PAGE);
-});
-(async () => {
- await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
- const base = `http://127.0.0.1:${server.address().port}`;
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pill-composer-'));
- const browser = await h.launch({ tmpDir });
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
- try {
- const context = await browser.newContext();
- const pg = await h.openPage(browser, context, { width: 390, height: 844 });
- await pg.goto(`${base}/`, { maxMs: 5000 });
- let pill = false;
- for (let i = 0; i < 40 && !pill; i++) { await wait(100); pill = await pg.evaluate(`(() => { const p = document.querySelector('.ovdk-pill'); return !!p && p.getBoundingClientRect().height > 0; })()`); }
- if (!pill) { console.log('pill-composer-chrome: the Dock drew no pill on this page (nothing to check)'); return; }
- const gap = async () => pg.evaluate(`(() => { const p = document.querySelector('.ovdk-pill').getBoundingClientRect(); const f = document.querySelector('form').getBoundingClientRect(); return Math.round(f.top - p.bottom); })()`);
- await wait(400);
- const first = await gap();
- assert.ok(first >= 8, `the pill stands above the composer (gap ${first} px)`);
- // A taller composer (a second line): the pill moves up with it.
- await pg.evaluate(`document.querySelector('textarea').style.minHeight = '96px'`);
- await wait(500);
- assert.ok(await gap() >= 8, 'and stays above it when the box grows');
- // No composer: the pill comes back to its own place.
- const before = await pg.evaluate(`document.querySelector('.ovdk-pill').getBoundingClientRect().bottom`);
- await pg.evaluate(`document.querySelector('form').remove()`);
- await wait(500);
- const after = await pg.evaluate(`document.querySelector('.ovdk-pill').getBoundingClientRect().bottom`);
- assert.ok(after > before + 40, `without a composer it comes back down (${Math.round(before)} → ${Math.round(after)})`);
- console.log('pill-composer-chrome: the pill stands above a bottom composer, follows it as it grows, and comes back without one');
- } finally {
- await browser.close();
- server.close();
- fs.rmSync(tmpDir, { recursive: true, force: true });
- }
-})().catch((e) => { console.error(e); process.exit(1); });