openvibers/openvibe.shared Commits
ov-float: one move engine for the PiP and the window (4.208.0) (#468)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -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`) |@@@ -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)",@@@ -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 boxform.setAttribute('data-ovc-no-files', '');@@@ -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);@@@ -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@@@ -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.@@@ -110,7 +110,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[dataconst 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) => {nnew file mode 100644@@@ -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);@@@ -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() {@@@ -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%)); }@@@ -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) => {@@@ -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;@@@ -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",@@@ -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",@@@ -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)) {@@@ -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 ──────────────────────────────────────────────nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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`);@@@ -15,6 +15,7 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-wiconst 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 = documentconst 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);
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# 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
## 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
**The menu keeps a stream playing, the Feed in the menu is searchable with the one composer at its top, and a Code task
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -22,7 +22,7 @@ were reconciled first.
@@ -22,7 +22,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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()` |
| 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`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.206.0",
+ "version": "4.208.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"sdk": "openvibe-sdk v0.147.0",
"features": [
"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 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)",
"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)",
"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)",
@@ -58,7 +58,7 @@
@@ -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
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': '94fe9cbede93' };
+ const VIBE_REV = { 'ov-composer.js': '50bdd857445c' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'ov-composer.js', // shared composer
'ov-composer.js', // shared composer
'ov-combo.js', // shared field dropdown
'ov-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
'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-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)
'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
'history.js', // cross-site history
'history.js', // cross-site history
@@ -607,7 +607,7 @@
@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '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]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -110,7 +110,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -110,7 +110,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
const mods = [];
const mods = [];
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded
// from beside this file; without it, a plain <dialog>.
// from beside this file; without it, a plain <dialog>.
- const COMPOSER_REV = { 'ov-window.js': '625783527b4e' };
+ const COMPOSER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
const selfSrc = (document.currentScript && document.currentScript.src) || '';
const selfSrc = (document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,815 @@
@@ -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);
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': '94fe9cbede93', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '50bdd857445c', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
'use strict';
const NETWORK = 'https://openvibe.network';
const NETWORK = 'https://openvibe.network';
- const USER_REV = { 'ov-window.js': '625783527b4e' };
+ const USER_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
const CSS = `.ovu { position: relative; display: grid; gap: 14px; padding: 0 0 18px; color: var(--text-primary, #e6edf7); font: 500 14.5px/1.4 var(--font, Inter, system-ui, sans-serif); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
.ovu-banner { position: relative; height: 92px; overflow: hidden; background: linear-gradient(135deg, hsl(var(--ovu-h, 220) 70% 42%), hsl(calc(var(--ovu-h, 220) + 40) 70% 28%)); }
@@ -33,7 +33,7 @@
@@ -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
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
// same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing.
- const WALLET_REV = { 'ov-window.js': '625783527b4e' };
+ const WALLET_REV = { 'ov-window.js': 'd3d8f9e3e6d2' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
function needWindow() {
return new Promise((ok) => {
return new Promise((ok) => {
@@ -23,6 +23,8 @@
@@ -23,6 +23,8 @@
(function (root) {
(function (root) {
'use strict';
'use strict';
if (typeof document === 'undefined' || root.OVWindow) return;
if (typeof document === 'undefined' || root.OVWindow) return;
+ const HERE = document.currentScript && document.currentScript.src;
+ const WINDOW_REV = { 'ov-float.js': '5c8251ac3738' };
const STYLES = ['glass', 'solid', 'outline'];
const STYLES = ['glass', 'solid', 'outline'];
const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; }
const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; }
@@ -86,6 +88,27 @@ html.ovw-locked { overflow: hidden; }
@@ -86,6 +88,27 @@ html.ovw-locked { overflow: hidden; }
const stack = [];
const stack = [];
let uid = 0;
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); }
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 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 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 */ } } };
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; }
@@ -122,15 +145,20 @@ html.ovw-locked { overflow: hidden; }
if (modal) document.documentElement.classList.add('ovw-locked');
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.
// 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') {
if (kind === 'panel') {
const saved = o.id ? place.get(o.id) : null;
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;
let left, top;
if (saved) { left = saved.x; top = saved.y; }
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 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; }
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') {
if (opener && opener.getBoundingClientRect && kind !== 'sheet') {
const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
@@ -144,6 +172,7 @@ html.ovw-locked { overflow: hidden; }
@@ -144,6 +172,7 @@ html.ovw-locked { overflow: hidden; }
setTitle(t) { title.textContent = t || ''; },
setTitle(t) { title.textContent = t || ''; },
close(why) {
close(why) {
if (closed) return; closed = true;
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);
const i = stack.indexOf(w); if (i >= 0) stack.splice(i, 1);
if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked');
if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked');
const gone = () => { el.remove(); scrim.remove(); };
const gone = () => { el.remove(); scrim.remove(); };
@@ -153,8 +182,33 @@ html.ovw-locked { overflow: hidden; }
@@ -153,8 +182,33 @@ html.ovw-locked { overflow: hidden; }
if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
},
},
modal,
modal,
+ float: null,
};
};
stack.push(w);
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'));
x.addEventListener('click', () => w.close('x'));
if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
el.addEventListener('keydown', (e) => {
el.addEventListener('keydown', (e) => {
@@ -166,8 +220,8 @@ html.ovw-locked { overflow: hidden; }
@@ -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) => {
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return;
if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return;
const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY;
const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.206.0",
+ "version": "4.208.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.206.0",
+ "version": "4.208.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.206.0",
+ "version": "4.208.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -144,6 +144,7 @@
@@ -144,6 +144,7 @@
"./messages": "./messages.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
"./ov-thread": "./ov-thread.js",
"./ov-window": "./ov-window.js",
"./ov-window": "./ov-window.js",
+ "./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
"./ov-composer": "./ov-composer.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice": "./ai-choice.js",
@@ -27,7 +27,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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.
// 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'] },
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'] },
'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(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
function writeOwn() {
function writeOwn() {
for (const [file, { name, files }] of Object.entries(OWN)) {
for (const [file, { name, files }] of Object.entries(OWN)) {
@@ -21,7 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,8 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// time something is sent, not on every page.
// 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
// 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.
// 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'];
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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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`);
@@ -15,6 +15,7 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-wi
@@ -15,6 +15,7 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-wi
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const server = http.createServer((req, res) => {
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-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.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">
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">
<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
@@ -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 }; })()`);
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: '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 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: '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) }; })()`);
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 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);
await page.evaluate('p1.close()'); await wait(300);