openvibers/openvibe.shared Commits

The Dock: your agents and your activity at the edge of every page (4.51.0) (#197)

OP OpenVibers committed 14 days ago · c5e93a736d · 11 files, +882 −18

Dock plan K-D1. dock.js draws a pill in the Dock's corner (the mark, a word
and a dot) while an agent works, one needs you, or the person pinned it; a tap
opens the Dock: a floating card on a desktop (drag to a corner, resize from
the free corner), a sheet on a phone. Agents is the Action Center's waiting
and running items, decided in place (OpenVibeActionCenter.mount); Activity is
the bell's list (OpenVibeNotifications.renderDock). The state is the
Network's (GET/PUT /api/v1/me/dock), so it follows the person between sites;
a 404 keeps it away for a day. navbar.js loads dock.js for signed-in people,
adds the account menu's Dock tile and OpenVibeNavbar.openDock (+0.6 KB
brotli, budget 41 KB). Desktop toasts stack above the pill.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e93f0fd..ad1cbd1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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,
ddiff --git a/README.md b/README.md
iindex 93ac50b..bd0ac59 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -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
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
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
ddiff --git a/STATUS.json b/STATUS.json
iindex 00b295a..26bca0c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/action-center.js b/action-center.js
iindex 2fcd461..f085963 100644
---- a/action-center.js
++++ b/action-center.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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);
ddiff --git a/dock.js b/dock.js
nnew file mode 100644
iindex 0000000..efe9288
---- /dev/null
++++ b/dock.js
@@@ -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);
ddiff --git a/files.js b/files.js
iindex 9f54524..2ce1e1e 100644
---- a/files.js
++++ b/files.js
@@@ -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
ddiff --git a/navbar.js b/navbar.js
iindex c16a272..638a3d4 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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);
// Dropdown
if (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;
ddiff --git a/notification-ui.js b/notification-ui.js
iindex d7d5451..94c3cdb 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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);
ddiff --git a/package.json b/package.json
iindex a31c232..2ce6a38 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
nnew file mode 100644
iindex 0000000..63122b6
---- /dev/null
++++ b/test/dock-chrome.test.js
@@@ -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); });
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex 9f8c10f..fd083ac 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -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 ──────────────────────────
{