openvibers/openvibe.shared Commits
The Dock: your agents and your activity at the edge of every page (4.51.0) (#197)
Changes
@@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.51.0 — 2026-10-30++++- **The Dock** (`dock.js`; the Dock plan of 2026-10-30, K-D1; master plan L13). The owner, 10-30: your agent and your++ chats "more accessible across sites … sleek, modern, minimal". It is one surface at the edge of every OpenVibe page.++ - **The pill:** the mark (busy while an agent works), a word and a dot (amber, with a ping, when something needs++ you). It shows only while an agent works, one needs you, or the person pinned it, in the corner the Dock sits in,++ above a phone's app bar.++ - **The Dock:** a floating card on a desktop and a sheet on a phone, with two tabs. **Agents** is what needs you,++ then what runs, with Approve and Decline in place. **Activity** is the bell's list.++ - **On a desktop:** it is not modal; the head (or a tab, after 6 px) drags it to a corner and the free corner++ resizes it.++ - **State:** it is the Network's, so it follows the person between sites:++ - `GET /api/v1/me/dock`, then one debounced `PUT` per burst of changes;++ - this site's copy paints it first;++ - an open Dock reopens on a desktop, never on a phone;++ - a 404 keeps it away for a day.++ - **Weight:** navbar.js grows by 0.6 KB brotli (the loader, the account menu's Dock tile, `openDock`; its budget++ is now 41 KB). The pill and the Dock are `dock.js`, 8.6 KB brotli, loaded for signed-in people only.++ - **Test:** `test/dock-chrome.test.js` covers when the pill shows, both tabs, the PUTs, drag and resize within the++ limits, restore on desktops only, the 404, signed out, and 320 px.++- **`OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts })`:** the Action Center's rows in++ another surface's container, with their own data and timer.++- **`OpenVibeNotifications.renderDock(container)`:** the bell's list without a frame, with Mark all read in its foot.++- **Desktop toasts** stack above the Dock's pill in the bottom-right corner (`--ov-dock-clear`).++## 4.50.1 — 2026-10-30- **The seasonal fall lands on the bar.** Once a day the mark's leaves (snow in winter) fell 120-280 px from the brand,@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.50.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.51.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.@@@ -526,6 +526,25 @@ 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 join in K-D2 and the agent's live screen in K-D3.++**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.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.50.1",++ "version": "4.51.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)",@@@ -3,6 +3,9 @@//// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()++// OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts }) → { refresh(), destroy() }++// the Dock's Agent tab (dock.js): the items of some buckets in a container of the Dock's, waiting first, the++// same rows and the same approve and decline, re-read every 15 s while something runs.//// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on// openvibe.network itself)@@@ -47,19 +50,19 @@function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };-- const apiBase = () => String((opts && opts.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');-- const crossOrigin = () => { try { return new URL(apiBase()).origin !== root.location.origin; } catch { return true; } };-- const tokenOf = () => { const t = opts && opts.token; return typeof t === 'function' ? t() : t; };-- function api(path, init = {}) {++ function apiWith(o, path, init = {}) {++ const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');++ let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };-- const t = tokenOf();++ const t = o && (typeof o.token === 'function' ? o.token() : o.token);if (t) headers.authorization = `Bearer ${t}`;-- return fetch(apiBase() + path, { ...init, headers, credentials: crossOrigin() ? 'omit' : 'include' }).then(async (r) => {++ return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {const body = await r.json().catch(() => ({}));if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }return body;});}++ const api = (path, init) => apiWith(opts, path, init);function ago(iso) {const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';const s = (Date.now() - t) / 1000;@@@ -145,6 +148,12 @@.ovac-acts button { width: 40px; height: 40px; }}html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }++ .ovac-mounted { display: flex; flex-direction: column; min-height: 0; color: var(--text-primary, #ececf1); }++ .ovac-mounted .ovac-list { padding: 4px 6px 10px; }++ .ovac-mounted svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .ovac-group { display: flex; align-items: center; gap: 8px; margin: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #8b8b96); }++ .ovac-group span { font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--text-secondary, #b6b6c0); }++ .ovac-group[data-bucket="waiting"] { color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); }@media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }`;document.head.appendChild(st);@@@ -275,14 +284,14 @@if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);}-- async function decide(item, what, btn) {++ async function decide(item, what, btn, call = api, reload = () => load({ quiet: true })) {if (!item || (what !== 'approve' && what !== 'decline')) return;item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });const old = item.querySelector('.ovac-err'); if (old) old.remove();try {-- await api(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });++ await call(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });btn.textContent = what === 'approve' ? 'Approved' : 'Declined';-- setTimeout(() => load({ quiet: true }), 600);++ setTimeout(reload, 600);} catch (e) {item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);@@@ -322,5 +331,53 @@}function toggle(o) { if (isOpen()) close(); else open(o); }-- root.OpenVibeActionCenter = { open, close, toggle, isOpen, _state: () => ({ data, tab }) };++ /**++ * A list of some buckets in someone else's container (the Dock's Agent tab): waiting first, then running, each++ * bucket under a small heading when there is more than one. Its own data, timer and errors; the popover's are++ * untouched. onCounts(counts) hears every answer, so the Dock's tab and pill can follow it.++ */++ function mount(container, o = {}) {++ styles();++ const mo = { ...o };++ const want = Array.isArray(mo.buckets) && mo.buckets.length ? mo.buckets.filter((b) => SHORT[b]) : ['waiting', 'running'];++ const call = (path, init) => apiWith(mo, path, init);++ let doc = null, t = 0, gone = false, busy = false;++ container.classList.add('ovac-mounted');++ container.innerHTML = '<div class="ovac-list" aria-live="polite"><div class="ovac-wait"><span></span></div></div>';++ const list = container.firstChild;++ const draw = () => {++ if (gone) return;++ if (doc && doc.error) {++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${doc.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${doc.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;++ return;++ }++ if (!doc) return;++ const groups = want.map((b) => [b, (doc.buckets && doc.buckets[b]) || []]).filter(([, items]) => items.length);++ if (!groups.length) {++ const [h, p] = mo.empty || ['Nothing is running', 'When an agent works for you, it shows here with its steps, and anything it asks you shows first.'];++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(h)}</b>${esc(p)}</div>`;++ return;++ }++ list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')++ + items.map(itemHTML).join('')).join('');++ };++ const refresh = async () => {++ if (gone || busy) return;++ busy = true; clearTimeout(t);++ try { doc = await call('/api/v1/me/actions'); } catch (e) { doc = { error: e }; }++ busy = false;++ if (gone) return;++ draw();++ if (doc && !doc.error && typeof mo.onCounts === 'function') { try { mo.onCounts(doc.counts || {}); } catch { /* */ } }++ if (doc && !doc.error && (doc.counts.running || 0) > 0) t = setTimeout(refresh, REFRESH_MS);++ };++ list.addEventListener('click', (e) => {++ const b = e.target.closest('button[data-do]'); if (!b) return;++ decide(b.closest('.ovac-item'), b.dataset.do, b, call, refresh);++ });++ refresh();++ return { refresh, destroy() { gone = true; clearTimeout(t); container.classList.remove('ovac-mounted'); container.innerHTML = ''; } };++ }++++ root.OpenVibeActionCenter = { open, close, toggle, isOpen, mount, _state: () => ({ data, tab }) };})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,484 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Dock (Dock plan 2026-10-30, K-D1): your agents and your activity at the edge of every OpenVibe page++//++// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:++// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)++// OpenVibeDock.open({ tab }) · close() · isOpen()++//++// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the++// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and++// re-reads every 30 s while something runs and when the page comes back. A 404 (Network without the route yet) keeps++// { off } for a day, and navbar.js does not load this file until then.++//++// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it++// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the++// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.++// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place) and Activity++// (notification-ui.js renderDock(): the bell's list). Chats join in K-D2, the agent's live screen in K-D3.++// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the++// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the++// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDock) return;++++ const W = [320, 720], H = [360, 900], DEF = { width: 400, height: 560 };++ 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 G = {++ 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"/>',++ pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',++ 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"/>',++ };++ const TABS = [['agent', 'Agents', G.spark], ['activity', 'Activity', G.bell]];++ const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };++++ let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;++ const mounts = {};++++ const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };++ const reduced = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; } catch { return false; } };++ 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}`);++++ function call(path, init = {}) {++ const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');++ const t = typeof o.token === 'function' ? o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));++ }++ const notify = () => { store(doc); paintPill(); };++++ /** 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 };++ doc = { ...(doc || {}), state: next, counts: counts() };++ pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };++ notify();++ clearTimeout(saveT);++ saveT = setTimeout(flush, 700);++ }++ async function flush() {++ const body = pending; pending = {};++ 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(); }++ } catch { /* offline, or no route yet: the copy stands and the next change tries again */ }++ }++++ function load(src, test) {++ return new Promise((ok) => {++ if (test()) return ok(true);++ const id = `ov-dock-${src.replace(/\W/g, '')}`;++ let sc = document.getElementById(id);++ if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src); sc.async = true; document.head.appendChild(sc); }++ sc.addEventListener('load', () => ok(test()));++ sc.addEventListener('error', () => ok(false));++ });++ }++++ function styles() {++ if (document.getElementById('ovdk-styles')) return;++ 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: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 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; } }++ @media (prefers-reduced-motion: reduce) { .ovdk-pill, .ovdk-m::before { animation: none !important; } }++ html[data-ov-motion="reduced"] .ovdk-pill, html[data-ov-motion="reduced"] .ovdk-m::before { animation: none; }++ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 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%); } }++ .ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .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; }++ .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: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); box-shadow: 0 1px 3px rgba(0,0,0,.25); }++ .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; }++ .ovdk-acts [data-act="pin"][aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-acts [data-act="pin"][aria-pressed="true"] svg { fill: color-mix(in srgb, currentColor 22%, transparent); }++ .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)); }++ .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; }++ .ovdk-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12.5px; }++ .ovdk-foot a, .ovdk-foot button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovdk-foot button { color: var(--text-secondary, #b6b6c0); }++ .ovdk-foot button:hover { color: var(--text-primary, #ececf1); }++ .ovdk-foot svg { width: 15px; height: 15px; }++ .ovdk-wait { display: flex; justify-content: center; padding: 36px; }++ .ovdk-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovdk-spin .8s linear infinite; }++ @keyframes ovdk-spin { to { transform: rotate(360deg); } }++ .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:hover .ovdk-size::after, .ovdk-size:active::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; } }++ @media (max-width: 640px) {++ .ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;++ border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }++ .ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }++ .ovdk-head { padding-top: 22px; cursor: default; }++ .ovdk-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }++ .ovdk-size { display: none; }++ .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }++ }++ html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }++ @media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }++ html[data-ov-motion="reduced"] .ovdk.is-in { animation: none; }++ `;++ document.head.appendChild(st);++ }++++ 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="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button>++ <button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</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);++++ 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());++ el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });++ el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });++ document.addEventListener('click', (e) => { if (isOpen() && phone() && !el.contains(e.target) && !(pill && pill.contains(e.target))) close(); });++ root.addEventListener('resize', () => { if (isOpen()) layout(); });++ bindMove();++ bindSize();++ }++++ // ── 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')) 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);++ }++++ /** 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);++ 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-pressed', String(on));++ const t = on ? "The Dock's button stays on every page" : "Keep the Dock's button on every page";++ b.title = t; b.setAttribute('aria-label', t);++ }++ function setTab(id, remember) {++ tab = TABS.some(([t]) => t === id) ? id : 'activity';++ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });++ if (remember && state().tab !== tab) save({ tab });++ fill(tab);++ }++++ async function fill(id) {++ const panel = el.querySelector(`[data-panel="${id}"]`);++ if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }++ mounts[id] = { refresh: null };++ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';++ if (id === 'agent') {++ const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));++ if (!ok) return failed(panel, id);++ panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ const m = root.OpenVibeActionCenter.mount(panel.firstChild, {++ token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],++ onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },++ });++ mounts[id] = m;++ panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {++ const anchor = e.currentTarget;++ close();++ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }++ });++ return;++ }++ const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));++ if (!ok) return failed(panel, id);++ const N = root.OpenVibeNotifications;++ if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }++ panel.innerHTML = '';++ N.renderDock(panel);++ mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };++ }++ 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>`;++ }++++ // ── The pill ─────────────────────────────────────────────++ let pill = null, readT = 0, bound = 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;++ const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;++ const corner = place().corner;++ const show = !isOpen() && !!(w || r || st.pinned);++ pill.hidden = !show;++ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';++ pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';++ pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');++ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : r ? (r > 1 ? `${r} working` : 'Working') : '';++ pill.setAttribute('aria-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);++ }++ function read() {++ clearTimeout(readT);++ if (!pill || document.hidden) return;++ const t = typeof o.token === 'function' ? o.token() : o.token;++ if (!t) return;++ fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })++ .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })++ .then((next) => {++ if (!next || !next.state || !pill) return;++ update(next);++ if (!isOpen() && next.state.open && !phone()) open();++ const c = next.counts || {};++ if (c.running || c.waiting) 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). */++ function mount(opts) {++ if (!opts) {++ clearTimeout(readT);++ if (pill) { pill.remove(); pill = null; }++ if (isOpen()) close();++ doc = 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', () => open());++ document.body.appendChild(pill);++ paintPill();++ }++ if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }++ read();++ }++++ // ── Open and close ───────────────────────────────────────++ function isOpen() { return !!(el && !el.hidden); }++ function open(opts = {}) {++ o = { ...o, ...opts };++ if (opts.doc) doc = opts.doc;++ if (!el) build();++ lastFocus = document.activeElement;++ const c = counts();++ const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');++ layout();++ badges();++ paintPin();++ el.hidden = false;++ el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');++ if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');++ setTab(want, false);++ if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();++ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++ function close() {++ if (!isOpen()) return;++ const inside = el.contains(document.activeElement);++ el.hidden = true;++ el.style.transform = '';++ el.removeAttribute('aria-modal');++ document.documentElement.classList.remove('ovdk-sheet-open');++ clear(0);++ save({ open: false });++ if (inside) {++ const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);++ try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }++ }++ }++ /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */++ function update(next) {++ if (!next || !next.state) return;++ doc = Object.keys(pending).length ? { ...next, state: state() } : next;++ store(doc);++ paintPill();++ if (!el) return;++ badges();++ paintPin();++ if (isOpen()) layout();++ }++++ root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -21,6 +21,7 @@ const BROWSER = Object.freeze(['notification-ui.js', // bell + notification panel'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click++ 'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person'account-switcher.js','user-card.js','ov-mark.js', // the OV brand mark drop-in@@@ -987,6 +987,27 @@const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = sibling('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 after the bell, from beside this file. A Network without the route yet (dock.js++ // keeps { off } for a day) is not asked again. dockApiBase overrides the Network (tests, staging).++ const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');++ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), 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>';++ function loadDock(then) {++ if (root.OpenVibeDock) return then();++ let sc = document.getElementById('ov-dock-loader');++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = sibling('dock.js'); sc.async = true; document.head.appendChild(sc); }++ sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));++ }++ function mountDock(u) {++ const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;++ if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);++ try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }++ loadDock(() => root.OpenVibeDock.mount(dockOpts()));++ }++ /** Open the Dock on a tab: 'agent' | 'activity'. */++ function openDock(tab) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open({ tab }); }); }++/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */function sectionsHTML(sections) {return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>@@@ -1667,7 +1688,7 @@queueIcons();countView();// Pages that wire the bell themselves run right after init(); give them the first go.-- setTimeout(() => mountBell(nav, u), 0);++ setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);// Dropdownif (u) {@@@ -1697,6 +1718,7 @@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,].filter(Boolean));const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);@@@ -1736,6 +1758,7 @@dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });nav.appendChild(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);@@@ -2071,6 +2094,9 @@setToken(token) { _config.token = token; },++ /** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */++ openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },++/** Get the bell mount point for OpenVibeNotifications. */getBellMount() {return _navEl?.querySelector('#openvibe-bell-mount') || null;@@@ -8,6 +8,7 @@// 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)@@@ -119,7 +120,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: max(16px, env(safe-area-inset-bottom, 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)) + var(--ov-dock-clear, 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; }@@@ -188,7 +189,15 @@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: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }.openvibe-notif-panel.open { display: flex; }-- .openvibe-notif-panel, .openvibe-notif-inbox { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }++ .openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }++ .openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }++ .openvibe-notif-dock .openvibe-nv-head { display: none; }++ .openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }++ .openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }++ .openvibe-notif-dock .openvibe-nv-foot [data-role="status"] { margin-right: auto; }++ .ovn-foot-read { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; color: var(--text-secondary, #b6b6c0); cursor: pointer; }++ .ovn-foot-read:hover { color: var(--text-primary, #ececf1); }++ .ovn-foot-read svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }.openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }@@@ -832,7 +841,7 @@</div></div><div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>-- <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;++ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'dock' ? `<button type="button" class="ovn-foot-read" data-act="read-all-foot">${svg(ICON.checkAll)}Mark all read</button>` : ''}${mode === 'panel' || mode === 'dock' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;const list = root.querySelector('[data-role="list"]');const input = root.querySelector('input[type="search"]');let qTimer = null;@@@ -865,6 +874,7 @@const unreadBtn = root.querySelector('.openvibe-nv-unread');unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());++ const footRead = root.querySelector('[data-act="read-all-foot"]'); if (footRead) footRead.addEventListener('click', () => markAllRead());root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));const closeBtn = root.querySelector('[data-act="close"]');if (closeBtn) closeBtn.addEventListener('click', () => closePanel());@@@ -1387,6 +1397,15 @@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;++ },/** Programmatically show a toast (real-time events). Marks nothing on the server. */push(notification) {showToast(notification);@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.50.1",++ "version": "4.51.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -43,6 +43,7 @@"./navbar": "./navbar.js","./notification-live": "./notification-live.js","./action-center": "./action-center.js",++ "./dock": "./dock.js","./notification-ui": "./notification-ui.js","./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js",nnew file mode 100644@@@ -0,0 +1,232 @@++'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.++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('dock-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const iso = (ago) => new Date(Date.now() - ago).toISOString();++const item = (o) => ({ actions: [], cost_usd: null, steps: null, kind: 'task', error: null, next_run_at: null, updated_at: iso(60e3), ...o });++const ACTIONS = {++ generated_at: iso(2000), sources: { actor: 'ok', codes: 'ok' },++ counts: { running: 2, waiting: 1, scheduled: 0, done: 0, failed: 0 },++ buckets: {++ waiting: [item({ id: 'apr_1', service: 'actor', agent: 'Researcher', bucket: 'waiting', state: 'waiting', title: 'Send the weekly digest?', summary: 'It wants to send from your address.', href: 'https://openvibe.actor/tasks/t1', actions: ['approve', 'decline'] })],++ running: [++ item({ id: 'tsk_1', service: 'codes', agent: 'Codex', bucket: 'running', state: 'running', title: 'Fix the flaky upload test', summary: 'Step 14: the suite.', href: 'https://openvibe.codes/run/tasks/tsk_1', steps: 14 }),++ item({ id: 't2', service: 'actor', agent: 'Actor', bucket: 'running', state: 'running', title: 'Compare three VPS offers', summary: 'Reading the providers.', href: 'https://openvibe.actor/tasks/t2', steps: 6 }),++ ],++ scheduled: [], done: [], failed: [],++ },++};++const NOTES = [++ { id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) },++ { id: 2, type: 'mention', category: 'social', service: 'chat', title: 'kai mentioned you', message: 'look at the shots?', priority: 'normal', is_read: 0, created_at: iso(900e3) },++];++++// The stand-in's state for the scenario under way.++let scenario = {};++const puts = [], decided = [];++const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });++++const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">++<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>++<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>++<script src="/navbar.js"></script>++<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin,++ ${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}++ links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ if (u.pathname === '/page') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE({ signedIn: u.searchParams.get('in') !== '0' })); }++ if (u.pathname === '/api/v1/me/dock') {++ if (scenario.missing) return json(404, { code: 'not_found' });++ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the Dock reads with the person\'s token');++ if (req.method === 'PUT') { const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); puts.push(b); scenario.state = { ...(scenario.state || defaultState()), ...b, place: { ...((scenario.state || defaultState()).place), ...(b.place || {}) } }; }++ return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });++ }++ if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);++ const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);++ if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }++ if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });++ if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });++ if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });++ const name = u.pathname.slice(1);++ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }++ res.writeHead(404); res.end();++ });++});++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++async function until(p, expr, what, ms = 5000) {++ const end = Date.now() + ms;++ for (;;) {++ const v = await p.evaluate(expr);++ if (v) return v;++ if (Date.now() > end) throw new Error(`timed out: ${what}`);++ 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: [...c.querySelectorAll('.ovac-group')].map((g) => g.dataset.bucket), items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;++++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));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dock-'));++ const browser = await h.launch({ tmpDir });++ const fresh = async (width, height = 860) => { const context = await browser.newContext(); const p = await h.openPage(browser, context, { width, height }); return p; };++ try {++ // ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 3 } };++ const p = await fresh(390);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await wait(700);++ 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 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');++ await p.close();++ }++++ // ── A phone: the pill says what matters, the sheet opens on Agents, 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');++ assert.ok(card.w === 390 && card.t > 60, 'a sheet across the phone, under the bar');++ assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open');++ 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');++ 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)})`);++ 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);++ assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked');++ await p.close();++ }++++ // ── 320 px: nothing runs off the screen, closed or open. ──++ {++ 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');++ 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', 400, 560], 'a 400 × 560 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();++ }++++ // ── Network without the route yet (404): no pill, 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);++ assert.strictEqual(await p.evaluate(PILL), null, 'no pill 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);++ assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');++ await p.close();++ }++ console.log('dock-chrome: the pill only when it matters, Agents and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 40 * 1024; // 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)++const NAVBAR_BROTLI_BUDGET = 41 * 1024; // 40.4 KB with the Dock's loader, its account-menu tile and openDock (4.51.0; the pill and the Dock are dock.js, loaded for signed-in people only); 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)// ── size budget and generated block ──────────────────────────{
@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,31 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.51.0 — 2026-10-30
+
+- **The Dock** (`dock.js`; the Dock plan of 2026-10-30, K-D1; master plan L13). The owner, 10-30: your agent and your
+ chats "more accessible across sites … sleek, modern, minimal". It is one surface at the edge of every OpenVibe page.
+ - **The pill:** the mark (busy while an agent works), a word and a dot (amber, with a ping, when something needs
+ you). It shows only while an agent works, one needs you, or the person pinned it, in the corner the Dock sits in,
+ above a phone's app bar.
+ - **The Dock:** a floating card on a desktop and a sheet on a phone, with two tabs. **Agents** is what needs you,
+ then what runs, with Approve and Decline in place. **Activity** is the bell's list.
+ - **On a desktop:** it is not modal; the head (or a tab, after 6 px) drags it to a corner and the free corner
+ resizes it.
+ - **State:** it is the Network's, so it follows the person between sites:
+ - `GET /api/v1/me/dock`, then one debounced `PUT` per burst of changes;
+ - this site's copy paints it first;
+ - an open Dock reopens on a desktop, never on a phone;
+ - a 404 keeps it away for a day.
+ - **Weight:** navbar.js grows by 0.6 KB brotli (the loader, the account menu's Dock tile, `openDock`; its budget
+ is now 41 KB). The pill and the Dock are `dock.js`, 8.6 KB brotli, loaded for signed-in people only.
+ - **Test:** `test/dock-chrome.test.js` covers when the pill shows, both tabs, the PUTs, drag and resize within the
+ limits, restore on desktops only, the 404, signed out, and 320 px.
+- **`OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts })`:** the Action Center's rows in
+ another surface's container, with their own data and timer.
+- **`OpenVibeNotifications.renderDock(container)`:** the bell's list without a frame, with Mark all read in its foot.
+- **Desktop toasts** stack above the Dock's pill in the bottom-right corner (`--ov-dock-clear`).
+
## 4.50.1 — 2026-10-30
## 4.50.1 — 2026-10-30
- **The seasonal fall lands on the bar.** Once a day the mark's leaves (snow in winter) fell 120-280 px from the brand,
- **The seasonal fall lands on the bar.** Once a day the mark's leaves (snow in winter) fell 120-280 px from the brand,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.50.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.51.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.
@@ -526,6 +526,25 @@ there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Netwo
@@ -526,6 +526,25 @@ 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 join in K-D2 and the agent's live screen in K-D3.
+
**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.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.50.1",
+ "version": "4.51.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)",
@@ -3,6 +3,9 @@
@@ -3,6 +3,9 @@
//
//
// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.
// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.
// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()
// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()
+// OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts }) → { refresh(), destroy() }
+// the Dock's Agent tab (dock.js): the items of some buckets in a container of the Dock's, waiting first, the
+// same rows and the same approve and decline, re-read every 15 s while something runs.
//
//
// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on
// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on
// openvibe.network itself)
// openvibe.network itself)
@@ -47,19 +50,19 @@
@@ -47,19 +50,19 @@
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
- const apiBase = () => String((opts && opts.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
- const crossOrigin = () => { try { return new URL(apiBase()).origin !== root.location.origin; } catch { return true; } };
- const tokenOf = () => { const t = opts && opts.token; return typeof t === 'function' ? t() : t; };
- function api(path, init = {}) {
+ function apiWith(o, path, init = {}) {
+ const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
+ let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
- const t = tokenOf();
+ const t = o && (typeof o.token === 'function' ? o.token() : o.token);
if (t) headers.authorization = `Bearer ${t}`;
if (t) headers.authorization = `Bearer ${t}`;
- return fetch(apiBase() + path, { ...init, headers, credentials: crossOrigin() ? 'omit' : 'include' }).then(async (r) => {
+ return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {
const body = await r.json().catch(() => ({}));
const body = await r.json().catch(() => ({}));
if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
return body;
return body;
});
});
}
}
+ const api = (path, init) => apiWith(opts, path, init);
function ago(iso) {
function ago(iso) {
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
const s = (Date.now() - t) / 1000;
const s = (Date.now() - t) / 1000;
@@ -145,6 +148,12 @@
@@ -145,6 +148,12 @@
.ovac-acts button { width: 40px; height: 40px; }
.ovac-acts button { width: 40px; height: 40px; }
}
}
html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }
html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }
+ .ovac-mounted { display: flex; flex-direction: column; min-height: 0; color: var(--text-primary, #ececf1); }
+ .ovac-mounted .ovac-list { padding: 4px 6px 10px; }
+ .ovac-mounted svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .ovac-group { display: flex; align-items: center; gap: 8px; margin: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #8b8b96); }
+ .ovac-group span { font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--text-secondary, #b6b6c0); }
+ .ovac-group[data-bucket="waiting"] { color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); }
@media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
@@ -275,14 +284,14 @@
@@ -275,14 +284,14 @@
if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);
if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);
}
}
- async function decide(item, what, btn) {
+ async function decide(item, what, btn, call = api, reload = () => load({ quiet: true })) {
if (!item || (what !== 'approve' && what !== 'decline')) return;
if (!item || (what !== 'approve' && what !== 'decline')) return;
item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });
item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });
const old = item.querySelector('.ovac-err'); if (old) old.remove();
const old = item.querySelector('.ovac-err'); if (old) old.remove();
try {
try {
- await api(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });
+ await call(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });
btn.textContent = what === 'approve' ? 'Approved' : 'Declined';
btn.textContent = what === 'approve' ? 'Approved' : 'Declined';
- setTimeout(() => load({ quiet: true }), 600);
+ setTimeout(reload, 600);
} catch (e) {
} catch (e) {
item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });
item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });
item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);
item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);
@@ -322,5 +331,53 @@
@@ -322,5 +331,53 @@
}
}
function toggle(o) { if (isOpen()) close(); else open(o); }
function toggle(o) { if (isOpen()) close(); else open(o); }
- root.OpenVibeActionCenter = { open, close, toggle, isOpen, _state: () => ({ data, tab }) };
+ /**
+ * A list of some buckets in someone else's container (the Dock's Agent tab): waiting first, then running, each
+ * bucket under a small heading when there is more than one. Its own data, timer and errors; the popover's are
+ * untouched. onCounts(counts) hears every answer, so the Dock's tab and pill can follow it.
+ */
+ function mount(container, o = {}) {
+ styles();
+ const mo = { ...o };
+ const want = Array.isArray(mo.buckets) && mo.buckets.length ? mo.buckets.filter((b) => SHORT[b]) : ['waiting', 'running'];
+ const call = (path, init) => apiWith(mo, path, init);
+ let doc = null, t = 0, gone = false, busy = false;
+ container.classList.add('ovac-mounted');
+ container.innerHTML = '<div class="ovac-list" aria-live="polite"><div class="ovac-wait"><span></span></div></div>';
+ const list = container.firstChild;
+ const draw = () => {
+ if (gone) return;
+ if (doc && doc.error) {
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${doc.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${doc.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
+ return;
+ }
+ if (!doc) return;
+ const groups = want.map((b) => [b, (doc.buckets && doc.buckets[b]) || []]).filter(([, items]) => items.length);
+ if (!groups.length) {
+ const [h, p] = mo.empty || ['Nothing is running', 'When an agent works for you, it shows here with its steps, and anything it asks you shows first.'];
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(h)}</b>${esc(p)}</div>`;
+ return;
+ }
+ list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
+ + items.map(itemHTML).join('')).join('');
+ };
+ const refresh = async () => {
+ if (gone || busy) return;
+ busy = true; clearTimeout(t);
+ try { doc = await call('/api/v1/me/actions'); } catch (e) { doc = { error: e }; }
+ busy = false;
+ if (gone) return;
+ draw();
+ if (doc && !doc.error && typeof mo.onCounts === 'function') { try { mo.onCounts(doc.counts || {}); } catch { /* */ } }
+ if (doc && !doc.error && (doc.counts.running || 0) > 0) t = setTimeout(refresh, REFRESH_MS);
+ };
+ list.addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-do]'); if (!b) return;
+ decide(b.closest('.ovac-item'), b.dataset.do, b, call, refresh);
+ });
+ refresh();
+ return { refresh, destroy() { gone = true; clearTimeout(t); container.classList.remove('ovac-mounted'); container.innerHTML = ''; } };
+ }
+
+ root.OpenVibeActionCenter = { open, close, toggle, isOpen, mount, _state: () => ({ data, tab }) };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,484 @@
@@ -0,0 +1,484 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Dock (Dock plan 2026-10-30, K-D1): your agents and your activity at the edge of every OpenVibe page
+//
+// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:
+// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)
+// OpenVibeDock.open({ tab }) · close() · isOpen()
+//
+// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the
+// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and
+// re-reads every 30 s while something runs and when the page comes back. A 404 (Network without the route yet) keeps
+// { off } for a day, and navbar.js does not load this file until then.
+//
+// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
+// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
+// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
+// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place) and Activity
+// (notification-ui.js renderDock(): the bell's list). Chats join in K-D2, the agent's live screen in K-D3.
+// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
+// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
+// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDock) return;
+
+ const W = [320, 720], H = [360, 900], DEF = { width: 400, height: 560 };
+ 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 G = {
+ 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"/>',
+ pin: '<path d="M14.5 3.5l6 6-3 1.5-3.5 3.5.5 4-1.5 1.5-4-4-4.5 4.5M9 15l-4-4 1.5-1.5 4 .5L14 6.5z"/>',
+ 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"/>',
+ };
+ const TABS = [['agent', 'Agents', G.spark], ['activity', 'Activity', G.bell]];
+ const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
+
+ let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
+ const mounts = {};
+
+ const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
+ const reduced = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; } catch { return false; } };
+ 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}`);
+
+ function call(path, init = {}) {
+ const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));
+ }
+ const notify = () => { store(doc); paintPill(); };
+
+ /** 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 };
+ doc = { ...(doc || {}), state: next, counts: counts() };
+ pending = { ...pending, ...patch, ...(patch.place ? { place: { ...(pending.place || {}), ...patch.place } } : {}) };
+ notify();
+ clearTimeout(saveT);
+ saveT = setTimeout(flush, 700);
+ }
+ async function flush() {
+ const body = pending; pending = {};
+ 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(); }
+ } catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
+ }
+
+ function load(src, test) {
+ return new Promise((ok) => {
+ if (test()) return ok(true);
+ const id = `ov-dock-${src.replace(/\W/g, '')}`;
+ let sc = document.getElementById(id);
+ if (!sc) { sc = document.createElement('script'); sc.id = id; sc.src = sibling(src); sc.async = true; document.head.appendChild(sc); }
+ sc.addEventListener('load', () => ok(test()));
+ sc.addEventListener('error', () => ok(false));
+ });
+ }
+
+ function styles() {
+ if (document.getElementById('ovdk-styles')) return;
+ 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: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 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; } }
+ @media (prefers-reduced-motion: reduce) { .ovdk-pill, .ovdk-m::before { animation: none !important; } }
+ html[data-ov-motion="reduced"] .ovdk-pill, html[data-ov-motion="reduced"] .ovdk-m::before { animation: none; }
+ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 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%); } }
+ .ovdk svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .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; }
+ .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: var(--bg-primary, #12141b); color: var(--text-primary, #ececf1); box-shadow: 0 1px 3px rgba(0,0,0,.25); }
+ .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; }
+ .ovdk-acts [data-act="pin"][aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-acts [data-act="pin"][aria-pressed="true"] svg { fill: color-mix(in srgb, currentColor 22%, transparent); }
+ .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)); }
+ .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; }
+ .ovdk-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12.5px; }
+ .ovdk-foot a, .ovdk-foot button { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovdk-foot button { color: var(--text-secondary, #b6b6c0); }
+ .ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
+ .ovdk-foot svg { width: 15px; height: 15px; }
+ .ovdk-wait { display: flex; justify-content: center; padding: 36px; }
+ .ovdk-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovdk-spin .8s linear infinite; }
+ @keyframes ovdk-spin { to { transform: rotate(360deg); } }
+ .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:hover .ovdk-size::after, .ovdk-size:active::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; } }
+ @media (max-width: 640px) {
+ .ovdk, .ovdk[data-corner] { left: 0; right: 0; top: auto; bottom: 0; width: auto !important; height: min(86vh, 720px) !important; height: min(86dvh, 720px) !important; max-width: none; max-height: none;
+ border-radius: 24px 24px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); transform-origin: 50% 100%; }
+ .ovdk.is-in { animation-name: ovdk-rise; animation-duration: .3s; }
+ .ovdk-head { padding-top: 22px; cursor: default; }
+ .ovdk-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }
+ .ovdk-size { display: none; }
+ .ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
+ }
+ html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
+ @media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
+ html[data-ov-motion="reduced"] .ovdk.is-in { animation: none; }
+ `;
+ document.head.appendChild(st);
+ }
+
+ 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="pin" aria-pressed="false" title="Keep the Dock's button on every page" aria-label="Keep the Dock's button on every page">${svg(G.pin)}</button>
+ <button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</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);
+
+ 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());
+ el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
+ el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
+ document.addEventListener('click', (e) => { if (isOpen() && phone() && !el.contains(e.target) && !(pill && pill.contains(e.target))) close(); });
+ root.addEventListener('resize', () => { if (isOpen()) layout(); });
+ bindMove();
+ bindSize();
+ }
+
+ // ── 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')) 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);
+ }
+
+ /** 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);
+ 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-pressed', String(on));
+ const t = on ? "The Dock's button stays on every page" : "Keep the Dock's button on every page";
+ b.title = t; b.setAttribute('aria-label', t);
+ }
+ function setTab(id, remember) {
+ tab = TABS.some(([t]) => t === id) ? id : 'activity';
+ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
+ if (remember && state().tab !== tab) save({ tab });
+ fill(tab);
+ }
+
+ async function fill(id) {
+ const panel = el.querySelector(`[data-panel="${id}"]`);
+ if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); return; }
+ mounts[id] = { refresh: null };
+ panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
+ if (id === 'agent') {
+ const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
+ if (!ok) return failed(panel, id);
+ panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
+ token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],
+ onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
+ });
+ mounts[id] = m;
+ panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
+ const anchor = e.currentTarget;
+ close();
+ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
+ });
+ return;
+ }
+ const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
+ if (!ok) return failed(panel, id);
+ const N = root.OpenVibeNotifications;
+ if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }
+ panel.innerHTML = '';
+ N.renderDock(panel);
+ mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
+ }
+ 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>`;
+ }
+
+ // ── The pill ─────────────────────────────────────────────
+ let pill = null, readT = 0, bound = 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;
+ const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
+ const corner = place().corner;
+ const show = !isOpen() && !!(w || r || st.pinned);
+ pill.hidden = !show;
+ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
+ pill.dataset.tone = w ? 'waiting' : r ? 'running' : 'idle';
+ pill.querySelector('.ov-mark').setAttribute('data-state', r && !w ? 'busy' : '');
+ pill.querySelector('.ovdk-t').textContent = w ? 'Needs you' : r ? (r > 1 ? `${r} working` : 'Working') : '';
+ pill.setAttribute('aria-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);
+ }
+ function read() {
+ clearTimeout(readT);
+ if (!pill || document.hidden) return;
+ const t = typeof o.token === 'function' ? o.token() : o.token;
+ if (!t) return;
+ fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })
+ .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })
+ .then((next) => {
+ if (!next || !next.state || !pill) return;
+ update(next);
+ if (!isOpen() && next.state.open && !phone()) open();
+ const c = next.counts || {};
+ if (c.running || c.waiting) 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). */
+ function mount(opts) {
+ if (!opts) {
+ clearTimeout(readT);
+ if (pill) { pill.remove(); pill = null; }
+ if (isOpen()) close();
+ doc = 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', () => open());
+ document.body.appendChild(pill);
+ paintPill();
+ }
+ if (!bound) { bound = true; document.addEventListener('visibilitychange', () => { if (!document.hidden) read(); }); }
+ read();
+ }
+
+ // ── Open and close ───────────────────────────────────────
+ function isOpen() { return !!(el && !el.hidden); }
+ function open(opts = {}) {
+ o = { ...o, ...opts };
+ if (opts.doc) doc = opts.doc;
+ if (!el) build();
+ lastFocus = document.activeElement;
+ const c = counts();
+ const want = opts.tab || (c.waiting || c.running ? 'agent' : state().tab || 'activity');
+ layout();
+ badges();
+ paintPin();
+ el.hidden = false;
+ el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
+ if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
+ setTab(want, false);
+ if (!state().open || state().tab !== tab) save({ open: true, tab }); else notify();
+ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]').focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+ function close() {
+ if (!isOpen()) return;
+ const inside = el.contains(document.activeElement);
+ el.hidden = true;
+ el.style.transform = '';
+ el.removeAttribute('aria-modal');
+ document.documentElement.classList.remove('ovdk-sheet-open');
+ clear(0);
+ save({ open: false });
+ if (inside) {
+ const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
+ try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
+ }
+ }
+ /** A fresh copy from the Network (navbar.js reads it while something runs): counts and anything changed elsewhere. */
+ function update(next) {
+ if (!next || !next.state) return;
+ doc = Object.keys(pending).length ? { ...next, state: state() } : next;
+ store(doc);
+ paintPill();
+ if (!el) return;
+ badges();
+ paintPin();
+ if (isOpen()) layout();
+ }
+
+ root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -21,6 +21,7 @@ const BROWSER = Object.freeze([
@@ -21,6 +21,7 @@ const BROWSER = Object.freeze([
'notification-ui.js', // bell + notification panel
'notification-ui.js', // bell + notification panel
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
+ 'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
@@ -987,6 +987,27 @@
@@ -987,6 +987,27 @@
const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = sibling('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 = sibling('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 after the bell, from beside this file. A Network without the route yet (dock.js
+ // keeps { off } for a day) is not asked again. dockApiBase overrides the Network (tests, staging).
+ const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
+ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), 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>';
+ function loadDock(then) {
+ if (root.OpenVibeDock) return then();
+ let sc = document.getElementById('ov-dock-loader');
+ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = sibling('dock.js'); sc.async = true; document.head.appendChild(sc); }
+ sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0));
+ }
+ function mountDock(u) {
+ const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token;
+ if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null);
+ try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; }
+ loadDock(() => root.OpenVibeDock.mount(dockOpts()));
+ }
+ /** Open the Dock on a tab: 'agent' | 'activity'. */
+ function openDock(tab) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open({ tab }); }); }
+
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
function sectionsHTML(sections) {
function sectionsHTML(sections) {
return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>
return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>
@@ -1667,7 +1688,7 @@
@@ -1667,7 +1688,7 @@
queueIcons();
queueIcons();
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
- setTimeout(() => mountBell(nav, u), 0);
+ setTimeout(() => { mountBell(nav, u); mountDock(u); }, 0);
// Dropdown
// Dropdown
if (u) {
if (u) {
@@ -1697,6 +1718,7 @@
@@ -1697,6 +1718,7 @@
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,
].filter(Boolean));
].filter(Boolean));
const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
@@ -1736,6 +1758,7 @@
@@ -1736,6 +1758,7 @@
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
nav.appendChild(dropdown);
nav.appendChild(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);
@@ -2071,6 +2094,9 @@
@@ -2071,6 +2094,9 @@
setToken(token) { _config.token = token; },
setToken(token) { _config.token = token; },
+ /** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */
+ openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
+
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
getBellMount() {
return _navEl?.querySelector('#openvibe-bell-mount') || null;
return _navEl?.querySelector('#openvibe-bell-mount') || null;
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
// 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)
@@ -119,7 +120,7 @@
@@ -119,7 +120,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: max(16px, env(safe-area-inset-bottom, 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)) + var(--ov-dock-clear, 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; }
@@ -188,7 +189,15 @@
@@ -188,7 +189,15 @@
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
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: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }
border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); animation: ovn-panel-in .2s cubic-bezier(.2,.8,.2,1); font-family: inherit; }
.openvibe-notif-panel.open { display: flex; }
.openvibe-notif-panel.open { display: flex; }
- .openvibe-notif-panel, .openvibe-notif-inbox { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
+ .openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
+ .openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }
+ .openvibe-notif-dock .openvibe-nv-head { display: none; }
+ .openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }
+ .openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }
+ .openvibe-notif-dock .openvibe-nv-foot [data-role="status"] { margin-right: auto; }
+ .ovn-foot-read { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0; border: 0; background: none; font: inherit; font-weight: 700; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
+ .ovn-foot-read:hover { color: var(--text-primary, #ececf1); }
+ .ovn-foot-read svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;
.openvibe-notif-inbox { display: flex; flex-direction: column; overflow: hidden; min-height: 420px; max-height: 78vh; border-radius: 18px;
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); font-family: inherit; }
@@ -832,7 +841,7 @@
@@ -832,7 +841,7 @@
</div>
</div>
</div>
</div>
<div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>
<div class="openvibe-nv-list" data-role="list" role="tabpanel" aria-label="All"></div>
- <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'panel' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;
+ <div class="openvibe-nv-foot"><span data-role="status"></span>${mode === 'dock' ? `<button type="button" class="ovn-foot-read" data-act="read-all-foot">${svg(ICON.checkAll)}Mark all read</button>` : ''}${mode === 'panel' || mode === 'dock' ? `<a href="${esc(inboxUrl())}">Full inbox →</a>` : ''}</div>`;
const list = root.querySelector('[data-role="list"]');
const list = root.querySelector('[data-role="list"]');
const input = root.querySelector('input[type="search"]');
const input = root.querySelector('input[type="search"]');
let qTimer = null;
let qTimer = null;
@@ -865,6 +874,7 @@
@@ -865,6 +874,7 @@
const unreadBtn = root.querySelector('.openvibe-nv-unread');
const unreadBtn = root.querySelector('.openvibe-nv-unread');
unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });
unreadBtn.addEventListener('click', () => { v.unread = !v.unread; unreadBtn.setAttribute('aria-pressed', String(v.unread)); reload(v); });
root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
root.querySelector('[data-act="read-all"]').addEventListener('click', () => markAllRead());
+ const footRead = root.querySelector('[data-act="read-all-foot"]'); if (footRead) footRead.addEventListener('click', () => markAllRead());
root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
root.querySelector('[data-act="push"]').addEventListener('click', () => togglePush(v));
const closeBtn = root.querySelector('[data-act="close"]');
const closeBtn = root.querySelector('[data-act="close"]');
if (closeBtn) closeBtn.addEventListener('click', () => closePanel());
if (closeBtn) closeBtn.addEventListener('click', () => closePanel());
@@ -1387,6 +1397,15 @@
@@ -1387,6 +1397,15 @@
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;
+ },
/** Programmatically show a toast (real-time events). Marks nothing on the server. */
/** Programmatically show a toast (real-time events). Marks nothing on the server. */
push(notification) {
push(notification) {
showToast(notification);
showToast(notification);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.50.1",
+ "version": "4.51.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": {
@@ -43,6 +43,7 @@
@@ -43,6 +43,7 @@
"./navbar": "./navbar.js",
"./navbar": "./navbar.js",
"./notification-live": "./notification-live.js",
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
+ "./dock": "./dock.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'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.
+// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('dock-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const iso = (ago) => new Date(Date.now() - ago).toISOString();
+const item = (o) => ({ actions: [], cost_usd: null, steps: null, kind: 'task', error: null, next_run_at: null, updated_at: iso(60e3), ...o });
+const ACTIONS = {
+ generated_at: iso(2000), sources: { actor: 'ok', codes: 'ok' },
+ counts: { running: 2, waiting: 1, scheduled: 0, done: 0, failed: 0 },
+ buckets: {
+ waiting: [item({ id: 'apr_1', service: 'actor', agent: 'Researcher', bucket: 'waiting', state: 'waiting', title: 'Send the weekly digest?', summary: 'It wants to send from your address.', href: 'https://openvibe.actor/tasks/t1', actions: ['approve', 'decline'] })],
+ running: [
+ item({ id: 'tsk_1', service: 'codes', agent: 'Codex', bucket: 'running', state: 'running', title: 'Fix the flaky upload test', summary: 'Step 14: the suite.', href: 'https://openvibe.codes/run/tasks/tsk_1', steps: 14 }),
+ item({ id: 't2', service: 'actor', agent: 'Actor', bucket: 'running', state: 'running', title: 'Compare three VPS offers', summary: 'Reading the providers.', href: 'https://openvibe.actor/tasks/t2', steps: 6 }),
+ ],
+ scheduled: [], done: [], failed: [],
+ },
+};
+const NOTES = [
+ { id: 1, type: 'agent_done', category: 'agents', service: 'codes', title: 'Codes finished a task', message: 'The suite passed.', priority: 'normal', is_read: 0, created_at: iso(300e3) },
+ { id: 2, type: 'mention', category: 'social', service: 'chat', title: 'kai mentioned you', message: 'look at the shots?', priority: 'normal', is_read: 0, created_at: iso(900e3) },
+];
+
+// The stand-in's state for the scenario under way.
+let scenario = {};
+const puts = [], decided = [];
+const defaultState = () => ({ open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: iso(0) });
+
+const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks</title><link rel="icon" href="data:,">
+<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
+<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
+<script src="/navbar.js"></script>
+<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin,
+ ${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}
+ links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ if (u.pathname === '/page') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE({ signedIn: u.searchParams.get('in') !== '0' })); }
+ if (u.pathname === '/api/v1/me/dock') {
+ if (scenario.missing) return json(404, { code: 'not_found' });
+ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the Dock reads with the person\'s token');
+ if (req.method === 'PUT') { const b = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); puts.push(b); scenario.state = { ...(scenario.state || defaultState()), ...b, place: { ...((scenario.state || defaultState()).place), ...(b.place || {}) } }; }
+ return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
+ }
+ if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
+ const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);
+ if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }
+ if (u.pathname === '/api/notifications/unread-count') return json(200, { count: 2 });
+ if (u.pathname === '/api/notifications/unread-by-tab') return json(200, { tabs: { all: 2 } });
+ if (u.pathname === '/api/notifications') return json(200, { notifications: NOTES, total: NOTES.length, has_more: false });
+ const name = u.pathname.slice(1);
+ if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
+ res.writeHead(404); res.end();
+ });
+});
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+async function until(p, expr, what, ms = 5000) {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await p.evaluate(expr);
+ if (v) return v;
+ if (Date.now() > end) throw new Error(`timed out: ${what}`);
+ 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: [...c.querySelectorAll('.ovac-group')].map((g) => g.dataset.bucket), items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;
+
+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));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dock-'));
+ const browser = await h.launch({ tmpDir });
+ const fresh = async (width, height = 860) => { const context = await browser.newContext(); const p = await h.openPage(browser, context, { width, height }); return p; };
+ try {
+ // ── Nothing runs, nothing waits, not pinned: no pill. Pinned: the pill, a mark alone. ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 3 } };
+ const p = await fresh(390);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await wait(700);
+ 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 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');
+ await p.close();
+ }
+
+ // ── A phone: the pill says what matters, the sheet opens on Agents, 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');
+ assert.ok(card.w === 390 && card.t > 60, 'a sheet across the phone, under the bar');
+ assert.strictEqual(await p.evaluate('document.getElementById("ovdk-pill").hidden'), true, 'the pill steps aside while the Dock is open');
+ 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');
+ 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)})`);
+ 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);
+ assert.strictEqual(await p.evaluate(CARD), null, 'a phone opens the Dock only when asked');
+ await p.close();
+ }
+
+ // ── 320 px: nothing runs off the screen, closed or open. ──
+ {
+ 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');
+ 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', 400, 560], 'a 400 × 560 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();
+ }
+
+ // ── Network without the route yet (404): no pill, 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);
+ assert.strictEqual(await p.evaluate(PILL), null, 'no pill 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);
+ assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
+ await p.close();
+ }
+ console.log('dock-chrome: the pill only when it matters, Agents and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 40 * 1024; // 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
+const NAVBAR_BROTLI_BUDGET = 41 * 1024; // 40.4 KB with the Dock's loader, its account-menu tile and openDock (4.51.0; the pill and the Dock are dock.js, loaded for signed-in people only); 39.8 KB with the site switcher's loader and visit (4.48.0); 38.9 KB with the menu's orb beside the brand, messages beside the bell, the account menu as a sheet on phones, the bar stepping away on phones and both balances through the Network (4.25.0); 37.05 KB with the search magnifier, "/" and Ctrl+K, search.js loaded on first use (4.21.0); measured 27.6 KB on 2026-09-22 (39.0 KB with every glyph inline); 29.4 KB with the top-of-page fade (2.15.0); 30.6 KB with Ask Actor and its glyph (4.2.0); 31.4 KB with every site in groups in the launcher (4.4.0); 32.1 KB with the season's badge and switches (4.11.0); 33.0 KB with Work together and the drawer anchored to the bar (4.13.0); 33.6 KB with account switching on every site and finger-sized brand links (4.14.0); 35.2 KB with the folded balance chip, Across OpenVibe with site marks and the icon preload (4.16.0); 36.2 KB with wallet: true, OpenCoins and Vibes in any site's bar (4.17.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{