openvibers/openvibe.shared Commits
The Dock pops out into a window of its own (4.54.0) (#201)
Changes
@@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.54.0 — 2026-10-30++++- **The Dock pops out into a window of its own** (Dock plan K-D4). On a desktop browser that can (Chromium's Document++ Picture-in-Picture), the Dock's header carries a "pop out" button: the panel moves into a window of about 360 × 520++ with its tabs and its styles, and the page keeps its pill, which now reads "In a window" — press it and the panel++ comes back. Close the window and the panel returns to the page.++ - **Where the browser cannot open such a window, nothing changes**: no button, no new behaviour.++ - **The window opens only from a click.** A Dock that opened a window by itself would be a window nobody asked for.++ - **The Network is told where the Dock is** (`open`, `tab`, `popped`), as it is today. A Network whose state++ contract does not know `popped` yet refuses the body; the Dock asks again without that one key, so the rest of++ the state still reaches it.++ - **The same Dock, the same look.** The panel's own DOM and styles go with it, along with the page's theme colours.++- `test/dock-chrome.test.js`: the pop-out button only where the browser can open a window, the panel moving into it++ and back, the page's pill saying where the Dock went, and the state reaching the Network.++- `test/dock-size.test.js`: the new size budget for `dock.js` (13.77 KB brotli: 12.27 KB on 2026-10-30 plus 1.5 KB++ for the pop-out).++## 4.53.1 — 2026-10-30- **The patch-notes link under "Recently shipped" wraps on a narrow screen.** It carries a whole post title ("Patch@@@ -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.53.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.54.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.53.1",++ "version": "4.54.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)",@@@ -24,7 +24,8 @@"shipped pill, recent list and /updates log","server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR","browser harness and perf budgets for tests",-- "a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them"++ "a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",++ "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went"],-- "updated": "2026-10-29"++ "updated": "2026-10-30"}@@@ -19,6 +19,8 @@// 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.++// Pop out (K-D4): on desktop Chromium the panel moves into a window of its own (Document Picture-in-Picture) — the same++// DOM, the same tabs, the page's own styles copied in; the page keeps the pill, which says where the Dock went.// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';@@@ -41,6 +43,8 @@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;++ // Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.++ let pipWin = null, popped = false, pipClosing = false, popOk = true;const mounts = {};const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };@@@ -74,7 +78,13 @@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) });++ let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });++ // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,++ // so the Dock's place and its tab still reach the Network.++ if (!r && 'popped' in body) {++ const rest = { ...body }; delete rest.popped;++ r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });++ }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 */ }}@@@ -227,6 +237,13 @@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; }++ // In a window of its own the panel is the window: full bleed, no card, no phone sheet.++ .ovdk-pip .ovdk, .ovdk-pip .ovdk[data-corner] { left: 0 !important; top: 0 !important; right: 0 !important; bottom: 0 !important;++ width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important;++ border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }++ .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }++ .ovdk-pip .ovdk-head::before { display: none !important; }++ .ovdk-pip .ovdk-size { display: none !important; }`;document.head.appendChild(st);}@@@ -242,6 +259,7 @@<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="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button><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>@@@ -258,6 +276,7 @@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="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });// A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a@@@ -271,6 +290,7 @@root.addEventListener('resize', () => { if (isOpen()) layout(); });bindMove();bindSize();++ paintPop();}// ── Where it sits ────────────────────────────────────────@@@ -591,6 +611,15 @@if (!pill) return;const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;const corner = place().corner;++ // Popped out: the page keeps the pill, and it says where the Dock went.++ if (popped) {++ pill.hidden = false;++ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';++ pill.dataset.tone = 'idle';++ pill.querySelector('.ovdk-t').textContent = 'In a window';++ pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');++ return;++ }const show = !isOpen() && !!(w || r || st.pinned);pill.hidden = !show;pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';@@@ -634,7 +663,7 @@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());++ pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });document.body.appendChild(pill);paintPill();}@@@ -642,6 +671,80 @@read();}++ // ── Pop out (K-D4) ────────────────────────────────────────++ // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and++ // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks++ // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.++ const PIP_W = 360, PIP_H = 520;++ const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;++ // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).++ const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');++++ function paintPop() {++ const b = el.querySelector('[data-act="pop"]');++ if (!b) return;++ b.hidden = !pip();++ const t = popped ? 'Bring the Dock back to the page' : 'Open the Dock in a window of its own';++ b.title = t; b.setAttribute('aria-label', t);++ }++++ /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */++ function dress(win) {++ const d = win.document;++ if (!d.documentElement) d.appendChild(d.createElement('html'));++ if (!d.head) d.documentElement.appendChild(d.createElement('head'));++ if (!d.body) d.documentElement.appendChild(d.createElement('body'));++ d.title = 'OpenVibe Dock';++ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ov-mark-css']) {++ const s = document.getElementById(id);++ if (!s) continue;++ const c = d.createElement('style');++ c.id = id; c.textContent = s.textContent;++ d.head.appendChild(c);++ }++ const cs = getComputedStyle(document.documentElement);++ for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }++ const b = getComputedStyle(document.body);++ const s = d.body.style;++ s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;++ s.background = b.backgroundColor;++ d.documentElement.style.colorScheme = cs.colorScheme || '';++ d.documentElement.classList.add('ovdk-pip');++ }++++ /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened++ * a window by itself would be a window nobody asked for. */++ async function popOut() {++ if (popped || !pip()) return;++ let win = null;++ try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }++ if (!win || !win.document) return;++ pipWin = win;++ popped = true;++ dress(win);++ win.document.body.appendChild(el);++ win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });++ paintPop();++ save({ open: true, popped: true });++ paintPill();++ }++++ /** The panel comes back to the page: the window was closed, or the pill was pressed. */++ function backToPage() {++ if (!popped) return;++ popped = false;++ const w = pipWin; pipWin = null;++ pipClosing = true;++ try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }++ pipClosing = false;++ if (el.parentNode !== document.body) document.body.appendChild(el);++ el.hidden = false;++ layout();++ paintPop();++ save({ popped: false });++ paintPill();++ }++// ── Open and close ───────────────────────────────────────function isOpen() { return !!(el && !el.hidden); }function open(opts = {}) {@@@ -654,6 +757,7 @@layout();badges();paintPin();++ paintPop();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');@@@ -664,6 +768,8 @@}function close() {if (!isOpen()) return;++ const wasPopped = popped;++ if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }const inside = el.contains(document.activeElement);el.hidden = true;el.style.transform = '';@@@ -671,7 +777,7 @@document.documentElement.classList.remove('ovdk-sheet-open');clear(0);clearTimeout(chats.timer); clearTimeout(chats.unreadT);-- save({ open: false });++ save(wasPopped ? { open: false, popped: false } : { open: false });if (inside) {const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.53.1",++ "version": "4.54.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -10,7 +10,10 @@// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone--// never reads as a tap outside the sheet.++// never reads as a tap outside the sheet;++// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with++// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the++// panel back; the Network is told.// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.const assert = require('assert');const fs = require('fs');@@@ -117,6 +120,21 @@ const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hireturn { 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 }; })()`;++// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the++// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.++const PIP = `(() => {++ const f = document.createElement('iframe');++ f.id = 'pip-stub';++ f.style.cssText = 'position:fixed;right:16px;bottom:16px;width:360px;height:520px;border:0;z-index:99999;background:#0d0d12';++ document.body.appendChild(f);++ const w = f.contentWindow;++ const shut = () => { try { w.dispatchEvent(new w.Event('pagehide')); } catch (e) { } try { f.remove(); } catch (e) { } };++ w.close = shut;++ window.__pip = { win: w, frame: f, close: shut };++ window.documentPictureInPicture = { requestWindow: async (o) => { window.__pip.opts = o || null; return w; } };++ return true;++})()`;++async function drag(p, from, to) {await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from[0], y: from[1] });await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: from[0], y: from[1], button: 'left', buttons: 1, clickCount: 1 });@@@ -256,6 +274,46 @@ async function drag(p, from, to) {await p.close();}++ // ── Pop out (K-D4): the button only where the browser can open a window; the panel goes there and comes back. ──++ {++ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };++ puts.length = 0;++ const p = await fresh(1280, 860);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ // Where the browser cannot open a window of its own, there is nothing to press.++ await p.evaluate("(() => { Object.defineProperty(window, 'documentPictureInPicture', { value: undefined, writable: true, configurable: true }); return true; })()");++ await until(p, PILL, 'the pill');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, CARD, 'the card');++ const POP = "document.querySelector('.ovdk [data-act=pop]')";++ assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button');++ assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own');++ // The browser can: the button appears.++ await p.evaluate(PIP);++ await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");++ await until(p, PILL, 'the pill again');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, `(() => { const b = ${POP}; return b && !b.hidden ? true : null; })()`, 'the pop-out button');++ // The panel moves into the window: the same DOM, the same tabs, the styles come with it.++ await p.evaluate(`${POP}.click()`);++ await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');++ assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 3, 'the same three tabs');++ assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");++ assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');++ const inWindow = await until(p, PILL, 'the pill stays');++ assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');++ await wait(900);++ assert.ok(puts.some((b) => b.popped === true && b.open === true), `the Network is told the Dock is in a window (${JSON.stringify(puts)})`);++ // The window is closed: the panel comes back to the page.++ await p.evaluate("window.__pip.close()");++ await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');++ assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window');++ await wait(900);++ assert.ok(puts.some((b) => b.popped === false), `and told it is back (${JSON.stringify(puts)})`);++ 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 };@@@ -272,7 +330,7 @@ async function drag(p, from, to) {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, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');++ console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');} finally {await browser.close();server.close();nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// dock.js — the Dock itself: the pill, the card, its tabs and (K-D4) the window it can move into. It loads for++// signed-in people only, on every page, so its size is a budget like any other browser file.++// Covers the brotli budget for dock.js and the other Dock siblings it pulls in at runtime.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { sizes, brotli } = require('../scripts/size-report');++++const ROOT = path.join(__dirname, '..');++const DOCK_PATH = path.join(ROOT, 'dock.js');++// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget is that++// plus 1.5 KB, so the pop-out has to stay inside it.++const DOCK_BROTLI_BUDGET = 14097; // 13.77 KB (12561 + 1536)++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];++++// ── the budget ──────────────────────────────────────────────++{++ const size = brotli(fs.readFileSync(DOCK_PATH));++ assert.ok(size <= DOCK_BROTLI_BUDGET, `dock.js is ${(size / 1024).toFixed(2)} KB brotli, budget ${(DOCK_BROTLI_BUDGET / 1024).toFixed(2)} KB — the Dock is loaded on every page for signed-in people`);++}++++// ── the Dock's siblings are browser files too, and the report knows them ──++{++ const s = sizes();++ assert.ok(s['dock.js'], 'the size report counts dock.js');++ for (const f of SIBLINGS) assert.ok(s[f], `the size report counts ${f}`);++}
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.54.0 — 2026-10-30
+
+- **The Dock pops out into a window of its own** (Dock plan K-D4). On a desktop browser that can (Chromium's Document
+ Picture-in-Picture), the Dock's header carries a "pop out" button: the panel moves into a window of about 360 × 520
+ with its tabs and its styles, and the page keeps its pill, which now reads "In a window" — press it and the panel
+ comes back. Close the window and the panel returns to the page.
+ - **Where the browser cannot open such a window, nothing changes**: no button, no new behaviour.
+ - **The window opens only from a click.** A Dock that opened a window by itself would be a window nobody asked for.
+ - **The Network is told where the Dock is** (`open`, `tab`, `popped`), as it is today. A Network whose state
+ contract does not know `popped` yet refuses the body; the Dock asks again without that one key, so the rest of
+ the state still reaches it.
+ - **The same Dock, the same look.** The panel's own DOM and styles go with it, along with the page's theme colours.
+- `test/dock-chrome.test.js`: the pop-out button only where the browser can open a window, the panel moving into it
+ and back, the page's pill saying where the Dock went, and the state reaching the Network.
+- `test/dock-size.test.js`: the new size budget for `dock.js` (13.77 KB brotli: 12.27 KB on 2026-10-30 plus 1.5 KB
+ for the pop-out).
+
## 4.53.1 — 2026-10-30
## 4.53.1 — 2026-10-30
- **The patch-notes link under "Recently shipped" wraps on a narrow screen.** It carries a whole post title ("Patch
- **The patch-notes link under "Recently shipped" wraps on a narrow screen.** It carries a whole post title ("Patch
@@ -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.53.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.54.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.53.1",
+ "version": "4.54.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)",
@@ -24,7 +24,8 @@
@@ -24,7 +24,8 @@
"shipped pill, recent list and /updates log",
"shipped pill, recent list and /updates log",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"browser harness and perf budgets for tests",
"browser harness and perf budgets for tests",
- "a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them"
+ "a CONFIRMATION_REQUESTED notification type: an agent owner is told when a sensitive-capability confirmation awaits them",
+ "the Dock pops out into a window of its own (Document Picture-in-Picture): the same panel, the same tabs, the page's pill saying where the Dock went"
],
],
- "updated": "2026-10-29"
+ "updated": "2026-10-30"
}
}
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// 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
// 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.
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
+// Pop out (K-D4): on desktop Chromium the panel moves into a window of its own (Document Picture-in-Picture) — the same
+// DOM, the same tabs, the page's own styles copied in; the page keeps the pill, which says where the Dock went.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -41,6 +43,8 @@
@@ -41,6 +43,8 @@
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
const 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;
let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null;
+ // Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.
+ let pipWin = null, popped = false, pipClosing = false, popOk = true;
const mounts = {};
const mounts = {};
const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
const phone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
@@ -74,7 +78,13 @@
@@ -74,7 +78,13 @@
const body = pending; pending = {};
const body = pending; pending = {};
if (!Object.keys(body).length) return;
if (!Object.keys(body).length) return;
try {
try {
- const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
+ let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
+ // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,
+ // so the Dock's place and its tab still reach the Network.
+ if (!r && 'popped' in body) {
+ const rest = { ...body }; delete rest.popped;
+ r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });
+ }
if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
}
}
@@ -227,6 +237,13 @@
@@ -227,6 +237,13 @@
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
html[data-ov-motion="reduced"] .ovdk.is-in { animation: none; }
html[data-ov-motion="reduced"] .ovdk.is-in { animation: none; }
+ // In a window of its own the panel is the window: full bleed, no card, no phone sheet.
+ .ovdk-pip .ovdk, .ovdk-pip .ovdk[data-corner] { left: 0 !important; top: 0 !important; right: 0 !important; bottom: 0 !important;
+ width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important;
+ border-radius: 0 !important; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)) !important; box-shadow: none !important; }
+ .ovdk-pip .ovdk-head { padding-top: 10px !important; cursor: grab !important; }
+ .ovdk-pip .ovdk-head::before { display: none !important; }
+ .ovdk-pip .ovdk-size { display: none !important; }
`;
`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -242,6 +259,7 @@
@@ -242,6 +259,7 @@
<header class="ovdk-head">
<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-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">
<div class="ovdk-acts">
+ <button type="button" data-act="pop" hidden title="Open the Dock in a window of its own" aria-label="Open the Dock in a window of its own">${svg(G.open)}</button>
<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="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>
<button type="button" data-act="close" title="Close" aria-label="Close the Dock">${svg(G.close)}</button>
</div>
</div>
@@ -258,6 +276,7 @@
@@ -258,6 +276,7 @@
if (j >= 0) { e.preventDefault(); setTab(tabs[j].dataset.tab, true); tabs[j].focus(); }
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="close"]').addEventListener('click', () => close());
+ el.querySelector('[data-act="pop"]').addEventListener('click', () => { if (popped) backToPage(); else popOut(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.querySelector('[data-act="pin"]').addEventListener('click', () => { save({ pinned: !state().pinned }); paintPin(); });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } });
// A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
// A tap outside closes the phone sheet. The path is the one at dispatch: a tap that redraws its own panel (opening a
@@ -271,6 +290,7 @@
@@ -271,6 +290,7 @@
root.addEventListener('resize', () => { if (isOpen()) layout(); });
root.addEventListener('resize', () => { if (isOpen()) layout(); });
bindMove();
bindMove();
bindSize();
bindSize();
+ paintPop();
}
}
// ── Where it sits ────────────────────────────────────────
// ── Where it sits ────────────────────────────────────────
@@ -591,6 +611,15 @@
@@ -591,6 +611,15 @@
if (!pill) return;
if (!pill) return;
const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
const c = counts(), st = state(), w = c.waiting || 0, r = c.running || 0;
const corner = place().corner;
const corner = place().corner;
+ // Popped out: the page keeps the pill, and it says where the Dock went.
+ if (popped) {
+ pill.hidden = false;
+ pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
+ pill.dataset.tone = 'idle';
+ pill.querySelector('.ovdk-t').textContent = 'In a window';
+ pill.setAttribute('aria-label', 'The Dock is in a window of its own \u2014 click to bring it back');
+ return;
+ }
const show = !isOpen() && !!(w || r || st.pinned);
const show = !isOpen() && !!(w || r || st.pinned);
pill.hidden = !show;
pill.hidden = !show;
pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
pill.dataset.corner = CORNERS.includes(corner) ? corner : 'bottom-right';
@@ -634,7 +663,7 @@
@@ -634,7 +663,7 @@
pill = document.createElement('button');
pill = document.createElement('button');
pill.type = 'button'; pill.id = 'ovdk-pill'; pill.className = 'ovdk-pill'; pill.hidden = true;
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.innerHTML = '<span class="ovdk-m"><span class="ov-mark" data-size="26"></span></span><span class="ovdk-t"></span>';
- pill.addEventListener('click', () => open());
+ pill.addEventListener('click', () => { if (popped) backToPage(); else open(); });
document.body.appendChild(pill);
document.body.appendChild(pill);
paintPill();
paintPill();
}
}
@@ -642,6 +671,80 @@
@@ -642,6 +671,80 @@
read();
read();
}
}
+ // ── Pop out (K-D4) ────────────────────────────────────────
+ // On desktop Chromium the panel can move into a window of its own (Document Picture-in-Picture). The same DOM and
+ // the same tabs go with it; the window is dressed with the Dock's styles and the theme's colours, so the Dock looks
+ // there as it does here. The page keeps the pill, which says where the Dock went; clicking it brings the panel back.
+ const PIP_W = 360, PIP_H = 520;
+ const pip = () => (root.documentPictureInPicture && typeof root.documentPictureInPicture.requestWindow === 'function') ? root.documentPictureInPicture : null;
+ // The theme's tokens, so the window is painted in the same colours (the names only: the values are read from the page).
+ const TOKENS = 'accent accent-light accent-strong bg bg-card bg-elevated bg-hover bg-input bg-primary bg-secondary bg-tertiary border border-light danger danger-strong gold live-red on-accent on-accent-strong success text-muted text-primary text-secondary warning'.split(' ');
+
+ function paintPop() {
+ const b = el.querySelector('[data-act="pop"]');
+ if (!b) return;
+ b.hidden = !pip();
+ const t = popped ? 'Bring the Dock back to the page' : 'Open the Dock in a window of its own';
+ b.title = t; b.setAttribute('aria-label', t);
+ }
+
+ /** The window gets the Dock's styles, the theme's colours and the page's own type: the Dock looks the same there. */
+ function dress(win) {
+ const d = win.document;
+ if (!d.documentElement) d.appendChild(d.createElement('html'));
+ if (!d.head) d.documentElement.appendChild(d.createElement('head'));
+ if (!d.body) d.documentElement.appendChild(d.createElement('body'));
+ d.title = 'OpenVibe Dock';
+ for (const id of ['ovdk-styles', 'ovac-styles', 'openvibe-notif-styles', 'ov-mark-css']) {
+ const s = document.getElementById(id);
+ if (!s) continue;
+ const c = d.createElement('style');
+ c.id = id; c.textContent = s.textContent;
+ d.head.appendChild(c);
+ }
+ const cs = getComputedStyle(document.documentElement);
+ for (const n of TOKENS) { const v = cs.getPropertyValue('--' + n); if (v) d.documentElement.style.setProperty('--' + n, v.trim()); }
+ const b = getComputedStyle(document.body);
+ const s = d.body.style;
+ s.fontFamily = b.fontFamily; s.fontSize = b.fontSize; s.fontWeight = b.fontWeight; s.lineHeight = b.lineHeight;
+ s.background = b.backgroundColor;
+ d.documentElement.style.colorScheme = cs.colorScheme || '';
+ d.documentElement.classList.add('ovdk-pip');
+ }
+
+ /** The panel moves into a window of its own. Only from a click: the browser wants a gesture, and a Dock that opened
+ * a window by itself would be a window nobody asked for. */
+ async function popOut() {
+ if (popped || !pip()) return;
+ let win = null;
+ try { win = await pip().requestWindow({ width: PIP_W, height: PIP_H }); } catch { return; }
+ if (!win || !win.document) return;
+ pipWin = win;
+ popped = true;
+ dress(win);
+ win.document.body.appendChild(el);
+ win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });
+ paintPop();
+ save({ open: true, popped: true });
+ paintPill();
+ }
+
+ /** The panel comes back to the page: the window was closed, or the pill was pressed. */
+ function backToPage() {
+ if (!popped) return;
+ popped = false;
+ const w = pipWin; pipWin = null;
+ pipClosing = true;
+ try { if (w && !w.closed) w.close(); } catch { /* the window is already gone */ }
+ pipClosing = false;
+ if (el.parentNode !== document.body) document.body.appendChild(el);
+ el.hidden = false;
+ layout();
+ paintPop();
+ save({ popped: false });
+ paintPill();
+ }
+
// ── Open and close ───────────────────────────────────────
// ── Open and close ───────────────────────────────────────
function isOpen() { return !!(el && !el.hidden); }
function isOpen() { return !!(el && !el.hidden); }
function open(opts = {}) {
function open(opts = {}) {
@@ -654,6 +757,7 @@
@@ -654,6 +757,7 @@
layout();
layout();
badges();
badges();
paintPin();
paintPin();
+ paintPop();
el.hidden = false;
el.hidden = false;
el.classList.remove('is-in'); void el.offsetWidth; el.classList.add('is-in');
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');
if (phone()) { el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open'); } else el.removeAttribute('aria-modal');
@@ -664,6 +768,8 @@
@@ -664,6 +768,8 @@
}
}
function close() {
function close() {
if (!isOpen()) return;
if (!isOpen()) return;
+ const wasPopped = popped;
+ if (wasPopped) { popped = false; const w = pipWin; pipWin = null; pipClosing = true; try { if (w) w.close(); } catch { /* already gone */ } pipClosing = false; if (el.parentNode !== document.body) document.body.appendChild(el); }
const inside = el.contains(document.activeElement);
const inside = el.contains(document.activeElement);
el.hidden = true;
el.hidden = true;
el.style.transform = '';
el.style.transform = '';
@@ -671,7 +777,7 @@
@@ -671,7 +777,7 @@
document.documentElement.classList.remove('ovdk-sheet-open');
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clear(0);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
- save({ open: false });
+ save(wasPopped ? { open: false, popped: false } : { open: false });
if (inside) {
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.53.1",
+ "version": "4.54.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": {
@@ -10,7 +10,10 @@
@@ -10,7 +10,10 @@
// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;
// • desktop toasts are told to stack above the pill; nothing runs off a 320 px screen;
// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
// • Chats (K-D2): the person's conversations from Chat's API with their unread counts, a thread opened in place (theirs
// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
// and mine), Enter sends, the thread is marked read, the open chat is the Dock's state, and opening one on a phone
-// never reads as a tap outside the sheet.
+// never reads as a tap outside the sheet;
+// • Pop out (K-D4): the button only where the browser can open a window of its own; the panel moves into it with
+// its tabs and its styles, the page keeps the pill saying where the Dock went, and closing the window brings the
+// panel back; the Network is told.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -117,6 +120,21 @@ const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hi
@@ -117,6 +120,21 @@ const CARD = `(() => { const c = document.querySelector('.ovdk'); if (!c || c.hi
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,
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 }; })()`;
groups: [...c.querySelectorAll('.ovac-group')].map((g) => g.dataset.bucket), items: c.querySelectorAll('.ovac-item').length, notes: c.querySelectorAll('.openvibe-notif-item').length }; })()`;
+// A stand-in for the browser's Document Picture-in-Picture window: a same-origin frame with a real document, so the
+// Dock can move into it (K-D4) and be seen coming back. `__pip.close()` is the person closing that window.
+const PIP = `(() => {
+ const f = document.createElement('iframe');
+ f.id = 'pip-stub';
+ f.style.cssText = 'position:fixed;right:16px;bottom:16px;width:360px;height:520px;border:0;z-index:99999;background:#0d0d12';
+ document.body.appendChild(f);
+ const w = f.contentWindow;
+ const shut = () => { try { w.dispatchEvent(new w.Event('pagehide')); } catch (e) { } try { f.remove(); } catch (e) { } };
+ w.close = shut;
+ window.__pip = { win: w, frame: f, close: shut };
+ window.documentPictureInPicture = { requestWindow: async (o) => { window.__pip.opts = o || null; return w; } };
+ return true;
+})()`;
+
async function drag(p, from, to) {
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: '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 });
await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: from[0], y: from[1], button: 'left', buttons: 1, clickCount: 1 });
@@ -256,6 +274,46 @@ async function drag(p, from, to) {
@@ -256,6 +274,46 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── Pop out (K-D4): the button only where the browser can open a window; the panel goes there and comes back. ──
+ {
+ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
+ puts.length = 0;
+ const p = await fresh(1280, 860);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ // Where the browser cannot open a window of its own, there is nothing to press.
+ await p.evaluate("(() => { Object.defineProperty(window, 'documentPictureInPicture', { value: undefined, writable: true, configurable: true }); return true; })()");
+ await until(p, PILL, 'the pill');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, CARD, 'the card');
+ const POP = "document.querySelector('.ovdk [data-act=pop]')";
+ assert.strictEqual(await p.evaluate(`!!${POP}`), true, 'the header carries a pop-out button');
+ assert.strictEqual(await p.evaluate(`${POP}.hidden`), true, 'hidden where the browser cannot open a window of its own');
+ // The browser can: the button appears.
+ await p.evaluate(PIP);
+ await p.evaluate("document.querySelector('.ovdk [data-act=close]').click()");
+ await until(p, PILL, 'the pill again');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, `(() => { const b = ${POP}; return b && !b.hidden ? true : null; })()`, 'the pop-out button');
+ // The panel moves into the window: the same DOM, the same tabs, the styles come with it.
+ await p.evaluate(`${POP}.click()`);
+ await until(p, "!!(window.__pip && window.__pip.win.document.querySelector('.ovdk'))", 'the panel in the window');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk')"), false, 'and not in the page any more');
+ assert.strictEqual(await p.evaluate("window.__pip.win.document.querySelectorAll('.ovdk [role=tab]').length"), 3, 'the same three tabs');
+ assert.strictEqual(await p.evaluate("!!window.__pip.win.document.getElementById('ovdk-styles')"), true, "the Dock's styles come with it");
+ assert.deepStrictEqual(await p.evaluate("window.__pip.opts"), { width: 360, height: 520 }, 'a window of about 360 × 520');
+ const inWindow = await until(p, PILL, 'the pill stays');
+ assert.strictEqual(inWindow.text, 'In a window', 'the page keeps the pill, and it says where the Dock went');
+ await wait(900);
+ assert.ok(puts.some((b) => b.popped === true && b.open === true), `the Network is told the Dock is in a window (${JSON.stringify(puts)})`);
+ // The window is closed: the panel comes back to the page.
+ await p.evaluate("window.__pip.close()");
+ await until(p, "!!document.querySelector('.ovdk')", 'the panel back on the page');
+ assert.strictEqual(await p.evaluate("!!window.__pip.win.document.querySelector('.ovdk')"), false, 'and out of the window');
+ await wait(900);
+ assert.ok(puts.some((b) => b.popped === false), `and told it is back (${JSON.stringify(puts)})`);
+ await p.close();
+ }
+
// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
// ── Network without the route yet (404): no pill, and the site stops asking for a day. Signed out: none. ──
{
{
scenario = { missing: true };
scenario = { missing: true };
@@ -272,7 +330,7 @@ async function drag(p, from, to) {
@@ -272,7 +330,7 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
await p.close();
}
}
- console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean');
+ console.log('dock-chrome: the pill only when it matters, Agents, Chats and Activity, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// dock.js — the Dock itself: the pill, the card, its tabs and (K-D4) the window it can move into. It loads for
+// signed-in people only, on every page, so its size is a budget like any other browser file.
+// Covers the brotli budget for dock.js and the other Dock siblings it pulls in at runtime.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { sizes, brotli } = require('../scripts/size-report');
+
+const ROOT = path.join(__dirname, '..');
+const DOCK_PATH = path.join(ROOT, 'dock.js');
+// dock.js was 12.27 KB brotli on 2026-10-30 (4.53.1, before the pop-out). K-D4 added 1.45 KB; the budget is that
+// plus 1.5 KB, so the pop-out has to stay inside it.
+const DOCK_BROTLI_BUDGET = 14097; // 13.77 KB (12561 + 1536)
+const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js'];
+
+// ── the budget ──────────────────────────────────────────────
+{
+ const size = brotli(fs.readFileSync(DOCK_PATH));
+ assert.ok(size <= DOCK_BROTLI_BUDGET, `dock.js is ${(size / 1024).toFixed(2)} KB brotli, budget ${(DOCK_BROTLI_BUDGET / 1024).toFixed(2)} KB — the Dock is loaded on every page for signed-in people`);
+}
+
+// ── the Dock's siblings are browser files too, and the report knows them ──
+{
+ const s = sizes();
+ assert.ok(s['dock.js'], 'the size report counts dock.js');
+ for (const f of SIBLINGS) assert.ok(s[f], `the size report counts ${f}`);
+}