openvibers/openvibe.shared Commits
4.152.0: floating cards that follow you across OpenVibe sites; the next site starts sooner (#342)
Changes
@@@ -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@@@ -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 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.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)",@@@ -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@@@ -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);nnew file mode 100644@@@ -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);@@@ -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",@@@ -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'] },@@@ -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 ──────────────────────────{nnew file mode 100644@@@ -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); });
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# 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
## 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
- **The menu marks the page you are on** (the owner, on openvibe.chat/messages: "highlight based on page"). In
@@ -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.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
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.151.0",
+ "version": "4.152.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)",
@@ -22,7 +22,7 @@ const BROWSER = Object.freeze([
@@ -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)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'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-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-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
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
@@ -539,7 +539,7 @@
@@ -539,7 +539,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1297,6 +1297,38 @@
@@ -1297,6 +1297,38 @@
if (document.getElementById('ov-presence-loader')) return;
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);
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.
// 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 || {})); }); }
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.
// Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
@@ -1955,6 +1987,7 @@
@@ -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).
// 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 (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms();
if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }
if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); }
+ watchOverlays(); warmLinks();
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
@@ -2364,6 +2397,10 @@
@@ -2364,6 +2397,10 @@
const OpenVibeNavbar = {
const OpenVibeNavbar = {
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
/** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */
busy(on) { busy(!!on); },
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 = {}) {
init(opts = {}) {
Object.assign(_config, opts);
Object.assign(_config, opts);
new file mode 100644
new file mode 100644
@@ -0,0 +1,451 @@
@@ -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);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -48,6 +48,7 @@
@@ -48,6 +48,7 @@
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
"./presence": "./presence.js",
"./presence": "./presence.js",
+ "./overlay": "./overlay.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-share": "./dock-share.js",
"./dock-share": "./dock-share.js",
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
@@ -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
// 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).
// 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',
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.
// 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'] },
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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 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 ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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); });