openvibers/openvibe.shared Commits

4.235.0: the layers — a tucked box is a small handle and grows to the window; menus are anchored in the top layer (#518)

OP OpenVibers committed 2 h ago · 767066b7e5 · 19 files, +533 −81

* 4.235.0: the layers — a tucked box is a small handle and grows to the window; menus are anchored in the top layer

* 4.235.0: Off removes the anchor's entrance (Calm keeps it); the new browser checks wait with eventually()

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d0442e..4e8d59b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,47 @@
# Changelog
++## 4.235.0 — 2026-11-13
++
++**The layers: what floats folds into a small handle and grows to the window, and what opens from a button is anchored
++to it** (the owner's phone, watching a live stream, 11-13: "a small button that lets you pop it in and out", "doesn't
++allow it to be off screen", "I can't even resize it bigger", "the little three dots doesn't actually open a menu").
++
++- **ov-float.js, tucked.**
++ - Thrown past a side (or T, or a tuck button), a box folds into a small handle at that edge: 30 × 64 px, 34 × 72 to
++ a finger, set with `--ov-float-tab-w/-h`. It used to leave a strip as tall as itself hanging off the screen.
++ - The fold is a clip animated with the Web Animations API, so nothing inside is squeezed, while the landing's spring
++ moves it to the side.
++ - The handle is a button that moves: a tap opens it, a drag up or down slides it along its side, and a pull sideways
++ brings it out under the finger.
++ - Tucked by hand, it opens again where it was (`ux`, kept in this browser only).
++- **ov-float.js, size.**
++ - It grows to the whole width of the window: `MAX_W` is 1 (was 0.60).
++ - Past what a docked frame holds (its width, or 85% of its height), a pinch, the corner or + lifts it over the page
++ where it is, and it keeps growing, up to 92% of the window's height.
++ - A double tap on a phone makes it the screen's width.
++ - New `f.sizeTo(px)`.
++- **ov-float.js, the place.**
++ - This browser's place wins. The account's (`/api/me/layout`) fills a device that has none, so a phone and a
++ desktop each keep their own.
++ - The account and `onChange` get only the account's fields (`placeOf`), never the browser's extras.
++ - A turn of the phone, the keyboard (the visual viewport), the address bar or a resize puts it back inside, once a
++ frame.
++ - While it moves, nothing in it is blurred: a backdrop blur repainted every frame made a phone's drag stutter.
++- **surfaces.js, `OVSurfaces.anchor(el, trigger, { place, gap, margin, sheet, animate })`.**
++ - The element goes in the top layer (the Popover API; fixed without it). No `overflow: hidden`, mask or stacking
++ context clips it.
++ - It is placed beside its button: `place` is side-align. It flips to the side with room, shifts to stay inside the
++ visual viewport, and is kept no taller than its side's room.
++ - It is placed again on scroll, resize and size changes, and released when its button leaves the page.
++ - `sheet: 'phone'` makes it a bottom sheet under 560 px.
++ - `data-ov-side` says where it went, and the default entrance grows from the button.
++ - `OVSurfaces.popover({ …, pop, place, sheet })` anchors its menu while it is open.
++- **kit-web.js.** Every `details.ovk-more` menu is anchored, not only those inside a rail: a phone's clipped layout, a
++ sheet or a rail no longer hides it.
++- Tests: `ov-float-chrome` (the handle's size and place, a slide, a pull, growing past the frame, a phone's double tap,
++ this device's place, the account's fields) and `surfaces-chrome` (a clipped menu at a phone's bottom opens above,
++ on top, inside the screen, and is released; `placeBox`).
++
## 4.234.0 — 2026-11-13
**The bottom bar hides all the way, and the composer stops repeating itself** (the owner's phone screenshots, 11-13).
ddiff --git a/README.md b/README.md
iindex 51a4265..5865bfb 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.234.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.235.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 a231e76..04aa8d6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.234.0",
++ "version": "4.235.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 82e9c2b..0c0db37 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': 'd3d8f9e3e6d2', 'agent-sheet.js': '8d150490ad64' };
++ const ACTOR_REV = { 'ov-window.js': '1c4db9b0ce9c', 'agent-sheet.js': '8d150490ad64' };
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 cbcf93e..01e5bca 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': '1b6c159fef2e' };
++ const VIBE_REV = { 'ov-composer.js': '53893bb875b6' };
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 4d3c961..34c1a3e 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': 'ffa4189c143c', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', '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': '8cc4d7ded88f', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'ee1f6f21dbba', '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/kit-web.js b/kit-web.js
iindex ff8d227..07c5ffc 100644
---- a/kit-web.js
++++ b/kit-web.js
@@@ -72,11 +72,14 @@
if (!once(d, 'ovkMore')) return;
d.addEventListener('toggle', () => {
if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; });
++ // The layer (surfaces.js 4.235.0): the menu is anchored to its button, in the top layer, placed on the screen,
++ // flipped above it when there is no room below, everywhere (a phone's clipped live layout, a rail, a sheet).
if (root.OVSurfaces) {
-- if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), close: () => { d.open = false; } });
++ if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), pop: d.querySelector('.ovk-pop'), close: () => { d.open = false; } });
if (d.open) d.__ovSurface.open(); else d.__ovSurface.close();
++ if (typeof root.OVSurfaces.anchor === 'function') return;
}
-- // Inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides
++ // Until surfaces.js has loaded: inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides
// even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on
// the screen. Without the Popover API it stays fixed, as before.
const pop = d.querySelector('.ovk-pop');
ddiff --git a/navbar.js b/navbar.js
iindex 18bc792..30e0992 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': 'f34f731c3a94', 'ov-glyphs.js': '2ee871bd8ffa', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', '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': 'ef289c4550b1', 'dock.js': 'b4e65d63a7f6', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', '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': 'b11405559831', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', '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': '91be6059adcf', 'ov-composer.js': '1b6c159fef2e' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '2ee871bd8ffa', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', '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': 'ef289c4550b1', 'dock.js': '337c77c239a5', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': '0cef1f895965', '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': 'b11405559831', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '51840ff5c0b9', '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': '91be6059adcf', 'ov-composer.js': '53893bb875b6' };
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 4769368..cecb8f9 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -348,7 +348,7 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
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': 'd3d8f9e3e6d2', 'ov-capture.js': '91f3b2cb25e3' };
++ const COMPOSER_REV = { 'ov-window.js': '1c4db9b0ce9c', 'ov-capture.js': '91f3b2cb25e3' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
ddiff --git a/ov-float.js b/ov-float.js
iindex 5b45b29..618ac66 100644
---- a/ov-float.js
++++ b/ov-float.js
@@@ -1,5 +1,5 @@
/**
-- * ov-float.js (openvibe-shared 4.208.0): the move engine behind a picture-in-picture, a panel window or any box that
++ * ov-float.js (openvibe-shared 4.208.0; the handle, the size and the window 4.235.0): the move engine behind a picture-in-picture, a panel window or any box that
* floats over a page. It is Live's PiP move (public/js/stream-pip.js, 2026-11-11: "move it anywhere … mobile and
* desktop … more solid feeling and more responsive") lifted out whole, so every floating thing behaves the same:
*
@@@ -8,14 +8,17 @@
* - Docked in `frame` (an element) or floating over the page (position: fixed). It follows the finger or the mouse
* with a transform each animation frame and lands with a spring (FLIP); a flick carries on where it was thrown.
* - Dropped over its frame it docks; anywhere else it floats. Near an edge it snaps; thrown past a side it tucks into
-- * that side, leaving a tab with its picture still showing. Two fingers pinch it, the corner sizes it, a double tap
-- * toggles small and large, and with focus the arrows move it, + and − size it, T tucks, F pops it out of its frame
-- * (or back in) and Home puts it back where it started.
++ * that side: it folds into a small handle at the edge (4.235.0; it used to leave a strip as tall as itself), the
++ * handle slides up and down that edge, a tap opens it where it was, and a pull brings it out under the finger.
++ * - Two fingers pinch it, the corner sizes it, a double tap toggles small and large, and with focus the arrows move it,
++ * + and − size it, T tucks, F pops it out of its frame (or back in) and Home puts it back where it started. It grows
++ * up to the whole width of the window: past what its frame holds it floats over the page and keeps growing.
* - Its place is kept as fractions of its frame: in this browser under `key`, and in the person's account when the
-- * page has GET/PATCH /api/me/layout (the `pip` map Live keeps). `OVFloat.store` is the account plug:
++ * page has GET/PATCH /api/me/layout (the `pip` map Live keeps). This browser's place wins; the account's fills a
++ * device that has none (a phone and a desktop each keep their own). `OVFloat.store` is the account plug:
* { get(key), put(key, value), load?() }; assign your own to keep it somewhere else.
* - `f.geom()`, `f.set(patch, { save })`, `f.tuck()`, `f.untuck()`, `f.toggleMode()`, `f.toggleSize()`, `f.reset()`,
-- * `f.paint()`, `f.settle()`, `f.save()`, `f.relayout()`, `f.destroy()`. `OVFloat.layout()` lists every attached box,
++ * `f.paint()`, `f.settle()`, `f.save()`, `f.sizeTo(px)`, `f.relayout()`, `f.destroy()`. `OVFloat.layout()` lists every attached box,
* and each save dispatches `ov:float` ({ key, geom, el }).
*
* The CSS (injected once) is behaviour, not looks: positioning, the lift, the tuck's translate, the tab and the resize
@@@ -27,12 +30,16 @@
if (typeof document === 'undefined' || root.OVFloat) return;
const BASE = 16 / 9; // the shape `w` is measured for
-- const MIN_W = 0.08, MAX_W = 0.60;
-- const MAX_H = 0.85; // of the frame's height, whatever the shape
++ const MIN_W = 0.08, MAX_W = 1; // up to its frame's whole width (4.235.0; was 0.60, a phone's PiP could not grow)
++ const MAX_H = 0.85; // docked: of the frame's height, whatever the shape
++ const FLOAT_MAX_H = 0.92; // floating: of the window's height
const MIN_PX = 150; // the smallest 16:9 width on a phone-sized frame
const SNAP = 0.04; // within this fraction of an edge, a drop snaps to it
const EDGE_PX = 12; // the margin it snaps to
-- const TAB_PX = 30; // what stays on screen of a tucked box
++ const TAB_PX = 30; // the tucked handle's width (a finger's: 34), and what stays on screen in a drag
++ const TAB_H = 64; // the tucked handle's height (a finger's: 72)
++ const TUCK_MS = 340; // folding into the handle and opening out of it
++ const OPEN_CLIP = 'inset(-80px -80px -80px -80px round 0px)'; // nothing clipped, its shadow included
const DRAG_PX = 4; // a press that moves less is a tap
const BIG_W = 0.5; // a double tap's large size
const PROJECT_MS = 120; // how far a flick carries
@@@ -41,17 +48,22 @@
const LAND_MS = 300;
const DEFAULT_IGNORE = 'a, button, input, textarea, select, [contenteditable], .ov-float-resize, [data-ov-float-resize], [data-ov-float-no-drag]';
-- const CSS = `:where(.ov-float) { position: absolute; box-sizing: border-box; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
++ const CSS = `:where(.ov-float) { position: absolute; box-sizing: border-box; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
++ --ov-float-tab-w: ${TAB_PX}px; --ov-float-tab-h: ${TAB_H}px; }
++@media (pointer: coarse) { :where(.ov-float) { --ov-float-tab-w: 34px; --ov-float-tab-h: 72px; } }
.ov-float.is-floating { position: fixed; z-index: 900; }
.ov-float.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
++.ov-float.is-lifted, .ov-float.is-lifted * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.ov-float.is-dragging { cursor: grabbing; }
--.ov-float.is-tucked-left { translate: calc(-100% + var(--ov-float-tab, ${TAB_PX}px)) 0; }
--.ov-float.is-tucked-right { translate: calc(100% - var(--ov-float-tab, ${TAB_PX}px)) 0; }
--.ov-float.is-tucked .ov-float-tab { display: grid; }
--.ov-float-tab { position: absolute; top: 0; bottom: 0; z-index: 4; width: var(--ov-float-tab, ${TAB_PX}px); display: none; place-items: center; padding: 0; border: 0; cursor: pointer;
-- color: #fff; font: 600 15px/1 system-ui, sans-serif; background: rgba(10, 12, 18, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
--.ov-float.is-tucked-left .ov-float-tab { right: 0; }
--.ov-float.is-tucked-right .ov-float-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }
++.ov-float.is-tucked { cursor: pointer; }
++.ov-float.is-tucked-right { clip-path: inset(max(0px, calc(50% - var(--ov-float-tab-h) / 2)) 0px max(0px, calc(50% - var(--ov-float-tab-h) / 2)) calc(100% - var(--ov-float-tab-w)) round 14px 0px 0px 14px); }
++.ov-float.is-tucked-left { clip-path: inset(max(0px, calc(50% - var(--ov-float-tab-h) / 2)) calc(100% - var(--ov-float-tab-w)) max(0px, calc(50% - var(--ov-float-tab-h) / 2)) 0px round 0px 14px 14px 0px); }
++.ov-float.is-tucked .ov-float-tab { display: grid; animation: ovFloatTabIn ${TUCK_MS}ms ease-out; }
++@keyframes ovFloatTabIn { from { opacity: 0; } }
++.ov-float-tab { position: absolute; top: 50%; z-index: 4; width: var(--ov-float-tab-w); height: min(var(--ov-float-tab-h), 100%); translate: 0 -50%; display: none; place-items: center; padding: 0; border: 0; cursor: pointer; touch-action: none;
++ color: #fff; font: 600 18px/1 system-ui, sans-serif; background: rgba(10, 12, 18, .62); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); }
++.ov-float.is-tucked-left .ov-float-tab { left: 0; border-radius: 0 14px 14px 0; box-shadow: inset -1px 0 0 rgba(255, 255, 255, .2); }
++.ov-float.is-tucked-right .ov-float-tab { right: 0; border-radius: 14px 0 0 14px; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .2); }
.ov-float-tab-l, .ov-float-tab-r { display: none; }
.ov-float.is-tucked-left .ov-float-tab-r, .ov-float.is-tucked-right .ov-float-tab-l { display: block; }
.ov-float-resize { position: absolute; right: 0; bottom: 0; z-index: 3; width: 22px; height: 22px; cursor: nwse-resize; touch-action: none; opacity: 0;
@@@ -59,7 +71,7 @@
.ov-float:hover .ov-float-resize, .ov-float:focus-within .ov-float-resize, .ov-float.is-resizing .ov-float-resize { opacity: 1; }
@media (pointer: coarse) { .ov-float-resize { width: 30px; height: 30px; opacity: .85; } }
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; }
++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; }
}`;
@@@ -263,9 +275,18 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
g.tuck = raw.tuck === 'left' || raw.tuck === 'right' ? raw.tuck : null;
if (typeof raw.muted === 'boolean') g.muted = raw.muted;
g.at = Number(raw.at) || 0;
++ // Where a box tucked by hand opens again (this browser only).
++ if (g.tuck && raw.ux != null && Number.isFinite(Number(raw.ux))) g.ux = clamp(raw.ux, 0, 1, 0);
return g;
}
++ /** The place as an account keeps it (live.layout's fields): no browser-only extras. */
++ function placeOf(g) {
++ const out = { x: g.x, y: g.y, w: g.w, mode: g.mode, tuck: g.tuck || null, at: g.at || 0 };
++ if (typeof g.muted === 'boolean') out.muted = g.muted;
++ return out;
++ }
++
function readSaved(key, legacyKeys) {
const keys = [key].concat(Array.isArray(legacyKeys) ? legacyKeys : []);
for (const k of keys) {
@@@ -282,12 +303,75 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
/* ── the instance ──────────────────────────────────────────────────────────────────────────────────────── */
function box(inst) {
++ const floating = inst.geom.mode === 'float' && !inFullscreen(inst);
return boxOf(inst.geom, frameRect(inst), {
aspect: inst.aspect, minPx: inst.minPx, minW: inst.minW, maxW: inst.maxW,
++ maxH: floating ? FLOAT_MAX_H : MAX_H,
elHeight: inst.el.offsetHeight || 0,
});
}
++ /** The handle a tucked box folds into, in px (the component may set its own with --ov-float-tab-w/-h). */
++ function tabSize(inst) {
++ let w = 0, h = 0;
++ try {
++ const cs = root.getComputedStyle(inst.el);
++ w = parseFloat(cs.getPropertyValue('--ov-float-tab-w'));
++ h = parseFloat(cs.getPropertyValue('--ov-float-tab-h'));
++ } catch { /* no computed style here */ }
++ return { w: w > 0 ? w : TAB_PX, h: h > 0 ? h : TAB_H };
++ }
++
++ /**
++ * The tuck's own motion: the box folds into its handle at the side, or opens out of it, as a clip, so nothing inside
++ * is squeezed (the move to the side is the landing's spring, at the same time). The CSS class holds the folded shape.
++ */
++ function animateClip(inst, opening, side) {
++ const el = inst.el;
++ if (!side || reduced() || typeof el.animate !== 'function') return;
++ const w = el.offsetWidth, h = el.offsetHeight;
++ if (!w || !h) return;
++ const t = tabSize(inst);
++ const v = Math.max(0, (h - t.h) / 2), across = Math.max(0, w - t.w);
++ const folded = side === 'right'
++ ? `inset(${v}px 0px ${v}px ${across}px round 14px 0px 0px 14px)`
++ : `inset(${v}px ${across}px ${v}px 0px round 0px 14px 14px 0px)`;
++ try {
++ if (inst.clipAnim) inst.clipAnim.cancel();
++ inst.clipAnim = el.animate([{ clipPath: opening ? folded : OPEN_CLIP }, { clipPath: opening ? OPEN_CLIP : folded }],
++ { duration: TUCK_MS, easing: 'cubic-bezier(.2, .9, .25, 1)' });
++ } catch { /* a browser that cannot animate a clip simply shows the end */ }
++ }
++
++ /**
++ * Size it so the box is `px` wide on screen (for another shape, the width it has at that 16:9 area): inside a docked
++ * frame while it fits; past what the frame holds it floats over the page, where it is, and keeps growing up to the
++ * window's width. Answers true when it changed frames (the caller takes its pointers back).
++ */
++ function sizeTo(inst, px) {
++ const a = inst.aspect;
++ const k = a ? Math.sqrt(a / BASE) || 1 : 1;
++ let b = box(inst);
++ if (inst.geom.mode === 'dock' && inst.frame && !inFullscreen(inst) && b.rw && b.rh) {
++ const hpx = a ? px / a : (inst.el.offsetHeight || 0);
++ if (px > b.rw + 0.5 || (a && hpx > MAX_H * b.rh + 0.5)) {
++ const r = inst.el.getBoundingClientRect();
++ toMode(inst, 'float', { left: r.left, top: r.top, width: px, height: hpx });
++ placeIn(inst);
++ paintNow(inst);
++ return true;
++ }
++ }
++ b = box(inst);
++ if (!b.rw) return false;
++ inst.geom.w = clamp(px / b.rw / k, inst.minW, inst.maxW, inst.geom.w);
++ b = box(inst);
++ inst.geom.x = Math.min(inst.geom.x, Math.max(0, 1 - b.wf));
++ inst.geom.y = Math.min(inst.geom.y, Math.max(0, 1 - b.hf));
++ paintNow(inst);
++ return false;
++ }
++
function paintNow(inst) {
const b = box(inst);
paint(inst.el, inst.geom, b, {
@@@ -321,15 +405,16 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
// reads the element's rect (a window's absolute place, for one) sees where it landed, not its static position.
if (!inst.el.classList.contains('is-lifted')) paintNow(inst);
inst.geom.at = Date.now();
++ // This browser keeps everything (where a tucked box opens again too); the account and listeners get the place.
try { if (root.localStorage) root.localStorage.setItem(inst.key, JSON.stringify(inst.geom)); } catch { /* private mode */ }
if (inst.store && typeof inst.store.put === 'function') {
-- try { inst.store.put(inst.accountKey, Object.assign({}, inst.geom)); } catch { /* the page's own store */ }
++ try { inst.store.put(inst.accountKey, placeOf(inst.geom)); } catch { /* the page's own store */ }
}
if (typeof inst.options.onChange === 'function') {
-- try { inst.options.onChange(Object.assign({}, inst.geom), inst.api); } catch (err) { console.error(err); }
++ try { inst.options.onChange(placeOf(inst.geom), inst.api); } catch (err) { console.error(err); }
}
try {
-- root.dispatchEvent(new root.CustomEvent('ov:float', { detail: { key: inst.key, geom: Object.assign({}, inst.geom), el: inst.el } }));
++ root.dispatchEvent(new root.CustomEvent('ov:float', { detail: { key: inst.key, geom: placeOf(inst.geom), el: inst.el } }));
} catch { /* an environment without CustomEvent */ }
}
@@@ -372,26 +457,31 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
}
function tuck(inst, side) {
++ if (side === null) { untuck(inst); return; }
const from = inst.el.getBoundingClientRect();
-- if (side === null) { inst.geom.tuck = null; }
-- else {
-- const b = box(inst);
-- inst.geom.tuck = side || (inst.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right');
-- }
++ const b = box(inst);
++ // Tucked by hand (the button, T): it opens again where it was. Thrown at a side, it opens at that side.
++ if (!inst.geom.tuck) inst.geom.ux = inst.geom.x;
++ inst.geom.tuck = side || (inst.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right');
save(inst);
land(inst, from);
++ animateClip(inst, false, inst.geom.tuck);
}
function untuck(inst) {
++ const side = inst.geom.tuck;
++ if (!side) return;
const from = inst.el.getBoundingClientRect();
const b = box(inst);
++ const margin = b.rw ? EDGE_PX / b.rw : 0;
inst.geom.tuck = null;
-- inst.geom.x = from.left + from.width / 2 < (b.rl + b.rw / 2)
-- ? (b.rw ? EDGE_PX / b.rw : 0)
-- : Math.max(0, 1 - b.wf - (b.rw ? EDGE_PX / b.rw : 0));
++ inst.geom.x = inst.geom.ux != null ? inst.geom.ux : side === 'left' ? margin : Math.max(0, 1 - b.wf - margin);
++ delete inst.geom.ux;
++ inst.untuckedAt = performance.now();
settleInst(inst);
save(inst);
land(inst, from);
++ animateClip(inst, true, side);
}
function toggleMode(inst) {
@@@ -403,11 +493,18 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
return true;
}
++ /** Small and large: large is half its frame, and on a phone the whole width of the screen (it floats to get it). */
function toggleSize(inst) {
const from = inst.el.getBoundingClientRect();
-- const big = inst.options.bigW == null ? BIG_W : inst.options.bigW;
-- inst.geom.w = inst.geom.w < big - 0.01 ? big : clamp(inst.defaults.w, inst.minW, inst.maxW, inst.defaults.w);
++ const vp = viewport(inst);
++ const b = box(inst);
++ const k = inst.aspect ? Math.sqrt(inst.aspect / BASE) || 1 : 1;
++ const bigW = inst.options.bigW == null ? BIG_W : inst.options.bigW;
++ const bigPx = vp.width && vp.width < 640 ? vp.width : bigW * k * b.rw;
++ delete inst.geom.ux;
inst.geom.tuck = null;
++ if (from.width < bigPx * 0.95) sizeTo(inst, bigPx);
++ else inst.geom.w = clamp(inst.defaults.w, inst.minW, inst.maxW, inst.defaults.w);
settleInst(inst);
save(inst);
land(inst, from);
@@@ -417,6 +514,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
const from = inst.el.getBoundingClientRect();
const d = inst.defaults;
Object.assign(inst.geom, { x: d.x, y: d.y, w: d.w, mode: inst.frame ? d.mode : 'float', tuck: null });
++ delete inst.geom.ux;
settleInst(inst);
save(inst);
land(inst, from);
@@@ -432,7 +530,8 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
const store = inst.store;
if (!store || typeof store.get !== 'function') return;
const apply = () => {
-- if (inst.destroyed || inst.touched) return;
++ // This device's own place wins; the account's opens a device that has none where the person left it.
++ if (inst.destroyed || inst.touched || inst.hadLocal) return;
const remote = normalize(store.get(inst.accountKey), inst.defaults, { hasFrame: !!inst.frame, minW: inst.minW, maxW: inst.maxW });
if ((remote.at || 0) > (inst.geom.at || 0)) {
inst.geom = remote;
@@@ -487,9 +586,11 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
clearTimeout(activeTimer);
activeTimer = setTimeout(() => el.classList.remove('is-active'), ACTIVE_MS);
};
++ const recapture = () => { for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* */ } } };
const onDown = (e) => {
if (e.button > 0) return;
-- if (e.target.closest && e.target.closest(ignore)) return;
++ // The tucked handle is a button that moves: a tap opens it, a drag slides it along its side or pulls it out.
++ if (e.target.closest && e.target.closest(ignore) && !e.target.closest('[data-ov-float-tab]')) return;
if (typeof inst.options.canDrag === 'function' && !inst.options.canDrag(e)) return;
inst.touched = true;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@@ -498,27 +599,46 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
if (pts.size === 2) {
if (drag && drag.lifted) el.style.transform = '';
drag = null;
-- pinch = { d: gap(), w: inst.geom.w };
++ pinch = { d: gap(), px: el.getBoundingClientRect().width };
el.classList.add('is-resizing');
return;
}
if (pts.size > 2) return;
drag = {
sx: e.clientX, sy: e.clientY, dx: 0, dy: 0, r: el.getBoundingClientRect(), lifted: false,
++ tucked: inst.geom.tuck || null, slide: false,
hist: [{ x: e.clientX, y: e.clientY, t: performance.now() }],
};
};
const onMove = (e) => {
if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch && pts.size === 2) {
-- inst.geom.w = clamp(pinch.w * (gap() / pinch.d), inst.minW, inst.maxW, pinch.w);
-- paintNow(inst);
++ // Up to the whole width of the window: past its frame it floats over the page and keeps growing.
++ if (sizeTo(inst, pinch.px * (gap() / pinch.d))) recapture();
return;
}
if (!drag) return;
const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
-- if (!drag.lifted && Math.hypot(dx, dy) < DRAG_PX) return;
-- if (!drag.lifted) lift();
++ if (!drag.lifted && !drag.slide && Math.hypot(dx, dy) < DRAG_PX) return;
++ if (drag.tucked && !drag.lifted && (drag.slide || Math.abs(dy) > Math.abs(dx))) {
++ // A tucked handle dragged up or down slides along its side, the whole box inside its frame.
++ if (!drag.slide) { drag.slide = true; el.classList.add('is-dragging'); el.style.transition = 'none'; }
++ const f = frameRect(inst), r = drag.r;
++ drag.dy = Math.min(Math.max(dy, f.top - r.top), f.top + f.height - r.bottom);
++ if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (drag && drag.slide) el.style.transform = `translate3d(0, ${drag.dy}px, 0)`; });
++ return;
++ }
++ if (!drag.lifted) {
++ if (drag.tucked) {
++ // Pulled out of its side: it opens under the finger and follows it.
++ const side = inst.geom.tuck;
++ inst.geom.tuck = null;
++ delete inst.geom.ux;
++ el.classList.remove('is-tucked', 'is-tucked-left', 'is-tucked-right');
++ animateClip(inst, true, side);
++ }
++ lift();
++ }
// Anywhere on screen; most of it may go past a side (that is how it tucks), never past the top or bottom.
const vp = viewport(inst);
const r = drag.r, vw = vp.width, vh = vp.height, tab = inst.options.tabPx == null ? TAB_PX : inst.options.tabPx;
@@@ -552,15 +672,28 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
const d = drag; drag = null;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
hint(false);
++ if (d.slide) {
++ // Slid along its side: it stays tucked there, at that height.
++ el.classList.remove('is-dragging');
++ el.style.transition = 'none';
++ el.style.transform = '';
++ const b = box(inst);
++ if (b.rh) inst.geom.y = clamp((d.r.top + d.dy - b.rt) / b.rh, 0, Math.max(0, 1 - b.hf), inst.geom.y);
++ paintNow(inst);
++ el.style.transition = '';
++ save(inst);
++ return;
++ }
if (!d.lifted) {
-- // A tap: the bar for a moment (a touch screen has no hover); a second within 300 ms toggles the size.
++ // A tap on a tucked box opens it; on an open one, the bar for a moment (a touch screen has no hover), and a
++ // second within 300 ms toggles the size.
++ if (inst.geom.tuck) { if (e.type === 'pointerup') untuck(inst); return; }
const now = performance.now();
if (inst.options.doubleTap !== false && now - lastTap < TAP_MS) { lastTap = 0; toggleSize(inst); }
else {
lastTap = now;
if (e.pointerType !== 'mouse' && inst.options.activeBar !== false) showActive();
}
-- if (inst.geom.tuck && e.type === 'pointerup') untuck(inst);
return;
}
el.classList.remove('is-dragging', 'is-lifted');
@@@ -612,8 +745,10 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
else if (k === 'ArrowRight') inst.geom.x += step;
else if (k === 'ArrowUp') inst.geom.y -= step;
else if (k === 'ArrowDown') inst.geom.y += step;
-- else if (k === '+' || k === '=') inst.geom.w = clamp(inst.geom.w + 0.03, inst.minW, inst.maxW, inst.geom.w);
-- else if (k === '-' || k === '_') inst.geom.w = clamp(inst.geom.w - 0.03, inst.minW, inst.maxW, inst.geom.w);
++ else if (k === '+' || k === '=' || k === '-' || k === '_') {
++ const r = el.getBoundingClientRect();
++ sizeTo(inst, r.width * (k === '+' || k === '=' ? 1.12 : 1 / 1.12));
++ }
else if (k === 'Home') { e.preventDefault(); reset(inst); return; }
else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { e.preventDefault(); inst.geom.tuck ? untuck(inst) : tuck(inst); return; }
else if (k === 'f' || k === 'F') { e.preventDefault(); toggleMode(inst); return; }
@@@ -622,6 +757,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
if (!moved) return;
const b = box(inst);
inst.geom.tuck = null;
++ delete inst.geom.ux;
inst.geom.x = Math.min(Math.max(0, inst.geom.x), Math.max(0, 1 - b.wf));
inst.geom.y = Math.min(Math.max(0, inst.geom.y), Math.max(0, 1 - b.hf));
paintNow(inst);
@@@ -643,7 +779,12 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
inst.el.append(tab);
inst.ownedTab = tab;
}
-- const onClick = (e) => { e.preventDefault(); e.stopPropagation(); untuck(inst); };
++ // A pointer tap already opened it (the gesture's pointerup); the click is a key's, or a tap the gesture missed.
++ const onClick = (e) => {
++ e.preventDefault(); e.stopPropagation();
++ if (performance.now() - (inst.untuckedAt || 0) < 700) return;
++ untuck(inst);
++ };
tab.addEventListener('click', onClick);
inst.cleanups.push(() => tab.removeEventListener('click', onClick));
}
@@@ -661,8 +802,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
let start = null;
const onDown = (e) => {
if (e.button > 0) return;
-- const b = box(inst);
-- start = { px: e.clientX, py: e.clientY, w: inst.geom.w, rw: b.rw };
++ start = { px: e.clientX, py: e.clientY, width: inst.el.getBoundingClientRect().width };
inst.touched = true;
inst.el.classList.add('is-resizing');
try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
@@@ -670,15 +810,10 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
e.stopPropagation();
};
const onMove = (e) => {
-- if (!start || !start.rw) return;
-- // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.
-- const k = inst.aspect ? Math.sqrt(inst.aspect / BASE) || 1 : 1;
++ if (!start || !start.width) return;
++ // The handle's diagonal pull is the box's own width; past its frame it floats and keeps growing.
const pull = inst.aspect ? ((e.clientX - start.px) + (e.clientY - start.py) * inst.aspect) / 2 : (e.clientX - start.px);
-- inst.geom.w = clamp(start.w + pull / start.rw / k, inst.minW, inst.maxW, start.w);
-- const b = box(inst);
-- inst.geom.x = Math.min(inst.geom.x, Math.max(0, 1 - b.wf));
-- inst.geom.y = Math.min(inst.geom.y, Math.max(0, 1 - b.hf));
-- paintNow(inst);
++ if (sizeTo(inst, Math.max(1, start.width + pull))) { try { handle.setPointerCapture(e.pointerId); } catch { /* */ } }
};
const end = (e) => {
if (!start) return;
@@@ -702,8 +837,16 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
}
function wireWindow(inst) {
-- const onResize = () => relayout(inst);
++ // A turn of the phone, a keyboard, the address bar or a new window size: back inside, once a frame.
++ let queued = 0;
++ const onResize = () => {
++ if (queued) return;
++ queued = requestAnimationFrame(() => { queued = 0; relayout(inst); });
++ };
++ const vv = root.visualViewport;
root.addEventListener('resize', onResize);
++ root.addEventListener('orientationchange', onResize);
++ if (vv && vv.addEventListener) vv.addEventListener('resize', onResize);
document.addEventListener('fullscreenchange', onResize);
let ro = null;
if (typeof ResizeObserver === 'function') {
@@@ -712,7 +855,10 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
ro.observe(inst.el);
}
inst.cleanups.push(() => {
++ if (queued) cancelAnimationFrame(queued);
root.removeEventListener('resize', onResize);
++ root.removeEventListener('orientationchange', onResize);
++ if (vv && vv.removeEventListener) vv.removeEventListener('resize', onResize);
document.removeEventListener('fullscreenchange', onResize);
try { ro && ro.disconnect(); } catch { /* */ }
});
@@@ -737,7 +883,9 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
store: o.store === null ? null : (o.store || root.OVFloat.store || null),
geom: null, touched: false, destroyed: false, cleanups: [], landTimer: 0, ownedTab: null, ownedResize: null, api: null,
};
-- inst.geom = normalize(readSaved(key, o.legacyKeys), defaults, { hasFrame: !!frame, minW, maxW });
++ const local = readSaved(key, o.legacyKeys);
++ inst.hadLocal = !!local;
++ inst.geom = normalize(local, defaults, { hasFrame: !!frame, minW, maxW });
injectStyles();
el.classList.add('ov-float');
if (o.tab !== false) ensureTab(inst);
@@@ -764,6 +912,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
paintNow(inst);
},
metrics: () => box(inst),
++ sizeTo: (px) => sizeTo(inst, px),
paint: () => paintNow(inst),
settle: () => { settleInst(inst); paintNow(inst); return Object.assign({}, inst.geom); },
save: () => save(inst),
@@@ -786,6 +935,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
inst.destroyed = true;
instances.delete(inst);
clearTimeout(inst.landTimer);
++ if (inst.clipAnim) { try { inst.clipAnim.cancel(); } catch { /* */ } }
for (const fn of inst.cleanups) { try { fn(); } catch { /* */ } }
inst.cleanups = [];
if (inst.ownedTab) { try { inst.ownedTab.remove(); } catch { /* */ } }
@@@ -809,7 +959,7 @@ html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-r
layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
_test: {
boxOf, settle: settleGeom, flick, tuckFor, overFrame, paint, normalize,
-- BASE, MAX_H, MIN_W, MAX_W, EDGE_PX, SNAP, PROJECT_MS,
++ BASE, MAX_H, FLOAT_MAX_H, MIN_W, MAX_W, EDGE_PX, SNAP, PROJECT_MS, TAB_H, placeOf,
},
};
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 3b8222c..b7d14f3 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': '1b6c159fef2e', 'ov-combo.js': '8001a3b19c2f' };
++ const THREAD_REV = { 'ov-composer.js': '53893bb875b6', '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 d8aecc8..bfc1792 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': 'd3d8f9e3e6d2' };
++ const USER_REV = { 'ov-window.js': '1c4db9b0ce9c' };
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 a89ee5e..602ffb8 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': 'd3d8f9e3e6d2' };
++ const WALLET_REV = { 'ov-window.js': '1c4db9b0ce9c' };
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 ef1dcef..3ec1da5 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': '5c8251ac3738' };
++ const WINDOW_REV = { 'ov-float.js': '1e087a0a11fd' };
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 2add4bf..d6bbe7e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.234.0",
++ "version": "4.235.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.234.0",
++ "version": "4.235.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 7ba9b6c..6cac864 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.234.0",
++ "version": "4.235.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/surfaces.js b/surfaces.js
iindex 16be4df..4826f08 100644
---- a/surfaces.js
++++ b/surfaces.js
@@@ -1,4 +1,26 @@
--/** Shared behavior for sheets and floating pop-overs. Load beside navbar.js or kit-web.js. */
++/**
++ * Shared behavior for sheets and floating pop-overs. Load beside navbar.js or kit-web.js.
++ *
++ * The layers (4.235.0; the owner, 11-13: "the little three dots doesn't actually open a menu … the same unified …
++ * cross-site shared UI system"): what opens from a button is ANCHORED to it.
++ *
++ * const a = OVSurfaces.anchor(el, trigger, { place: 'bottom-end', gap: 6, margin: 8, sheet: false, animate: true });
++ * a.update(); a.release();
++ *
++ * - It goes in the browser's top layer (the Popover API): nothing clips it, no overflow: hidden, no rail's mask, no
++ * stacking context. Without the API it is position: fixed.
++ * - It is placed beside its button: `place` is side-align (bottom|top|left|right, start|center|end). It flips to the
++ * other side when there is no room, shifts along to stay inside the visible screen (the visual viewport: the
++ * keyboard and a pinch zoom count) and is kept no taller than the room on its side (max-height).
++ * - It is placed again on a scroll, a resize, a turn of the phone, and when it or its button changes size.
++ * - `sheet: 'phone'` opens it as a sheet at the bottom of a phone's screen (under 560 px wide), for long menus and
++ * settings; `sheet: true` always.
++ * - The element says where it went: data-ov-side="bottom|top|left|right|sheet", and the transform origin is the
++ * button's side, so the default entrance (a short scale and fade, none when motion is off) grows from the button.
++ *
++ * OVSurfaces.popover({ el, trigger, close, pop, place, sheet }) anchors `pop` (the menu inside `el`) while it is open,
++ * so a <details> menu needs nothing else: one open at a time, a press elsewhere or Escape closes it.
++ */
(function (root) {
'use strict';
if (root.OVSurfaces || typeof document === 'undefined') return;
@@@ -80,11 +102,146 @@
return api;
}
-- function popover({ el, trigger, close }) {
++ /* ── the anchored layer ──────────────────────────────────────────────────────────────────────────────── */
++
++ const ANCHOR_CSS = `:where([data-ov-anchored]) { position: fixed; inset: auto; margin: 0; color: inherit; box-sizing: border-box; overscroll-behavior: contain; overflow: auto;
++ padding: 0; border: 0; background: none; }
++[data-ov-anchored][data-ov-anim] { animation: ovAnchorIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
++[data-ov-anchored][data-ov-side="sheet"] { left: 0 !important; right: 0 !important; top: auto !important; bottom: 0 !important; width: 100% !important; min-width: 0 !important; max-width: none !important;
++ border-radius: 18px 18px 0 0 !important; padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)) !important; }
++[data-ov-anchored][data-ov-side="sheet"][data-ov-anim] { animation: ovAnchorSheet var(--ov-dur-3, .26s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
++@keyframes ovAnchorIn { from { opacity: 0; transform: scale(.96); } }
++@keyframes ovAnchorSheet { from { transform: translateY(100%); } }
++html[data-ov-motion="off"] [data-ov-anchored] { animation: none !important; }`;
++
++ function anchorStyles() {
++ if (document.getElementById('ov-anchor-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ov-anchor-styles';
++ st.textContent = ANCHOR_CSS;
++ (document.head || document.documentElement).appendChild(st);
++ }
++
++ /** The screen a person can see: the visual viewport (the keyboard and a pinch zoom count), in layout coordinates. */
++ function visible() {
++ const vv = root.visualViewport;
++ if (vv) return { left: vv.offsetLeft, top: vv.offsetTop, width: vv.width, height: vv.height };
++ return { left: 0, top: 0, width: root.innerWidth, height: root.innerHeight };
++ }
++
++ /**
++ * Where a box of `size` goes beside `at` (the button's rect) inside `view`: the asked side, or the other one when it
++ * does not fit there and fits better there; then shifted along to stay inside. Pure (the tests call it).
++ */
++ function placeBox(at, size, view, opts) {
++ const o = opts || {};
++ const gap = o.gap == null ? 6 : o.gap, m = o.margin == null ? 8 : o.margin;
++ const [side0, align = 'end'] = String(o.place || 'bottom-end').split('-');
++ const vertical = side0 === 'top' || side0 === 'bottom';
++ const room = {
++ bottom: view.top + view.height - m - (at.bottom + gap), top: at.top - gap - (view.top + m),
++ right: view.left + view.width - m - (at.right + gap), left: at.left - gap - (view.left + m),
++ };
++ const need = vertical ? size.height : size.width;
++ const other = { bottom: 'top', top: 'bottom', left: 'right', right: 'left' }[side0] || 'top';
++ const side = room[side0] >= need || room[side0] >= room[other] ? side0 : other;
++ const maxMain = Math.max(0, room[side]);
++ let left, top;
++ if (vertical) {
++ const w = Math.min(size.width, view.width - 2 * m);
++ left = align === 'start' ? at.left : align === 'center' ? at.left + at.width / 2 - w / 2 : at.right - w;
++ left = Math.min(Math.max(view.left + m, left), view.left + view.width - m - w);
++ const h = Math.min(size.height, maxMain);
++ top = side === 'bottom' ? at.bottom + gap : at.top - gap - h;
++ return { left, top, side, maxHeight: maxMain, maxWidth: view.width - 2 * m, origin: `${align === 'start' ? 'left' : align === 'center' ? 'center' : 'right'} ${side === 'bottom' ? 'top' : 'bottom'}` };
++ }
++ const h = Math.min(size.height, view.height - 2 * m);
++ top = align === 'start' ? at.top : align === 'center' ? at.top + at.height / 2 - h / 2 : at.bottom - h;
++ top = Math.min(Math.max(view.top + m, top), view.top + view.height - m - h);
++ const w = Math.min(size.width, maxMain);
++ left = side === 'right' ? at.right + gap : at.left - gap - w;
++ return { left, top, side, maxHeight: view.height - 2 * m, maxWidth: maxMain, origin: `${side === 'right' ? 'left' : 'right'} ${align === 'start' ? 'top' : align === 'center' ? 'center' : 'bottom'}` };
++ }
++
++ function anchor(el, trigger, opts) {
++ const o = opts || {};
++ if (!el || !trigger) return { update() {}, release() {} };
++ anchorStyles();
++ const topLayer = typeof el.showPopover === 'function';
++ const sheetMode = () => o.sheet === true || (o.sheet === 'phone' && visible().width < 560);
++ const align = String(o.place || 'bottom-end').split('-')[1] || 'end';
++ let queued = 0, released = false, ro = null;
++ el.setAttribute('data-ov-anchored', '');
++ if (o.animate !== false) el.setAttribute('data-ov-anim', '');
++ if (topLayer) {
++ if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
++ try { if (!el.matches(':popover-open')) el.showPopover(); } catch { /* stays fixed */ }
++ }
++ function update() {
++ queued = 0;
++ if (released) return;
++ if (!trigger.isConnected) { api.release(); if (typeof o.onLost === 'function') o.onLost(); return; }
++ const view = visible();
++ // A component's own placement (position: absolute; right: 0, the old way) gives way to the layer's.
++ el.style.position = 'fixed'; el.style.right = 'auto'; el.style.bottom = 'auto';
++ if (!topLayer) el.style.zIndex = '1000';
++ if (sheetMode()) {
++ el.setAttribute('data-ov-side', 'sheet');
++ el.style.left = el.style.top = '';
++ el.style.maxHeight = `${Math.round(view.height * 0.85)}px`;
++ el.style.transformOrigin = 'center bottom';
++ return;
++ }
++ const at = trigger.getBoundingClientRect();
++ // Its whole height (scrollHeight: a max-height from the last time does not count), the side with room for
++ // it, no taller than that room, then its place on that side at the height it is drawn.
++ const p = placeBox(at, { width: el.offsetWidth, height: Math.max(el.offsetHeight, el.scrollHeight) }, view, o);
++ el.setAttribute('data-ov-side', p.side);
++ el.style.maxHeight = `${Math.max(120, Math.floor(p.maxHeight))}px`;
++ el.style.maxWidth = `${Math.floor(p.maxWidth)}px`;
++ const q = placeBox(at, { width: el.offsetWidth, height: el.offsetHeight }, view, Object.assign({}, o, { place: `${p.side}-${align}` }));
++ el.style.left = `${Math.round(q.left)}px`;
++ el.style.top = `${Math.round(q.top)}px`;
++ el.style.transformOrigin = q.origin;
++ }
++ const later = () => { if (!queued) queued = requestAnimationFrame(update); };
++ const vv = root.visualViewport;
++ root.addEventListener('scroll', later, { capture: true, passive: true });
++ root.addEventListener('resize', later);
++ if (vv) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); }
++ if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(later); ro.observe(el); ro.observe(trigger); }
++ const api = {
++ update,
++ release() {
++ if (released) return;
++ released = true;
++ if (queued) cancelAnimationFrame(queued);
++ root.removeEventListener('scroll', later, { capture: true });
++ root.removeEventListener('resize', later);
++ if (vv) { vv.removeEventListener('resize', later); vv.removeEventListener('scroll', later); }
++ try { ro && ro.disconnect(); } catch { /* */ }
++ if (topLayer && el.hasAttribute('popover')) { try { if (el.matches(':popover-open')) el.hidePopover(); } catch { /* closed */ } el.removeAttribute('popover'); }
++ for (const a of ['data-ov-anchored', 'data-ov-anim', 'data-ov-side']) el.removeAttribute(a);
++ for (const k of ['left', 'top', 'right', 'bottom', 'position', 'zIndex', 'maxHeight', 'maxWidth', 'transformOrigin']) el.style[k] = '';
++ },
++ };
++ update();
++ return api;
++ }
++
++ function popover({ el, trigger, close, pop: menu, place, sheet: asSheet, gap }) {
if (!el) return null;
-- const api = { open() { if (pop && pop !== api) pop.close(); if (root.OpenVibePanels) root.OpenVibePanels.closeAll(); pop = api; },
-- close() { if (pop !== api) return; pop = null; close(); },
-- destroy() { if (pop === api) pop = null; } };
++ let anchored = null;
++ const unanchor = () => { if (anchored) { anchored.release(); anchored = null; } };
++ const api = { open() {
++ if (pop && pop !== api) pop.close();
++ if (root.OpenVibePanels) root.OpenVibePanels.closeAll();
++ pop = api;
++ if (menu && trigger && !anchored) anchored = anchor(menu, trigger, { place, sheet: asSheet, gap, onLost: () => api.close() });
++ },
++ close() { unanchor(); if (pop !== api) return; pop = null; close(); },
++ update() { if (anchored) anchored.update(); },
++ destroy() { unanchor(); if (pop === api) pop = null; } };
api.el = el; api.trigger = trigger;
return api;
}
@@@ -115,6 +272,6 @@
}
new MutationObserver(syncDialogs).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['open'] });
syncDialogs();
-- root.OVSurfaces = { sheet, popover, closePopover() { if (pop) pop.close(); } };
++ root.OVSurfaces = { sheet, popover, anchor, closePopover() { if (pop) pop.close(); }, _test: { placeBox } };
if (typeof module !== 'undefined' && module.exports) module.exports = root.OVSurfaces;
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/ov-float-chrome.test.js b/test/ov-float-chrome.test.js
iindex bf603bd..8bacd50 100644
---- a/test/ov-float-chrome.test.js
++++ b/test/ov-float-chrome.test.js
@@@ -1,6 +1,7 @@
'use strict';
// ov-float.js (4.208.0) in a real headless Chrome: a box docked in its frame, lifted on the first move and floating
--// over the page, dropped back over its frame it docks, thrown past a side it tucks with its tab, the tab brings it
++// over the page, dropped back over its frame it docks, thrown past a side it tucks into a small handle that slides
++// along that side and pulls out, the tab brings it
// back, the arrows, + and T move and size it, the corner grip sizes it, Home puts it back, the place is kept and
// comes back on a reload, a pluggable account store receives each save, and `ov:float` and OVFloat.layout() say where
// it is.
@@@ -116,8 +117,34 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const tab = await page.evaluate(`(() => { const t = document.querySelector('.ov-float-tab'); return { display: getComputedStyle(t).display, label: t.getAttribute('aria-label') }; })()`);
assert.equal(tab.display, 'grid', 'the tab is on screen');
assert.equal(tab.label, 'Bring it back');
++ // 4.235.0: tucked, it is a small handle at the edge, not a strip as tall as itself.
++ const handle = async () => page.evaluate(`(() => { const t = document.querySelector('.ov-float-tab').getBoundingClientRect(); const el = document.getElementById('box'); return { w: t.width, h: t.height, left: t.left, top: t.top, clip: getComputedStyle(el).clipPath, frameLeft: document.getElementById('frame').getBoundingClientRect().left, y: window.f.geom().y }; })()`);
++ let hd;
++ await eventually(async () => {
++ hd = await handle();
++ assert.ok(Math.abs(hd.w - 30) < 1 && Math.abs(hd.h - 64) < 1, `a 30 × 64 handle (${hd.w} × ${hd.h})`);
++ assert.ok(Math.abs(hd.left) < 1, `at the side of the window it was thrown at (${hd.left})`);
++ assert.match(hd.clip, /inset/, 'the rest of it is folded away');
++ }, 'the handle');
++ // Dragged up or down, the handle slides along its side and stays tucked.
++ await drag(page, { x: hd.left + hd.w / 2, y: hd.top + hd.h / 2 }, { x: hd.left + hd.w / 2 + 3, y: hd.top + hd.h / 2 + 90 });
++ let slid;
++ await eventually(async () => {
++ slid = await handle();
++ assert.match((await box(page)).cls, /is-tucked-left/, 'still tucked after a slide');
++ assert.ok(slid.top > hd.top + 60 && slid.y > hd.y, `slid down its side (${hd.top} → ${slid.top})`);
++ }, 'the slide');
++ // Pulled out sideways, it opens under the finger and lands where it is let go.
++ await drag(page, { x: slid.left + slid.w / 2, y: slid.top + slid.h / 2 }, { x: slid.left + 260, y: slid.top + slid.h / 2 });
++ await eventually(async () => assert.doesNotMatch((await box(page)).cls, /is-tucked/, 'a pull brings it out'), 'pull');
++ assert.equal(await page.evaluate(`getComputedStyle(document.getElementById('box')).clipPath`), 'none', 'nothing folded once it is out');
++ // Thrown again and tapped: it opens at its side.
++ v = await box(page);
++ await drag(page, { x: v.left + v.width / 2, y: v.top + v.height / 2 }, { x: 1, y: v.top + v.height / 2 });
++ await eventually(async () => assert.match((await box(page)).cls, /is-tucked-left/, 'tucked again'), 'tuck again');
await page.evaluate(`document.querySelector('.ov-float-tab').click()`);
await eventually(async () => assert.doesNotMatch((await box(page)).cls, /is-tucked/, 'the tab brings it back'), 'untuck');
++ assert.ok(!(await page.evaluate(`window.puts.some((p) => 'ux' in p.value)`)), 'the account gets only the place, never the browser\'s extras');
// The keyboard: an arrow moves it, + grows it, T tucks, Home puts it back where it started.
await page.evaluate(`document.getElementById('box').focus()`);
@@@ -143,6 +170,19 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await eventually(async () => assert.ok((await box(page)).w > 0.25, 'the corner sizes it'), 'resize');
assert.ok((await page.evaluate(`JSON.parse(localStorage.getItem('test-float')).w`)) > 0.25, 'the new size is kept');
++ // Past what its frame holds it floats over the page and keeps growing, up to the window's width.
++ v = await box(page);
++ const grip2 = await page.evaluate(`(() => { const r = document.querySelector('.ov-float-resize').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
++ await drag(page, grip2, { x: 1270, y: grip2.y + 300 });
++ await eventually(async () => {
++ const g = await box(page);
++ assert.equal(g.mode, 'float', 'grown past its frame, it floats');
++ assert.ok(g.width > 800, `wider than its frame (${g.width})`);
++ assert.ok(g.width <= 1280 + 1 && g.left >= -1 && g.left + g.width <= 1281, 'and inside the window');
++ }, 'grow past the frame');
++ await key(page, 'Home');
++ await wait(400);
++
// The place comes back on a reload.
const last = await box(page);
await page.goto(base, { maxMs: 5000 });
@@@ -154,10 +194,27 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// A phone-sized frame: the 150 px floor, and the box stays inside it.
const ph = await openPage(browser, await browser.newContext(), { width: 390, height: 844, mobile: true });
await ph.goto(base, { maxMs: 5000 });
-- await ph.evaluate(`Object.assign(document.getElementById('frame').style, { width: '390px', height: '219px' }); window.f.set({ w: 0.25, mode: 'dock', x: 0.9, y: 0.9 }, { settle: true, save: true })`);
++ await ph.evaluate(`Object.assign(document.getElementById('frame').style, { left: '0px', width: '390px', height: '219px' }); window.f.set({ w: 0.25, mode: 'dock', x: 0.9, y: 0.9 }, { settle: true, save: true })`);
const small = await ph.evaluate(`(() => { const el = document.getElementById('box'); const r = el.getBoundingClientRect(); const f = document.getElementById('frame').getBoundingClientRect(); return { w: r.width, inside: r.left >= f.left - 1 && r.right <= f.right + 1 && r.top >= f.top - 1 && r.bottom <= f.bottom + 1 }; })()`);
assert.ok(small.w >= 149.5, `never smaller than 150 px (${small.w})`);
assert.ok(small.inside, 'shown inside its frame');
++ // A double tap on a phone: the whole width of the screen.
++ const pb = await ph.evaluate(`(() => { const r = document.getElementById('box').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`);
++ for (let i = 0; i < 2; i += 1) {
++ await ph.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pb.x, y: pb.y, button: 'left', clickCount: 1 });
++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pb.x, y: pb.y, button: 'left', clickCount: 1 });
++ await wait(60);
++ }
++ await eventually(async () => {
++ const full = await ph.evaluate(`(() => { const r = document.getElementById('box').getBoundingClientRect(); return { w: r.width, left: r.left, mode: window.f.geom().mode }; })()`);
++ assert.ok(full.w > 380 && full.left > -1 && full.left + full.w < 391, `a double tap fills the phone's width (${full.w})`);
++ }, 'double tap on a phone');
++ // This device's place wins over an older or newer one in the account; a device without one takes the account's.
++ await ph.evaluate(`window.remote = { 'test-float': { x: 0.5, y: 0.5, w: 0.3, mode: 'float', tuck: null, at: Date.now() + 60000 } }`);
++ const keep = await ph.evaluate(`JSON.parse(localStorage.getItem('test-float')).w`);
++ await ph.goto(base, { maxMs: 5000 });
++ await ph.evaluate(`window.remote = { 'test-float': { x: 0.5, y: 0.5, w: 0.3, mode: 'float', tuck: null, at: Date.now() + 60000 } }`);
++ await eventually(async () => assert.ok(Math.abs((await ph.evaluate('window.f.geom().w')) - keep) < 0.001, 'this phone keeps its own place'), 'own place');
await ph.close();
console.log('ov-float-chrome: docked and floating, the lift and the landing, the tuck and its tab, the keys, the corner, the kept place, the account store, ov:float and layout()');
} finally {
ddiff --git a/test/surfaces-chrome.test.js b/test/surfaces-chrome.test.js
iindex 8cf8fce..1bb882d 100644
---- a/test/surfaces-chrome.test.js
++++ b/test/surfaces-chrome.test.js
@@@ -25,7 +25,21 @@ const two=OVSurfaces.popover({el:b,trigger:document.querySelector('#two'),close:
document.querySelector('#one').onclick=()=>{a.hidden=false;one.open()};
document.querySelector('#two').onclick=()=>{b.hidden=false;two.open()};
</script>`;
++const anchorPage = `<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><style>
++body{margin:0;height:844px}.clip{position:absolute;left:0;right:0;top:700px;height:80px;overflow:hidden;background:#222}
++#more{position:absolute;right:8px;top:20px;width:40px;height:40px}
++.menu{position:absolute;right:0;top:calc(100% + 6px);width:210px;padding:6px;border:1px solid #444;border-radius:12px;background:#111;color:#eee}
++.menu button{display:block;width:100%;height:40px}
++</style><div class="clip"><details id="more"><summary>⋯</summary><div class="menu" id="menu"><button>One</button><button>Two</button><button>Three</button></div></details></div>
++<button id="far" style="position:absolute;left:4px;top:4px;width:30px;height:30px">L</button><div id="farmenu" class="menu" hidden><button>Left edge</button></div>
++<script src="/surfaces.js"></script><script>
++const d = document.getElementById('more');
++const surf = OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), pop: document.getElementById('menu'), close: () => { d.open = false; } });
++d.addEventListener('toggle', () => d.open ? surf.open() : surf.close());
++window.far = () => { const m = document.getElementById('farmenu'); m.hidden = false; return OVSurfaces.anchor(m, document.getElementById('far'), { place: 'bottom-end' }); };
++</script>`;
const server = http.createServer((req, res) => {
++ if (req.url === '/anchor') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(anchorPage); }
if (req.url === '/surfaces.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'surfaces.js'))); }
res.writeHead(200, { 'content-type': 'text/html' }); res.end(page);
});
@@@ -85,6 +99,35 @@ const server = http.createServer((req, res) => {
fs.writeFileSync(path.join(process.env.OV_SHEETS_SHOTS, 'shared-sheet-1280.png'), Buffer.from(shot.data, 'base64'));
await desktop.close();
}
-- console.log('surfaces-chrome: phone scroll lock, sheet scroll, focus, one pop-over and outside pointer ok');
++ // 4.235.0, the layer: a menu inside a clipped box at the bottom of a phone opens on top of everything, above its
++ // button (no room below), inside the screen, and goes back to its own place when it closes.
++ {
++ const a = await h.openPage(browser, context, { width: 390, height: 844 });
++ await a.goto(`http://127.0.0.1:${server.address().port}/anchor`, { maxMs: 5000 });
++ await a.evaluate(`document.getElementById('more').open = true`);
++ await new Promise((ok) => setTimeout(ok, 250));
++ const m = await a.evaluate(`(() => { const el = document.getElementById('menu'); const r = el.getBoundingClientRect(); const s = document.querySelector('#more summary').getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 10); return { side: el.dataset.ovSide, top: r.top, bottom: r.bottom, left: r.left, right: r.right, sTop: s.top, onTop: !!hit && el.contains(hit), popover: el.matches(':popover-open') }; })()`);
++ assert.equal(m.side, 'top', 'no room below: it opens above its button');
++ assert.ok(m.bottom <= m.sTop, 'above it');
++ assert.ok(m.onTop && m.popover, 'in the top layer, not clipped by its box');
++ assert.ok(m.left >= 8 && m.right <= 390 - 8 + 0.5, `inside the screen (${m.left}–${m.right})`);
++ await a.evaluate(`document.getElementById('more').open = false`);
++ await new Promise((ok) => setTimeout(ok, 120));
++ const back = await a.evaluate(`(() => { const el = document.getElementById('menu'); return { anchored: el.hasAttribute('data-ov-anchored'), popover: el.hasAttribute('popover'), left: el.style.left }; })()`);
++ assert.deepEqual(back, { anchored: false, popover: false, left: '' }, 'closed: back to its own place');
++ // A button at the left edge with an end-aligned menu: shifted to stay on the screen.
++ const f = await a.evaluate(`(() => { window.farA = far(); const r = document.getElementById('farmenu').getBoundingClientRect(); return { left: r.left, side: document.getElementById('farmenu').dataset.ovSide }; })()`);
++ assert.ok(f.left >= 8 - 0.5 && f.side === 'bottom', `shifted inside the left edge (${f.left})`);
++ await a.close();
++ const { placeBox } = await (async () => { const vm = require('vm'); const ctx = { window: {}, document: { addEventListener() {}, querySelectorAll: () => [], documentElement: { style: {} } }, MutationObserver: function () { this.observe = () => {}; } }; ctx.window = ctx; vm.createContext(ctx); vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'surfaces.js'), 'utf8'), ctx); return ctx.OVSurfaces._test; })();
++ const view = { left: 0, top: 0, width: 390, height: 844 };
++ const p1 = placeBox({ left: 300, right: 340, top: 100, bottom: 140, width: 40, height: 40 }, { width: 210, height: 300 }, view, { place: 'bottom-end' });
++ assert.equal(p1.side, 'bottom'); assert.equal(p1.left, 130); assert.equal(p1.top, 146);
++ const p2 = placeBox({ left: 300, right: 340, top: 700, bottom: 740, width: 40, height: 40 }, { width: 210, height: 300 }, view, { place: 'bottom-end' });
++ assert.equal(p2.side, 'top'); assert.equal(p2.top, 700 - 6 - 300);
++ const p3 = placeBox({ left: 300, right: 340, top: 480, bottom: 520, width: 40, height: 40 }, { width: 210, height: 900 }, view, { place: 'bottom-end' });
++ assert.equal(p3.side, 'top', 'a tall menu takes the side with more room'); assert.equal(p3.maxHeight, 480 - 6 - 8);
++ }
++ console.log('surfaces-chrome: phone scroll lock, sheet scroll, focus, one pop-over and outside pointer ok; the layer: top layer, flipped, shifted, released');
} finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
})().catch((e) => { console.error(e); process.exit(1); });