openvibers/openvibe.shared Commits
Shared 4.213.0: the menu button flight polished; rail menus in the top layer; the carried Live card designed (#480)
Changes
@@@ -1,5 +1,23 @@# Changelog++## 4.213.0 — 2026-11-11++++- **The menu button's flight, polished** (the owner, 11-11: "the icon becomes whiter than it was when idle … needs++ general polish … effects when it lands … improve both the open and close transitions"). search.js's flight now++ copies the pressed button exactly as it looked before the press (every computed paint frozen onto the copy: the++ navbar's colour rules stop at its edge, and the press had already turned it bright and half into an x). It drops++ first and glides into its tab, swelling a little and tilting into the turn with a short trail and a soft glow; it++ lands with a squash and a ring; flying back it does the same on the bar button. The menu's circle now grows from the++ button around the flight (it was cut short), the close orb turns in once the button has left, and turns away when++ closing with a button flying back. Calm plays the same path short, with no trail, tilt or glow; Off skips it.++- **The carried Live card, designed** (the player that keeps playing across sites, 4.212.0): at rest a live card keeps a++ small red dot in its corner; playing without sound (a browser that would not carry the sound over) it shows one++ "Tap for sound" chip, at rest too, that turns the sound on (only its icon on a card under 200 px); "Open the full++ page" draws a page with an arrow out of its corner, not fullscreen's two arrows.++- **A menu in a rail opens in the top layer** (Community's paste cards, 11-11: the ⋯ menu was cut off). A rail's edge++ fade is a mask, which hides even a fixed menu outside the rail's box; kit-web.js now shows a rail's `details.ovk-more`++ menu with the Popover API, placed under its button, and keeps the fixed placement where the API is missing.++## 4.212.0 — 2026-11-11A train of four pull requests (one line each):@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.212.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.213.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.212.0",++ "version": "4.213.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)",@@@ -76,11 +76,19 @@if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), close: () => { d.open = false; } });if (d.open) d.__ovSurface.open(); else d.__ovSurface.close();}-- // Inside a rail (a sideways scroller clips anything that hangs out of it) the menu is placed on the screen.++ // Inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides++ // even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on++ // the screen. Without the Popover API it stays fixed, as before.const pop = d.querySelector('.ovk-pop');-- if (!pop || !d.closest('.ovk-rail-track')) return;++ if (!pop || !d.closest('.ovk-rail-track, [data-ovk-scroll]')) return;++ const topLayer = typeof pop.showPopover === 'function';pop.classList.toggle('is-fixed', d.open);-- if (!d.open) { pop.style.left = pop.style.top = ''; return; }++ if (!d.open) {++ if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* already closed */ } pop.removeAttribute('popover'); }++ pop.style.left = pop.style.top = '';++ return;++ }++ if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }const r = d.getBoundingClientRect();const w = pop.offsetWidth, h = pop.offsetHeight;pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`;@@@ -602,7 +602,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'aee8e54637c2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '129aad6157d1', 'dock.js': 'aa5ad5d80283', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '6e2e58c4bcbf', 'ov-home.js': '0724f4913525', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'aee8e54637c2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aa5ad5d80283', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0724f4913525', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -73,7 +73,7 @@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>',++ open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-8.5 8.5M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10" 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>',@@@ -100,6 +100,22 @@.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}++/* At rest (controls hidden) a live card keeps a small red dot in its corner, so it reads as live without a tap (4.213.0). */++.ovo-dot{position:absolute;left:9px;top:9px;width:9px;height:9px;border-radius:50%;background:#e5484d;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 1px 4px rgba(0,0,0,.5);pointer-events:none;transition:opacity .2s}++.ovo-card.is-ui .ovo-dot{opacity:0}++@media (hover:hover) and (pointer:fine){.ovo-card:hover .ovo-dot{opacity:0}}++/* Playing without sound (a browser blocked carried sound): one clear chip says so and takes the tap, at rest too. */++.ovo-unmute{position:absolute;left:8px;bottom:8px;z-index:1;display:none;align-items:center;gap:6px;height:30px;padding:0 11px 0 8px;border:0;border-radius:999px;background:rgba(10,12,18,.72);color:#fff;font:700 12px/1 system-ui,sans-serif;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 0 0 1px rgba(255,255,255,.18)}++.ovo-unmute svg{width:16px;height:16px;flex:none}++.ovo-card.is-muted .ovo-unmute{display:inline-flex}++.ovo-card.is-muted .ovo-unmute.is-new{animation:ovo-call 1.6s cubic-bezier(.2,.8,.2,1) .4s 2}++@keyframes ovo-call{0%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,.45)}70%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 calc(10px * var(--ov-move, 1)) rgba(255,255,255,0)}100%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,0)}}++html[data-ov-motion="off"] .ovo-unmute.is-new{animation:none}++.ovo-unmute:hover{background:rgba(10,12,18,.86)}++.ovo-unmute:focus-visible{outline:2px solid #fff;outline-offset:2px}++.ovo-card.is-muted .ovo-row [data-ovo="sound"]{display:none}++.ovo-card.is-narrow .ovo-unmute span{display:none}++.ovo-card.is-narrow .ovo-unmute{padding:0 7px}.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;@@@ -166,6 +182,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xconst 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 */ }++ c.el.classList.toggle('is-narrow', w < 200); // the muted chip keeps only its icon on a small card}function nearest(c, x, y, w) {const b = bounds(), h = heightOf(c, w);@@@ -352,6 +369,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xconst 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 dot = d.live ? Object.assign(document.createElement('span'), { className: 'ovo-dot' }) : null;++ if (dot) dot.setAttribute('aria-hidden', 'true');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';@@@ -365,6 +384,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x});snd.setAttribute('data-ovo', 'sound');row.appendChild(snd);++ // Muted (the browser would not carry the sound over): the chip that turns it on, at rest as well.++ const chip = document.createElement('button');++ chip.type = 'button'; chip.className = 'ovo-unmute is-new';++ chip.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;++ chip.setAttribute('aria-label', 'Turn the sound on');++ chip.addEventListener('click', (e) => { e.stopPropagation(); chip.classList.remove('is-new'); snd.click(); });++ chip.addEventListener('pointerdown', (e) => e.stopPropagation());++ c.unmute = chip;}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; }));@@@ -374,7 +401,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xif (d.sound && embed) mediaFrame.addEventListener('load', () => {try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(mediaFrame.src).origin); } catch { /* browser may require a new tap */ }});-- el.append(mediaFrame, ui, grip);++ el.append(mediaFrame, ...(dot ? [dot] : []), ui, ...(c.unmute ? [c.unmute] : []), grip);document.body.appendChild(el);cards.set(c.id, c);let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }@@@ -491,6 +518,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xc.d.playing = !!m.playing;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'); }++ c.el.classList.toggle('is-muted', c.muted && !!m.playing);++ c.el.classList.toggle('is-narrow', c.el.getBoundingClientRect().width < 200);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;remember();@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.212.0",++ "version": "4.213.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.212.0",++ "version": "4.213.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.212.0",++ "version": "4.213.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -160,6 +160,8 @@.ovk-rail-prev { left: 6px; }.ovk-rail-next { right: 6px; }.ovk-pop.is-fixed { position: fixed; right: auto; top: auto; }++/* In the top layer (kit-web.js): the browser's popover defaults (centred, its own colours) give way to the menu's own. */++.ovk-pop.is-fixed[popover] { inset: auto; margin: 0; color: inherit; overflow: visible; }@media (max-width: 599px) {.ovk-rail { --ovk-rail-bleed: 16px; }@@@ -216,7 +216,8 @@.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }--.ovs-fly-part { position: absolute; display: grid; place-items: center; color: var(--ovk-text, var(--text-primary, #e8ecf4)); will-change: transform, opacity; }++.ovs-fly-part { position: absolute; display: grid; place-items: center; color: inherit; will-change: transform, opacity, filter; }++.ovs-fly-ring { position: fixed; z-index: 2147483600; border: 2px solid; border-radius: 50%; pointer-events: none; }.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }.ovs-fly-part--count { width: auto !important; height: auto !important; }@@@ -477,6 +478,12 @@.ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }++ /* 4.213.0: when the pressed button flew to its tab, the close turns in at the pill once it has left (a quarter turn,++ a little past its size); closing with the button flying back, it turns away where it is. */++ .ovs.ovs-anchored .ovs-orb.is-pop { animation: ovs-orb-in .38s cubic-bezier(.2, 1.3, .3, 1) .1s both; }++ .ovs.ovs-anchored .ovs-orb.is-gone { animation: ovs-orb-out .22s cubic-bezier(.5, 0, .75, .2) both; }++ @keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }++ @keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }.ovs.is-closing { animation: ovs-fade .28s ease-in both; }@@@ -1662,41 +1669,86 @@// ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the// tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications// tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's-- // icon and turns into it on the way (its own picture fades out as the tab's fades in, along one short arc); the-- // bell's count flies with it to the tab's count. Closing flies it back. Motion Off skips it; Reduced plays it short-- // and straight (Calm reduces, only Off removes).-- let flyFrom = null;++ // icon and turns into it on the way; the bell's count flies with it to the tab's count. Closing flies it back.++ // 4.213.0 (the owner, 11-11: "the icon becomes whiter than it was when idle … needs general polish … effects when it++ // lands … improve both the open and close transitions"):++ // - the copy is the button exactly as it looked when pressed: its look is frozen before the press turns it bright++ // and into an x, with every computed paint copied (the bar's colour rules stop at its edge, so a bare copy fell++ // back to the brightest text colour);++ // - it drops first and glides in last (a landing, not a line across the search field), swelling a little and++ // tilting into the turn, with a short trail and a glow while it moves;++ // - it lands: the tab's icon gives with a squash and a ring leaves it; flying back, the bar button does the same;++ // - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns++ // in once the button has left its place.++ // Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).++ let flyFrom = null, flyIdle = null;const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';function tabFace(id) {const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);return b ? (b.querySelector('.ovs-tab-ic') || b) : null;}-- /** What the eye sees of a button: its picture or its glyph, without ids or handlers. */++ const PAINT = ['color', 'fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity', 'transform', 'transform-origin', 'transform-box', 'visibility'];++ /** What the eye sees of a button, as it looks now: its picture or its glyph, every computed paint copied onto the copy++ * (so it keeps its colour outside the bar's CSS), without ids, handlers, transitions or animations. */function faceOf(node) {const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;const g = pic.cloneNode(true);++ try {++ const from = [pic, ...pic.querySelectorAll('*')], to = [g, ...g.querySelectorAll('*')];++ from.forEach((n, i) => {++ const t = to[i], cs = getComputedStyle(n);++ if (!t || !t.style) return;++ PAINT.forEach((k) => t.style.setProperty(k, cs.getPropertyValue(k)));++ t.style.transition = 'none'; t.style.animation = 'none';++ });++ } catch { /* a plain copy */ }if (g.removeAttribute) g.removeAttribute('id');if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));return g;}const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);-- function fly(fromEl, toEl, { back = false } = {}) {++ const inOut = (t) => (t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);++ const fadeAt = (g, a, b) => Math.min(1, Math.max(0, (g - a) / (b - a)));++ /** A ring that leaves a point and fades (where the flight lands). */++ function ring(r, color, big) {++ const d = Math.max(r.width, r.height) + 10, o = document.createElement('div');++ o.className = 'ovs-fly-ring'; o.setAttribute('aria-hidden', 'true');++ o.style.cssText = `left:${r.left + r.width / 2 - d / 2}px;top:${r.top + r.height / 2 - d / 2}px;width:${d}px;height:${d}px;border-color:${color}`;++ document.body.appendChild(o);++ const a = o.animate([{ transform: 'scale(.55)', opacity: .75 }, { transform: `scale(${big ? 1.9 : 1.35})`, opacity: 0 }], { duration: big ? 520 : 320, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'forwards' });++ a.finished.then(() => o.remove(), () => o.remove());++ }++ function fly(fromEl, toEl, { back = false, fromFace = null, toFace = null } = {}) {const level = motionLevel();if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;-- const short = level === 'reduced';-- const D = short ? 180 : back ? 300 : 420, E = short ? 'ease-out' : 'cubic-bezier(.45,0,.2,1)';++ // Only a button flies, and only a button lands: a larger opener (a page passing document.body) would be copied++ // whole and squashed on landing, the whole page with it.++ if (Math.max(A.width, A.height, B.width, B.height) > 160) return;++ const calm = level === 'reduced';++ const D = calm ? 240 : back ? 340 : 460, N = calm ? 8 : 22;++ const accent = (getComputedStyle(document.documentElement).getPropertyValue('--accent-light') || '').trim() || '#60a5fa';const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };-- const src = part(faceOf(fromEl), A), dst = part(faceOf(toEl), B), riders = [];++ const srcFace = () => (fromFace ? fromFace.cloneNode(true) : faceOf(fromEl));++ const ghosts = calm ? [] : [part(srcFace(), A), part(srcFace(), A)];++ const src = part(srcFace(), A), dst = part(toFace ? toFace.cloneNode(true) : faceOf(toEl), B), riders = [];document.body.appendChild(layer);const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;-- const lift = short ? 0 : Math.min(36, Math.hypot(dx, dy) * .12);-- const at = (k, sc) => `translate(${dx * k}px, ${dy * k - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;-- const back1 = (k, sc) => `translate(${-dx * (1 - k)}px, ${-dy * (1 - k) - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;-- src.animate([{ transform: 'none', opacity: 1 }, { transform: at(.5, (1 + s) / 2), opacity: .85, offset: .5 }, { transform: at(1, s), opacity: 0 }], { duration: D, easing: E, fill: 'forwards' });-- const end = dst.animate([{ transform: back1(0, 1 / s), opacity: 0 }, { transform: back1(.5, (1 + 1 / s) / 2), opacity: .5, offset: .5 }, { transform: 'none', opacity: 1 }], { duration: D, easing: E, fill: 'forwards' });++ // The path, from the bar to the tab: down fast, across slowly (both settle at the end). Flying back is the same++ // path walked the other way.++ const xs = (g) => g * g * (3 - 2 * g), ys = (g) => 1 - Math.pow(1 - g, 2.4);++ const X = (g) => (back ? 1 - xs(1 - g) : xs(g)), Y = (g) => (back ? 1 - ys(1 - g) : ys(g));++ const swell = calm ? 0 : .14, tilt = calm ? 0 : (dx < 0 ? -9 : 9);++ const frames = (fn) => Array.from({ length: N + 1 }, (_, i) => { const g = inOut(i / N); return Object.assign({ offset: i / N }, fn(g)); });++ const look = (g) => { const sc = 1 + (s - 1) * g + swell * Math.sin(Math.PI * g); return { sc, rot: tilt * Math.sin(Math.PI * g), glow: calm ? 0 : 7 * Math.sin(Math.PI * g) }; };++ const glow = (n) => (n > .2 ? `drop-shadow(0 0 ${n.toFixed(1)}px ${accent})` : 'none');++ const srcOp = (g) => 1 - fadeAt(g, .32, .74), dstOp = (g) => fadeAt(g, .26, .7);++ src.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });++ const end = dst.animate(frames((g) => { const l = look(g); return { transform: `translate(${-dx * (1 - X(g))}px, ${-dy * (1 - Y(g))}px) rotate(${l.rot}deg) scale(${l.sc / s})`, opacity: dstOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });++ // The trail: two fainter copies a beat behind, gone before it lands.++ ghosts.forEach((gh, i) => gh.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g) * (i ? .14 : .28) }; }), { duration: D, delay: 34 * (i + 1), easing: 'linear', fill: 'forwards' }));// The count rides along (the bell's red count to the tab's, and back), when both are showing.const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));@@@ -1705,7 +1757,7 @@const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();const box = part(pill, R1, ' ovs-fly-part--count');const mx = R2.left - R1.left, my = R2.top - R1.top;-- box.animate([{ transform: 'none' }, { transform: `translate(${mx / 2}px, ${my / 2 - lift}px)`, offset: .5 }, { transform: `translate(${mx}px, ${my}px)` }], { duration: D, easing: E, fill: 'forwards' });++ box.animate(frames((g) => ({ transform: `translate(${mx * X(g)}px, ${my * Y(g)}px) scale(${1 + (calm ? 0 : .12) * Math.sin(Math.PI * g)})` })), { duration: D, easing: 'linear', fill: 'forwards' });c1.style.opacity = c2.style.opacity = '0';riders.push(c1, c2);}@@@ -1714,19 +1766,25 @@const done = () => {hidden.forEach((n) => { n.style.opacity = ''; });layer.remove();-- const tabBtn = !back && toEl.closest && toEl.closest('.ovs-tab');-- if (tabBtn && !short) tabBtn.animate([{ transform: 'scale(.92)' }, { transform: 'scale(1.03)', offset: .6 }, { transform: 'none' }], { duration: 200, easing: 'ease-out' });++ // The landing: the icon gives under it (a squash, then up past its size and back) and a ring leaves it.++ const give = calm ? [{ transform: 'scale(.94)' }, { transform: 'none' }]++ : [{ transform: 'scale(1.16, .84)' }, { transform: 'scale(.94, 1.07)', offset: .42 }, { transform: 'scale(1.02, .99)', offset: .72 }, { transform: 'none' }];++ try { toEl.animate(give, { duration: calm ? 160 : 380, easing: 'cubic-bezier(.2,.9,.3,1)' }); } catch { /* */ }++ ring(B, accent, !calm && !back);};end.finished.then(done, done);}-- /** After the menu has painted its tabs: the sheet's own entrance is finished at once (the flight is the motion). */++ /** After the menu has painted its tabs: they are finished where they rest (so the flight lands on them), while the++ * menu's own circle keeps growing from the button around the flight. */function flyIn() {const from = flyFrom;if (!from || !el) return;const to = tabFace(door || tab);if (!to) return;-- try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }-- fly(from, to);++ // Anything that moves the tabs is finished (a computer's dropdown scales in); the circle and the fade only show++ // more of the menu, so they keep playing.++ try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { if (a.animationName === 'ovs-reveal' || a.animationName === 'ovs-in') return; try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }++ fly(from, to, { fromFace: flyIdle });}function open(opts = {}) {@@@ -1736,6 +1794,10 @@ctx.from = opts.from || null;if (!el) build();flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;++ // Its look now, before the press turns it bright and into an x: what flies (and what it flies back to). Only a++ // button-sized opener is copied.++ const fr = flyFrom ? flyFrom.getBoundingClientRect() : null;++ flyIdle = fr && fr.width && Math.max(fr.width, fr.height) <= 160 ? faceOf(flyFrom) : null;lastFocus = document.activeElement;// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's// tab, whose second half is every site; "tools" is You); else this site.@@@ -1763,7 +1825,7 @@try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }if (orbEl) {-- orbEl.classList.remove('is-x');++ orbEl.classList.remove('is-x', 'is-pop', 'is-gone');if (phoneSheet()) {// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed// button flies into its tab instead, the orb is simply there as the close X.@@@ -1772,7 +1834,7 @@const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);-- if (flyFrom) orbEl.classList.add('is-x');++ if (flyFrom) orbEl.classList.add('is-x', 'is-pop'); // the button has flown to its tab: the close turns in at the pillelse {orbEl.classList.add('is-from');void orbEl.offsetWidth;@@@ -1810,7 +1872,8 @@function close({ navigating = false } = {}) {// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).const backTo = flyFrom; flyFrom = null;-- if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true });++ if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });++ flyIdle = null;if (view === 'results' && rtabsEl) leaveResults();leaveDoor();if (!el || closing) return;@@@ -1820,11 +1883,13 @@// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.if (phoneSheet() && !calm() && el.classList.contains('is-open')) {el.classList.add('is-closing');-- if (orbEl) { orbEl.classList.remove('is-x'); orbEl.classList.add('is-from'); }-- closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from'); }, 280);++ // With a button flying back, the close turns away where it is (two things never land on the bar at once);++ // otherwise it glides back into the button's place.++ if (orbEl) { orbEl.classList.remove('is-x', 'is-pop'); orbEl.classList.add(backTo ? 'is-gone' : 'is-from'); }++ closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);} else {el.classList.remove('is-open');-- if (orbEl) orbEl.classList.remove('is-x', 'is-from');++ if (orbEl) orbEl.classList.remove('is-x', 'is-from', 'is-pop', 'is-gone');}if (surface) { surface.destroy(); surface = null; }else document.documentElement.style.overflow = '';
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# Changelog
+## 4.213.0 — 2026-11-11
+
+- **The menu button's flight, polished** (the owner, 11-11: "the icon becomes whiter than it was when idle … needs
+ general polish … effects when it lands … improve both the open and close transitions"). search.js's flight now
+ copies the pressed button exactly as it looked before the press (every computed paint frozen onto the copy: the
+ navbar's colour rules stop at its edge, and the press had already turned it bright and half into an x). It drops
+ first and glides into its tab, swelling a little and tilting into the turn with a short trail and a soft glow; it
+ lands with a squash and a ring; flying back it does the same on the bar button. The menu's circle now grows from the
+ button around the flight (it was cut short), the close orb turns in once the button has left, and turns away when
+ closing with a button flying back. Calm plays the same path short, with no trail, tilt or glow; Off skips it.
+- **The carried Live card, designed** (the player that keeps playing across sites, 4.212.0): at rest a live card keeps a
+ small red dot in its corner; playing without sound (a browser that would not carry the sound over) it shows one
+ "Tap for sound" chip, at rest too, that turns the sound on (only its icon on a card under 200 px); "Open the full
+ page" draws a page with an arrow out of its corner, not fullscreen's two arrows.
+- **A menu in a rail opens in the top layer** (Community's paste cards, 11-11: the ⋯ menu was cut off). A rail's edge
+ fade is a mask, which hides even a fixed menu outside the rail's box; kit-web.js now shows a rail's `details.ovk-more`
+ menu with the Popover API, placed under its button, and keeps the fixed placement where the API is missing.
+
## 4.212.0 — 2026-11-11
## 4.212.0 — 2026-11-11
A train of four pull requests (one line each):
A train of four pull requests (one line each):
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.212.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.213.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.212.0",
+ "version": "4.213.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)",
@@ -76,11 +76,19 @@
@@ -76,11 +76,19 @@
if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), close: () => { d.open = false; } });
if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), close: () => { d.open = false; } });
if (d.open) d.__ovSurface.open(); else d.__ovSurface.close();
if (d.open) d.__ovSurface.open(); else d.__ovSurface.close();
}
}
- // Inside a rail (a sideways scroller clips anything that hangs out of it) the menu is placed on the screen.
+ // Inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides
+ // even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on
+ // the screen. Without the Popover API it stays fixed, as before.
const pop = d.querySelector('.ovk-pop');
const pop = d.querySelector('.ovk-pop');
- if (!pop || !d.closest('.ovk-rail-track')) return;
+ if (!pop || !d.closest('.ovk-rail-track, [data-ovk-scroll]')) return;
+ const topLayer = typeof pop.showPopover === 'function';
pop.classList.toggle('is-fixed', d.open);
pop.classList.toggle('is-fixed', d.open);
- if (!d.open) { pop.style.left = pop.style.top = ''; return; }
+ if (!d.open) {
+ if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* already closed */ } pop.removeAttribute('popover'); }
+ pop.style.left = pop.style.top = '';
+ return;
+ }
+ if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }
const r = d.getBoundingClientRect();
const r = d.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const w = pop.offsetWidth, h = pop.offsetHeight;
pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`;
pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`;
@@ -602,7 +602,7 @@
@@ -602,7 +602,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f34f731c3a94', 'ov-glyphs.js': 'aee8e54637c2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '129aad6157d1', 'dock.js': 'aa5ad5d80283', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '6e2e58c4bcbf', 'ov-home.js': '0724f4913525', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'aee8e54637c2', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': 'aa5ad5d80283', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '0724f4913525', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -73,7 +73,7 @@
@@ -73,7 +73,7 @@
const ICON = {
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>',
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>',
+ open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-8.5 8.5M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10" 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>',
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>',
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>',
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>',
@@ -100,6 +100,22 @@
@@ -100,6 +100,22 @@
.ovo-top,.ovo-row{display:flex;align-items:center;gap:6px;padding:7px 8px}
.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{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-live::before{content:'';width:6px;height:6px;border-radius:50%;background:#fff}
+/* At rest (controls hidden) a live card keeps a small red dot in its corner, so it reads as live without a tap (4.213.0). */
+.ovo-dot{position:absolute;left:9px;top:9px;width:9px;height:9px;border-radius:50%;background:#e5484d;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 1px 4px rgba(0,0,0,.5);pointer-events:none;transition:opacity .2s}
+.ovo-card.is-ui .ovo-dot{opacity:0}
+@media (hover:hover) and (pointer:fine){.ovo-card:hover .ovo-dot{opacity:0}}
+/* Playing without sound (a browser blocked carried sound): one clear chip says so and takes the tap, at rest too. */
+.ovo-unmute{position:absolute;left:8px;bottom:8px;z-index:1;display:none;align-items:center;gap:6px;height:30px;padding:0 11px 0 8px;border:0;border-radius:999px;background:rgba(10,12,18,.72);color:#fff;font:700 12px/1 system-ui,sans-serif;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 0 0 1px rgba(255,255,255,.18)}
+.ovo-unmute svg{width:16px;height:16px;flex:none}
+.ovo-card.is-muted .ovo-unmute{display:inline-flex}
+.ovo-card.is-muted .ovo-unmute.is-new{animation:ovo-call 1.6s cubic-bezier(.2,.8,.2,1) .4s 2}
+@keyframes ovo-call{0%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,.45)}70%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 calc(10px * var(--ov-move, 1)) rgba(255,255,255,0)}100%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,0)}}
+html[data-ov-motion="off"] .ovo-unmute.is-new{animation:none}
+.ovo-unmute:hover{background:rgba(10,12,18,.86)}
+.ovo-unmute:focus-visible{outline:2px solid #fff;outline-offset:2px}
+.ovo-card.is-muted .ovo-row [data-ovo="sound"]{display:none}
+.ovo-card.is-narrow .ovo-unmute span{display:none}
+.ovo-card.is-narrow .ovo-unmute{padding:0 7px}
.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-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-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;
.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;
@@ -166,6 +182,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -166,6 +182,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
const p = cornerXY(c, corner, w);
const p = cornerXY(c, corner, w);
place(c, p.x, p.y, w, true);
place(c, p.x, p.y, w, true);
try { localStorage.setItem(`${KEY}:${c.kindKey}`, JSON.stringify({ corner, w })); } catch { /* private mode */ }
try { localStorage.setItem(`${KEY}:${c.kindKey}`, JSON.stringify({ corner, w })); } catch { /* private mode */ }
+ c.el.classList.toggle('is-narrow', w < 200); // the muted chip keeps only its icon on a small card
}
}
function nearest(c, x, y, w) {
function nearest(c, x, y, w) {
const b = bounds(), h = heightOf(c, w);
const b = bounds(), h = heightOf(c, w);
@@ -352,6 +369,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -352,6 +369,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
const ui = document.createElement('div'); ui.className = 'ovo-ui';
const ui = document.createElement('div'); ui.className = 'ovo-ui';
const top = document.createElement('div'); top.className = 'ovo-top';
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); }
if (d.live) { const l = document.createElement('span'); l.className = 'ovo-live'; l.textContent = 'LIVE'; top.appendChild(l); }
+ const dot = d.live ? Object.assign(document.createElement('span'), { className: 'ovo-dot' }) : null;
+ if (dot) dot.setAttribute('aria-hidden', 'true');
const name = document.createElement('span'); name.className = 'ovo-name'; name.textContent = d.title || '';
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'));
top.append(name, button('Close', ICON.close, () => close(c.id), 'ovo-x'));
const row = document.createElement('div'); row.className = 'ovo-row';
const row = document.createElement('div'); row.className = 'ovo-row';
@@ -365,6 +384,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -365,6 +384,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
});
});
snd.setAttribute('data-ovo', 'sound');
snd.setAttribute('data-ovo', 'sound');
row.appendChild(snd);
row.appendChild(snd);
+ // Muted (the browser would not carry the sound over): the chip that turns it on, at rest as well.
+ const chip = document.createElement('button');
+ chip.type = 'button'; chip.className = 'ovo-unmute is-new';
+ chip.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
+ chip.setAttribute('aria-label', 'Turn the sound on');
+ chip.addEventListener('click', (e) => { e.stopPropagation(); chip.classList.remove('is-new'); snd.click(); });
+ chip.addEventListener('pointerdown', (e) => e.stopPropagation());
+ c.unmute = chip;
}
}
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.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; }));
if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
@@ -374,7 +401,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -374,7 +401,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
if (d.sound && embed) mediaFrame.addEventListener('load', () => {
if (d.sound && embed) mediaFrame.addEventListener('load', () => {
try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(mediaFrame.src).origin); } catch { /* browser may require a new tap */ }
try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(mediaFrame.src).origin); } catch { /* browser may require a new tap */ }
});
});
- el.append(mediaFrame, ui, grip);
+ el.append(mediaFrame, ...(dot ? [dot] : []), ui, ...(c.unmute ? [c.unmute] : []), grip);
document.body.appendChild(el);
document.body.appendChild(el);
cards.set(c.id, c);
cards.set(c.id, c);
let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
@@ -491,6 +518,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -491,6 +518,8 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
c.d.playing = !!m.playing;
c.d.playing = !!m.playing;
const b = c.el.querySelector('[data-ovo="sound"]');
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 (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'); }
+ c.el.classList.toggle('is-muted', c.muted && !!m.playing);
+ c.el.classList.toggle('is-narrow', c.el.getBoundingClientRect().width < 200);
if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
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;
const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
remember();
remember();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.212.0",
+ "version": "4.213.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.212.0",
+ "version": "4.213.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.212.0",
+ "version": "4.213.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": {
@@ -160,6 +160,8 @@
@@ -160,6 +160,8 @@
.ovk-rail-prev { left: 6px; }
.ovk-rail-prev { left: 6px; }
.ovk-rail-next { right: 6px; }
.ovk-rail-next { right: 6px; }
.ovk-pop.is-fixed { position: fixed; right: auto; top: auto; }
.ovk-pop.is-fixed { position: fixed; right: auto; top: auto; }
+/* In the top layer (kit-web.js): the browser's popover defaults (centred, its own colours) give way to the menu's own. */
+.ovk-pop.is-fixed[popover] { inset: auto; margin: 0; color: inherit; overflow: visible; }
@media (max-width: 599px) {
@media (max-width: 599px) {
.ovk-rail { --ovk-rail-bleed: 16px; }
.ovk-rail { --ovk-rail-bleed: 16px; }
@@ -216,7 +216,8 @@
@@ -216,7 +216,8 @@
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */
/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */
.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }
.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }
-.ovs-fly-part { position: absolute; display: grid; place-items: center; color: var(--ovk-text, var(--text-primary, #e8ecf4)); will-change: transform, opacity; }
+.ovs-fly-part { position: absolute; display: grid; place-items: center; color: inherit; will-change: transform, opacity, filter; }
+.ovs-fly-ring { position: fixed; z-index: 2147483600; border: 2px solid; border-radius: 50%; pointer-events: none; }
.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }
.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }
.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.ovs-fly-part--count { width: auto !important; height: auto !important; }
.ovs-fly-part--count { width: auto !important; height: auto !important; }
@@ -477,6 +478,12 @@
@@ -477,6 +478,12 @@
.ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
.ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
+ /* 4.213.0: when the pressed button flew to its tab, the close turns in at the pill once it has left (a quarter turn,
+ a little past its size); closing with the button flying back, it turns away where it is. */
+ .ovs.ovs-anchored .ovs-orb.is-pop { animation: ovs-orb-in .38s cubic-bezier(.2, 1.3, .3, 1) .1s both; }
+ .ovs.ovs-anchored .ovs-orb.is-gone { animation: ovs-orb-out .22s cubic-bezier(.5, 0, .75, .2) both; }
+ @keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }
+ @keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }
.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }
.ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }
.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
.ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
.ovs.is-closing { animation: ovs-fade .28s ease-in both; }
.ovs.is-closing { animation: ovs-fade .28s ease-in both; }
@@ -1662,41 +1669,86 @@
@@ -1662,41 +1669,86 @@
// ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the
// ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the
// tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications
// tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications
// tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's
// tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's
- // icon and turns into it on the way (its own picture fades out as the tab's fades in, along one short arc); the
- // bell's count flies with it to the tab's count. Closing flies it back. Motion Off skips it; Reduced plays it short
- // and straight (Calm reduces, only Off removes).
- let flyFrom = null;
+ // icon and turns into it on the way; the bell's count flies with it to the tab's count. Closing flies it back.
+ // 4.213.0 (the owner, 11-11: "the icon becomes whiter than it was when idle … needs general polish … effects when it
+ // lands … improve both the open and close transitions"):
+ // - the copy is the button exactly as it looked when pressed: its look is frozen before the press turns it bright
+ // and into an x, with every computed paint copied (the bar's colour rules stop at its edge, so a bare copy fell
+ // back to the brightest text colour);
+ // - it drops first and glides in last (a landing, not a line across the search field), swelling a little and
+ // tilting into the turn, with a short trail and a glow while it moves;
+ // - it lands: the tab's icon gives with a squash and a ring leaves it; flying back, the bar button does the same;
+ // - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns
+ // in once the button has left its place.
+ // Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).
+ let flyFrom = null, flyIdle = null;
const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
function tabFace(id) {
function tabFace(id) {
const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
return b ? (b.querySelector('.ovs-tab-ic') || b) : null;
return b ? (b.querySelector('.ovs-tab-ic') || b) : null;
}
}
- /** What the eye sees of a button: its picture or its glyph, without ids or handlers. */
+ const PAINT = ['color', 'fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity', 'transform', 'transform-origin', 'transform-box', 'visibility'];
+ /** What the eye sees of a button, as it looks now: its picture or its glyph, every computed paint copied onto the copy
+ * (so it keeps its colour outside the bar's CSS), without ids, handlers, transitions or animations. */
function faceOf(node) {
function faceOf(node) {
const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;
const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;
const g = pic.cloneNode(true);
const g = pic.cloneNode(true);
+ try {
+ const from = [pic, ...pic.querySelectorAll('*')], to = [g, ...g.querySelectorAll('*')];
+ from.forEach((n, i) => {
+ const t = to[i], cs = getComputedStyle(n);
+ if (!t || !t.style) return;
+ PAINT.forEach((k) => t.style.setProperty(k, cs.getPropertyValue(k)));
+ t.style.transition = 'none'; t.style.animation = 'none';
+ });
+ } catch { /* a plain copy */ }
if (g.removeAttribute) g.removeAttribute('id');
if (g.removeAttribute) g.removeAttribute('id');
if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
return g;
return g;
}
}
const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);
const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);
- function fly(fromEl, toEl, { back = false } = {}) {
+ const inOut = (t) => (t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
+ const fadeAt = (g, a, b) => Math.min(1, Math.max(0, (g - a) / (b - a)));
+ /** A ring that leaves a point and fades (where the flight lands). */
+ function ring(r, color, big) {
+ const d = Math.max(r.width, r.height) + 10, o = document.createElement('div');
+ o.className = 'ovs-fly-ring'; o.setAttribute('aria-hidden', 'true');
+ o.style.cssText = `left:${r.left + r.width / 2 - d / 2}px;top:${r.top + r.height / 2 - d / 2}px;width:${d}px;height:${d}px;border-color:${color}`;
+ document.body.appendChild(o);
+ const a = o.animate([{ transform: 'scale(.55)', opacity: .75 }, { transform: `scale(${big ? 1.9 : 1.35})`, opacity: 0 }], { duration: big ? 520 : 320, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'forwards' });
+ a.finished.then(() => o.remove(), () => o.remove());
+ }
+ function fly(fromEl, toEl, { back = false, fromFace = null, toFace = null } = {}) {
const level = motionLevel();
const level = motionLevel();
if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;
if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;
const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();
const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();
if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;
if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;
- const short = level === 'reduced';
- const D = short ? 180 : back ? 300 : 420, E = short ? 'ease-out' : 'cubic-bezier(.45,0,.2,1)';
+ // Only a button flies, and only a button lands: a larger opener (a page passing document.body) would be copied
+ // whole and squashed on landing, the whole page with it.
+ if (Math.max(A.width, A.height, B.width, B.height) > 160) return;
+ const calm = level === 'reduced';
+ const D = calm ? 240 : back ? 340 : 460, N = calm ? 8 : 22;
+ const accent = (getComputedStyle(document.documentElement).getPropertyValue('--accent-light') || '').trim() || '#60a5fa';
const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');
const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');
const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };
const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };
- const src = part(faceOf(fromEl), A), dst = part(faceOf(toEl), B), riders = [];
+ const srcFace = () => (fromFace ? fromFace.cloneNode(true) : faceOf(fromEl));
+ const ghosts = calm ? [] : [part(srcFace(), A), part(srcFace(), A)];
+ const src = part(srcFace(), A), dst = part(toFace ? toFace.cloneNode(true) : faceOf(toEl), B), riders = [];
document.body.appendChild(layer);
document.body.appendChild(layer);
const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;
const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;
- const lift = short ? 0 : Math.min(36, Math.hypot(dx, dy) * .12);
- const at = (k, sc) => `translate(${dx * k}px, ${dy * k - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;
- const back1 = (k, sc) => `translate(${-dx * (1 - k)}px, ${-dy * (1 - k) - lift * Math.sin(Math.PI * k)}px) scale(${sc})`;
- src.animate([{ transform: 'none', opacity: 1 }, { transform: at(.5, (1 + s) / 2), opacity: .85, offset: .5 }, { transform: at(1, s), opacity: 0 }], { duration: D, easing: E, fill: 'forwards' });
- const end = dst.animate([{ transform: back1(0, 1 / s), opacity: 0 }, { transform: back1(.5, (1 + 1 / s) / 2), opacity: .5, offset: .5 }, { transform: 'none', opacity: 1 }], { duration: D, easing: E, fill: 'forwards' });
+ // The path, from the bar to the tab: down fast, across slowly (both settle at the end). Flying back is the same
+ // path walked the other way.
+ const xs = (g) => g * g * (3 - 2 * g), ys = (g) => 1 - Math.pow(1 - g, 2.4);
+ const X = (g) => (back ? 1 - xs(1 - g) : xs(g)), Y = (g) => (back ? 1 - ys(1 - g) : ys(g));
+ const swell = calm ? 0 : .14, tilt = calm ? 0 : (dx < 0 ? -9 : 9);
+ const frames = (fn) => Array.from({ length: N + 1 }, (_, i) => { const g = inOut(i / N); return Object.assign({ offset: i / N }, fn(g)); });
+ const look = (g) => { const sc = 1 + (s - 1) * g + swell * Math.sin(Math.PI * g); return { sc, rot: tilt * Math.sin(Math.PI * g), glow: calm ? 0 : 7 * Math.sin(Math.PI * g) }; };
+ const glow = (n) => (n > .2 ? `drop-shadow(0 0 ${n.toFixed(1)}px ${accent})` : 'none');
+ const srcOp = (g) => 1 - fadeAt(g, .32, .74), dstOp = (g) => fadeAt(g, .26, .7);
+ src.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });
+ const end = dst.animate(frames((g) => { const l = look(g); return { transform: `translate(${-dx * (1 - X(g))}px, ${-dy * (1 - Y(g))}px) rotate(${l.rot}deg) scale(${l.sc / s})`, opacity: dstOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });
+ // The trail: two fainter copies a beat behind, gone before it lands.
+ ghosts.forEach((gh, i) => gh.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g) * (i ? .14 : .28) }; }), { duration: D, delay: 34 * (i + 1), easing: 'linear', fill: 'forwards' }));
// The count rides along (the bell's red count to the tab's, and back), when both are showing.
// The count rides along (the bell's red count to the tab's, and back), when both are showing.
const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);
const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);
const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));
const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));
@@ -1705,7 +1757,7 @@
@@ -1705,7 +1757,7 @@
const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();
const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();
const box = part(pill, R1, ' ovs-fly-part--count');
const box = part(pill, R1, ' ovs-fly-part--count');
const mx = R2.left - R1.left, my = R2.top - R1.top;
const mx = R2.left - R1.left, my = R2.top - R1.top;
- box.animate([{ transform: 'none' }, { transform: `translate(${mx / 2}px, ${my / 2 - lift}px)`, offset: .5 }, { transform: `translate(${mx}px, ${my}px)` }], { duration: D, easing: E, fill: 'forwards' });
+ box.animate(frames((g) => ({ transform: `translate(${mx * X(g)}px, ${my * Y(g)}px) scale(${1 + (calm ? 0 : .12) * Math.sin(Math.PI * g)})` })), { duration: D, easing: 'linear', fill: 'forwards' });
c1.style.opacity = c2.style.opacity = '0';
c1.style.opacity = c2.style.opacity = '0';
riders.push(c1, c2);
riders.push(c1, c2);
}
}
@@ -1714,19 +1766,25 @@
@@ -1714,19 +1766,25 @@
const done = () => {
const done = () => {
hidden.forEach((n) => { n.style.opacity = ''; });
hidden.forEach((n) => { n.style.opacity = ''; });
layer.remove();
layer.remove();
- const tabBtn = !back && toEl.closest && toEl.closest('.ovs-tab');
- if (tabBtn && !short) tabBtn.animate([{ transform: 'scale(.92)' }, { transform: 'scale(1.03)', offset: .6 }, { transform: 'none' }], { duration: 200, easing: 'ease-out' });
+ // The landing: the icon gives under it (a squash, then up past its size and back) and a ring leaves it.
+ const give = calm ? [{ transform: 'scale(.94)' }, { transform: 'none' }]
+ : [{ transform: 'scale(1.16, .84)' }, { transform: 'scale(.94, 1.07)', offset: .42 }, { transform: 'scale(1.02, .99)', offset: .72 }, { transform: 'none' }];
+ try { toEl.animate(give, { duration: calm ? 160 : 380, easing: 'cubic-bezier(.2,.9,.3,1)' }); } catch { /* */ }
+ ring(B, accent, !calm && !back);
};
};
end.finished.then(done, done);
end.finished.then(done, done);
}
}
- /** After the menu has painted its tabs: the sheet's own entrance is finished at once (the flight is the motion). */
+ /** After the menu has painted its tabs: they are finished where they rest (so the flight lands on them), while the
+ * menu's own circle keeps growing from the button around the flight. */
function flyIn() {
function flyIn() {
const from = flyFrom;
const from = flyFrom;
if (!from || !el) return;
if (!from || !el) return;
const to = tabFace(door || tab);
const to = tabFace(door || tab);
if (!to) return;
if (!to) return;
- try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }
- fly(from, to);
+ // Anything that moves the tabs is finished (a computer's dropdown scales in); the circle and the fade only show
+ // more of the menu, so they keep playing.
+ try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { if (a.animationName === 'ovs-reveal' || a.animationName === 'ovs-in') return; try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }
+ fly(from, to, { fromFace: flyIdle });
}
}
function open(opts = {}) {
function open(opts = {}) {
@@ -1736,6 +1794,10 @@
@@ -1736,6 +1794,10 @@
ctx.from = opts.from || null;
ctx.from = opts.from || null;
if (!el) build();
if (!el) build();
flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;
flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;
+ // Its look now, before the press turns it bright and into an x: what flies (and what it flies back to). Only a
+ // button-sized opener is copied.
+ const fr = flyFrom ? flyFrom.getBoundingClientRect() : null;
+ flyIdle = fr && fr.width && Math.max(fr.width, fr.height) <= 160 ? faceOf(flyFrom) : null;
lastFocus = document.activeElement;
lastFocus = document.activeElement;
// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
// The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
// tab, whose second half is every site; "tools" is You); else this site.
// tab, whose second half is every site; "tools" is You); else this site.
@@ -1763,7 +1825,7 @@
@@ -1763,7 +1825,7 @@
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }
if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }
if (orbEl) {
if (orbEl) {
- orbEl.classList.remove('is-x');
+ orbEl.classList.remove('is-x', 'is-pop', 'is-gone');
if (phoneSheet()) {
if (phoneSheet()) {
// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
// From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
// button flies into its tab instead, the orb is simply there as the close X.
// button flies into its tab instead, the orb is simply there as the close X.
@@ -1772,7 +1834,7 @@
@@ -1772,7 +1834,7 @@
const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
- if (flyFrom) orbEl.classList.add('is-x');
+ if (flyFrom) orbEl.classList.add('is-x', 'is-pop'); // the button has flown to its tab: the close turns in at the pill
else {
else {
orbEl.classList.add('is-from');
orbEl.classList.add('is-from');
void orbEl.offsetWidth;
void orbEl.offsetWidth;
@@ -1810,7 +1872,8 @@
@@ -1810,7 +1872,8 @@
function close({ navigating = false } = {}) {
function close({ navigating = false } = {}) {
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
const backTo = flyFrom; flyFrom = null;
const backTo = flyFrom; flyFrom = null;
- if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true });
+ if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });
+ flyIdle = null;
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
leaveDoor();
leaveDoor();
if (!el || closing) return;
if (!el || closing) return;
@@ -1820,11 +1883,13 @@
@@ -1820,11 +1883,13 @@
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
el.classList.add('is-closing');
el.classList.add('is-closing');
- if (orbEl) { orbEl.classList.remove('is-x'); orbEl.classList.add('is-from'); }
- closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from'); }, 280);
+ // With a button flying back, the close turns away where it is (two things never land on the bar at once);
+ // otherwise it glides back into the button's place.
+ if (orbEl) { orbEl.classList.remove('is-x', 'is-pop'); orbEl.classList.add(backTo ? 'is-gone' : 'is-from'); }
+ closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);
} else {
} else {
el.classList.remove('is-open');
el.classList.remove('is-open');
- if (orbEl) orbEl.classList.remove('is-x', 'is-from');
+ if (orbEl) orbEl.classList.remove('is-x', 'is-from', 'is-pop', 'is-gone');
}
}
if (surface) { surface.destroy(); surface = null; }
if (surface) { surface.destroy(); surface = null; }
else document.documentElement.style.overflow = '';
else document.documentElement.style.overflow = '';