openvibers/openvibe.shared Commits

ov-float: one move engine for the PiP and the window (4.208.0) (#468)

OP OpenVibers committed 2 days ago · 696ea55e94 · 20 files, +1186 −21

* ov-float: the PiP's move engine, adopted by the window (4.206.0)

* ov-float: 4.208.0 after the two release trains already on 4.206 and 4.207

* ov-float: stop the account store on a rejected key, refresh the sibling hashes

* ov-float: a pinch that began after the lift returns to its frame

* ov-float: measure the frame's content box, so a bordered frame still gives a 150 px floor

* Merge remote-tracking branch 'origin/main' into ov-float-shared

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	dock-vibe.js
#	dock.js
#	navbar.js
#	ov-thread.js
#	package-lock.json
#	package.json

* Dock size: 10 bytes over on Windows after the 4.206 menu work; budget 21400

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 96d2faf..f78c021 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,23 @@
# Changelog
++## 4.208.0 — 2026-11-11
++
++**One move engine for every floating thing: the picture-in-picture's drag, flick, tuck, pinch, corner and place, now
++shared and adopted by the window.**
++
++- **`ov-float.js`.** `OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange })` gives any box Live's
++ picture-in-picture behaviour: docked in its frame or fixed over the page, the lift on the first move, a transform
++ each animation frame, the FLIP landing with the spring, a flick, edge snapping, the tuck into a side with its tab,
++ pinch and corner sizing, a double tap, the keyboard (arrows, + −, T, F, Home), fullscreen, reduced motion, and its
++ place as fractions of its frame. The place is kept in localStorage by `key` and in the account through
++ `OVFloat.store` (`GET`/`PATCH /api/me/layout`, the `pip` map); `OVFloat.layout()` and the `ov:float` event say where
++ every box is. Its behaviour CSS is injected once; a component keeps its own look.
++- **A panel window moves, tucks and keeps its place (`ov-window.js`).** A panel's head is an OVFloat handle: drag it
++ anywhere, flick it, throw it past a side to tuck it with a tab, and it opens where it was left. The absolute place
++ older releases wrote stays readable, and the size the corner grip keeps is unchanged.
++- **Live's picture-in-picture uses it (`stream-pip.js`, in the Live release).** The viewer sessions, waits and
++ actions stay Live's; the move, tuck, size, keyboard and persistence are the shared engine's.
++
## 4.206.0 — 2026-11-11
**The menu keeps a stream playing, the Feed in the menu is searchable with the one composer at its top, and a Code task
ddiff --git a/README.md b/README.md
iindex 5be1cb1..a9f6e6d 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.206.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.208.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.
@@@ -22,7 +22,7 @@ were reconciled first.
| Kind | Files |
|---|---|
--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `vibes-pay.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `soft.js`, `openvibe-sw.js` |
++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `ov-float.js` (the move engine behind a PiP or a panel: dock/float, tuck, size and the kept place), `vibes-pay.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `soft.js`, `openvibe-sw.js` |
| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `glyphs` (the network's icons, one registry: `NAMES`, `path(name)`, `svg(name, { size, title })`, `fromFa(name)`, `FA`; the browser drop-in `ov-glyphs.js` shows them in place of Font Awesome's and keeps Font Awesome's classes, animations and `data-fa-transform`; also as fonts, `/shared/ov-glyphs.woff2` with `ov-glyphs-font.css` (`ovi ovi-<name>`) or `ov-glyphs-fa.css` (Font Awesome's names), `glyph-fonts/ov-glyphs.{ttf,otf}` and `glyph-codepoints.json`, and as the sprite `/shared/ov-glyphs.svg#ovg-<name>`), `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Link its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
ddiff --git a/STATUS.json b/STATUS.json
iindex 4c160e7..dc7222a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,13 +4,14 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.206.0",
++ "version": "4.208.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)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"features": [
++ "one move engine for a floating thing: OVFloat.attach gives the PiP's drag, flick, tuck and kept place to any box, and a panel window adopts it (4.208.0)",
"the menu Feed searches Community with examples and uses the one composer for pictures and files; the Chat summary opens the Vibe tab (4.206.0)",
"the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 3c17283..d5a9ddc 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -58,7 +58,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': '94fe9cbede93' };
++ const VIBE_REV = { 'ov-composer.js': '50bdd857445c' };
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 bd0cbeb..0a0d762 100644
---- a/dock.js
++++ b/dock.js
@@@ -89,7 +89,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': '2c597ddf5604', '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': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
++ const DOCK_REV = { 'dock-vibe.js': '061fd680f4f7', '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': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
ddiff --git a/files.js b/files.js
iindex 82bfe66..e45bd88 100644
---- a/files.js
++++ b/files.js
@@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'ov-composer.js', // shared composer
'ov-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
++ 'ov-float.js', // the move engine behind a PiP or a panel: dock/float, tuck, size, place (4.208.0)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
'history.js', // cross-site history
ddiff --git a/navbar.js b/navbar.js
iindex e93bdd3..a020f6f 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -607,7 +607,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': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '1428fea2f91c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '2dc02f5569ac', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b5a78ad67810', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-composer.js b/ov-composer.js
iindex 714ceb7..e96e2b7 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -110,7 +110,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
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': '625783527b4e' };
++ const COMPOSER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
ddiff --git a/ov-float.js b/ov-float.js
nnew file mode 100644
iindex 0000000..5b45b29
---- /dev/null
++++ b/ov-float.js
@@@ -0,0 +1,815 @@
++/**
++ * ov-float.js (openvibe-shared 4.208.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:
++ *
++ * const f = OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange });
++ *
++ * - 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.
++ * - 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:
++ * { 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,
++ * 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
++ * grip. A component adds its own colours, border, shadow and children. `aspect: null`, `size: false` and
++ * `resize: false` let a panel window keep its own height and width while it gets the move, the tuck and the place.
++ */
++(function (root) {
++ 'use strict';
++ 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_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 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
++ const TAP_MS = 300;
++ const ACTIVE_MS = 3200;
++ 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; }
++.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-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-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;
++ background: linear-gradient(135deg, transparent 52%, rgba(255, 255, 255, .72) 52%, rgba(255, 255, 255, .72) 60%, transparent 60%, transparent 70%, rgba(255, 255, 255, .72) 70%, rgba(255, 255, 255, .72) 78%, transparent 78%); }
++.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; }
++@media (prefers-reduced-motion: reduce) {
++ html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
++}`;
++
++ const instances = new Set();
++ let uid = 0;
++
++ function clamp(v, lo, hi, dflt) {
++ const n = Number(v);
++ if (!Number.isFinite(n)) return dflt;
++ return Math.min(hi, Math.max(lo, n));
++ }
++
++ function viewport(inst) {
++ const de = document.documentElement;
++ // A floating element's percentage place is measured on the document's client box, not on innerWidth when a
++ // scrollbar takes 15 px of it. Live's PiP keeps the window size it always used; a free panel asks for `client`.
++ const client = inst && inst.options.viewport === 'client' && de;
++ const w = (client ? de.clientWidth : root.innerWidth) || 0;
++ const h = (client ? de.clientHeight : root.innerHeight) || 0;
++ return { left: 0, top: 0, width: w, height: h, right: w, bottom: h };
++ }
++
++ function rectOf(el) {
++ return el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : viewport();
++ }
++
++ /** The box an absolutely positioned child measures its percentages on: the frame's padding box, not its border. */
++ function contentRect(el) {
++ const r = rectOf(el);
++ if (!el || !el.clientWidth || !el.clientHeight) return r;
++ const left = r.left + (el.clientLeft || 0), top = r.top + (el.clientTop || 0);
++ return { left, top, width: el.clientWidth, height: el.clientHeight, right: left + el.clientWidth, bottom: top + el.clientHeight };
++ }
++
++ function reduced() {
++ const m = document.documentElement && document.documentElement.getAttribute('data-ov-motion');
++ if (m === 'off') return true;
++ try { return m !== 'on' && root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
++ }
++
++ function inFullscreen(inst) {
++ const fs = typeof document !== 'undefined' && document.fullscreenElement;
++ return !!(fs && inst && inst.frame && (fs === inst.frame || fs.contains(inst.frame)));
++ }
++
++ function frameRect(inst) {
++ if (inst.geom.mode === 'float' && !inFullscreen(inst)) return viewport(inst);
++ return inst.frame ? contentRect(inst.frame) : viewport(inst);
++ }
++
++ /* ── geometry (pure; the tests call these) ─────────────────────────────────────────────────────────────── */
++
++ /**
++ * The box as fractions of its frame: width, height and the place it may go, for the source's own shape.
++ * aspect: null keeps the element's own height (a panel window); `elHeight` is its measured height.
++ */
++ function boxOf(geom, rect, opts) {
++ const o = opts || {};
++ const a = o.aspect === null ? null : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE);
++ const minW = o.minW == null ? MIN_W : o.minW;
++ const maxW = o.maxW == null ? MAX_W : o.maxW;
++ const maxH = o.maxH == null ? MAX_H : o.maxH;
++ const rw = (rect && rect.width) || 0, rh = (rect && rect.height) || 0;
++ const rl = (rect && rect.left) || 0, rt = (rect && rect.top) || 0;
++ let w = clamp(geom && geom.w, minW, maxW, Math.min(0.25, maxW));
++ if (a == null) {
++ return { a: null, wf: w, hf: rh && o.elHeight ? o.elHeight / rh : 0, rw, rh, rl, rt };
++ }
++ // On a phone-sized frame a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
++ // (the saved size is unchanged, so a desktop shows it as saved).
++ const minPx = o.minPx == null ? MIN_PX : o.minPx;
++ if (rw && rw < (o.phoneFramePx == null ? 640 : o.phoneFramePx)) w = Math.max(w, Math.min(maxW, minPx / rw));
++ let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 box of width w
++ if (rw && rh && (wf * rw) / a > maxH * rh) wf = (maxH * rh * a) / rw;
++ wf = Math.min(wf, 1);
++ const hf = rw && rh ? (wf * rw) / a / rh : wf / a;
++ return { a, wf, hf, rw, rh, rl, rt };
++ }
++
++ /** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
++ function settleGeom(geom, box, opts) {
++ const o = opts || {};
++ const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
++ const snap = o.snap == null ? SNAP : o.snap;
++ const mx = box.rw ? edge / box.rw : 0, my = box.rh ? edge / box.rh : 0;
++ const maxX = Math.max(0, 1 - box.wf), maxY = Math.max(0, 1 - (box.hf || 0));
++ let x = clamp(geom.x, 0, maxX, 0), y = clamp(geom.y, 0, maxY, 0);
++ if (x < snap) x = Math.min(mx, maxX); else if (x > maxX - snap) x = Math.max(0, maxX - mx);
++ if (y < snap) y = Math.min(my, maxY); else if (y > maxY - snap) y = Math.max(0, maxY - my);
++ geom.x = x; geom.y = y;
++ return geom;
++ }
++
++ /** The last 90 ms of a drag: the speed it was let go at, and where a flick carries it. */
++ function flick(hist, from, opts) {
++ const o = opts || {};
++ const h = Array.isArray(hist) && hist.length ? hist : [{ x: 0, y: 0, t: 0 }];
++ const a = h[0], z = h[h.length - 1];
++ const dt = Math.max(16, (z.t || 0) - (a.t || 0));
++ const vx = ((z.x || 0) - (a.x || 0)) / dt, vy = ((z.y || 0) - (a.y || 0)) / dt;
++ const fast = Math.hypot(vx, vy) > (o.fastAt == null ? 0.6 : o.fastAt);
++ return { vx, vy, fast, left: from.left + (fast ? vx * PROJECT_MS : 0), top: from.top + (fast ? vy * PROJECT_MS : 0) };
++ }
++
++ /** Past a side of its frame by more than a third of itself (or thrown at it): tucked into that side. */
++ function tuckFor(point, from, frame, speed, opts) {
++ const o = opts || {};
++ const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
++ const out = from.width / 3;
++ if (point.left < frame.left - out || (speed.fast && speed.vx < -1.2 && point.left < frame.left + edge)) return 'left';
++ if (point.left + from.width > frame.right + out || (speed.fast && speed.vx > 1.2 && point.left + from.width > frame.right - edge)) return 'right';
++ return null;
++ }
++
++ function overFrame(cx, cy, frame) {
++ return !!frame && cx >= frame.left && cx <= frame.right && cy >= frame.top && cy <= frame.bottom;
++ }
++
++ /** Draw a geometry: the percentages, the shape, and the state classes the component styles. */
++ function paint(el, geom, box, opts) {
++ const o = opts || {};
++ const tuck = geom.tuck;
++ const floating = !!o.floating;
++ const classes = el.classList;
++ classes.toggle('is-floating', floating);
++ classes.toggle('is-tucked', !!tuck);
++ classes.toggle('is-tucked-left', tuck === 'left');
++ classes.toggle('is-tucked-right', tuck === 'right');
++ classes.toggle('is-portrait', !!box.a && box.a < 1);
++ // Shown inside its frame even when a turn of the phone or a smaller window would push it out; the saved place
++ // is not changed, so it comes back when there is room again. A tucked one sits at its side (CSS `translate`
++ // leaves only its tab on screen; `transform` stays free for the landing).
++ const left = tuck === 'left' ? 0 : tuck === 'right' ? Math.max(0, 1 - box.wf) : Math.min(Math.max(0, geom.x), Math.max(0, 1 - box.wf));
++ el.style.left = `${left * 100}%`;
++ el.style.top = `${Math.min(Math.max(0, geom.y), Math.max(0, 1 - (box.hf || 0))) * 100}%`;
++ if (o.size !== false) {
++ el.style.width = `${box.wf * 100}%`;
++ if (box.a) el.style.aspectRatio = String(box.a);
++ }
++ }
++
++ /* ── the account plug (GET/PATCH /api/me/layout, the shape Live's stream-pip.js keeps) ───────────────── */
++
++ function makeDefaultStore() {
++ const state = { data: null, loading: null, pending: {}, timer: 0, off: false };
++ function load() {
++ if (state.off || typeof fetch !== 'function') return Promise.resolve(null);
++ if (!state.loading) {
++ state.loading = fetch('/api/me/layout', { credentials: 'same-origin', headers: { accept: 'application/json' } })
++ .then((r) => {
++ if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) { state.off = true; return null; }
++ return r.ok ? r.json() : null;
++ })
++ .then((d) => { state.data = d && typeof d === 'object' ? d : {}; return state.data; })
++ .catch(() => null);
++ }
++ return state.loading;
++ }
++ function get(key) {
++ const pip = state.data && state.data.pip;
++ return pip && pip[key] ? pip[key] : null;
++ }
++ function put(key, value) {
++ if (state.off || typeof fetch !== 'function') return;
++ state.pending[key] = value;
++ if (state.data) { state.data.pip = state.data.pip || {}; state.data.pip[key] = value; }
++ clearTimeout(state.timer);
++ state.timer = setTimeout(() => {
++ const pip = state.pending; state.pending = {};
++ fetch('/api/me/layout', {
++ method: 'PATCH', credentials: 'same-origin',
++ headers: { 'content-type': 'application/json', accept: 'application/json' },
++ body: JSON.stringify({ pip }),
++ }).then((r) => { if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) state.off = true; })
++ .catch(() => { /* the next move tries again */ });
++ }, 1500);
++ }
++ return { load, get, put, _state: state };
++ }
++
++ function normalizeDefaults(raw, hasFrame) {
++ const d = raw && typeof raw === 'object' ? raw : {};
++ return {
++ x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),
++ mode: hasFrame ? (d.mode === 'float' ? 'float' : 'dock') : 'float',
++ tuck: d.tuck === 'left' || d.tuck === 'right' ? d.tuck : null,
++ muted: typeof d.muted === 'boolean' ? d.muted : undefined,
++ at: 0,
++ };
++ }
++
++ function normalize(raw, fallback, opts) {
++ const o = opts || {};
++ if (!raw || typeof raw !== 'object') return Object.assign({}, fallback);
++ const g = Object.assign({}, fallback);
++ g.x = clamp(raw.x, 0, 1, fallback.x);
++ g.y = clamp(raw.y, 0, 1, fallback.y);
++ g.w = clamp(raw.w, o.minW == null ? MIN_W : o.minW, o.maxW == null ? MAX_W : o.maxW, fallback.w);
++ g.mode = o.hasFrame === false ? 'float' : (raw.mode === 'float' ? 'float' : raw.mode === 'dock' ? 'dock' : fallback.mode);
++ 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;
++ return g;
++ }
++
++ function readSaved(key, legacyKeys) {
++ const keys = [key].concat(Array.isArray(legacyKeys) ? legacyKeys : []);
++ for (const k of keys) {
++ try {
++ const raw = root.localStorage && root.localStorage.getItem(k);
++ if (!raw) continue;
++ const v = JSON.parse(raw);
++ if (v && typeof v === 'object') return v;
++ } catch { /* private mode, or a key from another shape */ }
++ }
++ return null;
++ }
++
++ /* ── the instance ──────────────────────────────────────────────────────────────────────────────────────── */
++
++ function box(inst) {
++ return boxOf(inst.geom, frameRect(inst), {
++ aspect: inst.aspect, minPx: inst.minPx, minW: inst.minW, maxW: inst.maxW,
++ elHeight: inst.el.offsetHeight || 0,
++ });
++ }
++
++ function paintNow(inst) {
++ const b = box(inst);
++ paint(inst.el, inst.geom, b, {
++ floating: inst.geom.mode === 'float' && !inFullscreen(inst),
++ size: inst.options.size !== false,
++ });
++ return b;
++ }
++
++ function settleInst(inst) {
++ return settleGeom(inst.geom, box(inst));
++ }
++
++ function placeIn(inst) {
++ const fs = document.fullscreenElement;
++ let want;
++ if (inst.geom.mode === 'float' && !inFullscreen(inst)) want = document.body;
++ else if (inst.frame) want = fs && !fs.contains(inst.frame) ? fs : inst.frame;
++ else want = document.body;
++ if (want && inst.el.parentNode !== want) {
++ const v = inst.el.querySelector('video');
++ want.appendChild(inst.el);
++ // A media element moved in one task keeps playing; play() covers a browser that paused it.
++ if (v && v.paused) v.play().catch(() => {});
++ }
++ }
++
++ function save(inst) {
++ if (inst.destroyed) return;
++ // A drop clears the lifted inline left/top before it saves; draw the final place first so an onChange that
++ // 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();
++ 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 */ }
++ }
++ if (typeof inst.options.onChange === 'function') {
++ try { inst.options.onChange(Object.assign({}, 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 } }));
++ } catch { /* an environment without CustomEvent */ }
++ }
++
++ /** Move it to its place with a spring from where it was (`from`: its rect on screen before). */
++ function land(inst, from) {
++ placeIn(inst);
++ const el = inst.el;
++ el.style.transition = 'none';
++ el.style.transform = '';
++ paintNow(inst);
++ if (!from || reduced()) { el.style.transition = ''; return; }
++ const to = el.getBoundingClientRect();
++ if (!to.width) { el.style.transition = ''; return; }
++ const dx = from.left - to.left, dy = from.top - to.top, s = from.width / to.width;
++ if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(s - 1) < 0.01) { el.style.transition = ''; return; }
++ el.style.transformOrigin = '0 0';
++ el.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${s})`;
++ void el.offsetWidth;
++ el.style.transition = `transform ${LAND_MS}ms var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1))`;
++ el.style.transform = '';
++ clearTimeout(inst.landTimer);
++ inst.landTimer = setTimeout(() => {
++ if (!inst.destroyed) { el.style.transition = ''; el.style.transformOrigin = ''; }
++ }, LAND_MS + 40);
++ }
++
++ /** Switch frames keeping its size and place on screen (`rect`: where it is now). */
++ function toMode(inst, mode, rect) {
++ if (!inst.frame) mode = 'float';
++ inst.geom.mode = mode;
++ const b0 = box(inst);
++ if (!b0.rw || !b0.rh) return;
++ const a = inst.aspect || BASE;
++ const k = Math.sqrt(a / BASE) || 1;
++ if (inst.options.size !== false) inst.geom.w = clamp(rect.width / b0.rw / k, inst.minW, inst.maxW, inst.geom.w);
++ const b = box(inst);
++ inst.geom.x = (rect.left - b.rl) / b.rw;
++ inst.geom.y = (rect.top - b.rt) / b.rh;
++ settleInst(inst);
++ }
++
++ function tuck(inst, side) {
++ 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');
++ }
++ save(inst);
++ land(inst, from);
++ }
++
++ function untuck(inst) {
++ const from = inst.el.getBoundingClientRect();
++ const b = box(inst);
++ 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));
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function toggleMode(inst) {
++ if (!inst.frame) return false;
++ const from = inst.el.getBoundingClientRect();
++ toMode(inst, inst.geom.mode === 'float' ? 'dock' : 'float', from);
++ save(inst);
++ land(inst, from);
++ return true;
++ }
++
++ 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);
++ inst.geom.tuck = null;
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function reset(inst) {
++ 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 });
++ settleInst(inst);
++ save(inst);
++ land(inst, from);
++ }
++
++ function relayout(inst) {
++ if (inst.destroyed || inst.el.classList.contains('is-lifted')) return;
++ placeIn(inst);
++ paintNow(inst);
++ }
++
++ function syncAccount(inst) {
++ const store = inst.store;
++ if (!store || typeof store.get !== 'function') return;
++ const apply = () => {
++ if (inst.destroyed || inst.touched) 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;
++ placeIn(inst);
++ paintNow(inst);
++ }
++ };
++ if (typeof store.load === 'function') {
++ let p;
++ try { p = store.load(); } catch { p = null; }
++ if (p && typeof p.then === 'function') p.then(apply).catch(() => { /* no account on this page */ });
++ else apply();
++ } else apply();
++ }
++
++ /* ── gestures ──────────────────────────────────────────────────────────────────────────────────────────── */
++
++ function wireMove(inst) {
++ const el = inst.el;
++ const handle = inst.options.handle && inst.options.handle.addEventListener ? inst.options.handle : el;
++ const ignore = inst.options.ignore || DEFAULT_IGNORE;
++ const hintClass = inst.options.hintClass || 'ov-float-dock-hint';
++ const pts = new Map(); // pointerId → { x, y }
++ let drag = null, pinch = null, raf = 0, lastTap = 0, activeTimer = 0;
++ const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
++ const hint = (on) => { if (inst.frame && inst.frame.classList) inst.frame.classList.toggle(hintClass, !!on); };
++ const paint = () => {
++ raf = 0;
++ if (!drag || !drag.lifted) return;
++ el.style.transform = `translate3d(${drag.dx}px, ${drag.dy}px, 0)`;
++ const c = drag.r;
++ const p = inst.frame ? contentRect(inst.frame) : null;
++ if (p) hint(overFrame(c.left + drag.dx + c.width / 2, c.top + drag.dy + c.height / 2, p));
++ };
++ const lift = () => {
++ drag.lifted = true;
++ // Over the page from the first move, at the same place on screen, so it can go anywhere.
++ const r = drag.r;
++ const layer = document.fullscreenElement || document.body;
++ if (el.parentNode !== layer) layer.appendChild(el);
++ // Moving the element in the DOM releases any pointer capture it had; take the pointers it is following back.
++ for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } }
++ el.classList.add('is-dragging', 'is-lifted');
++ el.style.transition = 'none';
++ el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.width}px`;
++ const v = el.querySelector('video');
++ if (v && v.paused) v.play().catch(() => {});
++ if (typeof inst.options.onLift === 'function') { try { inst.options.onLift(inst.api); } catch { /* */ } }
++ };
++ const showActive = () => {
++ el.classList.add('is-active');
++ clearTimeout(activeTimer);
++ activeTimer = setTimeout(() => el.classList.remove('is-active'), ACTIVE_MS);
++ };
++ const onDown = (e) => {
++ if (e.button > 0) return;
++ if (e.target.closest && e.target.closest(ignore)) 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 });
++ try { el.setPointerCapture(e.pointerId); } catch { /* a pointer the browser no longer tracks */ }
++ e.preventDefault();
++ if (pts.size === 2) {
++ if (drag && drag.lifted) el.style.transform = '';
++ drag = null;
++ pinch = { d: gap(), w: inst.geom.w };
++ 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,
++ 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);
++ 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();
++ // 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;
++ drag.dx = Math.min(Math.max(dx, -r.left - r.width + tab), vw - r.left - tab);
++ drag.dy = Math.min(Math.max(dy, -r.top), vh - r.top - r.height);
++ const now = performance.now();
++ drag.hist.push({ x: e.clientX, y: e.clientY, t: now });
++ while (drag.hist.length > 2 && now - drag.hist[0].t > 90) drag.hist.shift();
++ if (!raf) raf = requestAnimationFrame(paint);
++ };
++ const onUp = (e) => {
++ pts.delete(e.pointerId);
++ try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
++ if (pinch) {
++ if (pts.size < 2) {
++ pinch = null;
++ el.classList.remove('is-resizing');
++ // A pinch that began after the lift put the box over the page; put it back in its frame for its mode.
++ if (el.classList.contains('is-lifted')) {
++ el.classList.remove('is-dragging', 'is-lifted');
++ el.style.left = ''; el.style.top = ''; el.style.width = ''; el.style.transform = '';
++ placeIn(inst);
++ }
++ settleInst(inst);
++ paintNow(inst);
++ save(inst);
++ }
++ return;
++ }
++ if (!drag) return;
++ const d = drag; drag = null;
++ if (raf) { cancelAnimationFrame(raf); raf = 0; }
++ hint(false);
++ if (!d.lifted) {
++ // A tap: the bar for a moment (a touch screen has no hover); a second within 300 ms toggles the size.
++ 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');
++ // Where it was let go, and where a flick carries it (the last 90 ms, 120 ms on).
++ const fl = flick(d.hist, { left: d.r.left + d.dx, top: d.r.top + d.dy });
++ const from = { left: d.r.left + d.dx, top: d.r.top + d.dy, width: d.r.width, height: d.r.height };
++ const vp = viewport(inst);
++ const vhei = vp.height;
++ const top = Math.min(Math.max(0, fl.top), vhei - from.height);
++ const p = inst.frame ? contentRect(inst.frame) : null;
++ const fs = inFullscreen(inst);
++ const overPlayer = !!p && (fs || overFrame(fl.left + from.width / 2, top + from.height / 2, p));
++ const frame = overPlayer ? p : vp;
++ const tuckSide = tuckFor({ left: fl.left }, from, frame, fl);
++ el.style.transform = '';
++ el.style.left = ''; el.style.top = ''; el.style.width = '';
++ toMode(inst, overPlayer ? 'dock' : 'float', {
++ left: Math.min(Math.max(frame.left, fl.left), frame.right - from.width),
++ top,
++ width: from.width,
++ height: from.height,
++ });
++ inst.geom.tuck = tuckSide;
++ save(inst);
++ land(inst, from);
++ };
++ handle.addEventListener('pointerdown', onDown);
++ el.addEventListener('pointermove', onMove);
++ el.addEventListener('pointerup', onUp);
++ el.addEventListener('pointercancel', onUp);
++ inst.cleanups.push(() => {
++ handle.removeEventListener('pointerdown', onDown);
++ el.removeEventListener('pointermove', onMove);
++ el.removeEventListener('pointerup', onUp);
++ el.removeEventListener('pointercancel', onUp);
++ clearTimeout(activeTimer);
++ hint(false);
++ });
++ }
++
++ function wireKeys(inst) {
++ const el = inst.el;
++ const onKey = (e) => {
++ if (e.target !== el || e.altKey || e.ctrlKey || e.metaKey) return;
++ const step = e.shiftKey ? 0.1 : 0.02;
++ const k = e.key;
++ let moved = true;
++ if (k === 'ArrowLeft') inst.geom.x -= step;
++ 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 === '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; }
++ else return;
++ e.preventDefault();
++ if (!moved) return;
++ const b = box(inst);
++ inst.geom.tuck = null;
++ 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);
++ save(inst);
++ };
++ el.addEventListener('keydown', onKey);
++ inst.cleanups.push(() => el.removeEventListener('keydown', onKey));
++ }
++
++ function ensureTab(inst) {
++ let tab = inst.el.querySelector('[data-ov-float-tab]');
++ if (!tab) {
++ tab = document.createElement('button');
++ tab.type = 'button';
++ tab.className = 'ov-float-tab';
++ tab.dataset.ovFloatTab = '';
++ tab.setAttribute('aria-label', 'Bring it back');
++ tab.innerHTML = '<span class="ov-float-tab-l" aria-hidden="true">‹</span><span class="ov-float-tab-r" aria-hidden="true">›</span>';
++ inst.el.append(tab);
++ inst.ownedTab = tab;
++ }
++ const onClick = (e) => { e.preventDefault(); e.stopPropagation(); untuck(inst); };
++ tab.addEventListener('click', onClick);
++ inst.cleanups.push(() => tab.removeEventListener('click', onClick));
++ }
++
++ function ensureResize(inst) {
++ let handle = inst.el.querySelector('[data-ov-float-resize]');
++ if (!handle) {
++ handle = document.createElement('span');
++ handle.className = 'ov-float-resize';
++ handle.dataset.ovFloatResize = '';
++ handle.setAttribute('aria-hidden', 'true');
++ inst.el.append(handle);
++ inst.ownedResize = handle;
++ }
++ 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 };
++ inst.touched = true;
++ inst.el.classList.add('is-resizing');
++ try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
++ e.preventDefault();
++ 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;
++ 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);
++ };
++ const end = (e) => {
++ if (!start) return;
++ start = null;
++ inst.el.classList.remove('is-resizing');
++ try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
++ settleInst(inst);
++ paintNow(inst);
++ save(inst);
++ };
++ handle.addEventListener('pointerdown', onDown);
++ handle.addEventListener('pointermove', onMove);
++ handle.addEventListener('pointerup', end);
++ handle.addEventListener('pointercancel', end);
++ inst.cleanups.push(() => {
++ handle.removeEventListener('pointerdown', onDown);
++ handle.removeEventListener('pointermove', onMove);
++ handle.removeEventListener('pointerup', end);
++ handle.removeEventListener('pointercancel', end);
++ });
++ }
++
++ function wireWindow(inst) {
++ const onResize = () => relayout(inst);
++ root.addEventListener('resize', onResize);
++ document.addEventListener('fullscreenchange', onResize);
++ let ro = null;
++ if (typeof ResizeObserver === 'function') {
++ ro = new ResizeObserver(onResize);
++ if (inst.frame) ro.observe(inst.frame);
++ ro.observe(inst.el);
++ }
++ inst.cleanups.push(() => {
++ root.removeEventListener('resize', onResize);
++ document.removeEventListener('fullscreenchange', onResize);
++ try { ro && ro.disconnect(); } catch { /* */ }
++ });
++ }
++
++ /* ── public API ───────────────────────────────────────────────────────────────────────────────────────── */
++
++ function attach(el, options) {
++ if (!el || typeof el !== 'object' || !el.classList) throw new TypeError('OVFloat.attach(el, options): el is required');
++ const o = options || {};
++ const frame = o.frame || null;
++ const key = String(o.key || `ov-float-${++uid}`);
++ const minW = clamp(o.minW == null ? MIN_W : o.minW, 0, 1, MIN_W);
++ const maxW = clamp(o.maxW == null ? MAX_W : o.maxW, minW, 1, MAX_W);
++ const defaults = normalizeDefaults(o.defaults, !!frame);
++ const inst = {
++ el, frame, key,
++ accountKey: o.accountKey == null ? key : String(o.accountKey),
++ options: o, defaults, minW, maxW,
++ aspect: o.aspect === null ? null : (typeof o.aspect === 'function' ? Number(o.aspect()) || BASE : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE)),
++ minPx: Number(o.minPx) > 0 ? Number(o.minPx) : MIN_PX,
++ 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 });
++ injectStyles();
++ el.classList.add('ov-float');
++ if (o.tab !== false) ensureTab(inst);
++ if (o.resize !== false) ensureResize(inst);
++ wireMove(inst);
++ wireKeys(inst);
++ wireWindow(inst);
++ placeIn(inst);
++ paintNow(inst);
++
++ inst.api = {
++ el, key,
++ geom: () => Object.assign({}, inst.geom),
++ set(patch, opts) {
++ Object.assign(inst.geom, patch || {});
++ if (opts && opts.settle) settleInst(inst);
++ placeIn(inst);
++ paintNow(inst);
++ if (opts && opts.save) save(inst);
++ return Object.assign({}, inst.geom);
++ },
++ setAspect(a) {
++ inst.aspect = a === null ? null : (Number(a) > 0 ? Number(a) : BASE);
++ paintNow(inst);
++ },
++ metrics: () => box(inst),
++ paint: () => paintNow(inst),
++ settle: () => { settleInst(inst); paintNow(inst); return Object.assign({}, inst.geom); },
++ save: () => save(inst),
++ tuck: (side) => tuck(inst, side),
++ untuck: () => untuck(inst),
++ toggleTuck: () => (inst.geom.tuck ? untuck(inst) : tuck(inst)),
++ toggleMode: () => toggleMode(inst),
++ toggleSize: () => toggleSize(inst),
++ reset: () => reset(inst),
++ relayout: () => relayout(inst),
++ destroy: () => destroy(inst),
++ };
++ instances.add(inst);
++ syncAccount(inst);
++ return inst.api;
++ }
++
++ function destroy(inst) {
++ if (inst.destroyed) return;
++ inst.destroyed = true;
++ instances.delete(inst);
++ clearTimeout(inst.landTimer);
++ for (const fn of inst.cleanups) { try { fn(); } catch { /* */ } }
++ inst.cleanups = [];
++ if (inst.ownedTab) { try { inst.ownedTab.remove(); } catch { /* */ } }
++ if (inst.ownedResize) { try { inst.ownedResize.remove(); } catch { /* */ } }
++ inst.el.classList.remove('ov-float', 'is-floating', 'is-lifted', 'is-dragging', 'is-tucked', 'is-tucked-left', 'is-tucked-right', 'is-resizing', 'is-active');
++ }
++
++ function injectStyles() {
++ if (document.getElementById('ov-float-styles')) return;
++ const st = document.createElement('style');
++ st.id = 'ov-float-styles';
++ st.textContent = CSS;
++ (document.head || document.documentElement).appendChild(st);
++ }
++
++ const defaultStore = makeDefaultStore();
++ root.OVFloat = {
++ attach,
++ store: defaultStore,
++ newStore: makeDefaultStore,
++ 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,
++ },
++ };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 2a35516..7536097 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': '94fe9cbede93', 'ov-combo.js': '3e426e56fc75' };
++ const THREAD_REV = { 'ov-composer.js': '50bdd857445c', 'ov-combo.js': '3e426e56fc75' };
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 259eba6..d8aecc8 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': '625783527b4e' };
++ const USER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
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 b319725..fde8370 100644
---- a/ov-wallet.js
++++ b/ov-wallet.js
@@@ -33,7 +33,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': '625783527b4e' };
++ const WALLET_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
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 7e2bef1..ef1dcef 100644
---- a/ov-window.js
++++ b/ov-window.js
@@@ -23,6 +23,8 @@
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OVWindow) return;
++ const HERE = document.currentScript && document.currentScript.src;
++ const WINDOW_REV = { 'ov-float.js': '5c8251ac3738' };
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; }
@@@ -86,6 +88,27 @@ html.ovw-locked { overflow: hidden; }
const stack = [];
let uid = 0;
function styles() { if (document.getElementById('ovw-styles')) return; const st = document.createElement('style'); st.id = 'ovw-styles'; st.textContent = CSS; document.head.appendChild(st); }
++
++ // The panel's move, tuck and place are OVFloat's (ov-float.js, loaded from beside this file the first time a panel
++ // opens). A page that already loaded it uses that.
++ let floatLoading = null;
++ const floatWait = [];
++ function withFloat(cb) {
++ if (root.OVFloat) { cb(root.OVFloat); return; }
++ if (!HERE || floatLoading === false) return;
++ if (floatLoading) { floatWait.push(cb); return; }
++ floatWait.push(cb);
++ const s = document.createElement('script');
++ const rev = WINDOW_REV['ov-float.js'];
++ s.src = HERE.replace(/ov-window\.js(?:\?.*)?$/, 'ov-float.js') + (rev ? `?v=${rev}` : '');
++ s.onload = () => {
++ floatLoading = null;
++ for (const fn of floatWait.splice(0)) { try { fn(root.OVFloat); } catch (e) { console.error(e); } }
++ };
++ s.onerror = () => { floatLoading = false; floatWait.length = 0; };
++ floatLoading = s;
++ (document.head || document.documentElement).appendChild(s);
++ }
const phone = () => !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches);
const focusables = (el) => [...el.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((x) => x.offsetParent !== null || x === document.activeElement);
const place = { get(id) { try { return JSON.parse(root.localStorage.getItem(`ov:window:${id}`) || 'null'); } catch { return null; } }, set(id, v) { try { root.localStorage.setItem(`ov:window:${id}`, JSON.stringify(v)); } catch { /* private mode */ } } };
@@@ -122,15 +145,20 @@ html.ovw-locked { overflow: hidden; }
if (modal) document.documentElement.classList.add('ovw-locked');
// Where it opens: a panel beside its opener (or where it was left), a dialog centred, a sheet at the bottom.
++ let panelLeft = 0, panelTop = 0;
if (kind === 'panel') {
const saved = o.id ? place.get(o.id) : null;
-- const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = root.innerHeight;
++ const cs = document.documentElement;
++ const r = el.getBoundingClientRect(), vw = cs.clientWidth || root.innerWidth, vh = cs.clientHeight || root.innerHeight;
let left, top;
if (saved) { left = saved.x; top = saved.y; }
else if (opener && opener.getBoundingClientRect) { const f = opener.getBoundingClientRect(); left = f.left; top = f.bottom + 8; if (top + r.height > vh - 12) top = Math.max(12, f.top - r.height - 8); }
else { left = vw - r.width - 24; top = 72; }
-- el.style.left = `${Math.max(8, Math.min(left, vw - r.width - 8))}px`;
-- el.style.top = `${Math.max(8, Math.min(top, vh - 60))}px`;
++ left = Math.max(8, Math.min(left, vw - r.width - 8));
++ top = Math.max(8, Math.min(top, vh - 60));
++ panelLeft = left; panelTop = top;
++ el.style.left = `${left}px`;
++ el.style.top = `${top}px`;
}
if (opener && opener.getBoundingClientRect && kind !== 'sheet') {
const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
@@@ -144,6 +172,7 @@ html.ovw-locked { overflow: hidden; }
setTitle(t) { title.textContent = t || ''; },
close(why) {
if (closed) return; closed = true;
++ if (w.float) { try { w.float.destroy(); } catch { /* */ } w.float = null; }
const i = stack.indexOf(w); if (i >= 0) stack.splice(i, 1);
if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked');
const gone = () => { el.remove(); scrim.remove(); };
@@@ -153,8 +182,33 @@ html.ovw-locked { overflow: hidden; }
if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
},
modal,
++ float: null,
};
stack.push(w);
++ if (kind === 'panel') {
++ const panelId = o.id ? String(o.id) : '';
++ withFloat((OVFloat) => {
++ if (closed) return;
++ w.float = OVFloat.attach(el, {
++ frame: null, key: panelId ? `ov:window:float:${panelId}` : undefined,
++ defaults: {
++ x: Math.max(0, Math.min(1, panelLeft / Math.max(1, (document.documentElement.clientWidth || root.innerWidth)))),
++ y: Math.max(0, Math.min(1, panelTop / Math.max(1, (document.documentElement.clientHeight || root.innerHeight)))),
++ mode: 'float',
++ },
++ aspect: null, size: false, resize: false, doubleTap: false, activeBar: false,
++ viewport: 'client', handle: head, store: null, hintClass: 'ovw-dock-hint',
++ onChange(geom) {
++ if (!panelId) return;
++ // The fractions convert back to the same pixels without reading a rect mid-landing. (The old
++ // key stays as the absolute place older releases wrote, and for the size the grip keeps there.)
++ const vw = document.documentElement.clientWidth || root.innerWidth;
++ const vh = document.documentElement.clientHeight || root.innerHeight;
++ place.set(panelId, Object.assign(place.get(panelId) || {}, { x: Math.round(geom.x * vw), y: Math.round(geom.y * vh) }));
++ },
++ });
++ });
++ }
x.addEventListener('click', () => w.close('x'));
if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
el.addEventListener('keydown', (e) => {
@@@ -166,8 +220,8 @@ html.ovw-locked { overflow: hidden; }
}
});
-- // A panel moves by its head on a computer; a sheet goes down when swiped down on a phone.
-- if (kind === 'panel' || kind === 'sheet') {
++ // A sheet goes down when swiped down on a phone; a panel's head is OVFloat's handle (with its tuck and place).
++ if (kind === 'sheet') {
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return;
const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY;
ddiff --git a/package-lock.json b/package-lock.json
iindex d42c1b0..990411d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.206.0",
++ "version": "4.208.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.206.0",
++ "version": "4.208.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 3101259..12c71ae 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.206.0",
++ "version": "4.208.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -144,6 +144,7 @@
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
"./ov-window": "./ov-window.js",
++ "./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
"./ov-ai-choice": "./ai-choice.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 63dd38a..03e3099 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
-- 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex 68148c9..cb9cdce 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -21,7 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 21300; // 4.203.0 adds 0.13 KB for the lazy DM edit loader and button; prior budget was 21100 bytes. // 20.61 KB with 4.202.0 page-end docking (#442, 2026-11-11); was 20.55 KB with the Code tab (ask 350, 2026-11-10: Chat · Code · Agents; the tab lives in dock-code.js, only its mount branch ships in dock.js); was 20940 with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
++const DOCK_BROTLI_BUDGET = 21400; // 4.208.0: 21310 bytes on Windows after 4.206.0, 10 over the old 21300; the budget is bytes, not platform.
++// 4.203.0 adds 0.13 KB for the lazy DM edit loader and button; prior budget was 21100 bytes. // 20.61 KB with 4.202.0 page-end docking (#442, 2026-11-11); was 20.55 KB with the Code tab (ask 350, 2026-11-10: Chat · Code · Agents; the tab lives in dock-code.js, only its mount branch ships in dock.js); was 20940 with "Open in Chat" as an icon over your chats and in a thread (ask 336, 2026-11-10); 20.30 KB 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/ov-float-chrome.test.js b/test/ov-float-chrome.test.js
nnew file mode 100644
iindex 0000000..bf603bd
---- /dev/null
++++ b/test/ov-float-chrome.test.js
@@@ -0,0 +1,168 @@
++'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
++// 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.
++//
++// node test/ov-float-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('ov-float-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;height:1400px;background:#111}#frame{position:absolute;left:40px;top:40px;width:800px;height:450px;background:#222}
++#box{width:200px;background:#3b82f6;border-radius:14px;outline:0}#box:focus-visible{outline:2px solid #fff}</style>
++<div id="frame"><div id="box" tabindex="0" aria-label="A floating box"></div></div>
++<script src="/ov-float.js"></script>
++<script>
++window.puts = [];
++window.remote = null;
++OVFloat.store = { load: () => Promise.resolve({}), get: (key) => window.remote && window.remote[key], put: (key, value) => window.puts.push({ key, value }) };
++window.events = [];
++addEventListener('ov:float', (e) => window.events.push({ key: e.detail.key, geom: e.detail.geom }));
++window.f = OVFloat.attach(document.getElementById('box'), {
++ frame: document.getElementById('frame'), key: 'test-float', defaults: { x: 0.1, y: 0.1, w: 0.25 }, aspect: 16 / 9,
++ onChange: (g) => { window.saved = g; },
++});
++</script>`);
++});
++const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}/`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-float-'));
++ const browser = await launch({ tmpDir });
++ const mouse = async (page, type, x, y) => page.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 });
++ const drag = async (page, from, to) => {
++ // Slow enough not to be a flick: the last 90 ms carry only a few pixels, so the drop is where it was let go.
++ await mouse(page, 'mousePressed', from.x, from.y);
++ for (let i = 1; i <= 4; i += 1) {
++ await mouse(page, 'mouseMoved', from.x + ((to.x - from.x) * i) / 4, from.y + ((to.y - from.y) * i) / 4);
++ await wait(60);
++ }
++ await mouse(page, 'mouseMoved', to.x - 4, to.y - 4);
++ await wait(100);
++ await mouse(page, 'mouseMoved', to.x, to.y);
++ await mouse(page, 'mouseReleased', to.x, to.y);
++ };
++ const key = async (page, k, shift) => {
++ const codes = { ArrowRight: 39, ArrowLeft: 37, Home: 36, '=': 187, '-': 189, t: 84, f: 70 };
++ const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], modifiers: shift ? 8 : 0, text: k.length === 1 ? k : undefined };
++ await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev });
++ await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev });
++ };
++ const box = (page) => page.evaluate(`(() => { const el = document.getElementById('box'); const r = el.getBoundingClientRect(); return { left: r.left, top: r.top, width: r.width, height: r.height, cls: el.className, parent: el.parentElement.id, leftStyle: el.style.left, widthStyle: el.style.width, mode: window.f.geom().mode, x: window.f.geom().x, w: window.f.geom().w }; })()`);
++ try {
++ const page = await openPage(browser, await browser.newContext(), { width: 1280, height: 800 });
++ await page.goto(base, { maxMs: 5000 });
++ await wait(250);
++ let v = await box(page);
++ assert.equal(v.parent, 'frame', 'docked in its frame');
++ assert.match(v.cls, /ov-float/, 'the engine class');
++ assert.doesNotMatch(v.cls, /is-floating/, 'not floating yet');
++ assert.equal(v.leftStyle, '10%', 'placed as a fraction of the frame');
++ assert.equal(v.widthStyle, '25%', 'sized as a fraction of the frame');
++ assert.ok(Math.abs(v.width - 200) < 2, `the frame is 800 px (${v.width})`);
++
++ // Lift on the first move: over the page, following with a transform, then a slow drop back inside docks it.
++ const c = { x: v.left + v.width / 2, y: v.top + v.height / 2 };
++ await mouse(page, 'mousePressed', c.x, c.y);
++ await mouse(page, 'mouseMoved', c.x + 8, c.y + 6);
++ await wait(100);
++ await mouse(page, 'mouseMoved', c.x + 8, c.y + 6);
++ const lifted = await page.evaluate(`(() => { const el = document.getElementById('box'); return { parent: el.parentElement.tagName, cls: el.className, transform: el.style.transform, hint: document.getElementById('frame').className }; })()`);
++ assert.equal(lifted.parent, 'BODY', 'lifted over the page');
++ assert.match(lifted.cls, /is-lifted/, 'marked as lifted');
++ assert.match(lifted.cls, /is-dragging/, 'marked as dragging');
++ await mouse(page, 'mouseReleased', c.x + 8, c.y + 6);
++ await wait(400);
++ assert.equal((await box(page)).mode, 'dock', 'a slow drop inside still docks');
++
++ // Dragged outside its frame it floats over the page.
++ v = await box(page);
++ await drag(page, { x: v.left + v.width / 2, y: v.top + v.height / 2 }, { x: 900, y: 600 });
++ await eventually(async () => {
++ v = await box(page);
++ assert.equal(v.mode, 'float', 'dropped outside the frame it floats');
++ assert.match(v.cls, /is-floating/, 'fixed over the page');
++ }, 'a drop over the page floats');
++ assert.ok((await page.evaluate('window.puts.length')) >= 1, 'the account store got the save');
++ assert.ok((await page.evaluate('window.events.length')) >= 1, 'ov:float fired');
++ assert.equal((await page.evaluate('OVFloat.layout().length')), 1, 'layout lists it');
++ const stored = await page.evaluate(`JSON.parse(localStorage.getItem('test-float'))`);
++ assert.equal(stored.mode, 'float', 'the place is kept in the browser');
++
++ // Dropped back over the frame it docks.
++ v = await box(page);
++ await drag(page, { x: v.left + v.width / 2, y: v.top + v.height / 2 }, { x: 400, y: 300 });
++ await eventually(async () => assert.equal((await box(page)).mode, 'dock', 'dropped over its frame it docks'), 'dock again');
++ assert.equal((await box(page)).parent, 'frame', 'and is a child of the frame again');
++
++ // Thrown past the left: tucked into that side, its tab on screen; the tab brings it back.
++ 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 into the left'), 'tuck');
++ 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');
++ 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');
++
++ // The keyboard: an arrow moves it, + grows it, T tucks, Home puts it back where it started.
++ await page.evaluate(`document.getElementById('box').focus()`);
++ const before = await box(page);
++ await key(page, 'ArrowRight');
++ await eventually(async () => assert.ok((await box(page)).x > before.x, 'ArrowRight moves it right'), 'arrow');
++ const width0 = (await box(page)).w;
++ await key(page, '=');
++ await eventually(async () => assert.ok((await box(page)).w > width0, '= grows it'), 'grow');
++ await key(page, 't');
++ await eventually(async () => assert.match((await box(page)).cls, /is-tucked/, 'T tucks it'), 'T');
++ await key(page, 'Home');
++ await eventually(async () => {
++ const g = await box(page);
++ assert.equal(g.mode, 'dock', 'Home docks it again');
++ assert.ok(Math.abs(g.x - 0.1) < 0.001 && Math.abs(g.w - 0.25) < 0.001, 'and puts it back where it started');
++ }, 'Home');
++
++ // The corner grip sizes it.
++ v = await box(page);
++ const grip = 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, grip, { x: grip.x + 80, y: grip.y + 50 });
++ 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');
++
++ // The place comes back on a reload.
++ const last = await box(page);
++ await page.goto(base, { maxMs: 5000 });
++ await wait(300);
++ const again = await box(page);
++ assert.ok(Math.abs(again.width - last.width) < 2 && Math.abs(again.left - last.left) < 2, `the place came back (${again.left}/${again.width} vs ${last.left}/${last.width})`);
++ await page.close();
++
++ // 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 })`);
++ 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');
++ 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 {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/ov-float.test.js b/test/ov-float.test.js
nnew file mode 100644
iindex 0000000..f7a6f24
---- /dev/null
++++ b/test/ov-float.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++// The move engine's geometry (ov-float.js, 4.208.0): the box a float takes in its frame, the snap after a drop, the
++// tuck decision and the flick's projection. These are the numbers the browser check then drives by hand.
++//
++// node test/ov-float.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'ov-float.js'), 'utf8');
++const ctx = { window: {}, document: {}, localStorage: { getItem: () => null, setItem() {} }, console, setTimeout, clearTimeout, fetch: null };
++vm.createContext(ctx);
++vm.runInContext(src, ctx);
++const { boxOf, settle, flick, tuckFor } = ctx.window.OVFloat._test;
++
++const frame = (width, height) => ({ left: 0, top: 0, width, height, right: width, bottom: height });
++const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-6, `${msg}: ${a} vs ${b}`);
++const geom = (x, y, w) => ({ x, y, w: w == null ? 0.25 : w, mode: 'dock', tuck: null });
++
++let n = 0;
++const check = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
++
++check('a portrait source keeps its shape and the area of a 16:9 box', () => {
++ const land = boxOf(geom(0.7, 0.7), frame(1280, 720), { aspect: 16 / 9 });
++ const port = boxOf(geom(0.7, 0.7), frame(1280, 720), { aspect: 9 / 16 });
++ near(land.wf, 0.25, 'the 16:9 width is w');
++ const area = (b) => (b.wf * 1280) * ((b.wf * 1280) / b.a);
++ near(area(port), area(land), 'same area in pixels');
++ assert.ok(port.hf > port.wf, 'taller than wide');
++ near((port.wf * 1280) / (port.hf * 720), 9 / 16, 'drawn 9:16');
++});
++
++check('never taller than 85% of the frame', () => {
++ const b = boxOf(geom(0.7, 0.7, 0.6), frame(1280, 720), { aspect: 9 / 16 });
++ assert.ok(b.hf <= 0.85 + 1e-9, `height ${b.hf}`);
++ near(b.hf, 0.85, 'capped at the limit');
++});
++
++check('on a phone-sized frame it is never smaller than a 150 px wide 16:9 box', () => {
++ const b = boxOf(geom(0.7, 0.7), frame(390, 219), { aspect: 16 / 9 });
++ near(b.wf * 390, 150, '150 px wide');
++ const p = boxOf(geom(0.7, 0.7), frame(390, 219), { aspect: 9 / 16 });
++ near((p.wf * 390) * ((p.wf * 390) / p.a), 150 * (150 / (16 / 9)), 'a portrait one keeps that area');
++ near(boxOf(geom(0.7, 0.7), frame(1280, 720), { aspect: 16 / 9 }).wf, 0.25, 'a desktop frame shows the saved size');
++});
++
++check('a free-height box keeps its own height and width (a panel window)', () => {
++ const b = boxOf(geom(0.1, 0.2, 0.3), frame(1000, 800), { aspect: null, elHeight: 240 });
++ assert.strictEqual(b.a, null);
++ near(b.wf, 0.3, 'the saved width fraction');
++ near(b.hf, 0.3, 'the measured height over the frame');
++});
++
++check('a drop near an edge snaps to a 12 px margin; the middle stays put', () => {
++ const rect = frame(1280, 720);
++ const o = geom(0.01, 0.02);
++ settle(o, boxOf(o, rect, { aspect: 16 / 9 }));
++ near(o.x, 12 / 1280, 'left edge');
++ near(o.y, 12 / 720, 'top edge');
++ const b = boxOf(o, rect, { aspect: 16 / 9 });
++ const r = geom(1 - b.wf - 0.01, 1 - b.hf - 0.01);
++ settle(r, boxOf(r, rect, { aspect: 16 / 9 }));
++ near(r.x, 1 - b.wf - 12 / 1280, 'right edge');
++ near(r.y, 1 - b.hf - 12 / 720, 'bottom edge');
++ const m = geom(0.4, 0.3);
++ settle(m, boxOf(m, rect, { aspect: 16 / 9 }));
++ assert.strictEqual(m.x, 0.4); assert.strictEqual(m.y, 0.3);
++});
++
++check('a flick projects the last 90 ms 120 ms on; a slow drop stays put', () => {
++ const fast = flick([{ x: 100, y: 100, t: 0 }, { x: 160, y: 70, t: 50 }], { left: 100, top: 100 });
++ assert.ok(fast.fast, 'fast enough to carry');
++ near(fast.vx, 1.2, 'vx');
++ near(fast.vy, -0.6, 'vy');
++ near(fast.left, 100 + 1.2 * 120, 'carried right');
++ near(fast.top, 100 - 0.6 * 120, 'carried up');
++ const slow = flick([{ x: 100, y: 100, t: 0 }, { x: 101, y: 100, t: 400 }], { left: 100, top: 100 });
++ assert.ok(!slow.fast, 'too slow to carry');
++ near(slow.left, 100, 'stays where it was let go');
++ near(slow.top, 100, 'stays where it was let go');
++});
++
++check('thrown past a side, or flicked at it, it tucks into that side', () => {
++ const rect = frame(1280, 720);
++ const from = { left: -80, top: 200, width: 300, height: 169 };
++ assert.strictEqual(tuckFor({ left: -140 }, from, rect, { fast: false, vx: 0 }), 'left', 'well past the left');
++ assert.strictEqual(tuckFor({ left: 1100 }, from, rect, { fast: false, vx: 0 }), 'right', 'well past the right');
++ assert.strictEqual(tuckFor({ left: -80 }, from, rect, { fast: true, vx: -1.6 }), 'left', 'flicked left at the edge');
++ assert.strictEqual(tuckFor({ left: 1100 }, from, rect, { fast: true, vx: 1.6 }), 'right', 'flicked right at the edge');
++ assert.strictEqual(tuckFor({ left: 400 }, from, rect, { fast: false, vx: 0 }), null, 'inside stays a float');
++ assert.strictEqual(tuckFor({ left: 200 }, from, rect, { fast: true, vx: -2 }), null, 'a fast drag across the middle is not a tuck');
++});
++
++check('a floating box is placed as fractions of the window', () => {
++ const b = boxOf({ x: 0.95, y: 0.95, w: 0.25, mode: 'float' }, frame(390, 844), { aspect: 9 / 16, minPx: 150 });
++ near((b.wf * 390) * ((b.wf * 390) / b.a), 150 * (150 / (16 / 9)), 'the same area as a 150 px 16:9 box');
++ assert.ok(b.hf <= 1, 'inside the window');
++});
++
++console.log(`ov-float geometry: ${n} passed`);
ddiff --git a/test/ov-window-chrome.test.js b/test/ov-window-chrome.test.js
iindex 06fc2f0..43845b9 100644
---- a/test/ov-window-chrome.test.js
++++ b/test/ov-window-chrome.test.js
@@@ -15,6 +15,7 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-wi
const root = path.join(__dirname, '..');
const server = http.createServer((req, res) => {
if (req.url.startsWith('/ov-window.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-window.js'))); }
++ 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"><body style="margin:0;height:1600px;background:#111;color:#eee">
<button id="open" style="position:absolute;left:40px;top:40px">Open</button><button id="pan" style="position:absolute;left:300px;top:40px">Panel</button><input id="page" style="position:absolute;left:40px;top:600px">
@@@ -73,8 +74,12 @@ window.mk = (o) => { const f = document.createElement('div'); const a = document
const hb = await page.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 40, y: r.top + 20 }; })()`);
await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: hb.x, y: hb.y, button: 'left', clickCount: 1 });
await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 60, y: hb.y + 30, button: 'left' });
++ await wait(80);
await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 200, y: hb.y + 150, button: 'left' });
-- await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 200, y: hb.y + 150, button: 'left', clickCount: 1 });
++ await wait(100);
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 201, y: hb.y + 151, button: 'left' });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: hb.x + 201, y: hb.y + 151, button: 'left', clickCount: 1 });
++ await wait(400);
const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
await eventually(async () => assert.deepEqual(await page.evaluate(`JSON.parse(localStorage.getItem('ov:window:dock-test'))`), moved, 'where it was left is kept'));
await page.evaluate('p1.close()'); await wait(300);