openvibers/openvibe.shared Commits

4.239.0: one floating action, a composer that fits its column, the close orb with depth (#522)

OP OpenVibers committed 1 h ago · 99d0922d5c · 17 files, +316 −15

* 4.239.0: one floating action (OVFloat.action), the composer fits its column, the close orb as a lit sphere

* 4.239.0: the close orb keeps its wider hit area (no overflow clip)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 91a6ee0..3d8fb86 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,31 @@
# Changelog
++## 4.239.0 — 2026-11-13
++
++**One floating action, a composer that fits its column, a close button with depth** (the owner, 11-13: "this button
++is buggy … it disappears when you scroll … it should just use a floating button that actually moves its position …
++it's one button, it's not jumping around, it's transitioning"; "this camera button will flash in and out"; "improve
++composer … better responsive support and size on mobile to fit better"; "make the close button look slightly 3D and …
++glowing … make it look kind of cool").
++
++- **`OVFloat.action(el, opts)` (ov-float.js), the floating action.** One element with a home in the page: while its
++ home is on screen it sits there; when the home scrolls away (before the person reaches it, after they pass it, or
++ either: `side`), the same element glides to the dock at the bottom of the window, above the bars
++ (`[data-ov-bottom-bar]`, the app bar), the keyboard and a footer that has come up, and back when its home returns.
++ Never a copy and never hidden: its home keeps the space, the move is a transform (FLIP; from far away it rises from
++ just past the edge), Calm shortens it and Off places it at once. `when()` keeps it home (e.g. only phones float it);
++ `maxWidth`, `stretch`, `gap`, `above`, `avoid`; `ov:fab` and `data-ov-fab="floating|home"` say where it is;
++ `check()` and `destroy()`; `OVFloat.actions()` lists them. Live's Go live adopts it next, replacing its copied dock.
++- **The composer fits its column (ov-composer.js).**
++ - A box narrower than its words says their short form ("Message…", or the site's `data-ovc-short`) and the long
++ one again when there is room; a site that changes its words later is followed. Words never wrap or clip.
++ - Under 340 px the chat preset's tools tighten into quiet 36 px discs; under 240 px they go under the words.
++- **The close orb (search.js)** is a lit sphere: a highlight up and to the left, a shade under it, an accent rim, a
++ soft glow, a white ×; a sheen sweeps across once as the menu opens; hover brightens the glow; a press sinks it.
++ Nothing moves at rest.
++- Tests: `float-action-chrome` (one element floats and comes home, above the bar and the footer, `when()`,
++ `ov:fab`, `destroy()`).
++
## 4.238.0 — 2026-11-13
**The menu's top is a composer, you lead it, no text arrows anywhere, a ringing bell, You gets the height** (the owner's
ddiff --git a/README.md b/README.md
iindex 96ebe79..35dece6 100644
---- a/README.md
++++ b/README.md
@@@ -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.238.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.239.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 46f38e2..c61b15f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.238.0",
++ "version": "4.239.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/dock-actor.js b/dock-actor.js
iindex e1b7b1f..91d6276 100644
---- a/dock-actor.js
++++ b/dock-actor.js
@@@ -26,7 +26,7 @@
if (typeof document === 'undefined' || root.OpenVibeDockActor) return;
const POLL_MS = 15000;
-- const ACTOR_REV = { 'ov-window.js': '01be12417e83', 'agent-sheet.js': '8d9980041b99' };
++ const ACTOR_REV = { 'ov-window.js': '55f98e14ef27', 'agent-sheet.js': '8d9980041b99' };
const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
const STATE = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' };
// The lines Actor sends as "the current step" when there is none: the state already says them.
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 8ac5d78..9801860 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
-- const VIBE_REV = { 'ov-composer.js': '0b6fa41f19fd' };
++ const VIBE_REV = { 'ov-composer.js': '79b34aff4452' };
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex dd2236a..cf6943b 100644
---- a/dock.js
++++ b/dock.js
@@@ -84,7 +84,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '9a99b96cd9cd', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'ec74eb15f094', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
++ const DOCK_REV = { 'dock-vibe.js': '654261348535', 'dock-code.js': 'cb97274524d0', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '759f07eebc81', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '1b6262204e91', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/navbar.js b/navbar.js
iindex 1cf7b8d..7523dc2 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -609,7 +609,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '666dfbe6906d', 'dock.js': '0e1c74cf0c01', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '4e7d405117de', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e883e47b958a', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '504bfeabcd49', 'ov-composer.js': '0b6fa41f19fd' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '228a974d7922', 'dock.js': '6adcf85f7bd8', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '4e7d405117de', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '504bfeabcd49', 'ov-composer.js': '79b34aff4452' };
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.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex ebbec4f..0859dfd 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -365,12 +365,28 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
.ovc-v2 .ovt-send--round:not(:disabled):active { transform: scale(.9); }
.ovc-v2 .ovt-send--round:not(:disabled):active svg { transform: translateY(-3px); }
html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc-v2 .ovt-send--round svg { animation: none; transition: none; }
++/* 4.239.0 (the owner, 11-13: the stream's side chat wrapped "Send a message…" onto two lines beside three tools,
++ "improve composer … better responsive support and size on mobile to fit better"): the words keep one line; in a
++ narrow box the tools tighten into quiet discs, and in a very narrow one they go under the words. */
++.ovc-v2 textarea::placeholder { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++@container ovc (max-width: 340px) {
++ .ovc-v2[data-preset=chat].ovt-composer { padding: 4px 4px 4px 10px; }
++ .ovc-v2[data-preset=chat] .ovt-c-row { gap: 2px; }
++ .ovc-v2[data-preset=chat] .ovt-c-tools { gap: 0; }
++ .ovc-v2[data-preset=chat] .ovt-c-tools > .ovt-btn, .ovc-v2[data-preset=chat] .ovt-send { width: 36px; height: 36px; min-width: 36px; }
++ .ovc-v2[data-preset=chat] .ovt-c-tools > .ovt-btn:not([aria-expanded="true"]):not([aria-pressed="true"]) { background: transparent; }
++ .ovc-v2[data-preset=chat] textarea { padding: 7px 2px; }
++}
++@container ovc (max-width: 240px) {
++ .ovc-v2[data-preset=chat] .ovc-body { grid-template-columns: minmax(0, 1fr); }
++ .ovc-v2[data-preset=chat] .ovt-c-row { justify-content: flex-end; }
++}
`;
function styles() { if (document.getElementById('ovc-styles')) return; const st = document.createElement('style'); st.id = 'ovc-styles'; st.textContent = CSS; document.head.appendChild(st); }
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// from beside this file; without it, a plain <dialog>.
-- const COMPOSER_REV = { 'ov-window.js': '01be12417e83', 'ov-capture.js': '6bdc48bfd415' };
++ const COMPOSER_REV = { 'ov-window.js': '55f98e14ef27', 'ov-capture.js': '6bdc48bfd415' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
@@@ -493,12 +509,42 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
}
root.addEventListener('storage', (e) => { if (e.key === PREFS_KEY) { prefs = Object.assign({}, DEFAULTS, store.get(PREFS_KEY) || {}); live.forEach((c) => c.refresh()); } });
++ // 4.239.0 (the owner, 11-13: the stream's side chat cut "Send a message…" beside its tools): a box narrower than its
++ // words says their short form ("Message…", or the site's data-ovc-short), and the long one again when there is
++ // room. A site that changes the words later is followed (the long form is whatever it set last).
++ function fitPlaceholder(text) {
++ if (typeof ResizeObserver !== 'function' || text.__ovcFit) return;
++ text.__ovcFit = true;
++ let full = text.placeholder || '';
++ const shortOf = () => text.getAttribute('data-ovc-short') || (full.length > 12 ? 'Message…' : full);
++ let ctx = null;
++ const width = (s) => {
++ try {
++ ctx = ctx || document.createElement('canvas').getContext('2d');
++ const cs = getComputedStyle(text);
++ ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
++ return ctx.measureText(s).width + (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0) + 4;
++ } catch { return 0; }
++ };
++ const fit = () => {
++ const now = text.placeholder || '';
++ if (now !== full && now !== shortOf()) full = now;
++ const w = text.clientWidth;
++ if (!w || !full) return;
++ const next = width(full) > w ? shortOf() : full;
++ if (next !== now) text.placeholder = next;
++ };
++ new ResizeObserver(fit).observe(text);
++ fit();
++ }
++
// ── One box.
function enhance(form) {
if (!form || form.__ovc) return form && form.__ovc;
styles();
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
if (!text) return null;
++ fitPlaceholder(text);
const row = form.querySelector('.ovt-c-row');
// A box drawn by its own page marks its row with data-ovc-tools (Vibe's); else the composer's own row.
const tools = form.querySelector('.ovt-c-tools') || form.querySelector('[data-ovc-tools]');
ddiff --git a/ov-float.js b/ov-float.js
iindex 0fcc105..73cd76a 100644
---- a/ov-float.js
++++ b/ov-float.js
@@@ -74,7 +74,14 @@ html[data-ov-motion="off"] .ov-float { transition: none !important; }
html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-resize { transition: none !important; animation: none !important; }
@media (prefers-reduced-motion: reduce) {
html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
--}`;
++}
++/* The floating action (4.239.0): its home holds the space; floating, it is fixed at the dock with a lift. */
++.ov-fab-home { position: relative; }
++.ov-fab-floating { position: fixed !important; z-index: 70; left: 0; right: 0; bottom: calc(var(--ov-fab-bottom, 12px) + env(safe-area-inset-bottom, 0px));
++ width: var(--ov-fab-w, auto); max-width: calc(100vw - 32px); margin: 0 auto !important; /* centred by margins: the glide owns transform */
++ filter: drop-shadow(0 14px 22px rgba(0, 0, 0, .45)) drop-shadow(0 2px 6px rgba(0, 0, 0, .3)); transition: bottom .25s cubic-bezier(.2, 1, .3, 1); }
++html[data-ov-motion=off] .ov-fab-floating { transition: none; }
++`;
const instances = new Set();
let uid = 0;
@@@ -943,6 +950,124 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
inst.el.classList.remove('ov-float', 'is-floating', 'is-lifted', 'is-dragging', 'is-tucked', 'is-tucked-left', 'is-tucked-right', 'is-resizing', 'is-active');
}
++ // ── The floating action (4.239.0; the owner, 11-13: "this button is buggy … it disappears when you scroll … it should
++ // just use a floating button that actually moves its position … it's one button, it's not jumping around, it's
++ // transitioning … aware of the page design"; "this camera button will flash in and out … a single button with smart
++ // positioning"). One element with a home in the page: while its home is on screen it sits there; when the home
++ // scrolls away, the same element glides to the dock (the bottom of the window, above the bars, the keyboard and a
++ // footer that has come up), and back when the home returns. Never a copy, never a blink: its home keeps the space,
++ // and the move is a transform (FLIP) the browser composites. Calm shortens it; Off places it at once.
++ //
++ // const a = OVFloat.action(el, { when, side, gap, maxWidth, stretch, above, avoid, onChange });
++ // a.floating · a.check() · a.destroy()
++ //
++ // when() whether it may float at all now (e.g. only on a phone); default always
++ // side 'both' (default): it floats while its home is above or below the window; 'below' only before the
++ // person reaches it; 'above' only after they pass it
++ // gap px between the dock and what is under it (12)
++ // maxWidth the floating width at most (560); stretch: true widens it to the window less the gutters
++ // above the bars it sits over (selector; default the app bar and any [data-ov-bottom-bar])
++ // avoid what it rises above when on screen (default the footer)
++ // Every change dispatches `ov:fab` ({ floating }) on the element; `data-ov-fab="floating|home"` styles it.
++ const fabs = new Set();
++ function action(el, o = {}) {
++ if (!el || !el.parentNode || el.__ovFab) return el && el.__ovFab;
++ injectStyles();
++ const home = document.createElement('div');
++ home.className = 'ov-fab-home';
++ el.parentNode.insertBefore(home, el);
++ home.appendChild(el);
++ el.dataset.ovFab = 'home';
++ let floating = false, seen = true, raf = 0, dead = false, anim = null;
++ const motion = () => document.documentElement.getAttribute('data-ov-motion') || 'auto';
++ const may = () => { try { return typeof o.when === 'function' ? !!o.when() : true; } catch { return false; } };
++ const gap = Number.isFinite(o.gap) ? o.gap : 12;
++ const vh = () => (root.visualViewport ? root.visualViewport.height + root.visualViewport.offsetTop : innerHeight);
++ // How high the dock must sit: over the fixed bars at the bottom, over the keyboard, over a footer on screen.
++ function floor() {
++ let lift = Math.max(0, innerHeight - vh());
++ const bars = o.above || '[data-ov-bottom-bar], .ovnav-appbar, .ov-appbar';
++ document.querySelectorAll(bars).forEach((b) => {
++ const r = b.getBoundingClientRect(); if (!r.height || r.bottom < innerHeight - 4) return;
++ const pos = getComputedStyle(b).position; if (pos !== 'fixed' && pos !== 'sticky') return;
++ lift = Math.max(lift, innerHeight - r.top);
++ });
++ document.querySelectorAll(o.avoid || '#ov-footer, footer.ov-footer').forEach((f) => {
++ const r = f.getBoundingClientRect(); if (r.height && r.top < innerHeight) lift = Math.max(lift, innerHeight - r.top);
++ });
++ return lift;
++ }
++ function place() {
++ if (!floating) return;
++ const max = Number.isFinite(o.maxWidth) ? o.maxWidth : 560;
++ const room = document.documentElement.clientWidth - 32;
++ const w = Math.min(max, room, o.stretch ? room : Math.max(home.getBoundingClientRect().width, 1));
++ el.style.setProperty('--ov-fab-w', `${Math.round(w)}px`);
++ el.style.setProperty('--ov-fab-bottom', `${Math.round(floor() + gap)}px`);
++ }
++ function glide(from) {
++ if (anim) { try { anim.cancel(); } catch { /* */ } anim = null; }
++ const m = motion();
++ if (m === 'off' || !from || !from.width || typeof el.animate !== 'function') return;
++ const to = el.getBoundingClientRect();
++ const dx = (from.left + from.width / 2) - (to.left + to.width / 2), dy = (from.top + from.height / 2) - (to.top + to.height / 2);
++ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
++ // Over a long way (from far above or below the window) it rises from just past the edge instead.
++ const far = Math.abs(dy) > innerHeight * 0.6;
++ const start = far ? `translate(0, ${dy > 0 ? 40 : -40}px) scale(.96)` : `translate(${dx}px, ${dy}px)`;
++ anim = el.animate([{ transform: start, opacity: far ? 0 : 1 }, { transform: 'none', opacity: 1 }],
++ { duration: m === 'reduced' ? 180 : (far ? 360 : 460), easing: 'cubic-bezier(.2, 1, .3, 1)' });
++ anim.onfinish = () => { anim = null; };
++ }
++ function set(next) {
++ if (next === floating || dead) return;
++ const from = el.getBoundingClientRect();
++ if (next) home.style.minHeight = `${Math.round(from.height)}px`;
++ floating = next;
++ el.dataset.ovFab = next ? 'floating' : 'home';
++ el.classList.toggle('ov-fab-floating', next);
++ if (next) place(); else { el.style.removeProperty('--ov-fab-w'); el.style.removeProperty('--ov-fab-bottom'); }
++ if (!next) home.style.minHeight = '';
++ glide(from);
++ try { el.dispatchEvent(new CustomEvent('ov:fab', { bubbles: true, detail: { floating: next } })); } catch { /* */ }
++ if (typeof o.onChange === 'function') { try { o.onChange(next); } catch { /* the page's */ } }
++ }
++ function check() {
++ raf = 0;
++ if (dead) return;
++ const r = home.getBoundingClientRect();
++ const below = r.top >= vh() - floor() - 8, above = r.bottom <= 8;
++ seen = !(below || above);
++ const side = o.side || 'both';
++ const want = may() && !seen && (side === 'both' || (side === 'below' && below) || (side === 'above' && above));
++ set(want);
++ place();
++ }
++ const later = () => { if (!raf) raf = requestAnimationFrame(check); };
++ const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(later, { threshold: [0, 0.5, 1] }) : null;
++ if (io) io.observe(home);
++ addEventListener('scroll', later, { passive: true });
++ addEventListener('resize', later);
++ if (root.visualViewport) root.visualViewport.addEventListener('resize', later);
++ const handle = {
++ el, home,
++ get floating() { return floating; },
++ check: later,
++ destroy() {
++ dead = true; if (io) io.disconnect();
++ removeEventListener('scroll', later); removeEventListener('resize', later);
++ if (root.visualViewport) root.visualViewport.removeEventListener('resize', later);
++ el.classList.remove('ov-fab-floating'); delete el.dataset.ovFab; el.__ovFab = null;
++ if (home.parentNode) { home.parentNode.insertBefore(el, home); home.remove(); }
++ fabs.delete(handle);
++ },
++ };
++ el.__ovFab = handle;
++ fabs.add(handle);
++ later();
++ return handle;
++ }
++
function injectStyles() {
if (document.getElementById('ov-float-styles')) return;
const st = document.createElement('style');
@@@ -954,6 +1079,8 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
const defaultStore = makeDefaultStore();
root.OVFloat = {
attach,
++ action,
++ actions: () => [...fabs],
store: defaultStore,
newStore: makeDefaultStore,
layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
ddiff --git a/ov-thread.js b/ov-thread.js
iindex c893527..b0a6d9f 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
-- const THREAD_REV = { 'ov-composer.js': '0b6fa41f19fd', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': '79b34aff4452', 'ov-combo.js': '8001a3b19c2f' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
ddiff --git a/ov-user.js b/ov-user.js
iindex 8b48847..34063d1 100644
---- a/ov-user.js
++++ b/ov-user.js
@@@ -14,7 +14,7 @@
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
const NETWORK = 'https://openvibe.network';
-- const USER_REV = { 'ov-window.js': '01be12417e83' };
++ const USER_REV = { 'ov-window.js': '55f98e14ef27' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
ddiff --git a/ov-wallet.js b/ov-wallet.js
iindex cff29d9..bf91a15 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -72,7 +72,7 @@
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
-- const WALLET_REV = { 'ov-window.js': '01be12417e83' };
++ const WALLET_REV = { 'ov-window.js': '55f98e14ef27' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
ddiff --git a/ov-window.js b/ov-window.js
iindex 6a6be94..9b51684 100644
---- a/ov-window.js
++++ b/ov-window.js
@@@ -24,7 +24,7 @@
'use strict';
if (typeof document === 'undefined' || root.OVWindow) return;
const HERE = document.currentScript && document.currentScript.src;
-- const WINDOW_REV = { 'ov-float.js': 'f41a3aefc6ad' };
++ const WINDOW_REV = { 'ov-float.js': '9ba3b25cfaf5' };
const STYLES = ['glass', 'solid', 'outline'];
const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; }
ddiff --git a/package-lock.json b/package-lock.json
iindex 825880e..5dd2a79 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.238.0",
++ "version": "4.239.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.238.0",
++ "version": "4.239.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex aa68bcd..8a6aa46 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.238.0",
++ "version": "4.239.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex 89c6928..4330303 100644
---- a/search.js
++++ b/search.js
@@@ -660,6 +660,24 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
.ovs.ovs-anchored .ovs-orb.is-x:active svg { transform: rotate(90deg) scale(.86); }
html[data-ov-motion=off] .ovs.ovs-anchored .ovs-orb.is-x.is-pop { animation: none; }
@media (hover: hover) { .ovs.ovs-anchored .ovs-orb.is-x:hover svg { transform: rotate(90deg); } }
++ /* 4.239.0 (the owner, 11-13: "make the close button look slightly 3D and look kind of like glowing … a better effect …
++ make it look kind of cool"): a lit sphere (a highlight up and to the left, a shade under it, a rim of the accent),
++ a soft glow around it, a sheen that sweeps across once as the menu opens, a brighter glow on hover, and a press
++ that sinks it. Nothing moves at rest. */
++ .ovs.ovs-anchored .ovs-orb.is-x { color: #fff; --ovo-a: #fff; --ovo-b: #fff; /* no overflow clip: ::after is the 4 px wider hit area */
++ background: radial-gradient(120% 120% at 32% 22%, color-mix(in srgb, #fff 30%, var(--accent-light, #60a5fa)) 0%, color-mix(in srgb, var(--accent, #3b82f6) 72%, #0b1220) 42%, color-mix(in srgb, var(--accent, #3b82f6) 30%, #04060c) 100%);
++ box-shadow: inset 0 1.5px 0 rgba(255, 255, 255, .42), inset 0 -4px 8px rgba(0, 0, 0, .45), 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 55%, transparent),
++ 0 0 22px -2px color-mix(in srgb, var(--accent, #3b82f6) 75%, transparent), 0 10px 22px -8px rgba(0, 0, 0, .8);
++ transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), box-shadow .25s, filter .25s; }
++ .ovs.ovs-anchored .ovs-orb.is-x svg { filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .45)); }
++ .ovs.ovs-anchored .ovs-orb.is-x::before { display: block; content: ""; position: absolute; inset: 0; border-radius: 50%; border: 0; opacity: 1; pointer-events: none;
++ background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, .55) 48%, transparent 62%) no-repeat; background-size: 260% 100%; background-position: 130% 0;
++ animation: ovs-orb-sheen .9s cubic-bezier(.3, .6, .3, 1) .38s 1 both; }
++ @keyframes ovs-orb-sheen { to { background-position: -40% 0; } }
++ @media (hover: hover) { .ovs.ovs-anchored .ovs-orb.is-x:hover { filter: brightness(1.12); box-shadow: inset 0 1.5px 0 rgba(255, 255, 255, .5), inset 0 -4px 8px rgba(0, 0, 0, .4), 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 70%, transparent), 0 0 30px 0 color-mix(in srgb, var(--accent, #3b82f6) 85%, transparent), 0 10px 22px -8px rgba(0, 0, 0, .8); } }
++ .ovs.ovs-anchored .ovs-orb.is-x:active { transform: scale(.9); box-shadow: inset 0 3px 8px rgba(0, 0, 0, .55), 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 50%, transparent), 0 0 14px -2px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent); }
++ html[data-ov-motion=off] .ovs.ovs-anchored .ovs-orb.is-x::before { animation: none; background: none; }
++ html[data-ov-motion=reduced] .ovs.ovs-anchored .ovs-orb.is-x::before { animation-duration: .5s; }
.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; }
}
ddiff --git a/test/float-action-chrome.test.js b/test/float-action-chrome.test.js
nnew file mode 100644
iindex 0000000..e55c970
---- /dev/null
++++ b/test/float-action-chrome.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++// OVFloat.action (4.239.0) in a real headless Chrome on a phone-sized page: a button with its home in the middle of a
++// long page floats at the bottom while its home is off screen (before the person reaches it and after they pass it),
++// above a fixed bottom bar, and comes back to its home when the home is on screen. It is one element the whole time
++// (never a copy, never hidden), its home keeps the space, it rises above a footer that comes up, `ov:fab` says when
++// it moves, `when()` can keep it home, and destroy() puts it back as it was (the owner, 11-13: "it's one button, it's
++// not jumping around, it's transitioning").
++//
++// node test/float-action-chrome.test.js
++const assert = require('node:assert/strict');
++const { eventually, launch, openPage, findChrome } = require('../browser-harness');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const http = require('node:http');
++if (process.env.OV_SKIP_BROWSER === '1' || !findChrome()) { console.log('float-action-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const server = http.createServer((req, res) => {
++ if (req.url.startsWith('/ov-float.js')) {
++ res.writeHead(200, { 'content-type': 'application/javascript' });
++ return res.end(fs.readFileSync(path.join(root, 'ov-float.js')));
++ }
++ res.writeHead(200, { 'content-type': 'text/html' });
++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<style>body{margin:0;background:#111;color:#fff;font:16px system-ui}.pad{height:1400px}#card{padding:20px;background:#222}
++#act{display:block;width:260px;height:52px;margin:0 auto;border:0;border-radius:14px;background:#3b82f6;color:#fff;font:700 17px system-ui}
++#bar{position:fixed;left:0;right:0;bottom:0;height:64px;background:#000}#ov-footer{height:300px;background:#333}</style>
++<div class="pad"></div><div id="card"><p>Turn on your camera to go live</p><div id="act-wrap"><button id="act" type="button">Turn on camera</button></div></div>
++<div class="pad"></div><footer id="ov-footer">footer</footer><nav id="bar" data-ov-bottom-bar></nav>
++<script src="/ov-float.js"></script>
++<script>
++window.moves = [];
++window.allow = true;
++document.addEventListener('ov:fab', (e) => window.moves.push(e.detail.floating));
++window.a = OVFloat.action(document.getElementById('act'), { when: () => window.allow });
++</script>`);
++});
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-fab-'));
++ const browser = await launch({ tmpDir });
++ try {
++ const p = await openPage(browser, await browser.newContext(), { width: 390, height: 844, mobile: true });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ const st = () => p.evaluate(`(() => { const b = document.getElementById('act'), r = b.getBoundingClientRect(), h = document.querySelector('.ov-fab-home').getBoundingClientRect();
++ return { count: document.querySelectorAll('#act').length, fab: b.dataset.ovFab, pos: getComputedStyle(b).position, visible: getComputedStyle(b).display !== 'none' && r.width > 0,
++ bottomGap: Math.round(innerHeight - r.bottom), homeH: Math.round(h.height), w: Math.round(r.width), centred: Math.abs((r.left + r.right) / 2 - innerWidth / 2) < 2 }; })()`);
++
++ // Before the person reaches it: floating, above the 64 px bar, the same width, centred; its home keeps 52 px.
++ await eventually(async () => assert.equal((await st()).fab, 'floating', 'before its home: it floats'));
++ let s = await st();
++ assert.deepEqual([s.count, s.pos, s.visible, s.homeH, s.w, s.centred], [1, 'fixed', true, 52, 260, true], 'one element, fixed, its home holds the space');
++ assert.ok(s.bottomGap >= 64 + 12 - 1 && s.bottomGap <= 64 + 12 + 4, `above the bar with a 12 px gap (${s.bottomGap})`);
++
++ // Its home on screen: back home, in the page.
++ await p.evaluate(`document.getElementById('card').scrollIntoView({ block: 'center' }), true`);
++ await eventually(async () => assert.equal((await st()).fab, 'home', 'its home on screen: back home'));
++ s = await st();
++ assert.deepEqual([s.count, s.pos, s.visible], [1, 'static', true], 'the same element, in the page');
++
++ // Past it: floating again; at the end of the page it rises above the footer.
++ await p.evaluate(`window.scrollTo(0, document.getElementById('card').offsetTop + 600), true`);
++ await eventually(async () => assert.equal((await st()).fab, 'floating', 'past its home: it floats'));
++ await p.evaluate(`window.scrollTo(0, document.body.scrollHeight), true`);
++ await eventually(async () => {
++ const r = await p.evaluate(`(() => { const b = document.getElementById('act').getBoundingClientRect(), f = document.getElementById('ov-footer').getBoundingClientRect(); return { b: Math.round(b.bottom), f: Math.round(f.top) }; })()`);
++ assert.ok(r.b <= r.f - 10, `above the footer (${r.b} vs ${r.f})`);
++ });
++
++ // when() can keep it home; ov:fab said every move; destroy() puts it back as it was.
++ await p.evaluate(`window.allow = false, window.a.check(), true`);
++ await eventually(async () => assert.equal((await st()).fab, 'home', 'when() false: home'));
++ assert.deepEqual(await p.evaluate('window.moves'), [true, false, true, false], 'ov:fab for each move');
++ await p.evaluate(`window.a.destroy(), true`);
++ assert.equal(await p.evaluate(`!document.querySelector('.ov-fab-home') && document.getElementById('act').parentNode.id`), 'act-wrap', 'destroy() puts it back');
++ console.log('float-action-chrome: one element floats while its home is away, above the bar and the footer, and comes home ok');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });