openvibers/openvibe.shared Commits

4.152.0: floating cards that follow you across OpenVibe sites; the next site starts sooner (#342)

OP OpenVibers committed 5 days ago · 2208c9ffb7 · 10 files, +621 −7

* wip

* Merge remote-tracking branch 'origin/main' into overlay-host

# Conflicts:
#	navbar.js

* 4.152.0: floating cards that follow you across OpenVibe sites (overlay.js); the next site starts sooner

* overlay.js: served and exported; the navbar's budget 46 KB (the cards' loader and link warming)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3809f3d..9f71dfc 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 4.152.0 — 2026-11-08
++
++- **Floating cards that follow you across OpenVibe** (`overlay.js`; the owner: a mini player you move and resize like
++ YouTube on Android, with the room's chat, that keeps going on other sites, and an SDK any site, agent, Vibe chat or
++ Codes can use). `OpenVibeNavbar.overlay({ id, src, title, href, live, side })` opens a card that frames a first-party
++ page (Live's `/embed/<name>`, a chat, an agent): drag it and it settles in the nearest corner (a flick carries it),
++ pull or throw it off the side to close it, resize it with its grip, two fingers or a double tap, and a tap shows
++ its controls (LIVE, its name, sound for a Live embed, chat, open the full page, close). It sits between the navbar
++ and the phone's app bar, never under either; its chat opens beside it, or as a sheet above the app bar on a phone.
++ At most three at once, each in its own corner; corner and size remembered per kind on the device.
++- **Across sites:** while a card is open (or a page declares one: `OVOverlay.declare(id, …)`, for Live's own mini
++ player), a same-tab link to another OpenVibe site carries it in `#ovo=…`; the next site's navbar loads overlay.js,
++ opens the same cards and takes the hash out of the address at once (it never reaches a server). Only first-party
++ https sources open, checked again on arrival; a reload on the same site brings the cards back.
++- **The next site starts sooner:** when a pointer, a finger or the keyboard reaches for a link to another OpenVibe
++ site, the navbar opens the connection to it then (preconnect + dns-prefetch, up to 12 origins a page).
++- Test: `test/overlay-chrome.test.js`.
++
## 4.151.0 — 2026-11-08
- **The menu marks the page you are on** (the owner, on openvibe.chat/messages: "highlight based on page"). In
ddiff --git a/README.md b/README.md
iindex cc7bdf3..71aa4f1 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.151.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.152.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 a98dc43..2bcbd77 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.151.0",
++ "version": "4.152.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/files.js b/files.js
iindex 2b7a433..d985dc3 100644
---- a/files.js
++++ b/files.js
@@@ -22,7 +22,7 @@ const BROWSER = Object.freeze([
'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
-- 'presence.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
++ 'presence.js', 'overlay.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
ddiff --git a/navbar.js b/navbar.js
iindex 86a4ad4..353c77e 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -539,7 +539,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'c036db4ac93a', 'dock.js': 'ef5c198edf47', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'c036db4ac93a', 'dock.js': 'ef5c198edf47', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '09f79a2bb136' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@@ -1297,6 +1297,38 @@
if (document.getElementById('ov-presence-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = siblingV('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
}
++ // Floating cards (overlay.js, 4.152.0): a live picture, a chat or an agent that follows the person across pages and
++ // OpenVibe sites. Loaded only when a card is coming (a link carried one in its #ovo=, or one was open before this
++ // page's reload) or a page asks for one with OpenVibeNavbar.overlay(…).
++ function loadOverlay(then) {
++ if (root.OVOverlay) { if (then) then(root.OVOverlay); return; }
++ let sc = document.getElementById('ov-overlay-loader');
++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-overlay-loader'; sc.src = siblingV('overlay.js'); sc.async = true; document.head.appendChild(sc); }
++ if (then) sc.addEventListener('load', () => { if (root.OVOverlay) then(root.OVOverlay); }, { once: true });
++ }
++ function watchOverlays() {
++ let waiting = false;
++ try { waiting = /(?:^#|&)ovo=/.test(root.location.hash || '') || !!sessionStorage.getItem('ov-overlay'); } catch { /* private mode */ }
++ if (waiting) loadOverlay();
++ }
++ // Faster to the next OpenVibe site: when a pointer or finger reaches for a link to another one, connect to it then.
++ function warmLinks() {
++ if (root.__ovWarm || typeof document.addEventListener !== 'function') return;
++ root.__ovWarm = true;
++ const done = new Set();
++ const warm = (e) => {
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a) return;
++ let u; try { u = new URL(a.href, root.location.href); } catch { return; }
++ if (u.protocol !== 'https:' || u.origin === root.location.origin || done.has(u.origin) || !/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || done.size > 12) return;
++ done.add(u.origin);
++ const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; l.crossOrigin = ''; document.head.appendChild(l);
++ const d = document.createElement('link'); d.rel = 'dns-prefetch'; d.href = u.origin; document.head.appendChild(d);
++ };
++ document.addEventListener('pointerover', warm, { passive: true, capture: true });
++ document.addEventListener('touchstart', warm, { passive: true, capture: true });
++ document.addEventListener('focusin', warm, { passive: true, capture: true });
++ }
// The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
@@@ -1955,6 +1987,7 @@
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }
++ watchOverlays(); warmLinks();
nav.innerHTML = `
${brandHTML(brand)}
<div class="openvibe-navbar-links">
@@@ -2364,6 +2397,10 @@
const OpenVibeNavbar = {
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
busy(on) { busy(!!on); },
++ /** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise<handle|null>. */
++ overlay(desc) { return new Promise((ok) => loadOverlay((O) => ok(O.open(desc)))); },
++ /** The overlay host itself, loaded (for declare / close / list): Promise<OVOverlay>. */
++ overlays() { return new Promise((ok) => loadOverlay(ok)); },
init(opts = {}) {
Object.assign(_config, opts);
ddiff --git a/overlay.js b/overlay.js
nnew file mode 100644
iindex 0000000..f168b52
---- /dev/null
++++ b/overlay.js
@@@ -0,0 +1,451 @@
++/**
++ * OpenVibe overlays (Shared 4.152.0): small floating surfaces that stay with a person across pages and across OpenVibe
++ * sites: a live channel's picture, a chat, an agent at work, a Codes task. The owner, 11-08: "move and resize it like
++ * YouTube on Android", "a chat button for the room", "make it work across sites", "an SDK any site, agent, Vibe chat
++ * or Codes can use".
++ *
++ * OVOverlay.open({ id, src, title, href, live, side: { title, src }, aspect, w, corner, portable }) → handle
++ * src a first-party https URL drawn in a sandboxed frame (Live's /embed/<name>, a chat room, an agent)
++ * title shown on the card; href: where "open" goes (the full page); live: the LIVE badge
++ * side a second frame the card's chat button opens beside it (a sheet above the app bar on a phone)
++ * portable (default true) it follows the person to another OpenVibe site they open from a link
++ * OVOverlay.close(id) · OVOverlay.list() · OVOverlay.declare(id, descriptor | null)
++ * declare: a page that draws its own floating player (Live's mini player moves its <video>) says what to open on
++ * the next site, without this file drawing anything here.
++ *
++ * Handling (one gesture set for every card): drag anywhere and it settles in the nearest corner, a flick carries it;
++ * more than half off the side, or a sideways throw past the edge, closes it; the grip on its inner corner (mouse),
++ * two fingers or a double tap resize it; a tap shows its controls (a mouse hover always does). It sits between the
++ * navbar and the phone's app bar, never under either. Corner and size are kept per card kind on this device.
++ *
++ * Across sites: while a portable card is open (or declared), a same-tab click on a link to another OpenVibe site adds
++ * `#ovo=<data>` to it; the next site's navbar sees it, loads this file and opens the same cards (the hash never
++ * reaches a server, and is removed from the address at once). Only first-party https sources are ever opened: the
++ * hosts below, checked again on arrival. Within one site the cards come back after a reload (sessionStorage).
++ */
++(function (root) {
++ 'use strict';
++ if (!root || root.OVOverlay || typeof document === 'undefined') return;
++
++ // Every source a card may frame, and every site a card may follow the person to.
++ const FIRST_PARTY = /^(?:[a-z0-9-]+\.)*openvibe\.(?:live|chat|actor|codes|network|games|video|design|report|my|link|computer|tools|services|space|community|news|wiki|blog|mail|email|events|help|quest|rent|work|food|host|deals|coupons|trade|tips|vip|zone|run|search|today|surf|meme|vision|exchange|fun|cam|casino|tours|shoes|connect|media|pics|download|domains|studio|party|locker|watch|bot|inventory)$/;
++ const KEY = 'ov-overlay';
++ const MAX_CARDS = 3;
++ const cards = new Map(); // id → card
++ const declared = new Map(); // id → descriptor (drawn by the page itself)
++ let styleEl = null, clicksWired = false;
++
++ const reduced = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
++ const phone = () => { try { return root.matchMedia('(max-width: 759px)').matches; } catch { return false; } };
++ const fine = () => { try { return root.matchMedia('(hover: hover) and (pointer: fine)').matches; } catch { return false; } };
++
++ /** A first-party https URL, or null. */
++ function safeSrc(u) {
++ try {
++ const url = new URL(String(u || ''), root.location.href);
++ if (url.protocol !== 'https:' && !(url.origin === root.location.origin && /^https?:$/.test(url.protocol))) return null;
++ if (url.origin !== root.location.origin && !FIRST_PARTY.test(url.hostname)) return null;
++ return url.href;
++ } catch { return null; }
++ }
++ const clean = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u001f]/g, '').slice(0, n);
++
++ /** A descriptor as it may travel: only these fields, every URL checked. */
++ function portableOf(d) {
++ const src = safeSrc(d && d.src);
++ if (!src) return null;
++ const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live };
++ const href = safeSrc(d.href); if (href) out.href = href;
++ const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };
++ if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);
++ return out;
++ }
++
++ const ICON = {
++ chat: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-5 4V6a1 1 0 0 1 1-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
++ open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
++ sound: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor"/><path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
++ muted: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor"/><path d="M17 9.5l5 5M22 9.5l-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
++ close: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
++ grip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13L13 3M8 13l5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
++ };
++
++ function styles() {
++ if (styleEl) return;
++ styleEl = document.createElement('style');
++ styleEl.id = 'ov-overlay-style';
++ styleEl.textContent = `
++.ovo-card{position:fixed;left:0;top:0;z-index:9980;width:var(--ovo-w,320px);aspect-ratio:var(--ovo-ar,16/9);border-radius:14px;overflow:hidden;background:#000;
++ transform:translate3d(var(--ovo-x,0px),var(--ovo-y,0px),0);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.1);
++ touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:grab;will-change:transform}
++.ovo-card.is-settling{transition:transform .34s cubic-bezier(.2,.8,.2,1),width .34s cubic-bezier(.2,.8,.2,1),opacity .25s}
++.ovo-card.is-dragging{cursor:grabbing;box-shadow:0 26px 60px -14px rgba(0,0,0,.7),0 0 0 1px rgba(255,255,255,.16)}
++.ovo-card.is-leaving{opacity:.35}
++.ovo-card.is-entering{opacity:0;transform:translate3d(var(--ovo-x,0px),calc(var(--ovo-y,0px) + 16px),0) scale(.96)}
++.ovo-frame{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000;pointer-events:none}
++.ovo-card.is-live-input .ovo-frame{pointer-events:auto}
++.ovo-ui{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:space-between;opacity:0;transition:opacity .2s;background:linear-gradient(rgba(0,0,0,.62),rgba(0,0,0,0) 42%,rgba(0,0,0,0) 58%,rgba(0,0,0,.55))}
++.ovo-card.is-ui .ovo-ui{opacity:1}
++@media (hover:hover) and (pointer:fine){.ovo-card:hover .ovo-ui{opacity:1}}
++.ovo-top,.ovo-row{display:flex;align-items:center;gap:6px;padding:7px 8px}
++.ovo-live{flex:none;display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:999px;background:#e5484d;color:#fff;font:800 10px/1.6 system-ui,sans-serif;letter-spacing:.06em}
++.ovo-live::before{content:'';width:6px;height:6px;border-radius:50%;background:#fff}
++.ovo-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;font:700 12.5px/1.3 system-ui,sans-serif;text-shadow:0 1px 2px rgba(0,0,0,.5)}
++.ovo-row{justify-content:flex-end}
++.ovo-b{flex:none;display:inline-grid;place-items:center;width:34px;height:34px;border:0;border-radius:999px;background:rgba(20,22,30,.55);color:#fff;cursor:pointer;
++ -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);transition:background-color .15s,transform .15s}
++.ovo-b svg{width:18px;height:18px}
++.ovo-b:hover{background:rgba(255,255,255,.24)}
++.ovo-b:active{transform:scale(.9)}
++.ovo-b:focus-visible{outline:2px solid #fff;outline-offset:1px}
++.ovo-b[aria-pressed="true"]{background:#fff;color:#0b0f19}
++.ovo-x{width:28px;height:28px}
++.ovo-x svg{width:15px;height:15px}
++.ovo-grip{position:absolute;width:26px;height:26px;display:grid;place-items:center;color:rgba(255,255,255,.85);cursor:nwse-resize;opacity:0;transition:opacity .2s}
++.ovo-grip svg{width:14px;height:14px}
++.ovo-card:hover .ovo-grip{opacity:1}
++.ovo-card[data-corner="br"] .ovo-grip{left:0;top:0;transform:rotate(180deg)}
++.ovo-card[data-corner="bl"] .ovo-grip{right:0;top:0;transform:rotate(270deg);cursor:nesw-resize}
++.ovo-card[data-corner="tr"] .ovo-grip{left:0;bottom:0;transform:rotate(90deg);cursor:nesw-resize}
++.ovo-card[data-corner="tl"] .ovo-grip{right:0;bottom:0}
++@media (hover:none){.ovo-grip{display:none}}
++.ovo-side{position:fixed;z-index:9979;display:flex;flex-direction:column;overflow:hidden;border-radius:16px;background:var(--bg-card,#101624);
++ box-shadow:0 20px 48px -14px rgba(0,0,0,.65),0 0 0 1px var(--border,rgba(255,255,255,.12));opacity:0;transform:translateY(10px) scale(.98);transform-origin:var(--ovo-origin,100% 100%);
++ transition:opacity .22s,transform .3s cubic-bezier(.2,.8,.2,1)}
++.ovo-side.is-open{opacity:1;transform:none}
++.ovo-side-h{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-primary,#e6edf7);font:700 13px/1.3 system-ui,sans-serif}
++.ovo-side-h span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ovo-side-h .ovo-b{background:transparent;color:inherit;width:30px;height:30px}
++.ovo-side-h .ovo-b:hover{background:rgba(127,127,127,.18)}
++.ovo-side iframe{flex:1;width:100%;border:0;background:transparent}
++@media (prefers-reduced-motion:reduce){.ovo-card.is-settling,.ovo-side{transition:none}.ovo-card.is-entering{transform:translate3d(var(--ovo-x,0px),var(--ovo-y,0px),0)}}
++`;
++ (document.head || document.documentElement).appendChild(styleEl);
++ }
++
++ // ── Where a card may sit: between the navbar and the phone's app bar ──
++ function bounds() {
++ const nav = document.querySelector('.openvibe-navbar');
++ const nr = nav ? nav.getBoundingClientRect() : null;
++ const top = Math.max(8, nr && nr.bottom > 0 ? nr.bottom + 8 : 64);
++ const bar = document.querySelector('.ovnav-appbar');
++ const barH = bar ? bar.getBoundingClientRect().height : 0;
++ const m = phone() ? 10 : 16;
++ return { left: m, right: root.innerWidth - m, top, bottom: root.innerHeight - barH - m };
++ }
++ const heightOf = (c, w) => Math.round(w / c.aspect);
++ function clampW(w) {
++ const min = phone() ? 160 : 220, max = Math.min(phone() ? root.innerWidth - 20 : 640, root.innerWidth * (phone() ? 0.94 : 0.6));
++ return Math.round(Math.max(min, Math.min(max, w)));
++ }
++ function cornerXY(c, corner, w) {
++ const b = bounds(), h = heightOf(c, w);
++ return { x: corner[1] === 'l' ? b.left : b.right - w, y: corner[0] === 't' ? b.top : Math.max(b.top, b.bottom - h) };
++ }
++ function place(c, x, y, w, settle) {
++ c.x = x; c.y = y; c.w = w;
++ c.el.classList.toggle('is-settling', !!settle && !reduced());
++ c.el.style.setProperty('--ovo-w', `${w}px`);
++ c.el.style.setProperty('--ovo-x', `${Math.round(x)}px`);
++ c.el.style.setProperty('--ovo-y', `${Math.round(y)}px`);
++ placeSide(c);
++ }
++ function settle(c, corner, w) {
++ c.corner = corner;
++ c.el.setAttribute('data-corner', corner);
++ const p = cornerXY(c, corner, w);
++ place(c, p.x, p.y, w, true);
++ try { localStorage.setItem(`${KEY}:${c.kindKey}`, JSON.stringify({ corner, w })); } catch { /* private mode */ }
++ }
++ function nearest(c, x, y, w) {
++ const b = bounds(), h = heightOf(c, w);
++ return `${y + h / 2 < (b.top + b.bottom) / 2 ? 't' : 'b'}${x + w / 2 < (b.left + b.right) / 2 ? 'l' : 'r'}`;
++ }
++ function freeCorner(want, self) {
++ const used = new Set([...cards.values()].filter((c) => c !== self).map((c) => c.corner));
++ if (!used.has(want)) return want;
++ return ['br', 'bl', 'tr', 'tl'].find((k) => !used.has(k)) || want;
++ }
++ function showUi(c) {
++ c.el.classList.add('is-ui');
++ clearTimeout(c.idle);
++ c.idle = setTimeout(() => { if (!c.el.matches(':focus-within')) c.el.classList.remove('is-ui'); }, 3200);
++ }
++
++ // ── One gesture set: drag (a flick carries it, off the side closes it), pinch and the grip resize, double tap ──
++ function wire(c) {
++ const box = c.el, pts = new Map(), vel = [];
++ const capture = (id) => { try { box.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } };
++ let drag = null, pinch = null, grip = null, lastTap = 0;
++ box.addEventListener('pointerdown', (e) => {
++ if (e.target.closest('.ovo-b')) return;
++ if (e.target.closest('.ovo-grip')) { grip = { x: e.clientX, w: c.w }; capture(e.pointerId); e.preventDefault(); return; }
++ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ capture(e.pointerId);
++ if (pts.size === 2) { const [a, b] = [...pts.values()]; pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1, w: c.w }; drag = null; return; }
++ drag = { sx: e.clientX, sy: e.clientY, x: c.x, y: c.y, moved: false };
++ vel.length = 0;
++ });
++ box.addEventListener('pointermove', (e) => {
++ if (grip) { const w = clampW(grip.w + (e.clientX - grip.x) * (c.corner[1] === 'r' ? -1 : 1)); const p = cornerXY(c, c.corner, w); place(c, p.x, p.y, w, false); return; }
++ if (!pts.has(e.pointerId)) return;
++ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
++ if (pinch && pts.size === 2) {
++ const [a, b] = [...pts.values()];
++ const w = clampW(pinch.w * (Math.hypot(a.x - b.x, a.y - b.y) / pinch.d));
++ const cx = c.x + c.w / 2, cy = c.y + heightOf(c, c.w) / 2;
++ place(c, cx - w / 2, cy - heightOf(c, w) / 2, w, false);
++ return;
++ }
++ if (!drag) return;
++ const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
++ if (!drag.moved && Math.hypot(dx, dy) < 6) return;
++ if (!drag.moved) { drag.moved = true; box.classList.add('is-dragging'); }
++ vel.push({ x: e.clientX, y: e.clientY, t: performance.now() }); if (vel.length > 6) vel.shift();
++ place(c, drag.x + dx, drag.y + dy, c.w, false);
++ const b = bounds();
++ box.classList.toggle('is-leaving', c.x + c.w < b.left + c.w * 0.45 || c.x > b.right - c.w * 0.55);
++ });
++ const end = (e) => {
++ if (grip) { grip = null; settle(c, c.corner, c.w); return; }
++ pts.delete(e.pointerId);
++ if (pinch) { if (pts.size < 2) { pinch = null; settle(c, nearest(c, c.x, c.y, c.w), c.w); } return; }
++ if (!drag) return;
++ const d = drag; drag = null;
++ box.classList.remove('is-dragging');
++ if (!d.moved) {
++ const now = performance.now();
++ if (now - lastTap < 300) { lastTap = 0; const big = c.w < clampW(phone() ? root.innerWidth : 520) * 0.8; settle(c, c.corner, clampW(big ? (phone() ? root.innerWidth - 20 : 520) : (phone() ? 200 : 320))); return; }
++ lastTap = now;
++ if (box.classList.contains('is-ui') && !fine()) { box.classList.remove('is-ui'); clearTimeout(c.idle); } else showUi(c);
++ return;
++ }
++ let vx = 0, vy = 0;
++ if (vel.length > 1) { const a = vel[0], z = vel[vel.length - 1]; const dt = Math.max(1, z.t - a.t); vx = (z.x - a.x) / dt; vy = (z.y - a.y) / dt; }
++ const b = bounds(), tdx = c.x - d.x, tdy = c.y - d.y;
++ const sideways = Math.abs(vx) > 1.2 && Math.abs(vx) > Math.abs(vy) * 1.5 && Math.abs(tdx) > Math.abs(tdy) * 2;
++ const off = c.x + c.w < b.left + c.w * 0.45 || c.x > b.right - c.w * 0.55
++ || (sideways && vx > 0 && c.x + c.w > b.right + c.w * 0.15) || (sideways && vx < 0 && c.x < b.left - c.w * 0.15);
++ if (off) { flyOff(c, vx >= 0 || c.x > b.right - c.w ? 1 : -1); return; }
++ box.classList.remove('is-leaving');
++ settle(c, nearest(c, c.x + vx * 180, c.y + vy * 180, c.w), c.w);
++ };
++ box.addEventListener('pointerup', end);
++ box.addEventListener('pointercancel', end);
++ box.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(c.id); });
++ }
++ function flyOff(c, dir) {
++ c.el.classList.remove('is-dragging');
++ if (reduced()) { close(c.id); return; }
++ c.el.classList.add('is-settling', 'is-leaving');
++ c.el.style.setProperty('--ovo-x', `${dir > 0 ? root.innerWidth + 20 : -c.w - 20}px`);
++ setTimeout(() => close(c.id), 300);
++ }
++
++ // ── The side frame (a chat beside the picture; a sheet above the app bar on a phone) ──
++ function toggleSide(c, force) {
++ const open = force != null ? force : !c.side;
++ const btn = c.el.querySelector('[data-ovo="side"]');
++ if (!open) {
++ const s = c.side; c.side = null;
++ if (btn) btn.setAttribute('aria-pressed', 'false');
++ if (s) { s.classList.remove('is-open'); setTimeout(() => s.remove(), reduced() ? 0 : 260); if (cards.has(c.id)) settle(c, c.corner, c.w); }
++ return;
++ }
++ if (c.side || !c.d.side) return;
++ const s = document.createElement('section');
++ s.className = 'ovo-side';
++ s.setAttribute('aria-label', c.d.side.title || `${c.d.title} chat`);
++ const h = document.createElement('div'); h.className = 'ovo-side-h';
++ const t = document.createElement('span'); t.textContent = c.d.side.title || `${c.d.title} · chat`;
++ const x = button('Close', ICON.close, () => toggleSide(c, false));
++ h.append(t, x);
++ const f = frame(c.d.side.src, c.d.side.title || 'Chat');
++ f.style.pointerEvents = 'auto';
++ s.append(h, f);
++ document.body.appendChild(s);
++ c.side = s;
++ if (btn) btn.setAttribute('aria-pressed', 'true');
++ placeSide(c);
++ if (phone()) { const r = s.getBoundingClientRect(), b = bounds(); place(c, c.corner[1] === 'l' ? b.left : b.right - c.w, Math.max(b.top, r.top - heightOf(c, c.w) - 8), c.w, true); }
++ requestAnimationFrame(() => requestAnimationFrame(() => s.classList.add('is-open')));
++ }
++ function placeSide(c) {
++ const s = c.side; if (!s) return;
++ const b = bounds(), h = heightOf(c, c.w);
++ if (phone()) {
++ const sh = Math.round(Math.min(root.innerHeight * 0.55, b.bottom - b.top - h - 12));
++ Object.assign(s.style, { left: '8px', right: '8px', width: 'auto', top: 'auto', bottom: `${root.innerHeight - b.bottom}px`, height: `${sh}px` });
++ s.style.setProperty('--ovo-origin', '50% 100%');
++ return;
++ }
++ const w = Math.max(320, c.w), sh = Math.min(460, b.bottom - b.top - 10);
++ const left = Math.min(Math.max(b.left, c.corner[1] === 'r' ? c.x + c.w - w : c.x), b.right - w);
++ const above = c.corner[0] === 'b';
++ const top = above ? Math.max(b.top, c.y - sh - 10) : Math.min(c.y + h + 10, b.bottom - sh);
++ Object.assign(s.style, { left: `${left}px`, top: `${top}px`, width: `${w}px`, height: `${sh}px`, right: 'auto', bottom: 'auto' });
++ s.style.setProperty('--ovo-origin', `${c.corner[1] === 'r' ? '100%' : '0%'} ${above ? '100%' : '0%'}`);
++ }
++
++ function button(label, icon, fn, cls) {
++ const b = document.createElement('button');
++ b.type = 'button'; b.className = `ovo-b${cls ? ` ${cls}` : ''}`;
++ b.setAttribute('aria-label', label); b.title = label; b.innerHTML = icon;
++ b.addEventListener('click', (e) => { e.stopPropagation(); fn(b); });
++ return b;
++ }
++ function frame(src, title) {
++ const f = document.createElement('iframe');
++ f.className = 'ovo-frame';
++ f.src = src;
++ f.title = title || 'OpenVibe';
++ f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms');
++ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
++ f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
++ return f;
++ }
++
++ // ── Opening and closing ──
++ function open(desc) {
++ const d = portableOf(desc);
++ if (!d) return null;
++ d.portable = desc.portable !== false;
++ if (cards.has(d.id)) { const c = cards.get(d.id); showUi(c); return handle(c); }
++ if (cards.size >= MAX_CARDS) close(cards.keys().next().value);
++ styles();
++ wireClicks();
++ const el = document.createElement('div');
++ el.className = 'ovo-card is-entering';
++ el.id = `ovo-${d.id}`;
++ el.setAttribute('role', 'region');
++ el.setAttribute('aria-label', `${d.title || 'OpenVibe'}${d.live ? ', live' : ''}, floating`);
++ const c = { id: d.id, d, el, x: 0, y: 0, w: 0, corner: 'br', side: null, idle: 0, aspect: d.aspect || 16 / 9, kindKey: new URL(d.src).hostname.split('.').slice(-2).join('.') };
++ el.style.setProperty('--ovo-ar', String(c.aspect));
++ const ui = document.createElement('div'); ui.className = 'ovo-ui';
++ const top = document.createElement('div'); top.className = 'ovo-top';
++ if (d.live) { const l = document.createElement('span'); l.className = 'ovo-live'; l.textContent = 'LIVE'; top.appendChild(l); }
++ const name = document.createElement('span'); name.className = 'ovo-name'; name.textContent = d.title || '';
++ top.append(name, button('Close', ICON.close, () => close(c.id), 'ovo-x'));
++ const row = document.createElement('div'); row.className = 'ovo-row';
++ // A player that speaks the embed messages (Live's /embed/<name>): its sound, and its state back (title, live, muted).
++ const embed = /^\/embed\//.test(new URL(d.src).pathname);
++ if (embed) {
++ const snd = button('Turn the sound on', ICON.muted, () => {
++ const f = el.querySelector('.ovo-frame');
++ try { f.contentWindow.postMessage({ type: 'ov-embed', cmd: c.muted === false ? 'mute' : 'unmute' }, new URL(d.src).origin); } catch { /* */ }
++ showUi(c);
++ });
++ snd.setAttribute('data-ovo', 'sound');
++ row.appendChild(snd);
++ }
++ if (d.side) { const s = button('Chat', ICON.chat, () => { toggleSide(c); showUi(c); }); s.setAttribute('data-ovo', 'side'); s.setAttribute('aria-pressed', 'false'); row.appendChild(s); }
++ if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
++ ui.append(top, row);
++ const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');
++ el.append(frame(d.src, d.title), ui, grip);
++ document.body.appendChild(el);
++ cards.set(c.id, c);
++ let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
++ const corner = freeCorner(desc.corner || (pref && /^[tb][lr]$/.test(pref.corner) ? pref.corner : 'br'), c);
++ const w = clampW(desc.w || (pref && pref.w) || (phone() ? Math.min(240, root.innerWidth * 0.56) : 360));
++ const p = cornerXY(c, corner, w);
++ c.corner = corner; el.setAttribute('data-corner', corner);
++ place(c, p.x, p.y, w, false);
++ void el.offsetWidth; // the entering style is drawn once; dropping it now plays the entrance
++ el.classList.add('is-settling');
++ el.classList.remove('is-entering');
++ wire(c);
++ if (!fine()) showUi(c);
++ remember();
++ return handle(c);
++ }
++ function handle(c) {
++ return { id: c.id, close: () => close(c.id), el: c.el, update: (u) => { if (u && u.title != null) { c.d.title = clean(u.title, 80); const n = c.el.querySelector('.ovo-name'); if (n) n.textContent = c.d.title; remember(); } } };
++ }
++ function close(id) {
++ const c = cards.get(id); if (!c) return;
++ toggleSide(c, false);
++ cards.delete(id);
++ clearTimeout(c.idle);
++ try { c.el.remove(); } catch { /* */ }
++ remember();
++ }
++ function list() { return [...cards.values()].map((c) => ({ ...c.d })); }
++ function declare(id, desc) {
++ const key = clean(id, 40);
++ if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }
++ if (declared.size) wireClicks();
++ }
++
++ // ── Within a site: the cards come back after a reload ──
++ function remember() {
++ try {
++ const keep = [...cards.values()].filter((c) => c.d.portable).map((c) => portableOf(c.d)).filter(Boolean);
++ if (keep.length) sessionStorage.setItem(KEY, JSON.stringify(keep)); else sessionStorage.removeItem(KEY);
++ } catch { /* private mode */ }
++ }
++
++ // ── Across sites: a link to another OpenVibe site carries the cards in its hash ──
++ const b64 = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++ const unb64 = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, '+').replace(/_/g, '/'))));
++ function travelling() {
++ const out = [...cards.values()].filter((c) => c.d.portable).map((c) => portableOf(c.d));
++ for (const d of declared.values()) out.push(d);
++ return out.filter(Boolean).slice(0, MAX_CARDS);
++ }
++ function handoffUrl(href) {
++ const list = travelling();
++ if (!list.length) return href;
++ try {
++ const u = new URL(href, root.location.href);
++ if (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;
++ const data = b64(JSON.stringify(list));
++ if (data.length > 3000) return href;
++ u.hash = `ovo=${data}`;
++ return u.href;
++ } catch { return href; }
++ }
++ function wireClicks() {
++ if (clicksWired) return;
++ clicksWired = true;
++ document.addEventListener('click', (e) => {
++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
++ const next = handoffUrl(a.href);
++ if (next !== a.href) a.href = next; // the browser follows the link as it was going to, with the cards
++ }, true);
++ }
++ /** On arrival: open the cards a link carried here, then take the hash out of the address. */
++ function restore() {
++ const m = /(?:^#|&)ovo=([A-Za-z0-9_-]{1,3000})/.exec(root.location.hash || '');
++ let list = null;
++ if (m) {
++ try { list = JSON.parse(unb64(m[1])); } catch { list = null; }
++ try { const u = new URL(root.location.href); u.hash = ''; root.history.replaceState(root.history.state, '', u.href.replace(/#$/, '')); } catch { /* */ }
++ } else {
++ try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }
++ }
++ if (!Array.isArray(list)) return 0;
++ let n = 0;
++ for (const d of list.slice(0, MAX_CARDS)) if (open(d)) n += 1;
++ return n;
++ }
++
++ root.addEventListener('message', (e) => {
++ const m = e.data;
++ if (!m || m.type !== 'ov-embed' || m.event !== 'state') return;
++ for (const c of cards.values()) {
++ const f = c.el.querySelector('.ovo-frame');
++ if (!f || e.source !== f.contentWindow || e.origin !== new URL(c.d.src).origin) continue;
++ c.muted = !!m.muted;
++ const b = c.el.querySelector('[data-ovo="sound"]');
++ if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }
++ if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
++ const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
++ }
++ });
++ root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });
++ root.OVOverlay = { open, close, list, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', restore, { once: true }); else restore();
++})(typeof window !== 'undefined' ? window : null);
ddiff --git a/package.json b/package.json
iindex 7c9a579..fc88e0d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.151.0",
++ "version": "4.152.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -48,6 +48,7 @@
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
++ "./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 7de2355..8ffbdb0 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js'];
++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex a25b9bf..387d123 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 = 45 * 1024; // 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 46 * 1024; // 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/overlay-chrome.test.js b/test/overlay-chrome.test.js
nnew file mode 100644
iindex 0000000..e4eb3ac
---- /dev/null
++++ b/test/overlay-chrome.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++// Floating cards (overlay.js, 4.152.0; the owner, 11-08: a mini player you move and resize like YouTube on Android,
++// with the room's chat, that works across OpenVibe sites). In a real headless Chrome: a card opens in the bottom
++// corner above the phone's app bar; a drag settles it in the nearest corner; its chat opens beside it; a link to
++// another OpenVibe site carries it in #ovo=; the next page opens it again and takes the hash out of the address; a
++// source that is not first-party is never opened; a reload brings it back. Skipped without Chrome.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const ROOT = path.join(__dirname, '..');
++const h = require('../browser-harness');
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('overlay-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Overlay</title><link rel="icon" href="data:,">
++<style>body{margin:0;background:#0b0f19} .openvibe-navbar{position:sticky;top:0;height:56px;background:#111827} .ovnav-appbar{position:fixed;left:0;right:0;bottom:0;height:58px;background:#111827}</style>
++</head><body><nav class="openvibe-navbar"></nav><p style="height:2000px"><a id="away" href="https://openvibe.games/achievements">Games</a> <a id="here" href="/b">here</a></p><div class="ovnav-appbar"></div>
++<script src="/shared/overlay.js"></script></body></html>`;
++const FRAME = '<!doctype html><body style="margin:0;background:#2b6;color:#fff;font:20px sans-serif">LIVE</body>';
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
++ if (u.pathname.startsWith('/frame')) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(FRAME); }
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);
++});
++const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) return null; const r = c.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), corner: c.dataset.corner }; })()`;
++
++(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-overlay-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390, height: 844 });
++ await p.goto(`${base}/a`, { maxMs: 5000 });
++ assert.ok(await until(p, `!!window.OVOverlay`), 'the host loads');
++ await p.evaluate(`OVOverlay.open({ id: 'live', src: '/frame-a', title: 'maticus', live: true, href: '/a', side: { src: '/frame-chat', title: 'maticus · chat' } }), true`);
++ await wait(500);
++ const r0 = await p.evaluate(RECT); if (!r0 || r0.corner !== 'br') console.log('r0', JSON.stringify(r0), await p.evaluate('document.querySelectorAll(".ovo-card").length'));
++ assert.ok(r0 && r0.corner === 'br', 'it opens in the bottom-right corner');
++ assert.strictEqual(r0.y + r0.h, 844 - 58 - 10, 'it sits right above the phone\'s app bar, never under it');
++ assert.strictEqual(r0.x + r0.w, 390 - 10, 'a margin from the side');
++
++ // A drag settles it in the nearest corner (top-left), under the navbar.
++ await p.evaluate(`(() => { const c = document.querySelector('.ovo-card'); const r = c.getBoundingClientRect();
++ const fire = (type, x, y) => c.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 7, clientX: x, clientY: y, pointerType: 'touch', isPrimary: true }));
++ const x0 = r.left + r.width / 2, y0 = r.top + r.height / 2; fire('pointerdown', x0, y0);
++ for (let i = 1; i <= 10; i++) fire('pointermove', x0 - (x0 - 90) * i / 10, y0 - (y0 - 150) * i / 10);
++ fire('pointerup', 90, 150); })(), true`);
++ await wait(450);
++ const r1 = await p.evaluate(RECT);
++ assert.deepStrictEqual([r1.corner, r1.x, r1.y], ['tl', 10, 56 + 8], 'top-left, under the navbar');
++
++ // Its chat opens as a sheet above the app bar on a phone.
++ await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);
++ await wait(400);
++ const side = await p.evaluate(`(() => { const s = document.querySelector('.ovo-side'); const r = s.getBoundingClientRect(); return { open: s.classList.contains('is-open'), bottom: Math.round(r.bottom), src: s.querySelector('iframe').getAttribute('src') }; })()`);
++ assert.deepStrictEqual(side, { open: true, bottom: 844 - 58 - 10, src: `${base}/frame-chat` }, 'the chat sheet, above the app bar');
++ await p.evaluate(`document.querySelector('[data-ovo="side"]').click(), true`);
++
++ // A link to another OpenVibe site carries it; one on this site does not.
++ const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
++ assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
++ const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
++ assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
++ assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash');
++ assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('https://example.com/x')`), 'https://example.com/x', 'not an OpenVibe site: no hash');
++
++ // Arriving with the hash: the card opens and the hash leaves the address.
++ const data = away.split('#ovo=')[1];
++ const q = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
++ await q.goto(`${base}/b#ovo=${data}`, { maxMs: 5000 });
++ assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'the carried card opens');
++ assert.strictEqual(await q.evaluate(`location.hash`), '', 'the hash is gone from the address');
++ assert.strictEqual(await q.evaluate(`document.querySelector('.ovo-card .ovo-name').textContent`), 'maticus');
++ // Reload: it comes back (this site's session).
++ await q.goto(`${base}/b`, { maxMs: 5000 });
++ assert.ok(await until(q, `!!document.querySelector('.ovo-card')`), 'a reload brings it back');
++ await q.evaluate(`OVOverlay.close('live'), true`);
++ assert.strictEqual(await q.evaluate(`sessionStorage.getItem('ov-overlay')`), null, 'closed: nothing kept');
++
++ // A source that is not first-party is never opened, from the hash or from a call.
++ const evil = Buffer.from(JSON.stringify([{ id: 'x', src: 'https://evil.example/steal', title: 'x' }])).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++ const e = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 900 });
++ await e.goto(`${base}/c#ovo=${evil}`, { maxMs: 5000 });
++ await until(e, `!!window.OVOverlay`);
++ await wait(200);
++ assert.strictEqual(await e.evaluate(`document.querySelectorAll('.ovo-card').length`), 0, 'a foreign source is refused');
++ assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'javascript:alert(1)' })`), null);
++ assert.strictEqual(await e.evaluate(`OVOverlay.open({ src: 'http://openvibe.live/embed/x' })`), null, 'plain http to another site is refused');
++ assert.ok(await e.evaluate(`!!OVOverlay.safeSrc('https://openvibe.live/embed/maticus')`), "Live's embed is first-party");
++ console.log('overlay-chrome: ok');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });