openvibers/openvibe.shared Commits
4.239.0: one floating action, a composer that fits its column, the close orb with depth (#522)
Changes
@@@ -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@@@ -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 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.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)",@@@ -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.@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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.@@@ -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]');@@@ -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-rinst.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-rconst 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 })),@@@ -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() {@@@ -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%)); }@@@ -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) => {@@@ -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; }@@@ -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",@@@ -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": {@@@ -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; }}nnew file mode 100644@@@ -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); });
@@ -1,5 +1,31 @@
@@ -1,5 +1,31 @@
# Changelog
# 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
## 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
**The menu's top is a composer, you lead it, no text arrows anywhere, a ringing bell, You gets the height** (the owner's
@@ -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.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
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.238.0",
+ "version": "4.239.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)",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
if (typeof document === 'undefined' || root.OpenVibeDockActor) return;
if (typeof document === 'undefined' || root.OpenVibeDockActor) return;
const POLL_MS = 15000;
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 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' };
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.
// The lines Actor sends as "the current step" when there is none: the state already says them.
@@ -114,7 +114,7 @@
@@ -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
// 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.
// 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 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) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -84,7 +84,7 @@
@@ -84,7 +84,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -609,7 +609,7 @@
@@ -609,7 +609,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': '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]}` : ''}`;
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.
@@ -365,12 +365,28 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
@@ -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 { transform: scale(.9); }
.ovc-v2 .ovt-send--round:not(:disabled):active svg { transform: translateY(-3px); }
.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; }
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); }
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 = [];
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
// 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>.
// 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) || '';
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -493,12 +509,42 @@ html[data-ov-motion=off] .ovc-v2 .ovt-send--round, html[data-ov-motion=off] .ovc
@@ -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()); } });
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.
// ── One box.
function enhance(form) {
function enhance(form) {
if (!form || form.__ovc) return form && form.__ovc;
if (!form || form.__ovc) return form && form.__ovc;
styles();
styles();
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
const text = form.querySelector('[data-ovt-text]') || form.querySelector('textarea');
if (!text) return null;
if (!text) return null;
+ fitPlaceholder(text);
const row = form.querySelector('.ovt-c-row');
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.
// 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]');
const tools = form.querySelector('.ovt-c-tools') || form.querySelector('[data-ovc-tools]');
@@ -74,7 +74,14 @@ html[data-ov-motion="off"] .ov-float { transition: none !important; }
@@ -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; }
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) {
@media (prefers-reduced-motion: reduce) {
html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
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();
const instances = new Set();
let uid = 0;
let uid = 0;
@@ -943,6 +950,124 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
@@ -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');
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() {
function injectStyles() {
if (document.getElementById('ov-float-styles')) return;
if (document.getElementById('ov-float-styles')) return;
const st = document.createElement('style');
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
@@ -954,6 +1079,8 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
const defaultStore = makeDefaultStore();
const defaultStore = makeDefaultStore();
root.OVFloat = {
root.OVFloat = {
attach,
attach,
+ action,
+ actions: () => [...fabs],
store: defaultStore,
store: defaultStore,
newStore: makeDefaultStore,
newStore: makeDefaultStore,
layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
@@ -24,7 +24,7 @@
@@ -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.
// 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 reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
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) || '';
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.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
'use strict';
const NETWORK = 'https://openvibe.network';
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 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); }
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%)); }
.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%)); }
@@ -72,7 +72,7 @@
@@ -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
// 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.
// 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) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
'use strict';
'use strict';
if (typeof document === 'undefined' || root.OVWindow) return;
if (typeof document === 'undefined' || root.OVWindow) return;
const HERE = document.currentScript && document.currentScript.src;
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 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; }
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; }
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.238.0",
+ "version": "4.239.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.238.0",
+ "version": "4.239.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.238.0",
+ "version": "4.239.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": {
@@ -660,6 +660,24 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
@@ -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); }
.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; }
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); } }
@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 .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; }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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); });