openvibers/openvibe.shared Commits

The Dock pops out into a window of its own (4.54.0) (#201)

OP OpenVibers committed 14 days ago · 901299f45d · 7 files, +220 −10

Dock plan K-D4. On a browser that can open one (Chromium's Document
Picture-in-Picture), the Dock's header carries a "pop out" button: the panel
moves into a window of about 360 x 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 one 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.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2a89583..60bd3cf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex d29373a..54732ec 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.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f11611e..03adc9c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/dock.js b/dock.js
iindex 6eee1fa..51c96d6 100644
---- a/dock.js
++++ b/dock.js
@@@ -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 { /* */ }
ddiff --git a/package.json b/package.json
iindex a24dbde..e4176f4 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex dfe73b5..ed89c75 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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.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,
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();
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
nnew file mode 100644
iindex 0000000..12acac3
---- /dev/null
++++ b/test/dock-size.test.js
@@@ -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}`);
++}