openvibers/openvibe.shared Commits
4.202.0: one dropdown, one window, user cards, eight groups, reveal (#456)
Changes
@@@ -1,5 +1,19 @@# Changelog++## 4.202.0 — 2026-11-11++++**One field dropdown, one window for small interfaces, one user card, a measured page end, eight site groups, and the reveal remade.**++++- **An empty footer column is never drawn (`footer.js`).** A group with no heading or no links is left out instead of++ showing as an untitled "0 links" fold.++- **One field dropdown (#447).** `ov-combo.js` adds async, grouped suggestions and optional creation; the composer handoff to Code and whole site names in the phone menu are included.++- **One window (#451).** `ov-window.js` supplies dialog, sheet and panel modes, and the composer's settings use it.++- **One user card (#452).** Shared mention hooks, the profile card, wallet window and display style settings.++- **Page end (#442).** A shared bottom measurement keeps the end of a page at 32 px on computer and 24 px on phone.++- **Eight site groups (#445).** The footer, rooms, You folders and home pages use the same groups.++- **Reveal (#448).** Headings emerge from blur, rules draw, and up to six cards stagger as they enter view.++- **Jump back in (#412).** The full history link and private history support are present from the 4.201.0 base.++## 4.201.0 — 2026-11-10**Notifications move into the one menu; the mark and the name are one button; one composer and Code on every site.**@@@ -93,7 +107,6 @@ From the owner's notes of 2026-11-10 (asks 334 to 350).event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loadedDock; the Vibe-only button rules stay in `dock-vibe.js`.--## 4.199.0 — 2026-11-10**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:@@@ -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.201.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.202.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.@@@ -838,7 +838,7 @@ Menus are modular: every site keeps the shared account menu and adds its own({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()/ setLinks() do the same at runtime. Signed-in users also get a"Recently used" row fed by the shared history module when it is loaded.--Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,++Sign-in and history: history: { type: 'tool'|'stream'|'video'|'clip'|'paste'|'thread'|'conversation'|'document'|'page'|…,title, url, icon } is recorded for the signed-in user once auth resolves(cross-site "Recently used" / History on the Network). silentLogin:'https://site/auth/login?silent=1&next={url}' — when nobody is signed in@@@ -4,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.201.0",++ "version": "4.202.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.146.0","features": [++ "the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)","the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)","the mark at work is the O as eight dots with three bright ones going round, and it has a state of its own for an agent that is thinking or writing (data-state=think); a phone's Dock opens and closes exactly as the menu does and wears its close button in its place; a button, field, select or text box that nobody styled wears the theme instead of the browser's grey (4.186.0)","the menu's You tab: your home right under your name (a 52px bar, was 68) and the AI card; Most used is the apps themselves under their bar, each a link and a full touch target; Jump back in last, saying what it is. A phone's Dock opens out of the navbar's menu button: its close orb starts on that orb and settles into its x while the navbar's orb makes the same move underneath, and closing runs it back (4.185.0)",@@@ -13,6 +13,10 @@* dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open. A link's icon is a* navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page)* a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do.++ *++ * Plan 279: the bar does not sit on the layout viewport's bottom edge. bottom.js measures the visible viewport++ * (--ovb-lift) and the bar moves up by it with `translate`, so the browser's toolbar sliding away or a keyboard++ * never leaves the bar behind; its height is measured back for whatever docks above it (--ovb-bar, --ovb-dock).*/(function (root) {'use strict';@@@ -43,7 +47,7 @@@media (max-width: 760px) {html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); }html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); }-- .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 9990; display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);++ .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--ovb-z-bar, 9990); translate: 0 calc(-1 * var(--ovb-lift, 0px)); display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px);border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease; }.ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer;@@@ -140,6 +144,7 @@document.body.appendChild(bar);barEl = bar;if (de && de.classList) de.classList.add('ov-has-appbar');++ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();placePill(bar, true);if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }// The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenevernnew file mode 100644@@@ -0,0 +1,142 @@++/**++ * openvibe-shared/bottom.js — the bottom of the screen, measured once for everything docked there.++ *++ * The owner, 2026-11-10 (plan 279): "That fixed bottom position is kind of buggy … as you scroll up and down,++ * so let's make that nice and smooth and perfect across all sites." A phone's visible viewport is not its layout++ * viewport: the browser's toolbar slides away, a keyboard opens, an orientation turns. A bar placed at bottom: 0++ * of the layout viewport is then somewhere behind the thing the person is looking at. This file measures the one++ * number every bottom-docked part needs and writes it where CSS can use it:++ *++ * --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;++ * the bar, the small player, the Dock's pill and the composer move up by it with `translate`++ * (a paint, never layout).++ * --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.++ * --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).++ * --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.++ * --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.++ * --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.++ *++ * One stacking order, written here only (the bottom of the screen, low to high):++ * 1. the app bar --ovb-z-bar 9990++ * 2. the small player --ovb-z-mini 9991++ * 3. the Dock's pill --ovb-z-pill 9995++ * 4. the Dock's card --ovb-z-dock 9996 (a phone's Dock sheet is 10010: it covers the navbar)++ * Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).++ *++ * navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses++ * these variables has its old value as the fallback, so a page whose bottom.js did not arrive keeps the bar it had.++ *++ * OpenVibeBottom.measure() measure now (a page that changed its own bottom calls it)++ * OpenVibeBottom.lift() the current lift in px++ * OpenVibeBottom.height() the visible viewport's height in px++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || !document.documentElement || root.OpenVibeBottom) return;++ const html = document.documentElement;++ let raf = 0;++ let observer = null;++ let resize = null;++ let watched = [];++++ function styles() {++ if (document.getElementById('ovb-styles')) return;++ const st = document.createElement('style');++ st.id = 'ovb-styles';++ st.textContent = `:root {++ --ovb-lift: 0px;++ --ovb-bar: 0px;++ --ovb-mini: 0px;++ --ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));++ --ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));++ --ovb-z-bar: 9990;++ --ovb-z-mini: 9991;++ --ovb-z-pill: 9995;++ --ovb-z-dock: 9996;++}++.ovnav-appbar, .ovmini, .ovdk-pill { will-change: translate; }`;++ (document.head || html).appendChild(st);++ }++++ const px = (n) => `${Math.round(n * 100) / 100}px`;++++ /** The height of an element that is actually on screen (a bar hidden under the page is not). */++ function onScreen(el) {++ if (!el) return 0;++ const cs = getComputedStyle(el);++ if (cs.display === 'none' || cs.visibility === 'hidden') return 0;++ const r = el.getBoundingClientRect();++ if (!r.height || r.bottom <= 0 || r.top >= root.innerHeight) return 0;++ return r.height;++ }++++ function measure() {++ raf = 0;++ const vv = root.visualViewport;++ const vh = vv ? vv.height : root.innerHeight;++ const top = vv ? vv.offsetTop : 0;++ const lift = Math.max(0, root.innerHeight - (top + vh));++ html.style.setProperty('--ovb-lift', px(lift));++ // A visible height of 0 happens between two documents; keep the last real one.++ if (vh > 0) html.style.setProperty('--ovb-vh', px(vh));++ const bar = onScreen(document.querySelector('.ovnav-appbar'));++ html.style.setProperty('--ovb-bar', px(bar));++ let mini = 0;++ try {++ if (root.matchMedia && root.matchMedia('(max-width: 640px)').matches) {++ const m = onScreen(document.querySelector('.ovmini:not(.is-leaving)'));++ if (m) mini = m + 8; // its 8 px above the bar++ }++ } catch { /* no matchMedia: no full-width small player to stack */ }++ html.style.setProperty('--ovb-mini', px(mini));++ }++++ const later = () => { if (!raf) raf = requestAnimationFrame(measure); };++++ /** Watch the parts that are added to the page after this file, and their size changes. */++ function watch() {++ const parts = [...document.querySelectorAll('.ovnav-appbar, .ovmini')];++ if (resize) { resize.disconnect(); watched = []; }++ else if (typeof ResizeObserver === 'function') resize = new ResizeObserver(later);++ if (!resize) return;++ for (const el of parts) { try { resize.observe(el); watched.push(el); } catch { /* */ } }++ measure();++ }++++ function start() {++ styles();++ const vv = root.visualViewport;++ if (vv && vv.addEventListener) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); }++ root.addEventListener('resize', later);++ root.addEventListener('orientationchange', later);++ root.addEventListener('load', later);++ document.addEventListener('visibilitychange', later);++ if (typeof MutationObserver === 'function' && document.body) {++ observer = new MutationObserver((records) => {++ let parts = false;++ for (const r of records) {++ if (r.target && r.target.closest && r.target.closest('.ovnav-appbar, .ovmini')) { parts = true; break; }++ for (const n of [...r.addedNodes, ...r.removedNodes]) {++ if (n.nodeType === 1 && (n.matches('.ovnav-appbar, .ovmini') || n.querySelector('.ovnav-appbar, .ovmini'))) { parts = true; break; }++ }++ if (parts) break;++ }++ if (parts) watch();++ });++ observer.observe(document.body, { childList: true, subtree: true });++ }++ watch();++ setTimeout(measure, 300); // fonts, a late app bar, a small player opened by the page++ }++++ root.OpenVibeBottom = {++ measure,++ refresh: later,++ lift: () => { const n = parseFloat(html.style.getPropertyValue('--ovb-lift')); return Number.isFinite(n) ? n : 0; },++ height: () => { const vv = root.visualViewport; return vv ? vv.height : root.innerHeight; },++ _stop: () => { if (observer) observer.disconnect(); if (resize) resize.disconnect(); },++ };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });++ else start();++ if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeBottom;++})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -2,7 +2,8 @@// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:--// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active }) → { refresh(), focus(), destroy() }++// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active, take }) → { refresh(), focus(), destroy() }++// take() → the words another part handed over (Vibe's "Build it in Code"), which go in the box.//// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,@@@ -215,10 +216,13 @@paint();if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);}++ // What another part handed over (Vibe's Build it in Code) goes in the box, ready to start.++ const take = () => { const t = typeof o.take === 'function' ? o.take() : ''; if (!t) return; text.value = t; text.dispatchEvent(new Event('input')); text.focus(); note.textContent = 'From your chat with Vibe. Start it, or change it first.'; };paint();read();++ take();return {-- refresh: read,++ refresh: () => { take(); read(); },focus: () => text.focus(),destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },};@@@ -206,7 +206,15 @@} catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }finally { attach.disabled = false; file.value = ''; }});-- form.append(attach, file);++ // 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.++ const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');++ build.addEventListener('click', () => {++ const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');++ const text = box.value.trim() || (last && last.text) || '';++ if (!text) { box.focus(); return; }++ build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));++ });++ form.append(attach, file, build);let mic = null;if (TALK) {mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));@@@ -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': '4eb6d94db445', 'dock-code.js': '7cc3b2f4b7ea', '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' };++ const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', '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' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -106,7 +106,7 @@const st = document.createElement('style');st.id = 'ovdk-styles';st.textContent = `-- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));++ .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))); translate: 0 calc(-1 * var(--ovb-lift, 0px));display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }.ovdk-pill[hidden] { display: none; }@@@ -124,7 +124,7 @@.ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }@keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }@keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }-- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 9996; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;++ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));nnew file mode 100644@@@ -0,0 +1,75 @@++# The bottom of the screen (plan 279) and the page's end (plan 280)++++The owner, 2026-11-10:++++> "That fixed bottom position is kind of buggy … as you scroll up and down, so let's make that nice and smooth and++> perfect across all sites."++++> "That huge wasted space … in between that and the footer … It's just waste."++++## One measured number++++A phone's visible viewport is not its layout viewport. The browser's toolbar slides away while the page scrolls, a++keyboard opens, an orientation turns; a bar placed at `bottom: 0` of the layout viewport is then behind the very++thing the person is looking at. `bottom.js` measures the difference and writes it where CSS can use it:++++| variable | meaning |++|---|---|++| `--ovb-lift` | px between the layout viewport's bottom and the visible (visual) viewport's bottom; every bottom-docked part moves up by it with `translate` (a paint, never layout) |++| `--ovb-vh` | the visible viewport's height in px, measured — never a `100vh`/`100dvh` guess |++| `--ovb-bar` | the app bar's height while it is on screen (0 when it is away) |++| `--ovb-mini` | the small player's height plus its 8 px gap while it is the full-width bar on a phone |++| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts |++| `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |++++`navbar.js` loads `bottom.js` on every page before the bar, the Dock and the small player draw. Every rule that uses++these variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.++++The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the++person scrolls; `--ovb-bar`/`--ovb-mini` are measured from the elements themselves by a `ResizeObserver`, so a bar++that steps aside for the keyboard or a player that changes shape is followed without a guess.++++## One stacking order++++The bottom of the screen, low to high (these are the only z-indexes that belong to it; all of them live in++`bottom.js`):++++| layer | class | z-index |++|---|---|---|++| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) |++| the small player | `.ovmini` | `--ovb-z-mini` (9991) |++| the Dock's pill | `.ovdk-pill` | `--ovb-z-pill` (9995) |++| the Dock's card | `.ovdk` | `--ovb-z-dock` (9996) |++++A phone's Dock sheet (10010) covers the navbar; sheets (10060), the notification panel (99999) and toasts/notices++(2147483000+) sit above all of these and do not belong to the bottom stack.++++## The page's end++++Content ends, then **32 px on a computer (24 on a phone)**, then the footer. That gap is the footer's own++`margin-top` (`footer.js`); a site may not add its own bottom padding above it. The last block's bottom margin++collapses with the footer's, so it does not add — but a container's `padding-bottom` does, and is removed in the++site that has it.++++`scripts/audit-page-end.js` measures the gap on every site's home at 390 and 1280 and lists every page over the rule:++++```++node scripts/audit-page-end.js++node scripts/audit-page-end.js --footer-rule # with the Shared rule applied (what is left for the sites)++node scripts/audit-page-end.js --hosts openvibe.wiki --width 390++```++++## The check++++`test/bottom-chrome.test.js` drives a real headless Chrome at 360×740, 390×844 and 430×932. It scrolls up and down++while a shimmed `visualViewport` opens and closes the toolbar (the layout viewport stays put, as a real toolbar's++update reaches the visible viewport first), and fails when:++++- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;++- the small player is not 8 px above the bar, or the Dock's pill not 16 px above the small player;++- any of the three overlaps another;++- a gap jumps by more than 1 px between samples.++++```++ov test test/bottom-chrome.test.js++```nnew file mode 100644@@@ -0,0 +1,38 @@++# One user card++++Load `/shared/ov-user.js` with the site's existing kit stylesheet. Loading the script has no side effects. The owner must enable Network's `user_card_enabled` setting before sites opt in.++++```js++const mounted = OVUser.card(document.getElementById('person'), { username: 'Example' });++await mounted.ready; // public summary, or null when unavailable++await mounted.refresh();++mounted.destroy();++++// Opt-in delegation, including dynamically added mentions and avatars:++const unbind = OVUser.bind(document);++// unbind() removes these hooks and closes its card.++```++++```html++<a href="https://openvibe.network/@Example">@Example</a>++<button type="button" data-ov-user="Example" aria-label="Open Example's user card"><img src="/picture.png" alt=""></button>++<a href="https://openvibe.network/@Example" data-ov-user="Example">Example</a>++```++++The hook handles clicks, context menus and keyboard activation on marked elements (`data-ov-user`, or the existing `data-username`) and canonical `/@username` links on the current site, Network or Live. Use links/buttons for keyboard access; custom elements need `role="button"` and `tabindex="0"`. Modified clicks keep their native behavior. Links within the card keep navigating normally. Rebinding the same root replaces its old listeners. The default opening is a plain native dialog with Close/Escape and focus restoration; browsers without modal dialogs retain normal link navigation.++++The main session can supply its own opening surface without changing the read:++++```js++OVUser.bind(document, { onOpen({ username, trigger }) {++ // Open the designed surface, then mount OVUser.card(surfaceHost, { username }).++} });++```++++`card` fetches only `GET https://openvibe.network/api/v1/people/:username/card`, without credentials or browser caching, with an eight-second deadline. Optional `networkUrl` and `fetchImpl` configure a deployment/test read. Network coalesces and caches the aggregate for 30 seconds while rechecking privacy on each call. The component adds no cache. Remount/refresh/destroy abort prior reads and ignore late results. Disabled/unavailable reads show a profile link; missing fields never become zero, offline, or placeholders. Text is text, links/pictures must be HTTPS, and only kit classes/plain markup are used.++++The card shows the available name, handle, picture, live state, level, streak, badges count, followers, weekly sites and profile/channel links. Weekly sites are opt-in at Network. This hook does not automatically replace existing site menus or invoke relationship/moderation actions.++++## Next for the design (For Fable)++++Design the shared card surface and its placement, avatar/live treatment, hierarchy and responsive behavior; add the person's field-visibility controls and explicit weekly-sites opt-in. Adopt the hook in site mentions/avatars and compose site-specific actions using the existing menu registration API. This PR intentionally has no version bump or new CSS.nnew file mode 100644@@@ -0,0 +1,12 @@++# OpenVibe.Shared 4.202.0 screenshots++++Captured from the branch's browser components in headless Chrome at 390 × 844 and 1280 × 900. The previews keep the component in context; the repository dropdown, eight-group footer, page end, Jump back in, composer settings, floating window and mention card use the branch's actual shared code.++++- `repository-field-{390,1280}.png` — typed repository search with grouped suggestions open.++- `footer-eight-groups-{390,1280}.png` — the eight site groups in the footer.++- `page-end-{390,1280}.png` — the bottom of a page on phone and computer.++- `jump-back-in-{390,1280}.png` — recent destinations and the full-history link.++- `composer-settings-{390,1280}.png` — settings as a phone sheet and a computer dialog.++- `floating-panel-{390,1280}.png` — phone sheet and computer floating panel.++- `mention-user-card-{390,1280}.png` — the card opened from a mention as a phone sheet and computer panel.++- `reveal-entering-{390,1280}.png`, `reveal-settled-{390,1280}.png` — two frames of the remade reveal.nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/page-end-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/page-end-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/repository-field-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/repository-field-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-390.png differ@@@ -38,6 +38,7 @@ const BROWSER = Object.freeze(['dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel'account-switcher.js','user-card.js',++ 'ov-user.js', // public user-card read and opt-in mention/avatar hook; plain kit markup'ov-mark.js', // the OV brand mark drop-in'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)'brands.js', // brand logos (Simple Icons, CC0), fetched by ov-glyphs.js the first time a page shows one (4.72.0)@@@ -53,7 +54,9 @@ const BROWSER = Object.freeze(['ov-upload.js', // shared file picker, upload queue and ov-progress'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css-- 'ov-composer.js', // the one composer (4.201.0): files, / and @, voice, drafts, your settings; ov-thread.js loads it++ 'ov-composer.js', // shared composer++ 'ov-combo.js', // shared field dropdown++ 'ov-window.js', // shared dialog, sheet, and panel'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@@@ -75,6 +78,7 @@ const BROWSER = Object.freeze(['release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader++ 'bottom.js', // the bottom of the screen measured once (plan 279): --ovb-lift for the app bar, the small player and the Dock'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads@@@ -33,6 +33,11 @@* Signed-in visitors get a cross-site row: their account, their channel, themes and the tools* hub, so the network behaves like one product instead of a ring of separate sites. It reads* the shared ov_token the same way navbar.js does and never blocks rendering on it.++ *++ * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"):++ * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that++ * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is++ * removed in that site (scripts/audit-page-end.js measures it on every home).*/(function (root, factory) {const api = factory();@@@ -85,24 +90,23 @@const REPORT_URL = 'https://openvibe.help/tickets/new';const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));-- // One place that knows what the network contains. Adding a property here gives every site-- // the link, with no edit anywhere else.-- const NETWORK = [-- { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },-- { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },-- { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },-- { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },-- { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },-- { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },-- { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },-- { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },-- { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },-- { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },-- { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },-- { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },-- { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },-- { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },++ // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of++ // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names++ // and order when it answers, so the footer, the launcher and the home pages read the same list.++ const GROUP_LINKS = [++ { id: 'watch', name: 'Watch & stream' },++ { id: 'talk', name: 'Talk & belong' },++ { id: 'play', name: 'Play' },++ { id: 'read', name: 'Read & ask' },++ { id: 'shop', name: 'Shop & money' },++ { id: 'tools', name: 'Free tools' },++ { id: 'make', name: 'Make with AI' },++ { id: 'build', name: 'For developers' },];++ const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`;++ const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' }));++ // Tool subdomains have no site of their own; brandFor names them "<Sub>.OpenVibe.Tools".++ const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']);const LEGAL = [{ key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },@@@ -189,9 +193,8 @@if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`;-- const found = NETWORK.find(n => n.id === service);if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;-- if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;++ if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`;return 'OpenVibe';}@@@ -265,13 +268,18 @@const service = c.service || detectService();const brand = c.brandName || brandFor(service);const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });-- // Most used sites first when the Frame service has spoken; the built-in list otherwise.-- const network = frame && frame.nav.length-- ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))-- : NETWORK.filter(n => n.id !== service);++ // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups,++ // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages.++ const network = frame && Array.isArray(frame.groups) && frame.groups.length++ ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' }))++ : NETWORK;++ // The ranked sites are still what the discover picks must not repeat.++ const nav = frame && frame.nav.length++ ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname))++ : [];const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;const popular = frame ? frame.footer.popular : [];-- const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 8).some(n => n.url === d.url)) : [];++ const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : [];const year = new Date().getFullYear();if (c.variant === 'compact') {@@@ -300,7 +308,10 @@const fold = (label, heading, body, count) => `<nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary><div class="ovf-fold-body">${body}</div></details></nav>`;-- const cols = (c.links || []).map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');++ // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots++ // showed two when a site passed empty groups).++ const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length)++ .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');const networkLinks = network.slice(0, 8);return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">@@@ -316,7 +327,7 @@</div>${cols}${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')}-- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}++ <a class="ovf-more" href="${NETWORK_URL}/sites" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}<a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''}${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}@@@ -382,9 +393,9 @@// ── Styles (injected once) ─────────────────────────────────const CSS = `--.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:32px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}--.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}++.ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}.ovf-inner{max-width:1240px;margin:0 auto}.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}@@@ -491,6 +502,7 @@ a.ovf-tile:active{transform:scale(.96)}@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}++@media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}}@media (max-width:560px){.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}@@@ -51,7 +51,7 @@--ovk-radius-sm: var(--ov-radius-1);--ovk-tap: 44px;--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */-- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */++ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);}@@@ -248,7 +248,7 @@ div.ovk-set-row { cursor: default; }.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }--.ovk-save { position: sticky; bottom: var(--ovk-bottom); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }++.ovk-save { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }.ovk-save .ovk-btn { width: 100%; }.ovk-form { display: grid; gap: 12px; margin: 0; }.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }@@@ -272,7 +272,7 @@ div.ovk-set-row { cursor: default; }.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }--.ovk-dock { position: sticky; bottom: var(--ovk-bottom); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }++.ovk-dock { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }@@@ -58,7 +58,7 @@const st = document.createElement('style');st.id = 'ov-mini-style';st.textContent = `--.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: 9990; left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }++.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: var(--ovb-z-mini, 9991); left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovb-bar, var(--ovnav-appbar-h, 0px))); translate: 0 calc(-1 * var(--ovb-lift, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }@@@ -82,7 +82,7 @@@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }@media (max-width: 640px) {/* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */-- .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }++ .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovb-base, var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px)))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }.ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }.ovmini-back { display: none; }.ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }@@@ -181,6 +181,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }video.muted = !!r.muted;arm(); source();document.body.appendChild(el);++ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();root.addEventListener('pagehide', leave);document.addEventListener('play', other, true);paint();@@@ -196,7 +197,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }el = null; video = null; live = false;if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }if (!quiet) forget();-- const off = () => node.remove();++ const off = () => { node.remove(); if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); };if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }}@@@ -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': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '01b6fff110cd', 'dock.js': '0ebe750a2e4d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '7477f93fd775', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', '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': '8eec18660ff4', 'dock.js': '4031e67d411d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', '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': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', '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.@@@ -2299,6 +2299,7 @@_navEl = nav;paintSlots();watchKeyboard();++ loadBottom();attachAvatarFallbacks(nav);hookTop();loadWalletChips();@@@ -2307,6 +2308,14 @@return nav;}++ // bottom.js: the visible viewport for the bar, the small player, the Dock's pill and the composer.++ let _bottomLoading = false;++ function loadBottom() {++ if (_bottomLoading || root.OpenVibeBottom) return;++ _bottomLoading = true;++ try { const sc = document.createElement('script'); sc.id = 'ovb-loader'; sc.src = siblingV('bottom.js'); sc.async = true; document.head.appendChild(sc); } catch { _bottomLoading = false; }++ }++// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──let _appBarLoading = false;const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);nnew file mode 100644@@@ -0,0 +1,263 @@++/**++ * ov-combo.js (openvibe-shared 4.202.0): one search-as-you-type dropdown for a field. Ask 348, the owner, on Codes'++ * repository field (a browser datalist): "a completely custom drop-down system … that works a little more intelligently++ * … async or dynamic fetching or search … configurable … You should be able to just create the new repository … through++ * this UI". It looks like the one dropdown (ov-drop.js) and works with the keyboard and a screen reader (a combobox++ * with a listbox).++ *++ * Without script the field is a plain input. With it:++ * <input name="repo" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos">++ * reads GET <source>?q=&limit=12 (same origin, the page's cookies) → { sections: [{ id, label, items: [{ label, url,++ * host, private, sub }] }], create: { allowed, host, owner } }, as you type (160 ms after the last key, the one before++ * aborted, answers kept a minute). Picking a row puts its url in the field. With create allowed, a last row offers++ * "Create “name” on OpenVibe Git": POST <create> { name, private: true } → { url, label }, and the new one goes in.++ * A site with its own data: OVCombo.enhance(input, { source: async (q, signal) => ({ sections }), create: async (name) => ({ url }) }).++ * Events on the input: 'ov-combo:pick' { detail: item } after a pick or a create.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVCombo) return;++++ const svg = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;++ const HOST_IC = {++ 'git.openvibe.codes': svg('<circle cx="7" cy="6" r="2.2"/><circle cx="7" cy="18" r="2.2"/><circle cx="17" cy="8" r="2.2"/><path d="M7 8.2v7.6M17 10.2c0 4-6 3-9.2 6"/>'),++ 'github.com': svg('<path d="M9 19c-4 1.3-4-2-5.6-2.5M14.6 21v-3.3a2.9 2.9 0 0 0-.8-2.2c2.7-.3 5.6-1.3 5.6-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.5 11.5 0 0 0-6 0C6.2 2.8 5.2 3.1 5.2 3.1a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.8 5.7 5.5 6a2.9 2.9 0 0 0-.8 2.2V21"/>'),++ recent: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),++ repo: svg('<path d="M5 4.5h11.5a1.5 1.5 0 0 1 1.5 1.5v13H6.5A1.5 1.5 0 0 1 5 17.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H18"/>'),++ plus: svg('<path d="M12 5v14M5 12h14"/>'),++ lock: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><rect x="5.5" y="11" width="13" height="9" rx="2"/><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3"/></svg>',++ };++ const CSS = `.ovcombo { position: fixed; z-index: 10030; box-sizing: border-box; max-height: min(60vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;++ background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 96%, transparent); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.42), 0 2px 6px rgba(0,0,0,.2);++ -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); color: var(--text-primary, #e6edf7); font: 600 14.5px/1.25 var(--font, system-ui, -apple-system, 'Segoe UI', sans-serif);++ transform-origin: 24px 0; animation: ovcombo-in .2s cubic-bezier(.2, 1.2, .3, 1) both; }++.ovcombo.is-up { transform-origin: 24px 100%; }++.ovcombo[hidden] { display: none; }++.ovcombo-sec { margin: 0; padding: 8px 10px 5px; font-size: 11.5px; font-weight: 700; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }++.ovcombo-sec:not(:first-child) { margin-top: 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); padding-top: 10px; }++.ovcombo-i { display: flex; align-items: center; gap: 11px; min-height: 46px; padding: 4px 12px 4px 10px; border-radius: 11px; cursor: pointer; }++.ovcombo-i[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }++.ovcombo-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }++.ovcombo-i[aria-selected="true"]:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent); }++.ovcombo-ic { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }++.ovcombo-ic svg { width: 18px; height: 18px; }++.ovcombo-i.is-create .ovcombo-ic { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }++.ovcombo-t { flex: 1; min-width: 0; display: grid; gap: 2px; }++.ovcombo-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovcombo-t b mark { background: none; color: var(--accent-light, var(--accent, #60a5fa)); }++.ovcombo-t small { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; color: var(--text-muted, #93a0b4); overflow: hidden; white-space: nowrap; }++.ovcombo-none { padding: 14px 12px; font-weight: 500; font-size: 13.5px; color: var(--text-muted, #93a0b4); }++.ovcombo-skel { display: flex; align-items: center; gap: 11px; height: 46px; padding: 0 10px; }++.ovcombo-skel i { flex: none; width: 30px; height: 30px; border-radius: 9px; }++.ovcombo-skel b { height: 11px; border-radius: 6px; width: var(--w, 60%); }++.ovcombo-skel i, .ovcombo-skel b { background: linear-gradient(90deg, color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent)); background-size: 200% 100%; animation: ovcombo-shine 1.1s linear infinite; }++.ovcombo-err { padding: 10px 12px; font-size: 13px; font-weight: 500; color: var(--live-red, #f87171); }++@keyframes ovcombo-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }++@keyframes ovcombo-shine { to { background-position: -200% 0; } }++html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, html[data-ov-motion=off] .ovcombo-skel b { animation: none; }`;++++ let pop = null, cur = null, uid = 0;++ function styles() { if (document.getElementById('ovcombo-styles')) return; const st = document.createElement('style'); st.id = 'ovcombo-styles'; st.textContent = CSS; document.head.appendChild(st); }++ function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }++ const nameOf = (q) => { const s = String(q || '').trim().replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, ''); const last = s.split('/').pop(); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(last || '') ? last : ''; };++ const ago = (t) => { const s = (Date.now() - new Date(t).getTime()) / 1000; if (!isFinite(s) || s < 0) return ''; return s < 3600 ? `${Math.max(1, Math.floor(s / 60))} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`; };++++ function enhance(input, opts) {++ if (!input || input.__ovcombo) return input && input.__ovcombo;++ styles();++ const o = Object.assign({}, opts);++ const src = o.source || input.getAttribute('data-ov-combo');++ const createAt = o.create || input.getAttribute('data-ov-combo-create');++ const listId = `ovcombo-${++uid}`;++ input.setAttribute('role', 'combobox');++ input.setAttribute('aria-autocomplete', 'list');++ input.setAttribute('aria-expanded', 'false');++ input.setAttribute('aria-controls', listId);++ input.setAttribute('autocomplete', 'off');++ input.removeAttribute('list');++ const cache = new Map();++ let rows = [], at = -1, ctl = null, timer = 0, lastQ = null, creating = false;++++ async function fetchQ(q) {++ const hit = cache.get(q);++ if (hit && Date.now() - hit.at < 60000) return hit.v;++ if (ctl) ctl.abort();++ ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;++ let v;++ if (typeof src === 'function') v = await src(q, ctl && ctl.signal);++ else {++ const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '12');++ const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: ctl && ctl.signal });++ if (!r.ok) throw new Error(`HTTP ${r.status}`);++ v = await r.json();++ }++ v = { sections: Array.isArray(v && v.sections) ? v.sections : [], create: (v && v.create) || null };++ cache.set(q, { at: Date.now(), v });++ return v;++ }++ function place() {++ if (!pop || pop.hidden) return;++ const r = input.getBoundingClientRect();++ const vh = root.innerHeight, vw = root.innerWidth;++ const below = vh - r.bottom - 12, above = r.top - 12;++ const up = below < 220 && above > below;++ pop.classList.toggle('is-up', up);++ const w = Math.min(Math.max(r.width, 280), vw - 16);++ pop.style.width = `${w}px`;++ pop.style.left = `${Math.max(8, Math.min(r.left, vw - w - 8))}px`;++ pop.style.maxHeight = `${Math.min(420, Math.max(160, up ? above : below))}px`;++ if (up) { pop.style.top = ''; pop.style.bottom = `${vh - r.top + 6}px`; } else { pop.style.bottom = ''; pop.style.top = `${r.bottom + 6}px`; }++ }++ function open() {++ if (!pop) { pop = el('div', 'ovcombo'); pop.setAttribute('role', 'listbox'); pop.hidden = true; document.body.appendChild(pop); pop.addEventListener('mousedown', (e) => e.preventDefault()); }++ pop.id = listId;++ pop.setAttribute('aria-label', input.getAttribute('aria-label') || input.placeholder || 'Choices');++ cur = api;++ pop.hidden = false;++ input.setAttribute('aria-expanded', 'true');++ place();++ }++ function close() {++ // A closed list stays closed: the answer still on its way is dropped.++ lastQ = null; clearTimeout(timer);++ if (ctl) { ctl.abort(); ctl = null; }++ if (cur === api && pop) { pop.hidden = true; pop.replaceChildren(); }++ input.setAttribute('aria-expanded', 'false');++ input.removeAttribute('aria-activedescendant');++ rows = []; at = -1;++ }++ function mark(text, q) {++ const b = el('b');++ const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;++ if (i < 0) { b.textContent = text; return b; }++ b.append(text.slice(0, i), el('mark', null, text.slice(i, i + q.length)), text.slice(i + q.length));++ return b;++ }++ function select(i) {++ at = i;++ rows.forEach((r, k) => r.el.setAttribute('aria-selected', String(k === at)));++ if (rows[at]) { input.setAttribute('aria-activedescendant', rows[at].el.id); rows[at].el.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');++ }++ function row(item, q, kind) {++ const d = el('div', `ovcombo-i${kind === 'create' ? ' is-create' : ''}`);++ d.id = `${listId}-${rows.length}`;++ d.setAttribute('role', 'option');++ d.setAttribute('aria-selected', 'false');++ const ic = el('span', 'ovcombo-ic');++ ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.host] || (kind === 'recent' ? HOST_IC.recent : HOST_IC.repo);++ const t = el('span', 'ovcombo-t');++ t.appendChild(kind === 'create' ? el('b', null, item.label) : mark(String(item.label || item.url || ''), q));++ const subBits = kind === 'create' ? [item.sub] : [item.host && item.host !== 'git.openvibe.codes' ? item.host : item.host ? 'OpenVibe Git' : null, item.sub || (item.updated_at ? ago(item.updated_at) : null)].filter(Boolean);++ if (subBits.length || item.private) {++ const s = el('small');++ if (item.private && kind !== 'create') { const l = el('span'); l.innerHTML = HOST_IC.lock; l.setAttribute('title', 'Private'); s.appendChild(l); }++ s.append(subBits.join(' · '));++ t.appendChild(s);++ }++ d.append(ic, t);++ const k = rows.length;++ d.addEventListener('click', () => choose(k));++ d.addEventListener('mousemove', () => { if (at !== k) select(k); });++ rows.push({ el: d, item, kind });++ return d;++ }++ function skeleton() {++ open();++ pop.replaceChildren();++ ['62%', '48%', '70%'].forEach((w) => { const s = el('div', 'ovcombo-skel'); s.style.setProperty('--w', w); s.append(el('i'), el('b')); pop.appendChild(s); });++ pop.setAttribute('aria-busy', 'true');++ }++ function paint(v, q) {++ open();++ pop.removeAttribute('aria-busy');++ pop.replaceChildren(); rows = []; at = -1;++ let any = false;++ for (const sec of v.sections) {++ const items = (sec.items || []).filter((x) => x && (x.url || x.label));++ if (!items.length) continue;++ any = true;++ const h = el('p', 'ovcombo-sec', sec.label || ''); h.setAttribute('role', 'presentation');++ pop.appendChild(h);++ for (const it of items) pop.appendChild(row(it, q, sec.id === 'recent' ? 'recent' : 'item'));++ }++ const name = nameOf(q);++ const c = v.create;++ const exists = rows.some((r) => String(r.item.label || '').split('/').pop().toLowerCase() === name.toLowerCase());++ if (createAt && c && c.allowed && name && !exists) {++ if (any) { const h = el('p', 'ovcombo-sec', 'New'); h.setAttribute('role', 'presentation'); pop.appendChild(h); }++ pop.appendChild(row({ label: `Create “${name}” on ${c.host === 'git.openvibe.codes' || !c.host ? 'OpenVibe Git' : c.host}`, sub: `Private, under ${c.owner ? '@' + c.owner : 'your account'}. You can share it later.`, name }, q, 'create'));++ }++ if (!rows.length) pop.appendChild(el('div', 'ovcombo-none', q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.'));++ place();++ if (rows.length && q) select(0);++ }++ async function look(q) {++ lastQ = q;++ if (!cache.has(q)) skeleton();++ try {++ const v = await fetchQ(q);++ if (lastQ !== q || document.activeElement !== input) return;++ paint(v, q);++ } catch (e) {++ if (e && e.name === 'AbortError') return;++ if (lastQ !== q) return;++ open(); pop.removeAttribute('aria-busy'); pop.replaceChildren(el('div', 'ovcombo-err', 'The list did not load. You can still paste an address.')); place();++ }++ }++ async function choose(i) {++ const r = rows[i]; if (!r || creating) return;++ if (r.kind !== 'create') { put(r.item); return; }++ creating = true;++ r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;++ try {++ let made;++ if (typeof createAt === 'function') made = await createAt(r.item.name);++ else {++ const res = await fetch(new URL(createAt, location.href).href, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ name: r.item.name, private: true }) });++ made = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error(made.message || made.error || `HTTP ${res.status}`);++ }++ cache.clear();++ put({ label: made.label || r.item.name, url: made.url, host: 'git.openvibe.codes', created: true });++ } catch (e) {++ const s = r.el.querySelector('small') || r.el.querySelector('.ovcombo-t').appendChild(el('small'));++ r.el.querySelector('b').textContent = `Create “${r.item.name}”`;++ s.textContent = String((e && e.message) || 'It was not created.').slice(0, 160);++ s.style.color = 'var(--live-red, #f87171)';++ } finally { creating = false; }++ }++ function put(item) {++ input.value = item.url || item.label || '';++ close();++ input.dispatchEvent(new Event('input', { bubbles: true }));++ input.dispatchEvent(new Event('change', { bubbles: true }));++ input.dispatchEvent(new CustomEvent('ov-combo:pick', { bubbles: true, detail: item }));++ input.__ovcomboPicked = input.value;++ }++++ input.addEventListener('focus', () => { if (input.__ovcomboPicked !== input.value || !input.value) look(input.value.trim()); });++ input.addEventListener('input', () => { if (input.__ovcomboPicked === input.value) return; input.__ovcomboPicked = null; clearTimeout(timer); timer = setTimeout(() => look(input.value.trim()), 160); });++ input.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== input) close(); }, 120));++ input.addEventListener('keydown', (e) => {++ const shown = pop && !pop.hidden && cur === api;++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {++ e.preventDefault();++ if (!shown) { look(input.value.trim()); return; }++ if (!rows.length) return;++ select(at < 0 ? (e.key === 'ArrowDown' ? 0 : rows.length - 1) : (at + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);++ } else if (e.key === 'Enter' && shown && at >= 0) { e.preventDefault(); e.stopPropagation(); choose(at); }++ else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }++ else if (e.key === 'Tab' && shown) close();++ });++ root.addEventListener('resize', place);++ root.addEventListener('scroll', place, true);++ const api = { input, open: () => look(input.value.trim()), close, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };++ input.__ovcombo = api;++ return api;++ }++++ function scan(scope) { (scope || document).querySelectorAll('input[data-ov-combo]').forEach((i) => enhance(i)); }++ root.OVCombo = { enhance, scan };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();++ document.addEventListener('ov:boost:load', () => scan());++})(typeof window !== 'undefined' ? window : globalThis);@@@ -27,6 +27,21 @@if (root.OpenVibeComposer || typeof document === 'undefined') return;const mods = [];++ // 4.202.0: your settings open in the one window (ov-window.js: a sheet on a phone, swiped down to close), loaded++ // from beside this file; without it, a plain <dialog>.++ const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };++ const selfSrc = (document.currentScript && document.currentScript.src) || '';++ function needWindow() {++ return new Promise((ok) => {++ if (root.OVWindow) return ok(root.OVWindow);++ const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);++ if (!m) return ok(null);++ const sc = document.createElement('script');++ sc.src = m[1] + 'ov-window.js' + (COMPOSER_REV['ov-window.js'] ? '?v=' + COMPOSER_REV['ov-window.js'] : '');++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);++ document.head.appendChild(sc);++ });++ }const live = new Set();const PREFS_KEY = 'ov:composer';const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };@@@ -207,8 +222,11 @@const t = cd.getData('text/plain') || '';const lines = t.split('\n').length;if (t.length > 6000 || lines > 120) {++ // Only where the box takes a text file (or a mod uploads): a box for pictures keeps the paste as text.++ const asFile = new File([t], 'pasted-text.txt', { type: 'text/plain' });++ if (!uploader() && !accepts(asFile)) return;e.preventDefault();-- add([new File([t], 'pasted-text.txt', { type: 'text/plain' })], { lines, text: t });++ add([asFile], { lines, text: t });say('The long paste went in as a file: ↩ on it puts it back in the box.');}});@@@ -308,13 +326,15 @@text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();}let lookT = 0;-- text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(look, trig && trig.kind === 'at' ? 140 : 0); });++ text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(() => { lookT = 0; look(); }, trig && trig.kind === 'at' ? 140 : 0); });text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));text.setAttribute('aria-autocomplete', 'list');// Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.form.addEventListener('keydown', (e) => {if (e.target !== text) return;++ // Enter right after the last letter: the slash list is read now, so "/settings" + Enter picks it.++ if (e.key === 'Enter' && lookT) { clearTimeout(lookT); lookT = 0; const w = caretWord(); if (w && w.kind === 'slash') look(); }if (!pop.hidden && items.length) {if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }@@@ -419,26 +439,32 @@// ── Your settings, in a sheet: what sends, voice, compact, your own commands.let sheet = null;-- function openSettings() {-- if (!sheet) {++ function openSettings(from) {++ const opener = from || document.activeElement;++ needWindow().then((W) => settingsIn(W, opener));++ }++ function settingsIn(W, opener) {++ let win = null;++ if (!W && !sheet) {sheet = el('dialog', 'ovc-set');sheet.setAttribute('aria-label', 'Your composer');sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });document.body.appendChild(sheet);}const k = mac ? '⌘' : 'Ctrl';-- sheet.innerHTML = `<form method="dialog" class="ovc-set-f"><header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>++ const host = W ? el('div', 'ovc-set ovc-set-in') : sheet;++ host.innerHTML = `<form method="dialog" class="ovc-set-f">${W ? '' : `<header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>`}<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset><label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label><label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label><fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul><div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset><p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;-- const f = sheet.querySelector('form');++ const f = host.querySelector('form');f.send_key.value = prefs.send_key;f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;f.compact.checked = prefs.density === 'compact';-- const list = sheet.querySelector('.ovc-set-l');++ const list = host.querySelector('.ovc-set-l');const paintList = () => {list.innerHTML = '';(prefs.snippets || []).forEach((s, i) => {@@@ -456,7 +482,7 @@else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });});-- sheet.querySelector('[data-ovc-add]').addEventListener('click', () => {++ host.querySelector('[data-ovc-add]').addEventListener('click', () => {const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }if (!t.trim()) { f.snippet.focus(); return; }@@@ -464,8 +490,10 @@f.trigger.value = ''; f.snippet.value = '';paintList(); f.trigger.focus();});-- if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', '');-- const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus();++ f.addEventListener('submit', (e) => e.preventDefault());++ if (W) win = W.open({ id: 'ov-composer-settings', title: 'Your composer', body: host, kind: 'dialog', size: 'sm', from: opener, focus: 'input[name=send_key]:checked' });++ else { if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', ''); const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus(); }++ return win;}function mod(m) {@@@ -4,7 +4,10 @@* navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.** <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them-- * <ul data-fx="reveal" data-fx-stagger="60">…</ul> its children rise in as they come on screen, 60 ms apart++ * <section data-fx="reveal" data-fx-stagger="40">…</section>++ * its heading rises 12 px (8 on a phone) out of a 4 px blur,++ * a [data-fx-rule] draws from the left, and its cards follow in++ * 40 ms steps (at most six; the rest together), once.* <b data-fx="count">12,480</b> the number counts up the first time it is seen* <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed* OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)@@@ -18,9 +21,17 @@* --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a* little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the* element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:-- * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a reveal-- * rises five, a tilt leans a third as far, a burst of confetti is a third the size, nothing overshoots. Only Off-- * (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.++ * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt++ * leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short++ * fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.++ *++ * The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script++ * runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is++ * visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script++ * knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6++ * in the container; a rule is [data-fx-rule] (or an <hr>); cards are [data-fx-card]s, the container's own children,++ * or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not++ * one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still.*/(function (root) {'use strict';@@@ -53,8 +64,30 @@+ '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'+ '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'+ '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'-- + '.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'-- + '.ovfx-in{opacity:1;transform:none;transition:opacity .5s var(--ov-ease-out),transform .6s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'++ // The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline++ // plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS.++ + '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}'++ + '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}'++ + '.ovfx-rule{transform-origin:left center}'++ + '@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}}'++ + '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}'++ + '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}'++ + '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}'++ + '@supports (animation-timeline:view()){.ovfx-head.ovfx-scroll{animation:ovfxHeadRise linear backwards;animation-timeline:view();animation-range:entry 0% cover 25%}.ovfx-rule.ovfx-scroll{animation:ovfxRuleDraw linear backwards;animation-timeline:view();animation-range:entry 5% cover 35%}.ovfx-card.ovfx-scroll{animation:ovfxCardRise linear backwards;animation-timeline:view();animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)}.ovfx-fade.ovfx-scroll{animation:ovfxFadeIn linear backwards;animation-timeline:view();animation-range:entry 0% cover 15%}}'++ + '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}'++ + '.ovfx-head.ovfx-in{opacity:1;transform:none;filter:blur(0);transition:opacity .5s var(--ov-ease-out),transform .55s var(--ov-ease-spring),filter .45s var(--ov-ease-out)}'++ + '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}'++ + '.ovfx-card.ovfx-in{opacity:1;transform:none;transition:opacity .45s var(--ov-ease-out),transform .55s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'++ + '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}'++ + '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}'++ + '.ovfx-fade.ovfx-wait{opacity:0}'++ + '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}'++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}'++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}'++ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}'++ + '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}}'++ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}'++ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade).ovfx-wait:not([data-fx-demo] *){opacity:1!important;transform:none!important;filter:none!important}'+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'@@@ -86,21 +119,100 @@}// ── Seen: reveal and count, once each, when they come on screen ──-- function seen(el) {-- if (el.__ovfxReveal) {-- const kids = el.__ovfxReveal;-- kids.forEach((k) => { k.classList.add('ovfx-in'); k.classList.remove('ovfx-hide'); });-- root.setTimeout(() => kids.forEach((k) => { k.classList.remove('ovfx-in'); k.style.removeProperty('--ovfx-d'); }), 1400 + kids.length * 80);-- el.__ovfxReveal = null;-- }-- if (el.__ovfxCount) { const c = el.__ovfxCount; el.__ovfxCount = null; countUp(el, c); }-- }-- function observe(el) {-- if (!('IntersectionObserver' in root)) return seen(el);-- io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); seen(e.target); } }), { rootMargin: '0px 0px -8% 0px' });++ function observe(el, fn) {++ if (!('IntersectionObserver' in root)) return fn(el);++ io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); (e.target.__ovfxSeen || fn)(e.target); } }), { rootMargin: '0px 0px -8% 0px' });++ el.__ovfxSeen = fn;io.observe(el);}const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };++ function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } }++++ // ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it++ // without any script on the main thread; the shared observer is the fallback. Only elements the script knows are++ // below the fold ever get a start state, and every path has a way back to the finished state.++ const HEAD_SEL = 'h1,h2,h3,h4,h5,h6';++ const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]';++ function findHead(el) {++ if (el.hasAttribute('data-fx-self')) return el;++ const marked = el.querySelector('[data-fx-heading]');++ if (marked) return marked;++ if (el.matches(HEAD_SEL)) return el;++ for (const h of el.querySelectorAll(HEAD_SEL)) {++ const card = h.closest(CARDISH);++ if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading++ let depth = 0, p = h;++ while (p && p !== el) { p = p.parentElement; depth++; }++ if (depth <= 3) return h;++ }++ return null;++ }++ function findRule(el, head) {++ const marked = el.querySelector('[data-fx-rule]');++ if (marked && marked !== head) return marked;++ const hr = el.querySelector('hr');++ return hr && hr !== head ? hr : null;++ }++ function findCards(el, head, rule) {++ const sel = el.getAttribute('data-fx-cards');++ if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } }++ const marked = Array.from(el.querySelectorAll('[data-fx-card]'));++ if (marked.length) return marked;++ const out = [];++ for (const k of Array.from(el.children)) {++ if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue;++ const cls = typeof k.className === 'string' ? k.className : '';++ if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) {++ const inner = Array.from(k.children);++ if (inner.length) { out.push(...inner); continue; }++ }++ out.push(k);++ }++ return out;++ }++ let viewTimeline = null;++ function hasViewTimeline() {++ if (viewTimeline != null) return viewTimeline;++ try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; }++ return viewTimeline;++ }++ function showReveal(el) {++ if (el.__ovfxShown) return;++ el.__ovfxShown = true;++ el.classList.add('ovfx-in');++ const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0;++ root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay);++ }++ function wireReveal(el) {++ if (off(el)) return;++ const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full';++ const self = el.hasAttribute('data-fx-self');++ const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule);++ const parts = [];++ if (head) parts.push([head, 'head', 0]);++ if (rule) parts.push([rule, 'rule', 0]);++ cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)]));++ if (!parts.length) return;++ // Never hide what someone can already see: a section below the fold is safe whole, one already on screen++ // is checked part by part.++ const live = below(el) ? parts : parts.filter(([k]) => below(k));++ if (!live.length) return;++ const timeline = hasViewTimeline();++ if (!timeline && !('IntersectionObserver' in root)) return;++ const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40));++ const follow = head ? 140 : 0;++ live.forEach(([k, kind, i]) => {++ k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card');++ if (timeline) {++ k.classList.add('ovfx-scroll');++ if (kind === 'card') k.style.setProperty('--ovfx-i', String(i));++ } else {++ k.classList.add('ovfx-wait');++ if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`);++ observe(k, showReveal);++ }++ });++ }function countUp(el, c) {const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1);@@@ -188,16 +300,10 @@if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });-- if (n === 'reveal' && !off(el)) {-- const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];-- if (!below(el)) continue; // already on screen when wired: never hide what someone can see-- const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));-- kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });-- el.__ovfxReveal = kids; observe(el);-- }++ if (n === 'reveal') wireReveal(el);if (n === 'count' && !off(el)) {const c = countOf(el);-- if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }++ if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); }}}}@@@ -306,35 +306,52 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur(o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });return byId;}-- // The network's groups, as folders for the sites past the first page (until the person arranges their home).-- const GROUP_FOLDERS = [['streams', 'Watch'], ['people', 'People'], ['make', 'Make'], ['answers', 'Read'], ['games', 'Play'], ['shop', 'Shop']];++ // The network's eight groups, in the menu's own order (OpenVibe.Network frame/sites.js GROUPS, the owner's++ // regrouping of 2026-11-10), as folders for the sites past the first page (until the person arranges their home).++ const GROUP_FOLDERS = [['watch', 'Watch & stream'], ['talk', 'Talk & belong'], ['play', 'Play'], ['read', 'Read & ask'], ['shop', 'Shop & money'], ['tools', 'Free tools'], ['make', 'Make with AI'], ['build', 'For developers']];++ // A group folder's id: `g-watch` …; Build keeps `build`, the id saved home screens already use.++ const folderId = (g) => (g === 'build' ? 'build' : `g-${g}`);++ // Saved before the regrouping: the old group ids map onto the new folders.++ const FOLDER_ALIAS = { 'g-streams': 'g-watch', 'g-people': 'g-talk', 'g-games': 'g-play', 'g-answers': 'g-read' };++ const normalizeFolder = (id) => (id.startsWith('folder:') ? `folder:${FOLDER_ALIAS[id.slice(7)] || id.slice(7)}` : id);function topSites(o) {const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));-- const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);-- return sites.slice(0, Math.max(6, 12 - 2 - mineFirst.length));++ const limit = Math.max(6, 12 - 2 - mineFirst.length);++ // Leave at least one app in every group for its folder: a group that is all singles has no folder to open.++ const left = new Map();++ for (const a of o.apps || []) left.set(a.group, (left.get(a.group) || 0) + 1);++ const sites = [];++ for (const a of o.apps || []) {++ if (a.group === 'build') continue;++ if ((left.get(a.group) || 0) <= 1) continue;++ left.set(a.group, left.get(a.group) - 1);++ sites.push(a.id);++ if (sites.length >= limit) break;++ }++ return sites;}function folderDefs(o) {const apps = o.apps || [];const top = new Set(topSites(o));-- const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: `g-${g}`, name, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))-- .filter((f) => f.apps.length >= 2);++ const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: folderId(g), name, group: g, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))++ .filter((f) => f.apps.length);return groups.concat([{ id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },-- { id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },]).filter((f) => f.apps.length);}-- /** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */++ /** Until the person arranges it: their own places first (signed in), then the most used and popular sites, then++ * one folder per group (the menu's eight, in its order) and the quick tools. */function defaultHome(o) {const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);-- const groupFolders = folderDefs(o).filter((f) => f.id.startsWith('g-')).map((f) => `folder:${f.id}`);-- return mineFirst.concat(topSites(o), groupFolders, ['folder:tools', 'folder:build']);++ const groupFolders = folderDefs(o).filter((f) => f.group).map((f) => `folder:${f.id}`);++ return mineFirst.concat(topSites(o), groupFolders, ['folder:tools']);}function layoutOf(o) {const byId = universe(o);const folders = folderDefs(o);const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));const l = readLocal();-- const home = (l ? l.home : defaultHome(o)).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);++ const home = (l ? l.home : defaultHome(o)).map(normalizeFolder).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);return { home, folders, arranged: !!l, byId };}@@@ -104,6 +104,8 @@panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']]));// Motion (4.183.0): Auto follows the device; Calm keeps every effect, smaller and slower; only Off stops them.panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));++ // Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.++ panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));}const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);@@@ -24,9 +24,19 @@// 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);-- // 4.201.0: the one composer (ov-composer.js: files, / and @, voice, drafts, your settings), from beside this file.-- const THREAD_REV = { 'ov-composer.js': 'b70e544155ac' };++ const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', '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() {++ if (root.OVCombo) { root.OVCombo.scan(); return; }++ if (loadCombo.on || !document.querySelector('input[data-ov-combo]')) return;++ loadCombo.on = true;++ var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);++ var s = document.createElement('script');++ s.async = true;++ s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');++ document.head.appendChild(s);++ }function loadComposer() {if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }if (loadComposer.on) return;@@@ -243,6 +253,7 @@root.OVThread = api;function start() {++ loadCombo();qa(document, '[data-ovt]').forEach(function (box) {if (box.dataset.ovtReady) return;box.dataset.ovtReady = '1';nnew file mode 100644@@@ -0,0 +1,273 @@++/* The public user card (ask 287, the owner: "one user card … across all sites … level … live streams … like Discord, but++ * way better"). Its look, 4.202.0: a banner from the person's own picture (blurred, tinted by their name's hue), the face++ * with its ring (red while live), the name and @handle, Live now as the first action when they are, level / streak /++ * badges / followers as chips, the sites they used this week, and the way to their profile and channel. Opened from a++ * mention or a face (OVUser.bind), it sits in the one window (ov-window.js, a panel beside what was pressed; a sheet on++ * a phone), loaded from beside this file.++ * OVUser.card(host, { username }) -> { ready, refresh(), destroy() }++ * Nothing mounts or intercepts clicks merely by loading this file. */++(function (root, factory) {++ 'use strict';++ const api = factory(root);++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVUser = api;++})(typeof window !== 'undefined' ? window : globalThis, function (root) {++ 'use strict';++ const NETWORK = 'https://openvibe.network';++ const USER_REV = { 'ov-window.js': 'c882d11d85ff' };++ 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%)); }++.ovu-banner::before { content: ""; position: absolute; inset: -30%; background: var(--ovu-bg, none) center / cover; filter: blur(26px) saturate(1.5); opacity: .75; }++.ovu-banner::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, var(--ovw-bg, #111826) 55%, transparent)); }++.ovu-head { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: end; gap: 12px; margin: -44px 16px 0; }++.ovu-face { position: relative; display: grid; place-items: center; width: 76px; height: 76px; border-radius: 50%; overflow: hidden; background: hsl(var(--ovu-h, 220) 55% 40%); color: #fff; font-size: 30px; font-weight: 800; box-shadow: 0 0 0 4px var(--ovw-bg, var(--bg-secondary, #111826)); }++.ovu-face img { width: 100%; height: 100%; object-fit: cover; }++.ovu-face.is-live { box-shadow: 0 0 0 3px var(--live-red, #ef4444), 0 0 0 6px var(--ovw-bg, var(--bg-secondary, #111826)); }++.ovu-names { min-width: 0; padding-bottom: 4px; }++.ovu-name { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; font-weight: 800; letter-spacing: -.01em; }++.ovu-handle { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 14px; }++.ovu-state { margin: 0 16px; font-size: 13px; color: var(--text-muted, #8b98ad); }++.ovu-live { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; margin: 0 16px; border-radius: 12px; background: var(--live-red, #ef4444); color: #fff; font-weight: 750; text-decoration: none; }++.ovu-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: ovu-beat 1.4s ease-in-out infinite; }++.ovu-live small { font-weight: 600; opacity: .85; }++.ovu-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 16px; padding: 0; list-style: none; }++.ovu-chips li { display: inline-flex; align-items: baseline; gap: 5px; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }++.ovu-chips b { font-size: 14px; color: var(--text-primary, #e6edf7); }++.ovu-sec { margin: 0 16px; }++.ovu-sec h3 { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #8b98ad); }++.ovu-sites { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }++.ovu-sites a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13px; font-weight: 600; }++.ovu-sites a:hover { border-color: var(--accent, #3b82f6); }++.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }++.ovu-acts a { flex: 1; display: grid; place-items: center; min-height: 42px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: inherit; font-weight: 650; text-decoration: none; }++.ovu-acts a:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }++.ovu-acts a:hover { filter: brightness(1.1); }++.ovu-wait { display: grid; gap: 10px; padding: 0 16px 18px; }++.ovu-wait i { display: block; height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovu-shine 1.2s ease-in-out infinite; }++.ovu-wait i:first-child { width: 55%; height: 18px; }++.ovu-wait i:nth-child(2) { width: 35%; }++.ovu-wait i:nth-child(3) { width: 80%; }++.ovu-none { margin: 0 16px; color: var(--text-secondary, #a8b3c4); }++@keyframes ovu-beat { 50% { opacity: .35; transform: scale(.75); } }++@keyframes ovu-shine { 50% { opacity: .45; } }++html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i { animation: none; }`;++ function styles(doc) { if (doc.getElementById('ovu-styles')) return; const st = doc.createElement('style'); st.id = 'ovu-styles'; st.textContent = CSS; doc.head.appendChild(st); }++ // The banner's hue: from the name, so a person's card always wears the same colour.++ const hueOf = (name) => { let h = 0; for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; };++ const compact = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);++ function needWindow() {++ return new Promise((ok) => {++ if (root.OVWindow) return ok(root.OVWindow);++ const m = /^(https?:\/\/[^?#]*\/)ov-user(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);++ if (!m || typeof document === 'undefined') return ok(null);++ const sc = document.createElement('script');++ sc.src = m[1] + 'ov-window.js' + (USER_REV['ov-window.js'] ? '?v=' + USER_REV['ov-window.js'] : '');++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);++ document.head.appendChild(sc);++ });++ }++ const mounts = new WeakMap();++ const bindings = new WeakMap();++ const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;++ function safeUrl(value) {++ try {++ const u = new URL(value);++ return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;++ } catch { return null; }++ }++ function element(doc, tag, text, className) {++ const el = doc.createElement(tag);++ if (text !== undefined) el.textContent = String(text);++ if (className) el.className = className;++ return el;++ }++ function link(doc, label, url) {++ const safe = safeUrl(url);++ if (!safe) return null;++ const a = element(doc, 'a', label, 'ovk-btn ovk-btn--quiet');++ a.href = safe;++ return a;++ }++ function render(section, person) {++ const doc = section.ownerDocument;++ section.replaceChildren();++ section.setAttribute('aria-label', `User @${person.username}`);++ section.style.setProperty('--ovu-h', String(hueOf(person.username)));++ const avatar = safeUrl(person.avatar_url);++ const banner = element(doc, 'div', undefined, 'ovu-banner');++ if (avatar && !/["'()\\\s]/.test(avatar)) banner.style.setProperty('--ovu-bg', `url("${avatar}")`);++ section.appendChild(banner);++ const head = element(doc, 'div', undefined, 'ovu-head');++ const live = person.live && person.live.now === true;++ const face = element(doc, 'span', undefined, `ovu-face${live ? ' is-live' : ''}`);++ if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }++ else face.textContent = person.username[0].toUpperCase();++ const names = element(doc, 'div', undefined, 'ovu-names');++ names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));++ names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));++ head.append(face, names);++ section.appendChild(head);++ if (live) {++ const a = link(doc, 'Live now', person.live.url);++ if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }++ } else if (person.live && person.live.now === false) section.appendChild(element(doc, 'p', 'Not live right now', 'ovu-state'));++ const chips = element(doc, 'ul', undefined, 'ovu-chips');++ for (const [key, label] of [['level', 'Level'], ['streak', 'day streak'], ['badges_count', 'badges'], ['followers', 'followers']]) {++ const value = person[key];++ if (!Number.isSafeInteger(value) || value < 0) continue;++ const li = element(doc, 'li');++ if (key === 'level') { li.append(label + ' '); li.appendChild(element(doc, 'b', value)); }++ else { li.appendChild(element(doc, 'b', compact(value))); li.append(` ${key === 'streak' && value === 1 ? 'day streak' : key === 'badges_count' && value === 1 ? 'badge' : key === 'followers' && value === 1 ? 'follower' : label}`); }++ chips.appendChild(li);++ }++ if (chips.childNodes.length) section.appendChild(chips);++ if (Array.isArray(person.active_sites)) {++ const list = element(doc, 'ul', undefined, 'ovu-sites');++ for (const site of person.active_sites) {++ if (!site || typeof site.name !== 'string') continue;++ const a = link(doc, site.name, site.url);++ if (a) { a.className = ''; const li = element(doc, 'li'); li.appendChild(a); list.appendChild(li); }++ }++ if (list.childNodes.length) { const sec = element(doc, 'div', undefined, 'ovu-sec'); sec.append(element(doc, 'h3', 'This week'), list); section.appendChild(sec); }++ }++ const acts = element(doc, 'div', undefined, 'ovu-acts');++ for (const [label, url] of [['Profile', person.profile_url], ['Channel', person.channel_url]]) {++ const a = link(doc, label, url);++ if (a) { a.className = ''; acts.appendChild(a); }++ }++ if (acts.childNodes.length) section.appendChild(acts);++ }++ function card(host, options = {}) {++ const username = usernameOf(options.username);++ if (!host?.ownerDocument || typeof host.replaceChildren !== 'function') throw new TypeError('OVUser.card: pass a host element');++ if (!username) throw new TypeError('OVUser.card: use a username of 3–24 letters, digits or underscores');++ mounts.get(host)?.destroy();++ const doc = host.ownerDocument;++ styles(doc);++ const section = element(doc, 'section', undefined, 'ovu');++ section.setAttribute('data-ov-user-card', '');++ host.replaceChildren(section);++ let generation = 0, controller, disposed = false;++ const profile = `${NETWORK}/@${encodeURIComponent(username)}`;++ const api = {++ ready: null,++ refresh() {++ if (disposed) return Promise.resolve(null);++ const ticket = ++generation;++ controller?.abort();++ const request = controller = new AbortController();++ const timer = setTimeout(() => request.abort(), 8000);++ const status = element(doc, 'div', undefined, 'ovu-wait'); status.setAttribute('role', 'status'); status.setAttribute('aria-label', 'Loading the profile');++ status.append(element(doc, 'i'), element(doc, 'i'), element(doc, 'i'));++ section.style.setProperty('--ovu-h', String(hueOf(username)));++ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');++ const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));++ const url = `${String(options.networkUrl || NETWORK).replace(/\/+$/, '')}/api/v1/people/${encodeURIComponent(username)}/card`;++ api.ready = Promise.resolve().then(() => fetcher(url, { credentials: 'omit', cache: 'no-store',++ headers: { Accept: 'application/json' }, signal: request.signal }))++ .then(async response => {++ if (!response.ok) throw new Error('Profile unavailable');++ const person = await response.json();++ if (!person || !usernameOf(person.username)) throw new Error('Profile unavailable');++ if (!disposed && ticket === generation) render(section, person);++ return person;++ }).catch(() => {++ if (!disposed && ticket === generation) {++ const message = element(doc, 'p', 'The profile did not load.', 'ovu-none'); message.setAttribute('role', 'status');++ const acts = element(doc, 'div', undefined, 'ovu-acts'); const a = link(doc, 'Open the profile', profile); if (a) { a.className = ''; acts.appendChild(a); }++ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), message, acts);++ }++ return null;++ }).finally(() => {++ clearTimeout(timer);++ if (!disposed && ticket === generation) section.removeAttribute('aria-busy');++ });++ return api.ready;++ },++ destroy() {++ if (disposed) return;++ disposed = true; generation++; controller?.abort();++ section.remove();++ if (mounts.get(host) === api) mounts.delete(host);++ },++ };++ mounts.set(host, api);++ api.refresh();++ return api;++ }++ /** Delegated hook: marked mentions/avatars and profile links; call only when the site opts in. */++ function bind(scope, options = {}) {++ if (!scope?.addEventListener) throw new TypeError('OVUser.bind: pass a document or element');++ bindings.get(scope)?.();++ const doc = scope.ownerDocument || scope;++ let close = null, disposed = false;++ function open(username, trigger) {++ close?.();++ if (root.OVWindow || selfSrc) {++ needWindow().then((W) => {++ if (!W) { openDialog(username, trigger); return; }++ const host = element(doc, 'div');++ const mounted = card(host, { ...options, username });++ let win = null;++ const cleanup = () => { mounted.destroy(); if (win) { const w = win; win = null; w.close(); } if (close === cleanup) close = null; };++ close = cleanup;++ win = W.open({ id: 'ov-user-card', kind: 'panel', size: 'sm', bare: true, title: `@${username}`, body: host, from: trigger, onClose: () => { win = null; mounted.destroy(); if (close === cleanup) close = null; } });++ });++ return true;++ }++ return openDialog(username, trigger);++ }++ function openDialog(username, trigger) {++ const dialog = element(doc, 'dialog');++ if (typeof dialog.showModal !== 'function') return false;++ dialog.setAttribute('aria-label', `User @${username}`);++ const dismiss = element(doc, 'button', 'Close', 'ovk-btn ovk-btn--quiet'); dismiss.type = 'button';++ const host = element(doc, 'div');++ dialog.append(dismiss, host); doc.body.appendChild(dialog);++ const mounted = card(host, { ...options, username });++ const cleanup = () => {++ mounted.destroy(); dialog.remove(); trigger.focus?.();++ if (close === cleanup) close = null;++ };++ close = cleanup;++ dismiss.addEventListener('click', cleanup);++ dialog.addEventListener('cancel', cleanup);++ dialog.addEventListener('close', cleanup);++ dialog.addEventListener('click', event => { if (event.target === dialog) cleanup(); });++ dialog.showModal();++ return true;++ }++ function handle(event) {++ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;++ if (event.type === 'click' && event.button > 0) return;++ const target = event.target?.closest?.('[data-ov-user], [data-username], a[href]');++ if (!target || target.closest('[data-ov-user-card]')) return;++ if (event.type === 'keydown' && (!['Enter', ' '].includes(event.key) || ['A', 'BUTTON'].includes(target.tagName) || event.repeat)) return;++ let username = usernameOf(target.getAttribute('data-ov-user') || target.getAttribute('data-username'));++ if (!username && target.tagName === 'A') {++ try {++ const base = doc.baseURI || NETWORK;++ const url = new URL(target.getAttribute('href'), base);++ if (!['https:', 'http:'].includes(url.protocol) || ![new URL(base).origin, NETWORK, 'https://openvibe.live'].includes(url.origin)) return;++ const match = /^\/@([A-Za-z0-9_]{3,24})\/?$/.exec(url.pathname);++ if (match) username = match[1];++ } catch { return; }++ }++ if (!username) return;++ if (typeof options.onOpen === 'function') {++ event.preventDefault(); options.onOpen({ username, trigger: target, event });++ } else if (open(username, target)) event.preventDefault();++ }++ for (const type of ['click', 'contextmenu', 'keydown']) scope.addEventListener(type, handle);++ const cleanup = () => {++ if (disposed) return;++ disposed = true; close?.();++ for (const type of ['click', 'contextmenu', 'keydown']) scope.removeEventListener(type, handle);++ if (bindings.get(scope) === cleanup) bindings.delete(scope);++ };++ bindings.set(scope, cleanup);++ return cleanup;++ }++ return { card, bind };++});@@@ -54,3 +54,4 @@ html[data-ov-motion=reduced] .ov-wallet-body.is-first > * { animation: ov-wallethtml[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off] .ov-wallet-wait { animation: none; }@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }@keyframes ov-wallet-fade { from { opacity: 0; } }++.ov-wallet.ov-wallet-in{width:auto;max-height:none;border:0;border-radius:0;box-shadow:none;overflow:visible}.ovw:has(.ov-wallet-in){background:none;border:0}.ov-wallet-in{border-radius:inherit}.ov-wallet-in .ov-wallet-head{padding-right:64px}.ovw.is-sheet .ov-wallet-in .ov-wallet-head{padding-top:26px}.ov-wallet-in .ov-wallet-body{max-height:none;overflow:visible}.ovw-b:has(>.ov-wallet-in){padding:0}@@@ -31,6 +31,21 @@const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });++ // 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': 'c882d11d85ff' };++ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';++ function needWindow() {++ return new Promise((ok) => {++ if (root.OVWindow) return ok(root.OVWindow);++ const m = /^(https?:\/\/[^?#]*\/)ov-wallet(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);++ if (!m) return ok(null);++ const sc = document.createElement('script');++ sc.src = m[1] + 'ov-window.js' + (WALLET_REV['ov-window.js'] ? '?v=' + WALLET_REV['ov-window.js'] : '');++ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);++ document.head.appendChild(sc);++ });++ }async function open(options) {close();const token = (options.getToken ? options.getToken() : options.token) || null;@@@ -41,7 +56,9 @@// sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;const controller = new AbortController();-- const dialog = el('dialog', 'ov-wallet');++ const W = await needWindow();++ let win = null;++ const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');dialog.setAttribute('aria-labelledby', 'ov-wallet-title');const header = el('header', 'ov-wallet-head'), heading = el('div');heading.append(el('h2', '', 'Wallet'));@@@ -49,18 +66,20 @@const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');header.append(heading, exit);const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');-- status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);++ status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body); if (W) exit.remove(); // the window's own × closes itconst previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would// make its saved state "hidden". Only a page without it needs the wallet's own lock.-- const ownLock = !root.OVSurfaces;++ const ownLock = !!W || !root.OVSurfaces; // in the window there is no <dialog> for surfaces.js to seelet disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands inconst wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; };const shown = {}; // what each balance last showed, so a change counts from itconst pending = new Map();const session = { close() {-- if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();++ if (disposed) return; disposed = true; controller.abort();++ if (win) { const w = win; win = null; w.close(); } else if (dialog.close) dialog.close();++ dialog.remove();if (ownLock) document.documentElement.style.overflow = previousOverflow;document.removeEventListener('openvibe-auth-changed', session.close);if (active === session) active = null;@@@ -247,10 +266,12 @@}}exit.addEventListener('click', session.close);-- dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });-- dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });++ if (!W) dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });++ if (!W) dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });document.addEventListener('openvibe-auth-changed', session.close);-- document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();++ if (W) win = W.open({ id: 'ov-wallet', title: 'Wallet', bare: true, body: dialog, kind: 'dialog', size: 'lg', width: 860, from: previousFocus, onClose: () => { win = null; session.close(); } });++ else { document.body.append(dialog); dialog.showModal(); exit.focus(); }++ if (ownLock) document.documentElement.style.overflow = 'hidden';// There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the// store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).const seed = kept();nnew file mode 100644@@@ -0,0 +1,211 @@++/**++ * ov-window.js (openvibe-shared 4.202.0): one window for every little interface. Asks 304 and 318, the owner: "a system++ * that's used for all the windows [in] harmony … people can make their own or … custom … everything … feels connected".++ * One frame (a head with the title and ×, a body, a foot) in three kinds, the same motion, the same keys:++ * dialog: centred on a computer; a sheet from the bottom on a phone++ * sheet: a sheet from the bottom on every screen++ * panel: floats beside what opened it on a computer, draggable by its head (where you leave it is kept per id);++ * a sheet on a phone++ * It grows out of the button that opened it (the navbar's pop motion), closes with ×, Escape, a press outside (a dialog++ * or a sheet; a panel stays until closed, and the page under it stays usable) or a swipe down on a phone, keeps the focus inside while open, and gives it back to that button. Windows stack: Escape closes the++ * top one. The look follows the person's theme and their window style, <html data-ov-window="glass|solid|outline">++ * (OVWindow.setStyle), on every window at once.++ *++ * const w = OVWindow.open({ id, title, body: node | text, foot: node, kind: 'dialog', size: 'md', from: button,++ * label, onClose, bare, width }); bare: the content draws its own head (the title is still++ * read); width: px, for content that wants its own++ * w.el, w.body, w.foot, w.setTitle(text), w.close()++ * A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVWindow) return;++++ 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; }++.ovw-scrim.is-clear { background: transparent; }++.ovw { position: fixed; z-index: var(--ovw-z, 10040); box-sizing: border-box; display: flex; flex-direction: column; max-height: min(86vh, 760px); color: var(--text-primary, #e6edf7);++ font: 500 15px/1.45 var(--font, Inter, system-ui, -apple-system, 'Segoe UI', sans-serif); border-radius: 20px; overflow: hidden;++ background: var(--ovw-bg); border: 1px solid var(--ovw-line); box-shadow: var(--ovw-shadow); -webkit-backdrop-filter: var(--ovw-blur); backdrop-filter: var(--ovw-blur);++ transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ovw-in .28s cubic-bezier(.2, 1.2, .3, 1) both; }++:root { --ovw-bg: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))) 92%, transparent); --ovw-line: var(--border, rgba(255, 255, 255, .12));++ --ovw-shadow: 0 28px 70px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .2); --ovw-blur: blur(16px) saturate(1.3); }++html[data-ov-window="solid"] { --ovw-bg: var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))); --ovw-blur: none; }++html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blur: none; --ovw-line: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); --ovw-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), 0 18px 40px rgba(0, 0, 0, .35); }++.ovw.is-out { animation: ovw-out .16s ease-in both; }++.ovw-h { flex: none; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 8px 6px 18px; border-bottom: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }++.ovw-t { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 700; letter-spacing: -.01em; }++.ovw-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }++.ovw-x:hover, .ovw-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); }++.ovw-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.ovw-b { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 18px 18px; }++.ovw-f { flex: none; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }++.ovw-f:empty { display: none; }++.ovw.is-dialog { left: 50%; top: 50%; width: min(var(--ovw-w), calc(100vw - 32px)); transform: translate(-50%, -50%); animation-name: ovw-in-c; }++.ovw.is-dialog.is-out { animation-name: ovw-out-c; }++.ovw.is-panel { width: min(var(--ovw-w), calc(100vw - 24px)); }++.ovw.is-panel .ovw-h { cursor: grab; touch-action: none; }++.ovw.is-panel.is-dragging { animation: none; transition: none; box-shadow: 0 34px 90px rgba(0, 0, 0, .55); }++.ovw.is-panel.is-dragging .ovw-h { cursor: grabbing; }++.ovw.is-sheet { left: 0; right: 0; bottom: 0; width: auto; max-height: 92vh; margin: 0 auto; max-width: 720px; border-radius: 22px 22px 0 0; border-bottom: 0; animation: ovw-up .32s cubic-bezier(.2, 1, .3, 1) both; }++.ovw.is-sheet.is-out { animation: ovw-down .2s ease-in both; }++.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }++.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }++.ovw.is-sheet.is-dragging { animation: none; }++.ovw { --ovw-w: 520px; }++.ovw.is-sm { --ovw-w: 380px; }++.ovw.is-lg { --ovw-w: 760px; }++.ovw.is-full { --ovw-w: 1100px; }++@keyframes ovw-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))) scale(.94); } }++@keyframes ovw-out { to { opacity: 0; transform: scale(.96); } }++@keyframes ovw-in-c { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px * var(--ov-move, 1))) scale(.95); } }++@keyframes ovw-out-c { to { opacity: 0; transform: translate(-50%, -50%) scale(.97); } }++@keyframes ovw-up { from { transform: translateY(100%); } }++@keyframes ovw-down { to { transform: translateY(100%); } }++@keyframes ovw-fade { from { opacity: 0; } }++html[data-ov-motion=off] .ovw, html[data-ov-motion=off] .ovw-scrim { animation: none !important; }++html.ovw-locked { overflow: hidden; }++/* bare: the content carries its own head (a card's banner); the title is for screen readers, × floats on top, and++ the top strip is still the handle (drag a panel, swipe a sheet). */++.ovw.is-bare .ovw-h { position: absolute; left: 0; right: 0; top: 0; z-index: 3; min-height: 52px; padding: 6px; justify-content: flex-end; border: 0; background: none; }++.ovw.is-bare .ovw-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }++.ovw.is-bare .ovw-x { width: 36px; height: 36px; border-radius: 50%; color: #fff; background: rgba(4, 8, 16, .42); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }++.ovw.is-bare .ovw-x:hover, .ovw.is-bare .ovw-x:focus-visible { background: rgba(4, 8, 16, .62); color: #fff; }++.ovw.is-bare .ovw-b { padding: 0; }++.ovw.is-bare.is-sheet .ovw-h { padding-top: 14px; }`;++++ 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); }++ 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 */ } } };++ function put(parent, v) { if (v == null) return; if (typeof v === 'string') parent.textContent = v; else parent.appendChild(v); }++++ function open(o = {}) {++ styles();++ const kind0 = ['dialog', 'sheet', 'panel'].includes(o.kind) ? o.kind : 'dialog';++ const kind = phone() ? 'sheet' : kind0;++ const id = o.id || `ovw-${++uid}`;++ const opener = o.from || document.activeElement;++ const modal = o.modal !== false && kind !== 'panel';++ const z = 10040 + stack.length * 2;++ const scrim = document.createElement('div');++ scrim.className = `ovw-scrim${modal ? '' : ' is-clear'}`;++ scrim.style.setProperty('--ovw-z', String(z));++ const el = document.createElement('section');++ el.className = `ovw is-${kind} is-${['sm', 'md', 'lg', 'full'].includes(o.size) ? o.size : 'md'}${o.bare ? ' is-bare' : ''}`;++ el.style.setProperty('--ovw-z', String(z + 1));++ if (Number(o.width) > 0) el.style.setProperty('--ovw-w', `${Math.round(Number(o.width))}px`);++ el.setAttribute('role', 'dialog');++ if (modal) el.setAttribute('aria-modal', 'true');++ el.dataset.ovWindow = id;++ const head = document.createElement('header'); head.className = 'ovw-h';++ const title = document.createElement('h2'); title.className = 'ovw-t'; title.id = `${id}-t`; title.textContent = o.title || '';++ const x = document.createElement('button'); x.type = 'button'; x.className = 'ovw-x'; x.setAttribute('aria-label', 'Close');++ x.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';++ head.append(title, x);++ const body = document.createElement('div'); body.className = 'ovw-b'; put(body, o.body);++ const foot = document.createElement('footer'); foot.className = 'ovw-f'; put(foot, o.foot);++ el.append(head, body, foot);++ if (o.title) el.setAttribute('aria-labelledby', title.id); else el.setAttribute('aria-label', o.label || 'Window');++ if (modal) document.body.append(scrim, el); else document.body.append(el);++ 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.++ if (kind === 'panel') {++ const saved = o.id ? place.get(o.id) : null;++ const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = 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`;++ }++ if (opener && opener.getBoundingClientRect && kind !== 'sheet') {++ const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();++ el.style.setProperty('--ov-pop-x', `${Math.round(f.left + f.width / 2 - r.left)}px`);++ el.style.setProperty('--ov-pop-y', `${Math.round(f.top + f.height / 2 - r.top)}px`);++ }++++ let closed = false;++ const w = {++ id, el, body, foot,++ setTitle(t) { title.textContent = t || ''; },++ close(why) {++ if (closed) return; closed = true;++ 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(); };++ if (document.documentElement.getAttribute('data-ov-motion') === 'off') gone();++ else { el.classList.add('is-out'); scrim.style.opacity = '0'; scrim.style.transition = 'opacity .16s'; let done = false; const end = () => { if (!done) { done = true; gone(); } }; el.addEventListener('animationend', end, { once: true }); setTimeout(end, 260); }++ if (opener && opener.focus && document.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch { /* */ } }++ if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }++ },++ modal,++ };++ stack.push(w);++ x.addEventListener('click', () => w.close('x'));++ if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));++ el.addEventListener('keydown', (e) => {++ if (e.key === 'Tab') {++ const f = focusables(el); if (!f.length) return;++ const first = f[0], last = f[f.length - 1];++ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }++ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }++ }++ });++++ // A panel moves by its head on a computer; a sheet goes down when swiped down on a phone.++ if (kind === 'panel' || 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;++ let moved = false;++ el.classList.add('is-dragging');++ try { head.setPointerCapture(e.pointerId); } catch { /* */ }++ const move = (ev) => {++ const dx = ev.clientX - sx, dy = ev.clientY - sy;++ if (!moved && Math.hypot(dx, dy) < 4) return;++ moved = true;++ if (kind === 'panel') {++ el.style.left = `${Math.max(8, Math.min(r.left + dx, root.innerWidth - r.width - 8))}px`;++ el.style.top = `${Math.max(8, Math.min(r.top + dy, root.innerHeight - 56))}px`;++ } else el.style.transform = `translateY(${Math.max(0, dy)}px)`;++ };++ const up = (ev) => {++ head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);++ el.classList.remove('is-dragging');++ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }++ const dy = ev.clientY - sy;++ if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');++ else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }++ };++ head.addEventListener('pointermove', move); head.addEventListener('pointerup', up); head.addEventListener('pointercancel', up);++ });++ }++++ const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];++ (first || x).focus({ preventScroll: true });++ return w;++ }++++ document.addEventListener('keydown', (e) => {++ if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;++ e.preventDefault();++ stack[stack.length - 1].close('escape');++ });++++ // The style is the person's display setting (theme-loader: saved, synced to their account, painted at boot as++ // <html data-ov-window>); setStyle saves through it, or straight onto the page where the theme loader is absent.++ function setStyle(name) {++ const v = STYLES.includes(name) ? name : 'glass';++ const T = root.OpenVibeThemeLoader;++ if (T && T.display && typeof T.display.set === 'function') { try { T.display.set({ window: v }); } catch { /* */ } }++ if (v === 'glass') document.documentElement.removeAttribute('data-ov-window'); else document.documentElement.setAttribute('data-ov-window', v);++ return v;++ }++++ root.OVWindow = { open, setStyle, styles: STYLES.slice(), top: () => stack[stack.length - 1] || null, closeAll: () => [...stack].reverse().forEach((w) => w.close('all')) };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.201.0",++ "version": "4.202.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.201.0",++ "version": "4.202.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.201.0",++ "version": "4.202.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -23,6 +23,7 @@"./assets": "./assets.js","./auth-client": "./auth-client.js","./brand": "./brand.js",++ "./bottom": "./bottom.js","./browser-harness": "./browser-harness.js","./builtin-themes": "./builtin-themes.js","./cache-policy": "./cache-policy.js",@@@ -127,6 +128,7 @@"./trace": "./trace.js","./ui": "./ui.js","./url-resolver": "./url-resolver.js",++ "./ov-user": "./ov-user.js","./user-card": "./user-card.js","./web-runtime": "./web-runtime.js","./route-transition": "./route-transition.js",@@@ -138,6 +140,8 @@"./thread": "./thread.js","./messages": "./messages.js","./ov-thread": "./ov-thread.js",++ "./ov-window": "./ov-window.js",++ "./ov-combo": "./ov-combo.js","./ov-composer": "./ov-composer.js","./ov-ai-choice": "./ai-choice.js","./ov-ai-choice.js": "./ov-ai-choice.js",@@@ -164,6 +168,7 @@],"scripts": {"test": "node test/run.js",++ "audit:page-end": "node scripts/audit-page-end.js","create-site": "node scripts/create-site.js","size": "node scripts/size-report.js","build:icons": "python3 scripts/build-nav-icons.py",@@@ -48,7 +48,7 @@--ovk-radius-sm: var(--ov-radius-1);--ovk-tap: 44px;--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */-- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */++ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);}/* Shape personalities, as in kit.css but at zero specificity (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners@@@ -21,26 +21,26 @@'use strict';const HOODS = Object.freeze({-- streams: 'Streams & places', games: 'Games & laughs', answers: 'News & answers',-- make: 'Make & build', shop: 'Shop & swap', people: 'People & you', build: 'For developers',++ watch: 'Watch & stream', talk: 'Talk & belong', play: 'Play', read: 'Read & ask',++ shop: 'Shop & money', tools: 'Free tools', make: 'Make with AI', build: 'For developers',});// id, name in menus, host, neighbourhood, OKLCH hue, dark accent, light accent, hero line (≤ 8 words), kit icon, motifconst ROWS = [-- ['fun', 'Fun', 'openvibe.fun', 'games', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],++ ['fun', 'Fun', 'openvibe.fun', 'play', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],['shoes', 'Shoes', 'open.shoes', 'shop', 25, '#FF7E76', '#B02A2D', 'Every shoe, every market, the right price', 'shoe', 'a shoe drawn as one line, price tags from several stores sliding into one all-in price'],-- ['tours', 'Tours', 'openvibe.tours', 'streams', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],-- ['today', 'Today', 'openvibe.today', 'answers', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],-- ['meme', 'Meme', 'openvibe.meme', 'games', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],-- ['domains', 'Domains', 'openvibe.domains', 'make', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],-- ['casino', 'Casino', 'openvibe.casino', 'games', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],-- ['exchange', 'Exchange', 'openvibe.exchange', 'shop', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],-- ['cam', 'Cam', 'openvibe.cam', 'streams', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],-- ['surf', 'Surf', 'openvibe.surf', 'games', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],-- ['report', 'Report', 'openvibe.report', 'answers', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],++ ['tours', 'Tours', 'openvibe.tours', 'watch', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],++ ['today', 'Today', 'openvibe.today', 'read', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],++ ['meme', 'Meme', 'openvibe.meme', 'play', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],++ ['domains', 'Domains', 'openvibe.domains', 'tools', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],++ ['casino', 'Casino', 'openvibe.casino', 'play', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],++ ['exchange', 'Exchange', 'openvibe.exchange', 'build', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],++ ['cam', 'Cam', 'openvibe.cam', 'watch', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],++ ['surf', 'Surf', 'openvibe.surf', 'play', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],++ ['report', 'Report', 'openvibe.report', 'read', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],['website', 'Website', 'openvibe.website', 'make', 275, '#8EA0FF', '#4B53C2', 'Serious sites, no login needed', 'computer', 'a browser frame assembling a real site from blocks while a cursor types the prompt'],-- ['vision', 'Vision', 'openvibe.vision', 'answers', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],-- ['design', 'Design', 'openvibe.design', 'make', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],++ ['vision', 'Vision', 'openvibe.vision', 'read', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],++ ['design', 'Design', 'openvibe.design', 'tools', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],];const ROOMS = Object.freeze(Object.fromEntries(ROWS.map(([id, name, host, hood, hue, dark, light, line, icon, motif]) =>[id, Object.freeze({ id, name, host, hood, hue, dark, light, line, icon, motif })])));nnew file mode 100644@@@ -0,0 +1,120 @@++#!/usr/bin/env node++'use strict';++// openvibe-shared/scripts/audit-page-end — plan 280 ("That huge wasted space … in between that and the footer …++// It's just waste"): measure the gap between the last thing on a site's home page and its footer, at 390 and 1280,++// and list every site over the rule: content ends, then 32 px on a computer (24 on a phone), then the footer.++//++// node scripts/audit-page-end.js every site, 390 and 1280++// node scripts/audit-page-end.js --footer-rule with the Shared rule (32/24) applied, to see what is left++// node scripts/audit-page-end.js --hosts openvibe.wiki,openvibe.games --width 390++// node scripts/audit-page-end.js --json research/page-end.json --strict++//++// A gap is the top of the footer minus the bottom of the lowest visible piece of content before it (text a person++// reads, or a picture/video), in the page's own coordinates. A site's bottom padding, a spacer or a margin on the++// last block shows up here; the footer's own margin-top is the Shared rule and is not site dead space.++// Real Chrome through ../browser-harness (CHROME_BIN or the usual paths). Reads only: no site is changed.++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const h = require('../browser-harness');++++// The first-party homes (the Network's own zones), the same ring the footer and the menu list.++const SITES = [++ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',++ 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news',++ 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals',++ 'openvibe.coupons', 'openre.stream', 'open.shoes', 'openvibe.today', 'openvibe.vision', 'openvibe.domains',++ 'openvibe.report', 'openvibe.meme', 'openvibe.casino', 'openvibe.surf', 'openvibe.fun', 'openvibe.tours',++ 'openvibe.exchange', 'openvibe.cam', 'openvibe.design',++];++const RULE = { computer: 32, phone: 24 };++++const args = process.argv.slice(2);++const flag = (name) => args.includes(`--${name}`);++const value = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; };++const widths = (value('width', '') ? [Number(value('width'))] : [1280, 390]).filter((n) => Number.isFinite(n) && n > 0);++const hosts = (value('hosts', '') ? value('hosts', '').split(',') : SITES).map((s) => s.trim()).filter(Boolean);++const footerRule = flag('footer-rule');++const strict = flag('strict');++const out = value('json', '');++const quiet = flag('quiet');++++// What the page answers: where the footer starts, the lowest visible content before it, and the site's own padding++// around the end of the page (the context that says where the dead space comes from).++const MEASURE = `(() => {++ const toFixed = (n) => Math.round(n * 10) / 10;++ const footers = [...document.querySelectorAll('footer, #ov-footer')].filter((f) => {++ const r = f.getBoundingClientRect();++ return r.height > 0 && getComputedStyle(f).display !== 'none' && getComputedStyle(f).visibility !== 'hidden';++ });++ if (!footers.length) return { footer: false };++ const footer = footers[footers.length - 1];++ const footerTop = footer.getBoundingClientRect().top + scrollY;++ let last = 0, who = '';++ for (const el of document.body.querySelectorAll('*')) {++ if (el.closest('footer, #ov-footer, nav, .openvibe-navbar, .ovnav-appbar, .ovmini, .ovdk, .ovdk-pill, script, style, link, meta, noscript, template, [aria-hidden="true"]')) continue;++ const cs = getComputedStyle(el);++ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed' || cs.position === 'sticky') continue;++ const r = el.getBoundingClientRect();++ if (!r.width || !r.height) continue;++ const bottom = r.bottom + scrollY;++ if (bottom > footerTop + 1) continue;++ const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());++ const media = el.matches('img,picture,video,canvas') || [...el.children].some((c) => c.matches('img,picture,video,canvas'));++ if (!ownText && !media) continue;++ if (bottom > last) { last = bottom; who = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/)[0] : ''); }++ }++ const main = document.querySelector('main') || document.body;++ return {++ footer: true, gap: toFixed(Math.max(0, footerTop - last)), who,++ footerTop: toFixed(footerTop), lastBottom: toFixed(last),++ footerMargin: getComputedStyle(footer).marginTop,++ mainPad: getComputedStyle(main).paddingBottom,++ bodyPad: getComputedStyle(document.body).paddingBottom,++ scrollHeight: document.documentElement.scrollHeight,++ };++})()`;++++const ink = (on) => (process.stdout.isTTY ? (s) => `\u001b[${on}m${s}\u001b[0m` : (s) => s);++const dim = ink(2), bold = ink(1), red = ink(31);++++(async () => {++ if (!h.findChrome()) {++ console.log('audit-page-end: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++ }++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-page-end-')) });++ const rows = [];++ try {++ for (const host of hosts) {++ for (const width of widths) {++ const rule = width <= 760 ? RULE.phone : RULE.computer;++ const page = await h.openPage(browser, await browser.newContext(), { width, height: width <= 760 ? 844 : 900 });++ let row;++ try {++ await page.goto(`https://${host}/`, { maxMs: 25000, quietMs: 700 });++ if (footerRule) await page.evaluate(`(() => { const s = document.createElement('style'); s.textContent = ${JSON.stringify('@media (max-width: 760px) { footer.ovf, #ov-footer { margin-top: 24px !important; } } @media (min-width: 761px) { footer.ovf, #ov-footer { margin-top: 32px !important; } }')}; document.head.appendChild(s); })()`);++ const m = await page.evaluate(MEASURE);++ row = { host, width, rule, ...m, over: m.footer ? Math.max(0, m.gap - rule) : null };++ } catch (e) {++ row = { host, width, rule, error: e.message.slice(0, 120) };++ }++ await page.close();++ rows.push(row);++ if (!quiet) {++ if (row.error) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('unreachable')} ${dim(row.error)}`);++ else if (!row.footer) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('no footer found')}`);++ else {++ const over = row.gap > rule + 1;++ console.log(`${host.padEnd(20)} ${String(width).padStart(4)} gap ${String(row.gap).padStart(6)}px rule ${String(rule).padStart(2)}px ${over ? red(`+${row.over}px over`) : bold('ok')} ${dim(`last ${row.who || '?'} · main pad ${row.mainPad} · footer margin ${row.footerMargin}`)}`);++ }++ }++ }++ }++ } finally { await browser.close(); }++ if (out) { fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); fs.writeFileSync(out, JSON.stringify({ generatedAt: new Date().toISOString(), footerRule, widths, rows }, null, 1)); console.log(`audit-page-end: wrote ${out}`); }++ const measured = rows.filter((r) => r.footer && !r.error);++ const over = measured.filter((r) => r.gap > r.rule + 1);++ console.log(`audit-page-end: ${measured.length}/${rows.length} measured, ${over.length} over the rule${footerRule ? ' (Shared rule applied)' : ''}${over.length ? ': ' + [...new Set(over.map((r) => r.host))].join(', ') : ''}`);++ if (strict && over.length) process.exit(1);++})().catch((e) => { console.error('audit-page-end:', e.stack || e.message); process.exit(2); });@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the// owner still saw the old menu hours after the new one shipped (11-07).const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'bottom.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.'ov-wallet.js', 'ov-wallet.css',// 4.189.0: the balances pill's icons and effects.@@@ -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'] },'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'] }, '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'] }, '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)) {@@@ -291,6 +291,8 @@.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }++/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */++@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }.ovs-tabs[hidden] { display: none; }@@@ -306,45 +306,107 @@ const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(objconst faqJsonLd = (items) => jsonLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]).map(([q, a]) => ({ '@type': 'Question', name: String(q), acceptedAnswer: { '@type': 'Answer', text: String(a) } })) });/**-- * Where each product leads next on the network: real, crawlable links with descriptive text, chosen per product so a-- * reader (and a search engine) sees how the sites fit together, not one long list of every domain. [to, what, href].++ * Where each home page leads next on the network: the other places in the same menu group, in the group's own order++ * (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of 2026-11-10), so "More on OpenVibe" shows the++ * same neighbourhood the launcher and the footer show. Up to four. [to, what, href]; `to` is the name and icon the++ * card shows (Email is `email`, OpenRestream `openre`, Studio `studio`, Locker `locker`).*/--const RELATED = {-- live: [['openre', 'Stream to every platform at once', 'https://openre.stream/'], ['video', 'Watch past streams and clips', 'https://openvibe.video/'], ['chat', 'Chat rooms and DMs', 'https://openvibe.chat/'], ['tips', 'Tips and alerts for your stream', 'https://openvibe.tips/']],-- openre: [['live', 'Go live from your browser', 'https://openvibe.live/'], ['video', 'Keep your broadcasts', 'https://openvibe.video/'], ['chat', 'One chat for every platform', 'https://openvibe.chat/']],-- video: [['live', 'Live streams now', 'https://openvibe.live/'], ['pics', 'Pictures, fast and open', 'https://openvibe.pics/'], ['tools', 'Convert and trim media', 'https://openvibe.tools/video-tools']],-- media: [['video', 'Videos, clips and VODs', 'https://openvibe.video/'], ['pics', 'Share pictures', 'https://openvibe.pics/'], ['download', 'Your files, anywhere', 'https://openvibe.download/']],-- chat: [['live', 'Live streams with chat', 'https://openvibe.live/'], ['actor', 'Agents that work while you are away', 'https://openvibe.actor/'], ['community', 'Posts and pastes', 'https://openvibe.community/']],-- tools: [['codes', 'Build apps with AI', 'https://openvibe.codes/'], ['services', 'APIs and SDK for developers', 'https://openvibe.services/'], ['actor', 'Hand a task to an agent', 'https://openvibe.actor/'], ['space', 'Host a site or an app', 'https://openvibe.space/']],-- codes: [['run', 'Run jobs on your own machine', 'https://openvibe.run/'], ['services', 'The SDK and APIs', 'https://openvibe.services/'], ['space', 'Publish what you build', 'https://openvibe.space/']],-- actor: [['tools', 'Hundreds of free tools', 'https://openvibe.tools/'], ['watch', 'Alerts when something changes', 'https://openvibe.watch/'], ['bot', 'Drive a robot from your browser', 'https://openvibe.bot/'], ['codes', 'Coding agents', 'https://openvibe.codes/']],-- services: [['tools', 'Tools with open APIs', 'https://openvibe.tools/developers'], ['space', 'Host apps on the network', 'https://openvibe.space/'], ['network', 'Sign-in for your app', 'https://openvibe.network/']],-- space: [['codes', 'Build it with AI', 'https://openvibe.codes/'], ['services', 'The SDK your app calls', 'https://openvibe.services/'], ['host', 'Hosting, compared', 'https://openvibe.host/']],-- run: [['codes', 'Coding agents', 'https://openvibe.codes/'], ['bot', 'Robots and machines', 'https://openvibe.bot/'], ['actor', 'Agents', 'https://openvibe.actor/']],-- watch: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['work', 'Job alerts', 'https://openvibe.work/'], ['actor', 'An agent to act on alerts', 'https://openvibe.actor/']],-- bot: [['live', 'Stream your robot', 'https://openvibe.live/'], ['actor', 'Let an agent drive', 'https://openvibe.actor/'], ['run', 'Your machines', 'https://openvibe.run/']],-- work: [['watch', 'Alerts for new jobs', 'https://openvibe.watch/'], ['actor', 'An agent on your side', 'https://openvibe.actor/'], ['rent', 'Find a place to live', 'https://openvibe.rent/']],-- rent: [['homes', 'Homes for sale', 'https://openvibe.homes/'], ['work', 'Jobs nearby', 'https://openvibe.work/'], ['food', 'Food near you', 'https://openvibe.food/']],-- homes: [['rent', 'Rentals', 'https://openvibe.rent/'], ['deals', 'Deals', 'https://openvibe.deals/'], ['help', 'Help nearby', 'https://openvibe.help/']],-- food: [['help', 'Help nearby', 'https://openvibe.help/'], ['deals', 'Grocery deals', 'https://openvibe.deals/'], ['coupons', 'Coupons that work', 'https://openvibe.coupons/']],-- deals: [['coupons', 'Coupons that work', 'https://openvibe.coupons/'], ['reviews', 'Reviews with receipts', 'https://openvibe.reviews/'], ['watch', 'Price alerts', 'https://openvibe.watch/']],-- coupons: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['trade', 'Markets and watchlists', 'https://openvibe.trade/']],-- reviews: [['deals', 'Deals', 'https://openvibe.deals/'], ['coupons', 'Coupons', 'https://openvibe.coupons/'], ['host', 'Hosting reviews', 'https://openvibe.host/']],-- trade: [['news', 'News, sourced', 'https://openvibe.news/'], ['watch', 'Market alerts', 'https://openvibe.watch/'], ['deals', 'Deals', 'https://openvibe.deals/']],-- news: [['wiki', 'Knowledge, with sources', 'https://openvibe.wiki/'], ['blog', 'Long-form writing', 'https://openvibe.blog/'], ['community', 'Discuss it', 'https://openvibe.community/']],-- blog: [['news', 'News', 'https://openvibe.news/'], ['wiki', 'Wiki', 'https://openvibe.wiki/'], ['community', 'Community', 'https://openvibe.community/']],-- wiki: [['news', 'News', 'https://openvibe.news/'], ['help', 'Help', 'https://openvibe.help/'], ['blog', 'Blog', 'https://openvibe.blog/']],-- community: [['chat', 'Chat', 'https://openvibe.chat/'], ['blog', 'Blog', 'https://openvibe.blog/'], ['live', 'Live', 'https://openvibe.live/']],-- help: [['food', 'Food near you', 'https://openvibe.food/'], ['wiki', 'Guides', 'https://openvibe.wiki/'], ['community', 'Ask the community', 'https://openvibe.community/']],-- games: [['quest', 'Quests and rewards', 'https://openvibe.quest/'], ['live', 'Stream your game', 'https://openvibe.live/'], ['vip', 'Memberships', 'https://openvibe.vip/']],-- quest: [['games', 'Browser games', 'https://openvibe.games/'], ['vip', 'Memberships', 'https://openvibe.vip/'], ['tips', 'Tips', 'https://openvibe.tips/']],-- tips: [['live', 'Go live', 'https://openvibe.live/'], ['vip', 'Memberships for creators', 'https://openvibe.vip/'], ['openre', 'Restream', 'https://openre.stream/']],-- vip: [['tips', 'Tips and paid messages', 'https://openvibe.tips/'], ['live', 'Live', 'https://openvibe.live/'], ['games', 'Games', 'https://openvibe.games/']],-- host: [['space', 'Host on OpenVibe', 'https://openvibe.space/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['tools', 'Network tools', 'https://openvibe.tools/network-tools']],-- pics: [['video', 'Videos', 'https://openvibe.video/'], ['tools', 'Image tools', 'https://openvibe.tools/image-tools'], ['community', 'Community', 'https://openvibe.community/']],-- network: [['live', 'Live', 'https://openvibe.live/'], ['tools', 'Tools', 'https://openvibe.tools/'], ['chat', 'Chat', 'https://openvibe.chat/'], ['actor', 'Agents', 'https://openvibe.actor/']],++const GROUPS = {++ watch: [++ ['live', 'Go live, watch streams', 'https://openvibe.live/'],++ ['video', 'Clips and past streams', 'https://openvibe.video/'],++ ['pics', 'Share pictures', 'https://openvibe.pics/'],++ ['download', 'Your files', 'https://openvibe.download/'],++ ['openre', 'Stream everywhere', 'https://openre.stream/'],++ ['cam', 'Your camera, in private', 'https://openvibe.cam/'],++ ['tours', 'Cities, routes, stays', 'https://openvibe.tours/'],++ ],++ talk: [++ ['community', 'Posts and pastes', 'https://openvibe.community/'],++ ['chat', 'Chat with anyone', 'https://openvibe.chat/'],++ ['tips', 'Support streamers', 'https://openvibe.tips/'],++ ['vip', 'Back your creators', 'https://openvibe.vip/'],++ ['email', 'Masks and inboxes', 'https://openvibe.email/'],++ ['help', 'Help nearby', 'https://openvibe.help/'],++ ['network', 'Your account', 'https://openvibe.network/'],++ ],++ play: [++ ['games', 'Play in your browser', 'https://openvibe.games/'],++ ['quest', 'Earn badges', 'https://openvibe.quest/'],++ ['surf', 'Surf maps and the web', 'https://openvibe.surf/'],++ ['fun', 'Something to do', 'https://openvibe.fun/'],++ ['meme', 'Memes with receipts', 'https://openvibe.meme/'],++ ['casino', 'Odds shown, chips free', 'https://openvibe.casino/'],++ ['inventory', 'Your items', 'https://openvibe.my/inventory'],++ ],++ read: [++ ['news', 'Sourced, not spun', 'https://openvibe.news/'],++ ['today', 'Your schedule', 'https://openvibe.today/'],++ ['report', 'Your numbers, honest', 'https://openvibe.report/'],++ ['search', 'Search everything', 'https://search.openvibe.network/'],++ ['wiki', 'Facts with sources', 'https://openvibe.wiki/'],++ ['blog', 'Write long-form', 'https://openvibe.blog/'],++ ['vision', 'Ask a picture', 'https://openvibe.vision/'],++ ],++ shop: [++ ['deals', 'Deals people share', 'https://openvibe.deals/'],++ ['coupons', 'Codes that work', 'https://openvibe.coupons/'],++ ['shoes', 'Every shoe, every price', 'https://open.shoes/'],++ ['reviews', 'Honest reviews', 'https://openvibe.reviews/'],++ ['trade', 'Watch the markets', 'https://openvibe.trade/'],++ ['rent', 'Places to rent', 'https://openvibe.rent/'],++ ['work', 'Find a job', 'https://openvibe.work/'],++ ],++ tools: [++ ['tools', 'Every online tool', 'https://openvibe.tools/'],++ ['design', 'Try themes live', 'https://openvibe.design/'],++ ['domains', 'Names and prices', 'https://openvibe.domains/'],++ ['host', 'Compare hosting', 'https://openvibe.host/'],++ ['studio', 'Edit photos in your browser', 'https://openvibe.studio/'],++ ['locker', 'Files only you can open', 'https://openvibe.locker/'],++ ['food', 'Food near you', 'https://openvibe.food/'],++ ],++ make: [++ ['space', 'Host a site or app', 'https://openvibe.space/'],++ ['codes', 'Code with any agent', 'https://openvibe.codes/'],++ ['bot', 'Drive a robot', 'https://openvibe.bot/'],++ ['actor', 'The agent engine', 'https://openvibe.actor/'],++ ['computer', 'Your computer, your way', 'https://openvibe.computer/'],++ ['connect', 'Share a port with Link', 'https://connect.openvibe.link/'],++ ],++ build: [++ ['services', 'APIs, SDK and keys', 'https://openvibe.services/'],++ ['exchange', 'AI models, one account', 'https://openvibe.exchange/'],++ ['run', 'Run jobs anywhere', 'https://openvibe.run/'],++ ['zone', 'S3 storage', 'https://openvibe.zone/'],++ ['events', 'Event streams', 'https://openvibe.events/'],++ ['watch', 'Know when it changes', 'https://openvibe.watch/'],++ ['git', 'Host and browse code', 'https://git.openvibe.codes/'],++ ],};--const DEFAULT_RELATED = RELATED.network;++// The service names home pages pass that are not the card's own name (Video is media-hub, Media is its API,++// Restream's service is openre, Email's mail, Studio's image, Locker's lock, Homes is Rent's section, Bench Report's).++const RELATED_ALIAS = {++ media: 'video', 'media-hub': 'video', video: 'video', stream: 'openre', openre: 'openre', restream: 'openre',++ mail: 'email', email: 'email', image: 'studio', studio: 'studio', lock: 'locker', locker: 'locker',++ homes: 'rent', bench: 'report',++};++const RELATED_ALL = Object.entries(GROUPS).flatMap(([group, members]) => members.map(([to, what, href]) => ({ group, to, what, href })));++const RELATED_BY_ID = new Map(RELATED_ALL.map((x) => [x.to, x]));++function relatedFor(key) {++ const id = RELATED_ALIAS[key] || key;++ const here = RELATED_BY_ID.get(id);++ if (!here) return null;++ return RELATED_ALL.filter((x) => x.group === here.group && x.to !== id).slice(0, 4).map((x) => [x.to, x.what, x.href]);++}++const RELATED = {};++for (const x of RELATED_ALL) RELATED[x.to] = relatedFor(x.to);++for (const [alias, id] of Object.entries(RELATED_ALIAS)) if (RELATED_BY_ID.has(id)) RELATED[alias] = relatedFor(id);++// A product without its own list shows one place from each of the first four groups, in the groups' order.++const DEFAULT_RELATED = ['live', 'community', 'games', 'news'].map((id) => {++ const x = RELATED_BY_ID.get(id);++ return [x.to, x.what, x.href];++});/*** "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same@@@ -676,4 +738,4 @@ html{overflow-x:clip}}`;--module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };++module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++// The bottom of a phone (plan 279, the owner: "That fixed bottom position is kind of buggy … as you scroll up and++// down, so let's make that nice and smooth and perfect across all sites"). A real headless Chrome at 360×740,++// 390×844 and 430×932, with the browser's toolbar sliding away under the page:++//++// • the toolbar is emulated with a visualViewport whose height changes while the layout viewport stays put (the++// shim below; a real toolbar can update the visible viewport before the layout one, which is where a bar at++// bottom: 0 is left behind);++// • the page scrolls up and down through four positions and the toolbar opens and closes;++// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px++// above the bar and the Dock's pill 16 px above the small player, and none of them overlap;++// • the gaps do not jump by more than 1 px between samples.++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('bottom-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const file = (res, name, type) => {++ const p = path.join(ROOT, name);++ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });++ res.end(fs.existsSync(p) ? fs.readFileSync(p) : ''); // the baseline (origin/main) has no bottom.js: the page still loads++};++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bottom</title>++<style>body{margin:0;background:#0b1120;color:#e6edf7;font:15px system-ui;min-height:2600px}main{padding:20px 16px 64px}p{height:38px;margin:0 0 14px}</style></head>++<body><main><h1>Bottom</h1>${'<p>The bar, the small player and the Dock all take their place from one measured number.</p>'.repeat(40)}</main>++<script src="/bottom.js"></script>++<script src="/appbar.js"></script>++<script src="/mini.js"></script>++<script src="/dock.js"></script>++<script>++const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++OpenVibeAppBar.render({++ config: { appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Games', href: '/games', icon: 'fa-gamepad' }, { label: 'Tools', href: '/tools', icon: 'fa-wrench' }] },++ links: [], activePage: null, nav: null,++ navIcon: () => '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="7" fill="currentColor"/></svg>',++ escapeAttr: esc, escapeHtml: esc, flatLinks: () => [],++});++OpenVibeMini.open({ page: '/', src: '/clip.webm', title: 'The harbour at night', by: 'Kim', t: 0, dur: 60, rate: 1, vol: 0, muted: true, playing: false });++OpenVibeDock.mount({ apiBase: location.origin, token: 'tok', user: { id: 7, username: 'ada', display_name: 'Ada' } });++</script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/clip.webm') return file(res, 'test/fixtures/ov-sync-clock.webm', 'video/webm');++ if (u.pathname === '/bottom.js' || u.pathname === '/appbar.js' || u.pathname === '/mini.js' || u.pathname === '/dock.js') return file(res, u.pathname.slice(1), 'application/javascript');++ if (u.pathname === '/api/v1/me/dock') {++ const body = { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 }, sources_partial: false };++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify(body));++ }++ res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' });++ res.end(PAGE);++});++++// A visual viewport the test can shrink without resizing the layout viewport: the toolbar sliding over the page.++const SHIM = `(() => {++ let vh = innerHeight, top = 0;++ const vv = new EventTarget();++ Object.defineProperties(vv, {++ height: { get: () => vh }, width: { get: () => innerWidth },++ offsetTop: { get: () => top }, offsetLeft: { get: () => 0 },++ pageTop: { get: () => top + scrollY }, pageLeft: { get: () => 0 }, scale: { get: () => 1 },++ });++ Object.defineProperty(window, 'visualViewport', { configurable: true, get: () => vv });++ window.__ovvv = {++ set(height, offset) { vh = height; top = offset || 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },++ reset() { vh = innerHeight; top = 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },++ };++})();`;++++const MEASURE = `(() => {++ const px = (n) => Math.round(n * 100) / 100;++ const vv = visualViewport;++ const visibleBottom = vv.offsetTop + vv.height;++ const box = (sel) => {++ const e = document.querySelector(sel);++ if (!e) return null;++ const cs = getComputedStyle(e);++ if (e.hidden || cs.display === 'none' || cs.visibility === 'hidden') return null;++ const r = e.getBoundingClientRect();++ if (!r.width || !r.height) return null;++ return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };++ };++ const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = box('#ovdk-pill');++ const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));++ return {++ visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),++ bar, mini, pill,++ gaps: {++ bar: bar && px(visibleBottom - bar.b),++ mini: (bar && mini) ? px(bar.t - mini.b) : null,++ pill: (mini && pill) ? px(mini.t - pill.b) : null,++ },++ overlap: { barMini: area(bar, mini), barPill: area(bar, pill), miniPill: area(mini, pill) },++ };++})()`;++++const SIZES = [[360, 740], [390, 844], [430, 932]];++const SCROLLS = [0, 900, 1800, 500];++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-bottom-')) });++ const report = [];++ let failed = 0;++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ for (const [width, height] of SIZES) {++ const page = await h.openPage(browser, await browser.newContext(), { width, height });++ try {++ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });++ await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });++ const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };++ assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)') && !!document.querySelector('#ovdk-pill') && !document.querySelector('#ovdk-pill').hidden`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);++ await wait(450); // the small player's entrance and the Dock's first read++ const samples = [];++ for (const [i, y] of SCROLLS.entries()) {++ for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {++ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, ${y}); 0`);++ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');++ await wait(90);++ const m = await page.evaluate(MEASURE);++ m.y = y; m.toolbar = label; m.i = i;++ samples.push(m);++ }++ }++ // The toolbar sliding while the page is still: the same samples, for the jump.++ for (const visible of [height, height - 56, height - 56, height]) {++ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, 900); 0`);++ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');++ await wait(90);++ samples.push({ ...(await page.evaluate(MEASURE)), y: 900, toolbar: 'slide', i: 'slide' });++ }++++ const failures = [];++ const check = (ok, text) => { if (!ok) failures.push(text); };++ for (const m of samples) {++ const at = `${m.y}px/${m.toolbar}`;++ check(!!m.bar, `${at}: the app bar is on the page`);++ check(!!m.mini, `${at}: the small player is on the page`);++ check(!!m.pill, `${at}: the Dock's pill is on the page`);++ if (!m.bar || !m.mini || !m.pill) continue;++ check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);++ check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);++ check(Math.abs(m.overlap.barPill) === 0, `${at}: the bar and the Dock overlap by ${m.overlap.barPill}px²`);++ check(Math.abs(m.overlap.miniPill) === 0, `${at}: the small player and the Dock overlap by ${m.overlap.miniPill}px²`);++ check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);++ check(Math.abs(m.gaps.pill - 16) <= 1, `${at}: the Dock sits ${m.gaps.pill}px above the small player (16)`);++ }++ const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };++ check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);++ check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);++ check(spread('pill') <= 1, `${width}x${height}: the Dock jumps ${spread('pill')}px between samples`);++ const barGaps = samples.map((m) => m.gaps.bar);++ const miniGaps = samples.map((m) => m.gaps.mini);++ const pillGaps = samples.map((m) => m.gaps.pill);++ report.push({++ size: `${width}x${height}`, samples: samples.length,++ bar: [Math.min(...barGaps), Math.max(...barGaps)],++ mini: [Math.min(...miniGaps), Math.max(...miniGaps)],++ pill: [Math.min(...pillGaps), Math.max(...pillGaps)],++ overlap: Math.max(...samples.map((m) => m.overlap.barMini + m.overlap.barPill + m.overlap.miniPill)),++ });++ if (failures.length) {++ failed++;++ console.error(`${width}x${height}: ${failures.length} failure(s)`);++ for (const f of failures.slice(0, 6)) console.error(` - ${f}`);++ }++ } finally { await page.close(); }++ }++ } finally { await browser.close(); }++ for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, pill ${r.pill[0]}..${r.pill[1]}px, overlap ${r.overlap}px²`);++ if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }++ console.log('bottom-chrome: the bar, the small player and the Dock hold the bottom at every size and toolbar state');++ server.close();++})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });@@@ -82,7 +82,15 @@ p.L.display.set({ icons: 'auto' }, { local: true });assert.strictEqual(p.html.getAttribute('data-ov-icons'), null);p = load({ store: new Map([['ov_display', JSON.stringify({ icons: 'line' })]]) });assert.strictEqual(p.html.getAttribute('data-ov-icons'), 'line', 'a saved preference paints on the next page');--assert.ok(/'data-ov-icons'\]/.test(SRC) || SRC.includes("'data-ov-oled', 'data-ov-icons']"), 'the preference travels with the look (PAINT_ATTRS)');++assert.ok(/'data-ov-icons'[,\]]/.test(SRC) && /const PAINT_ATTRS = \[[^\]]*'data-ov-icons'/.test(SRC), 'the preference travels with the look (PAINT_ATTRS)');++// Windows (4.202.0, asks 304 and 318): the person's window style is a display setting too, painted at boot.++p.L.display.set({ window: 'outline' }, { local: true });++assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline');++p.L.display.set({ window: 'neon' }, { local: true });++assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline', 'an unknown window style changes nothing');++p.L.display.set({ window: 'glass' }, { local: true });++assert.strictEqual(p.html.getAttribute('data-ov-window'), null, 'glass (the default) paints nothing');++assert.ok(/const PAINT_ATTRS = \[[^\]]*'data-ov-window'/.test(SRC), 'the window style travels with the look');const glyphCss = require('fs').readFileSync(require('path').join(__dirname, '..', 'ov-glyphs.js'), 'utf8');assert.ok(glyphCss.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the glyphs read the preference');assert.ok(glyphCss.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'the person\'s preference wins over an area\'s switch point');@@@ -41,7 +41,7 @@ const server = http.createServer((req, res) => {res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#111;color:#eee;font-family:sans-serif"><div id="part" style="width:380px"></div><script src="/dock-code.js"></script><script>window.signedIn = ${u.searchParams.get('out') ? 'false' : 'true'};--window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true });</script>`);++window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true, take: () => { const t = window.handed || ''; window.handed = ''; return t; } });</script>`);});(async () => {await new Promise((ok) => server.listen(0, '127.0.0.1', ok));@@@ -82,6 +82,14 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (assert.deepEqual(b, { text: '', note: 'Started. Follow it on openvibe.codes', link: 'https://openvibe.codes/tasks/t3' });assert.equal((await rows())[0].s, 'Waiting', 'a queued task says it is waiting');++ // Vibe's Build it in Code: the words another part hands over go in the box, ready to start.++ await page.evaluate(`window.handed = 'Build a goal bar for my sub goal'; window.part.refresh()`);++ b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);++ assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');++ await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);++ assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);++ await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);++// Stop.await page.evaluate(`[...document.querySelectorAll('.ovdc-task')].find((li) => li.textContent.includes('goal bar')).querySelector('.ovdc-stop').click()`);assert.ok(await until(`[...document.querySelectorAll('.ovdc-task')].some((li) => li.textContent.includes('goal bar') && li.textContent.includes('Stopped'))`), 'Stop stops it');@@@ -21,7 +21,7 @@ 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 = 21040; // 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 = 21100; // 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'];// ── the budget ──────────────────────────────────────────────@@@ -38,4 +38,12 @@ assert.ok(phone.length < css.length, 'the phone block exists');assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);++// An untitled or empty group is never a fold (4.202.0's screenshots showed two "0 links" folds with no heading).++{++ const empty = footer.buildHTML({ service: 'live', variant: 'full', links: [{ heading: '', items: [] }, { heading: 'Nothing', items: [] }, { heading: '', items: [{ name: 'X', url: '/x' }] }, ...links] });++ assert.ok(!/<h3><\/h3>/.test(empty), 'no fold without a heading');++ assert.ok(!/>0 links</.test(empty), 'no fold with no links');++ assert.strictEqual((empty.match(/<details class="ovf-fold"/g) || []).length, folds.length, 'only the real columns are folds');++}++console.log('footer-folds: all checks passed');@@@ -1,8 +1,9 @@'use strict';--// ov-fx.js (4.66.0, plan L45): the network's effects by data-fx. Each name wires its class or listener; a reveal hides--// only what is below the fold and shows it when it comes on screen; a number counts up and ends on its own text;--// under reduced motion or Calm nothing is hidden, nothing counts and no confetti falls; markup added later is wired;--// the navbar loads it only for a page that asks. linkedom with a fake IntersectionObserver and matchMedia.++// ov-fx.js (4.66.0, plan L45; the reveal remade for 278): the network's effects by data-fx. Each name wires its class++// or listener; a reveal gives its heading, rule and cards a start state only when they are below the fold and then++// shows them once — with the scroll timeline where view() exists (no script on the main thread while scrolling) and++// one shared IntersectionObserver otherwise. Calm keeps a short fade; Off shows everything at once. linkedom with a++// fake IntersectionObserver, matchMedia and CSS.supports.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -10,29 +11,32 @@ const { parseHTML } = require('linkedom');const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');--function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {++function setup({ reduce = false, calm = false, off = false, timeline = false, html = '' } = {}) {const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${off ? ' data-ov-motion="off"' : calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);const observed = [];class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }let io = null;const frames = [];++ const timers = [];window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };window.MutationObserver = MutationObserver;++ window.CSS = { supports: (q) => !!timeline && /animation-timeline/.test(q) };window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });window.innerHeight = 800;window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };window.cancelAnimationFrame = () => {};-- window.setTimeout = (fn) => { fn(); return 0; };++ window.setTimeout = (fn) => { timers.push(fn); return timers.length; };window.performance = { now: () => 0 };for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });window.OVFx = undefined; // linkedom windows share their expando propertiesnew Function('window', SRC)(window);const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };-- return { window, document, io: () => io, observed, run };++ const flush = () => { let n = 0; while (timers.length && n++ < 500) timers.shift()(); };++ return { window, document, io: () => io, observed, run, flush };}(async () => {-- // ── names wire their classes; the CSS carries the motion tokens and the reduced-motion guards ──++ // ── names wire their classes; the CSS carries the motion tokens and the Off/Calm guards ──{const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });const a = t.document.getElementById('a');@@@ -43,7 +47,7 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-move:\.35;--ov-dur-1:\.15s;[^}]*--ov-ease-spring:var\(--ov-ease-out\)\}/, 'Calm: a third of the distance, the calm lengths, no overshoot; never zero');assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{html:not\(\[data-ov-motion\]\)\{--ov-move:\.35;/, 'the same when the device asks and no loader has spoken');assert.match(css, /html\[data-ov-motion=off\]\{--ov-move:0;--ov-dur-1:0s/, 'only Off zeroes them');-- assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'), 'distances are multiplied by --ov-move');++ assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px))'), 'distances are multiplied by --ov-move');assert.ok(css.includes('html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}'), 'Calm keeps the glint, fainter and slower');assert.ok(css.includes('html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'motion off stops the glint, outside a demo scope');assert.ok(!/data-ov-motion=reduced\][^{]*\{[^}]*(animation|transition):none/.test(css), 'nothing is stopped under Calm');@@@ -57,47 +61,110 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {assert.ok(css.indexOf('html[data-ov-motion=reduced]{--ov-move:.35') > css.indexOf('html:is([data-theme=neon-tokyo]'), 'Calm comes last, so it wins over any feel');}-- // ── reveal: below the fold it waits for the screen; on screen it is never hidden ──++ // ── the reveal's CSS (278): heading out of a blur, rule from the left, six card steps, scroll timeline first ──{-- const t = setup({ html: '<ul id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400"><li>a</li><li>b</li><li>c</li></ul><ul id="top" data-fx="reveal" data-top="100"><li>x</li></ul>' });++ const t = setup();++ const css = t.document.getElementById('ov-fx-css').textContent;++ assert.ok(css.includes('--ovfx-rise:12px') && css.includes('@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}'), '12 px on a computer, 8 on a phone');++ assert.ok(css.includes('@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}'), 'the heading rises and sharpens from 4 px of blur');++ assert.ok(css.includes('@keyframes ovfxRuleDraw{from{transform:scaleX(0)'), 'the rule is drawn, not faded in');++ assert.ok(css.includes('.ovfx-rule{transform-origin:left center}'), 'from the left');++ assert.ok(css.includes('@supports (animation-timeline:view())'), 'the scroll timeline, where the browser has it');++ assert.ok(css.includes('animation-timeline:view();animation-range:entry 0% cover 25%'), 'the heading is driven by the scroll position');++ assert.ok(css.includes('animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)'), 'the cards step by index');++ assert.ok(css.includes('animation:ovfxHeadRise linear backwards'), 'backwards fill: once past the range the page\'s own styles are back, and nothing is filled forward');++ assert.ok(!/\.ovfx-head\{[^}]*opacity:0/.test(css), 'content is visible by default; only a start state the script or @supports adds hides it');++ assert.ok(css.includes('html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn'), 'Calm keeps only the fade');++ assert.ok(css.includes('html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important'), 'Off removes the motion');++ }++++ // ── reveal: below the fold it waits for the screen; heading, rule and cards step; on screen it is never hidden ──++ {++ const html = `<section id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400">++ <h2 id="h" data-top="1400">Live Now</h2><span id="r" data-fx-rule data-top="1400"></span>++ <div class="grid" id="grid">${[1, 2, 3, 4, 5, 6, 7].map((n) => `<a class="card" data-top="${1500 + n}">${n}</a>`).join('')}</div>++ </section><section id="top" data-fx="reveal" data-top="100"><h2 data-top="100">x</h2><p data-top="100">y</p></section>`;++ const t = setup({ html });const low = t.document.getElementById('low'), top = t.document.getElementById('top');-- assert.ok([...low.children].every((k) => k.classList.contains('ovfx-hide')), 'below the fold: hidden until seen');-- assert.strictEqual(low.children[2].style.getPropertyValue('--ovfx-d'), '80ms', 'staggered');-- assert.ok(![...top.children].some((k) => k.classList.contains('ovfx-hide')), 'on screen when wired: never hidden');-- assert.ok(t.observed.includes(low) && !t.observed.includes(top));-- t.io().fire(low);-- assert.ok([...low.children].every((k) => !k.classList.contains('ovfx-hide')), 'seen: shown');++ const head = t.document.getElementById('h'), rule = t.document.getElementById('r');++ const cards = [...t.document.querySelectorAll('#grid > a')];++ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-wait'), 'the heading has a start state');++ assert.ok(rule.classList.contains('ovfx-rule') && rule.classList.contains('ovfx-wait'), 'the rule has one too');++ assert.ok(cards.every((k) => k.classList.contains('ovfx-card') && k.classList.contains('ovfx-wait')), 'so do the cards');++ assert.ok(!t.document.getElementById('grid').classList.contains('ovfx-card'), 'the grid itself is not a card');++ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-d')), ['140ms', '180ms', '220ms', '260ms', '300ms', '340ms', '340ms'], '40 ms apart after the heading, at most six, the rest together');++ assert.strictEqual(t.observed.filter((x) => x === low).length, 0, 'the section is not observed as a whole: its parts are');++ assert.strictEqual(t.observed.length, 9, 'heading, rule and seven cards wait with the one observer');++ assert.ok(t.io() instanceof t.window.IntersectionObserver, 'one shared observer');++ assert.ok(![...top.querySelectorAll('*')].some((k) => k.classList.contains('ovfx-wait')), 'on screen when wired: never hidden');++ t.io().fire(head);++ assert.ok(head.classList.contains('ovfx-in'), 'seen: the transition runs');++ t.flush();++ assert.ok(!head.classList.contains('ovfx-wait') && !head.classList.contains('ovfx-in'), 'and the start and transition classes are gone, so the page own styles are back');++ t.io().fire(cards[2]);++ assert.ok(cards[2].classList.contains('ovfx-in'), 'a card runs its own transition');}-- // ── count: from 0 up to the number, ending on its own text ──++ // ── a section already on screen only waits for the parts that are below it ──{-- const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });-- const n = t.document.getElementById('n');-- t.io().fire(n);-- t.run(100);-- assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');-- assert.match(n.textContent, / viewers$/, 'keeps its words');-- t.run(5000);-- assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');-- t.run(5000);-- assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');-- assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');++ const t = setup({ html: '<section data-fx="reveal" data-top="100"><h2 id="h" data-top="100">top</h2><a id="c" data-top="1400">card</a></section>' });++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');++ assert.ok(!h.classList.contains('ovfx-head'), 'the heading someone can see is left alone');++ assert.ok(c.classList.contains('ovfx-card') && c.classList.contains('ovfx-wait'), 'the card below the fold waits');++ assert.deepStrictEqual(t.observed, [c], 'only that card is observed');++ }++++ // ── data-fx-self reveals the element itself, not its children ──++ {++ const t = setup({ html: '<h2 id="h" data-fx="reveal" data-fx-self data-top="1400">Title</h2>' });++ const h = t.document.getElementById('h');++ assert.ok(h.classList.contains('ovfx-head') && h.classList.contains('ovfx-wait'), 'the element itself has the start state');++ assert.strictEqual(t.observed.length, 1, 'one element waits for the screen');++ }++++ // ── a rule only when the section has one; cards can be named when the markup is not a list or grid ──++ {++ const t = setup({ html: '<div data-fx="reveal" data-fx-cards=".pick" data-top="1400"><h2 data-top="1400">x</h2><p data-top="1400">lede</p><div><a class="pick" data-top="1400">1</a><a class="pick" data-top="1400">2</a></div></div>' });++ assert.strictEqual(t.document.querySelector('.ovfx-rule'), null, 'no rule: nothing is drawn');++ assert.deepStrictEqual([...t.document.querySelectorAll('.pick')].map((k) => k.classList.contains('ovfx-card')), [true, true], 'the named cards are the cards');++ assert.ok(!t.document.querySelector('p').classList.contains('ovfx-card'), 'the lede is not a card when the cards are named');++ }++++ // ── with the scroll timeline the browser drives it: no observer and no script while scrolling ──++ {++ const t = setup({ timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><div class="grid">' + [1, 2, 3, 4, 5, 6, 7].map((n) => `<a data-top="${1500 + n}">${n}</a>`).join('') + '</div></section>' });++ const head = t.document.getElementById('h'), cards = [...t.document.querySelectorAll('.grid > a')];++ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-scroll'), 'the heading rides the scroll timeline');++ assert.ok(!head.classList.contains('ovfx-wait'), 'no start class: the keyframes hold the start state');++ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-i')), ['0', '1', '2', '3', '4', '5', '5'], 'six steps; the rest share the last');++ assert.strictEqual(t.observed.length, 0, 'no observer is used, so no script runs on the main thread while scrolling');}-- // ── Calm (the setting, or the device asking for less): everything still plays, a third as far ──++ // ── Calm (the setting, or the device asking for less): the reveal keeps only a short fade ──for (const opts of [{ reduce: true }, { calm: true }]) {-- const t = setup({ ...opts, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });-- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 1, 'the reveal waits to play');-- assert.strictEqual(t.observed.length, 2, 'the reveal and the number both wait for the screen');-- assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance');++ const t = setup({ ...opts, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><a id="c" data-top="1400">card</a></section>' });++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');++ assert.ok(h.classList.contains('ovfx-fade') && !h.classList.contains('ovfx-head'), 'the heading only fades');++ assert.ok(c.classList.contains('ovfx-fade') && !c.classList.contains('ovfx-card'), 'the card only fades');++ assert.strictEqual(h.style.getPropertyValue('--ovfx-d'), '', 'no rise or stagger delay');++ assert.strictEqual(t.observed.length, 3, 'the fade still waits for the screen');++ assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance for everything else');++ }++++ // ── the same fade on the scroll timeline when the browser has it ──++ {++ const t = setup({ reduce: true, timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><a id="c" data-top="1400">card</a></section>' });++ const h = t.document.getElementById('h'), c = t.document.getElementById('c');++ assert.ok(h.classList.contains('ovfx-fade') && h.classList.contains('ovfx-scroll'), 'the heading fades on the scroll timeline');++ assert.ok(c.classList.contains('ovfx-fade') && c.classList.contains('ovfx-scroll'), 'the card too');++ assert.strictEqual(t.observed.length, 0, 'still no observer');}-- assert.strictEqual(setup().window.OVFx.move(), 1, 'full motion: the whole distance');// ── motion off: nothing hidden, nothing counted, no confetti ──{-- const t = setup({ off: true, reduce: true, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });++ const t = setup({ off: true, reduce: true, html: '<section data-fx="reveal" data-top="1400"><h2 data-top="1400">x</h2><a data-top="1400">card</a></section><b id="n" data-fx="count" data-top="1400">900</b>' });assert.strictEqual(t.window.OVFx.move(), 0);-- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 0, 'nothing hidden');++ assert.strictEqual(t.document.querySelectorAll('.ovfx-wait,.ovfx-fade,.ovfx-head,.ovfx-card').length, 0, 'nothing hidden and nothing staged');assert.strictEqual(t.observed.length, 0, 'nothing waits to count');assert.strictEqual(t.document.getElementById('n').textContent, '900');assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');@@@ -105,12 +172,27 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {// ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──{-- const t = setup({ off: true, html: '<div data-fx-demo><ul id="in" data-fx="reveal" data-top="1400"><li>a</li></ul></div><ul id="out" data-fx="reveal" data-top="1400"><li>b</li></ul>' });-- assert.strictEqual(t.document.querySelectorAll('#in .ovfx-hide').length, 1, 'inside the scope the reveal waits to play');-- assert.strictEqual(t.document.querySelectorAll('#out .ovfx-hide').length, 0, 'outside it nothing is hidden: the setting holds for the rest of the page');++ const t = setup({ off: true, timeline: true, html: '<div data-fx-demo><section id="in" data-fx="reveal" data-top="1400"><h2 data-top="1400">a</h2></section></div><section id="out" data-fx="reveal" data-top="1400"><h2 data-top="1400">b</h2></section>' });++ assert.ok(t.document.querySelector('#in h2').classList.contains('ovfx-scroll'), 'inside the scope the reveal plays in full');++ assert.strictEqual(t.document.querySelectorAll('#out .ovfx-scroll').length, 0, 'outside it the setting holds');assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');}++ // ── count: from 0 up to the number, ending on its own text ──++ {++ const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });++ const n = t.document.getElementById('n');++ t.io().fire(n);++ t.run(100);++ assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');++ assert.match(n.textContent, / viewers$/, 'keeps its words');++ t.run(5000);++ assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');++ t.run(5000);++ assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');++ assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');++ }++// ── markup added later is wired ──{const t = setup();@@@ -129,5 +211,5 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');}-- console.log('fx: names wire their effects; reveal waits below the fold and never hides what is on screen; numbers end on their own text; Calm plays everything a third as far; only motion off keeps still; later markup wired; loaded only on request');++ console.log('fx: names wire their effects; the reveal stages a heading out of a blur, its rule from the left and six card steps, on the scroll timeline or one observer; Calm fades; only motion off keeps still; later markup wired; loaded only on request');})().catch((e) => { console.error(e); process.exit(1); });@@@ -97,8 +97,8 @@ const server = http.createServer((req, res) => {await openTab(p, 'you');assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');const first = await order(p);-- assert.strictEqual(first.length, 12, `twelve on Home by default (${first})`);-- assert.deepStrictEqual(first.slice(-2), ['folder:tools', 'folder:build'], 'Tools and Build are folders at the end');++ assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);++ assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');@@@ -132,7 +132,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);const removed = await order(p);-- assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');++ assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');// Done, then All apps puts it back.await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++// The one dropdown for a field (ov-combo.js, 4.202.0; ask 348) in a real headless Chrome against a stub source: the++// field becomes a combobox (no datalist); focus shows the sections, typing asks again (the earlier answer is not++// painted over the newer), the match is marked, the arrows and Enter pick and the address goes in; a name nobody has++// offers "Create … on OpenVibe Git", which posts the name and puts the new address in; a source that fails says so and++// the field still takes a pasted address; Escape closes; the list stays on screen near the bottom (it opens upward).++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-combo-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const REPOS = [++ { label: 'goosely/goal-bar', url: 'https://git.openvibe.codes/goosely/goal-bar.git', host: 'git.openvibe.codes', private: true },++ { label: 'goosely/site', url: 'https://git.openvibe.codes/goosely/site.git', host: 'git.openvibe.codes' },++ { label: 'goosely/overlay-kit', url: 'https://github.com/goosely/overlay-kit.git', host: 'github.com' },++];++const asked = [];++let failing = false;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/ov-combo.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-combo.js'))); }++ if (u.pathname === '/repos') {++ const q = (u.searchParams.get('q') || '').toLowerCase();++ asked.push(q);++ const answer = () => {++ if (failing) { res.writeHead(500); return res.end(); }++ const m = (r) => !q || r.label.toLowerCase().includes(q);++ res.writeHead(200, { 'content-type': 'application/json' });++ res.end(JSON.stringify({ sections: [{ id: 'recent', label: 'Recent', items: REPOS.slice(0, 1).filter(m) }, { id: 'git', label: 'On OpenVibe Git', items: REPOS.slice(1, 2).filter(m) }, { id: 'github', label: 'On GitHub', items: REPOS.slice(2).filter(m) }], create: { allowed: true, host: 'git.openvibe.codes', owner: 'goosely' } }));++ };++ // "go" answers late: a later query must not be painted over by it.++ return q === 'go' ? setTimeout(answer, 600) : answer();++ }++ if (u.pathname === '/create' && req.method === 'POST') {++ let body = ''; req.on('data', (c) => { body += c; });++ req.on('end', () => { const b = JSON.parse(body); res.writeHead(201, { 'content-type': 'application/json' }); res.end(JSON.stringify({ url: `https://git.openvibe.codes/goosely/${b.name}.git`, label: `goosely/${b.name}`, private: b.private })); });++ return;++ }++ 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;background:#111;color:#eee;font-family:sans-serif">++<form id="f" style="padding:40px 16px"><input id="repo" name="repo" list="old" aria-label="Repository" style="width:100%;height:40px" data-ov-combo="/repos" data-ov-combo-create="/create"><datalist id="old"></datalist></form>++<input id="low" data-ov-combo="/repos" aria-label="Low" style="position:fixed;bottom:8px;left:16px;width:300px;height:36px">++<script>window.picks = []; document.addEventListener('ov-combo:pick', (e) => window.picks.push(e.detail.url));</script><script src="/ov-combo.js"></script>`);++});++(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-combo-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });++ await page.goto(base + '/', { maxMs: 5000 });++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };++ const key = async (k) => { const codes = { Enter: 13, ArrowDown: 40, ArrowUp: 38, Escape: 27 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k] }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };++ const view = () => page.evaluate(`(() => { const p = document.querySelector('.ovcombo'); if (!p || p.hidden) return null; return { secs: [...p.querySelectorAll('.ovcombo-sec')].map((x) => x.textContent), rows: [...p.querySelectorAll('.ovcombo-i b')].map((x) => x.textContent), sel: [...p.querySelectorAll('.ovcombo-i')].findIndex((x) => x.getAttribute('aria-selected') === 'true'), marks: [...p.querySelectorAll('.ovcombo-i b mark')].map((x) => x.textContent), none: (p.querySelector('.ovcombo-none, .ovcombo-err') || {}).textContent || '' }; })()`);++++ const a = await page.evaluate(`({ role: repo.getAttribute('role'), list: repo.getAttribute('list'), exp: repo.getAttribute('aria-expanded'), ac: repo.getAttribute('autocomplete') })`);++ assert.deepEqual(a, { role: 'combobox', list: null, exp: 'false', ac: 'off' }, 'a combobox, the datalist gone');++++ await page.evaluate(`repo.focus()`);++ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden]) .ovcombo-i')`), 'focus shows the list');++ let v = await view();++ assert.deepEqual(v.secs, ['Recent', 'On OpenVibe Git', 'On GitHub']);++ assert.deepEqual(v.rows, ['goosely/goal-bar', 'goosely/site', 'goosely/overlay-kit']);++ assert.equal(await page.evaluate(`repo.getAttribute('aria-expanded')`), 'true');++++ // A slow answer for "go" must not paint over the answer for "goal".++ await page.send('Input.insertText', { text: 'go' });++ await wait(250);++ await page.send('Input.insertText', { text: 'al' });++ assert.ok(await until(`(() => { const p = document.querySelector('.ovcombo'); return p && !p.hidden && p.querySelectorAll('.ovcombo-i').length && [...p.querySelectorAll('.ovcombo-i b')][0].textContent === 'goosely/goal-bar'; })()`));++ await wait(700);++ v = await view();++ assert.deepEqual(v.rows, ['goosely/goal-bar', 'Create “goal” on OpenVibe Git'], 'only what matches "goal", and a Create row');++ assert.deepEqual(v.marks, ['goal'], 'the match is marked');++ assert.equal(v.sel, 0, 'the first row is ready for Enter');++ await key('Enter');++ assert.equal(await page.evaluate('repo.value'), 'https://git.openvibe.codes/goosely/goal-bar.git', 'Enter put the address in');++ assert.equal(await view(), null, 'and closed the list');++ assert.deepEqual(await page.evaluate('window.picks'), ['https://git.openvibe.codes/goosely/goal-bar.git']);++++ // A new name: Create … on OpenVibe Git.++ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);++ await page.send('Input.insertText', { text: 'sub-goal-bar' });++ assert.ok(await until(`[...document.querySelectorAll('.ovcombo .ovcombo-i b')].some((b) => b.textContent === 'Create “sub-goal-bar” on OpenVibe Git')`), 'a name nobody has can be created');++ v = await view();++ assert.match(await page.evaluate(`document.querySelector('.ovcombo .is-create small').textContent`), /Private, under @goosely/);++ await key('ArrowDown');++ await key('ArrowUp');++ await key('Enter');++ assert.ok(await until(`repo.value === 'https://git.openvibe.codes/goosely/sub-goal-bar.git'`), 'the new repository went in');++ assert.equal(await page.evaluate('window.picks.length'), 2);++++ // Escape closes; ArrowDown opens again.++ await key('ArrowDown');++ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden])')`));++ await key('Escape');++ assert.equal(await view(), null, 'Escape closes it');++++ // A source that fails says so; a pasted address still stands.++ failing = true;++ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);++ await page.send('Input.insertText', { text: 'zz' });++ assert.ok(await until(`/did not load/.test((document.querySelector('.ovcombo-err') || {}).textContent || '')`), 'a failed source says the list did not load');++ failing = false;++ assert.equal(await page.evaluate('repo.value'), 'zz');++++ // Near the bottom, it opens upward and stays on screen.++ await page.evaluate(`document.getElementById('low').focus()`);++ assert.ok(await until(`!!document.querySelector('.ovcombo.is-up:not([hidden]) .ovcombo-i')`), 'it opens upward near the bottom');++ const r = await page.evaluate(`(() => { const b = document.querySelector('.ovcombo').getBoundingClientRect(); return { top: b.top, bottom: b.bottom, left: b.left, right: b.right, vw: innerWidth, vh: innerHeight }; })()`);++ assert.ok(r.top >= 0 && r.bottom <= r.vh && r.left >= 8 && r.right <= r.vw - 8, `on screen ${JSON.stringify(r)}`);++ await page.close();++ console.log('ov-combo-chrome: a combobox (no datalist), sections, a slow answer not painted over a newer one, marks, Enter picks, Create on OpenVibe Git, Escape, a failed source, upward near the bottom');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -22,7 +22,7 @@ const form = thread.composer({});const server = http.createServer((req, res) => {const u = req.url.split('?')[0];-- const file = { '/ov-composer.js': 'application/javascript', '/thread.css': 'text/css' }[u];++ const file = { '/ov-composer.js': 'application/javascript', '/ov-window.js': 'application/javascript', '/thread.css': 'text/css' }[u];if (file) { res.writeHead(200, { 'content-type': file }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }if (u === '/send') { res.writeHead(204); return res.end(); }res.writeHead(200, { 'content-type': 'text/html' });@@@ -89,6 +89,12 @@ const server = http.createServer((req, res) => {await page.evaluate(`document.querySelector('#c1 [data-ovc-back]').click()`);assert.ok((await val()).startsWith('line 0\nline 1'), '↩ puts it back in the box');assert.equal(await page.evaluate(`document.querySelectorAll('#c1 .ovc-file').length`), 0);++ // A box that takes only pictures keeps a long paste as text (it is not turned into a file it would refuse).++ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.dataset.acc = i.getAttribute('accept'); i.setAttribute('accept', 'image/*'); })()`);++ f = await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); const dt = new DataTransfer(); dt.setData('text/plain', 'x\\n'.repeat(200));++ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); t.dispatchEvent(ev); return { prevented: ev.defaultPrevented, chips: document.querySelectorAll('#c1 .ovc-file').length }; })()`);++ assert.deepEqual(f, { prevented: false, chips: 0 }, 'a pictures-only box lets the long paste in as text');++ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.setAttribute('accept', i.dataset.acc); })()`);await clear();// "/": the site's commands, then settings; the arrows and Enter pick one, and Enter does not send.@@@ -179,12 +185,13 @@ const server = http.createServer((req, res) => {await type('/settings');await until(`!document.querySelector('#c1 .ovc-pop').hidden`);await key('Enter');-- assert.ok(await until(`!!document.querySelector('dialog.ovc-set[open]')`), '/settings opens your composer settings');-- f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);++ assert.ok(await until(`!!document.querySelector('.ovw .ovc-set-in')`), '/settings opens your composer settings, in the one window (ov-window.js, loaded from beside the composer)');++ assert.equal(await page.evaluate(`document.querySelector('.ovw .ovw-t').textContent`), 'Your composer');++ f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovw .ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);assert.deepEqual(f, { mine: ['/sig'], box: '' }, 'the sheet lists your commands; the /settings word left the box');// The count near the limit (600: it shows from 200 left).-- await page.evaluate(`document.querySelector('dialog.ovc-set').close()`);++ await page.evaluate(`OVWindow.closeAll()`);await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); t.value = 'x'.repeat(590); t.dispatchEvent(new Event('input', { bubbles: true })); })()`);f = await page.evaluate(`({ shown: !document.querySelector('#c1 .ovc-count').hidden, text: document.querySelector('#c1 .ovc-count').textContent, low: document.querySelector('#c1 .ovc-count').classList.contains('is-low') })`);assert.deepEqual(f, { shown: true, text: '10 left', low: true });@@@ -24,7 +24,17 @@ global.window = undefined;const sandbox = {};new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});const H = sandbox.OpenVibeHome;--const apps = ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot'].map((id) => ({ id, group: 'play' })).concat([{ id: 'ai', group: 'build' }]);--assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot', 'folder:tools', 'folder:build'], 'signed out: ten popular places and the two folders');++const apps = [++ ...['live', 'video', 'pics', 'stream', 'cam'].map((id) => ({ id, group: 'watch' })),++ ...['community', 'chat', 'tips', 'mail'].map((id) => ({ id, group: 'talk' })),++ ...['games', 'quest', 'surf', 'fun'].map((id) => ({ id, group: 'play' })),++ ...['news', 'today', 'report', 'wiki'].map((id) => ({ id, group: 'read' })),++ ...['deals', 'coupons', 'rent', 'work'].map((id) => ({ id, group: 'shop' })),++ ...['tools', 'design', 'domains', 'host'].map((id) => ({ id, group: 'tools' })),++ ...['space', 'codes', 'bot', 'actor'].map((id) => ({ id, group: 'make' })),++ ...['services', 'exchange', 'run'].map((id) => ({ id, group: 'build' })),++];++// Ten popular singles (one left in every group for its folder), then the menu's eight groups in its order.++assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'video', 'pics', 'stream', 'community', 'chat', 'tips', 'games', 'quest', 'surf', 'folder:g-watch', 'folder:g-talk', 'folder:g-play', 'folder:g-read', 'folder:g-shop', 'folder:g-tools', 'folder:g-make', 'folder:build', 'folder:tools'], 'signed out: ten popular places and one folder per group, in the menu\'s order');assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++const assert = require('assert');++const { parseHTML } = require('linkedom');++const OVUser = require('../ov-user');++const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');++const host = document.getElementById('host');++const person = { username: 'Example', display_name: '<img src=x onerror=alert(1)>', avatar_url: 'https://openvibe.network/avatar/Example?s=160', profile_url: 'https://openvibe.network/@Example', channel_url: 'https://openvibe.live/@Example', level: 4, streak: 3, badges_count: 0, followers: 12, live: { now: true, site: 'live', url: 'https://openvibe.live/@Example' }, active_sites: [{ id: 'live', name: '<Live>', url: 'https://openvibe.live' }] };++const event = (type, extras = {}) => { const e = new window.Event(type, { bubbles: true, cancelable: true }); Object.assign(e, extras); return e; };++(async () => {++ let body = person, calls = [];++ const fetchImpl = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => body }; };++ const mounted = OVUser.card(host, { username: '@Example', fetchImpl });++ await mounted.ready;++ assert.strictEqual(calls.length, 1);++ assert.strictEqual(calls[0].url, 'https://openvibe.network/api/v1/people/Example/card');++ assert.strictEqual(calls[0].options.credentials, 'omit');++ assert.strictEqual(calls[0].options.cache, 'no-store');++ assert.ok(['Level 4', '3 day streak', '0 badges', '12 followers'].every((t) => host.textContent.includes(t)), 'the numbers as chips (4.202.0 card)');++ assert.ok(host.textContent.includes('Live now') && host.textContent.includes('<Live>'));++ assert.strictEqual(host.querySelectorAll('img').length, 1, 'display name is text, never markup');++ assert.strictEqual(host.querySelector('img').getAttribute('onerror'), null);++ assert.strictEqual(host.querySelectorAll('style').length, 0, 'plain kit markup only');++ body = { username: 'Example', profile_url: person.profile_url, level: null, streak: null, badges_count: null, followers: null, avatar_url: 'javascript:alert(1)', channel_url: 'javascript:alert(1)' };++ await mounted.refresh();++ for (const label of ['Level', 'Streak', 'Badges', 'Followers', 'Offline', 'Live now', 'This week']) assert.ok(!host.textContent.includes(label), label + ' stays absent');++ assert.strictEqual(host.querySelector('img'), null);++ assert.strictEqual(host.querySelector('dl'), null);++ assert.strictEqual(host.querySelectorAll('a').length, 1, 'unsafe links omitted');++ mounted.destroy(); assert.strictEqual(host.childNodes.length, 0);++ assert.throws(() => OVUser.card(host, { username: '../bad' }), TypeError);++++ let resolveOld, oldSignal;++ const old = OVUser.card(host, { username: 'OldName', fetchImpl: (_url, options) => { oldSignal = options.signal; return new Promise(resolve => { resolveOld = resolve; }); } });++ await Promise.resolve();++ body = { username: 'NewName', profile_url: 'https://openvibe.network/@NewName' };++ const newer = OVUser.card(host, { username: 'NewName', fetchImpl });++ await newer.ready;++ assert.ok(oldSignal.aborted);++ resolveOld({ ok: true, json: async () => person }); await old.ready;++ assert.ok(host.textContent.includes('@NewName') && !host.textContent.includes('@Example'), 'late response cannot replace a newer mount');++ newer.destroy();++ const unavailable = OVUser.card(host, { username: 'Example', fetchImpl: async () => ({ ok: false, status: 503 }) });++ assert.strictEqual(await unavailable.ready, null);++ assert.ok(host.textContent.includes('The profile did not load.') && host.querySelector('a').href === person.profile_url);++ unavailable.destroy();++++ const mention = document.createElement('a'); mention.href = person.profile_url; mention.textContent = '@Example'; document.body.appendChild(mention);++ const avatar = document.createElement('button'); avatar.setAttribute('data-ov-user', 'Example'); avatar.appendChild(document.createElement('img')); document.body.appendChild(avatar);++ const marked = document.createElement('span'); marked.setAttribute('data-username', 'Example'); marked.setAttribute('role', 'button'); marked.tabIndex = 0; document.body.appendChild(marked);++ const foreign = document.createElement('a'); foreign.href = 'https://example.org/@Example'; document.body.appendChild(foreign);++ const opened = [];++ let unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });++ mention.dispatchEvent(event('click')); avatar.firstChild.dispatchEvent(event('click')); marked.dispatchEvent(event('keydown', { key: 'Enter' }));++ assert.deepStrictEqual(opened, ['Example', 'Example', 'Example']);++ mention.dispatchEvent(event('click', { ctrlKey: true })); foreign.dispatchEvent(event('click'));++ assert.strictEqual(opened.length, 3, 'modified and foreign links keep their native behavior');++ body = person;++ const inCard = OVUser.card(host, { username: 'Example', fetchImpl }); await inCard.ready;++ host.querySelector('a').dispatchEvent(event('click'));++ assert.strictEqual(opened.length, 3, 'links inside the card remain navigation');++ inCard.destroy();++ unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });++ mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4, 'rebinding does not duplicate listeners');++ unbind(); mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4);++++ // Native dialog opening/closing and request disposal through the default hook.++ const create = document.createElement.bind(document);++ document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };++ unbind = OVUser.bind(document, { fetchImpl });++ mention.dispatchEvent(event('click'));++ await new Promise(resolve => setImmediate(resolve));++ const dialog = document.querySelector('dialog');++ assert.ok(dialog?.hasAttribute('open'));++ assert.ok(dialog.textContent.includes('@Example'));++ dialog.querySelector('button').dispatchEvent(event('click'));++ assert.strictEqual(document.querySelector('dialog'), null);++ unbind();++ assert.ok(require('../files').BROWSER.includes('ov-user.js'));++ assert.strictEqual(require('../package.json').exports['./ov-user'], './ov-user.js');++ console.log('ov-user: single read, safe kit markup, absent fields, cancellation, stale-response protection, mention/avatar/keyboard hooks and dialog lifecycle passed');++})().catch(e => { console.error(e); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++// One window for every little interface (ov-window.js, 4.202.0; asks 304 and 318) in a real headless Chrome: a dialog++// grows out of its button, is named by its title, keeps the focus inside, closes with Escape and gives the focus back;++// windows stack and Escape closes the top one; a panel floats beside its button with no scrim, moves by its head, and++// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one++// attribute for every window; content given as text is set as text.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-window-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-window.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-window.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">++<script src="/ov-window.js"></script>++<script>++window.shut = [];++window.mk = (o) => { const f = document.createElement('div'); const a = document.createElement('input'); a.id = 'first'; const b = document.createElement('button'); b.id = 'last'; b.textContent = 'OK'; f.append(a, b); return OVWindow.open(Object.assign({ title: 'Your wallet', body: f, onClose: (why) => window.shut.push(why) }, o)); };++</script>`);++});++(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-window-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const key = async (page, k, shift) => { const codes = { Escape: 27, Tab: 9 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], modifiers: shift ? 8 : 0 }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 800 });++ await page.goto(base, { maxMs: 5000 });++ await page.evaluate(`document.getElementById('open').focus(); window.w1 = mk({ from: document.getElementById('open'), id: 'wallet' })`);++ await wait(400);++ let v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { kind: el.className, role: el.getAttribute('role'), modal: el.getAttribute('aria-modal'), named: document.getElementById(el.getAttribute('aria-labelledby')).textContent, focus: document.activeElement.id, scrim: !!document.querySelector('.ovw-scrim'), cx: Math.round(r.left + r.width / 2 - document.documentElement.clientWidth / 2), locked: document.documentElement.classList.contains('ovw-locked'), popX: el.style.getPropertyValue('--ov-pop-x') }; })()`);++ assert.match(v.kind, /is-dialog/);++ assert.deepEqual([v.role, v.modal, v.named, v.focus, v.scrim, v.locked], ['dialog', 'true', 'Your wallet', 'first', true, true], 'a named modal dialog, the focus on its first field, the page behind locked');++ assert.ok(Math.abs(v.cx) < 3, `centred on a computer (${v.cx})`);++ assert.ok(v.popX !== '', 'it grows out of its button');++ // The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.++ await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');++ assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');++ await key(page, 'Tab', true);++ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');++ // A second window on top: Escape closes the top one only.++ await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);++ await key(page, 'Escape'); await wait(350);++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');++ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');++ await key(page, 'Escape'); await wait(350);++ v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);++ assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');++ // A press outside a dialog closes it.++ await page.evaluate(`mk({})`); await wait(80);++ await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);++ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');++ // Text is text.++ await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);++ v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);++ assert.deepEqual(v, { t: '<b>x</b>', imgs: 0 }, 'markup given as text is shown as text');++ await page.evaluate('OVWindow.closeAll()'); await wait(300);++++ // A panel: beside its button, no scrim, the page usable, moved by its head, opened again where it was left.++ await page.evaluate(`window.p1 = mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);++ await wait(400);++ v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(), b = document.getElementById('pan').getBoundingClientRect(); return { scrim: !!document.querySelector('.ovw-scrim'), modal: el.getAttribute('aria-modal'), below: Math.round(r.top - b.bottom), left: Math.round(r.left - b.left), locked: document.documentElement.classList.contains('ovw-locked') }; })()`);++ assert.deepEqual(v, { scrim: false, modal: null, below: 8, left: 0, locked: false }, 'a panel sits under its button, with no scrim, and the page stays usable');++ const hb = await page.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 40, y: r.top + 20 }; })()`);++ await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: hb.x, y: hb.y, button: 'left', clickCount: 1 });++ await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 60, y: hb.y + 30, button: 'left' });++ await 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 });++ const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);++ 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(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);++ await wait(400);++ assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');++ await page.evaluate('OVWindow.closeAll()'); await wait(300);++++ // The window style: one attribute, every window.++ assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');++ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');++ assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');++ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);++ await page.close();++++ // A phone: every kind is a sheet; a swipe down on its head closes it.++ const ph = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800, mobile: true });++ await ph.goto(base, { maxMs: 5000 });++ await ph.evaluate(`mk({ kind: 'panel', title: 'Dock' })`); await wait(400);++ v = await ph.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { sheet: el.classList.contains('is-sheet'), bottom: Math.round(innerHeight - r.bottom), width: Math.round(r.width) }; })()`);++ assert.deepEqual(v, { sheet: true, bottom: 0, width: 390 }, 'a sheet across the bottom of a phone');++ const sh = await ph.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 120, y: r.top + 20 }; })()`);++ await ph.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sh.x, y: sh.y, button: 'left', clickCount: 1 });++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 80, button: 'left' });++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });++ await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });++ await wait(400);++ assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');++ assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);++ await ph.close();++ console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');++ } 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,62 @@++'use strict';++// The owner's one site list (2026-11-10, plan ask 274): the same eight groups, in the same order, in the footer's++// Network column and fallback, the rooms' neighbourhoods, the You tab's folders and each home page's "More on OpenVibe".++// OpenVibe.Network frame/sites.js GROUPS is the list; these are its consumers in Shared, checked against it.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const footer = require('../footer');++const rooms = require('../rooms');++const showcase = require('../showcase');++++const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];++const IDS = ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'];++// The tile ids as the home cards show them (Email, Restream, Studio and Locker keep their names).++const MEMBERS = [++ ['live', 'video', 'pics', 'download', 'openre', 'cam', 'tours'],++ ['community', 'chat', 'tips', 'vip', 'email', 'help', 'network'],++ ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'],++ ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'],++ ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'],++ ['tools', 'design', 'domains', 'host', 'studio', 'locker', 'food'],++ ['space', 'codes', 'bot', 'actor', 'computer', 'connect'],++ ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'],++];++++// The footer's fallback is the eight groups, in order, each linking to its /sites section.++assert.deepStrictEqual(footer.NETWORK.map((x) => x.name), LABELS, 'footer fallback: the eight labels in order');++assert.deepStrictEqual(footer.NETWORK.map((x) => x.url), IDS.map((id) => `https://openvibe.network/sites#group-${id}`), 'footer fallback: one link per group');++// The rooms' neighbourhoods repeat the same ids and labels.++assert.deepStrictEqual(Object.keys(rooms.HOODS), IDS, 'rooms: the eight ids in order');++assert.deepStrictEqual(Object.values(rooms.HOODS), LABELS, 'rooms: the eight labels in order');++for (const r of rooms.IDS) assert.ok(rooms.HOODS[rooms.get(r).hood], `${r}: a current neighbourhood`);++++// The You tab's folders are the same eight groups, in order (Build keeps the `build` id saved home screens use).++const homeSrc = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');++const gf = /const GROUP_FOLDERS = (\[\[[\s\S]*?\]\]);/.exec(homeSrc);++assert.ok(gf, 'ov-home has GROUP_FOLDERS');++const folders = JSON.parse(gf[1].replace(/'/g, '"'));++assert.deepStrictEqual(folders.map((f) => f[0]), IDS, 'You folders: the eight ids in order');++assert.deepStrictEqual(folders.map((f) => f[1]), LABELS, 'You folders: the eight labels in order');++++// "More on OpenVibe": each home page leads to the next places in its own group, in the group's order.++const ALIAS = { media: 'video', 'media-hub': 'video', stream: 'openre', restream: 'openre', mail: 'email', image: 'studio', lock: 'locker', homes: 'rent', bench: 'report' };++const groupOf = (id) => MEMBERS.findIndex((list) => list.includes(ALIAS[id] || id));++for (const list of MEMBERS) for (const id of list) assert.ok(showcase.RELATED[id], `${id}: has a "More on OpenVibe" list`);++for (const list of MEMBERS) for (const id of list) {++ const g = groupOf(id);++ const expected = MEMBERS[g].filter((x) => x !== id).slice(0, 4);++ assert.deepStrictEqual(showcase.RELATED[id].map((x) => x[0]), expected, `${id}: its group's next places, in group order`);++}++for (const [alias, id] of Object.entries(ALIAS)) {++ assert.ok(showcase.RELATED[alias], `${alias}: an alias for ${id}`);++ assert.ok(!showcase.RELATED[alias].some((x) => x[0] === id), `${alias}: never links to itself`);++ assert.ok(showcase.RELATED[alias].every((x) => groupOf(x[0]) === groupOf(id)), `${alias}: only its own group`);++}++assert.deepStrictEqual(showcase.DEFAULT_RELATED.map((x) => x[0]), ['live', 'community', 'games', 'news'], 'a product without a list: one place from each of the first four groups, in order');++++// The footer's live column uses the Frame's own groups (network column), not a head-list of ranked sites.++const footerSrc = fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8');++assert.ok(/frame\.groups\.map\(g => \(\{ name: g\.name, url: groupHref\(g\.id\)/.test(footerSrc), 'the live footer reads the Frame groups');++assert.ok(/const network = frame && Array\.isArray\(frame\.groups\)/.test(footerSrc), 'and falls back to the eight only when the Frame is away');++console.log('site groups: footer, rooms, You folders and home pages show the same eight groups in the menu\'s order');@@@ -18,7 +18,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));await p.goto(base, { maxMs: 5000 });assert(!p.state.urls.some((u) => u.includes('ov-wallet.js')), 'sheet is lazy');await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);-- const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&d.open), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);++ // 4.202.0: the wallet opens in the one window, which grows in (.28 s): measure once it has settled.++ { const end = Date.now() + 4000; while (Date.now() < end && !(await p.evaluate(`(() => { const w = document.querySelector('.ovw'); return !w || (w.getAnimations ? w.getAnimations().every((a) => a.playState !== 'running') : true); })()`))) await wait(50); }++ const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&(d.open||d.closest('.ovw'))), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');@@@ -353,7 +353,7 @@// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'] };++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them// below any site's own value (4.86.0).@@@ -371,7 +371,7 @@+ 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'+ 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'+ 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';-- const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'] };++ const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'], window: ['data-ov-window', 'glass'] };// ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting// rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),// off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving@@@ -404,7 +404,7 @@document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);}function readDisplay() {-- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto' };++ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }return out;}@@@ -546,7 +546,7 @@// page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;const PAINT_KEY = 'ov_paint';-- const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons'];++ const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons', 'data-ov-window'];const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;function seasonUntil(s) {@@@ -355,6 +355,8 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }++.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }++.ovc-set-in .ovc-set-f { padding: 0; }.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
@@ -1,5 +1,19 @@
@@ -1,5 +1,19 @@
# Changelog
# Changelog
+## 4.202.0 — 2026-11-11
+
+**One field dropdown, one window for small interfaces, one user card, a measured page end, eight site groups, and the reveal remade.**
+
+- **An empty footer column is never drawn (`footer.js`).** A group with no heading or no links is left out instead of
+ showing as an untitled "0 links" fold.
+- **One field dropdown (#447).** `ov-combo.js` adds async, grouped suggestions and optional creation; the composer handoff to Code and whole site names in the phone menu are included.
+- **One window (#451).** `ov-window.js` supplies dialog, sheet and panel modes, and the composer's settings use it.
+- **One user card (#452).** Shared mention hooks, the profile card, wallet window and display style settings.
+- **Page end (#442).** A shared bottom measurement keeps the end of a page at 32 px on computer and 24 px on phone.
+- **Eight site groups (#445).** The footer, rooms, You folders and home pages use the same groups.
+- **Reveal (#448).** Headings emerge from blur, rules draw, and up to six cards stagger as they enter view.
+- **Jump back in (#412).** The full history link and private history support are present from the 4.201.0 base.
+
## 4.201.0 — 2026-11-10
## 4.201.0 — 2026-11-10
**Notifications move into the one menu; the mark and the name are one button; one composer and Code on every site.**
**Notifications move into the one menu; the mark and the name are one button; one composer and Code on every site.**
@@ -93,7 +107,6 @@ From the owner's notes of 2026-11-10 (asks 334 to 350).
@@ -93,7 +107,6 @@ From the owner's notes of 2026-11-10 (asks 334 to 350).
event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.
event arrives later and used to close a menu opened again in between. The menu now ignores the one step it took itself.
- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loaded
- Budget: `dock.js` 20.43 KB brotli (budget 20.45 KB, was 20.30 KB) for the "Open in Chat" icon in the always-loaded
Dock; the Vibe-only button rules stay in `dock-vibe.js`.
Dock; the Vibe-only button rules stay in `dock-vibe.js`.
-
## 4.199.0 — 2026-11-10
## 4.199.0 — 2026-11-10
**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
@@ -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.201.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.202.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.
@@ -838,7 +838,7 @@ Menus are modular: every site keeps the shared account menu and adds its own
@@ -838,7 +838,7 @@ Menus are modular: every site keeps the shared account menu and adds its own
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
/ setLinks() do the same at runtime. Signed-in users also get a
/ setLinks() do the same at runtime. Signed-in users also get a
"Recently used" row fed by the shared history module when it is loaded.
"Recently used" row fed by the shared history module when it is loaded.
-Sign-in and history: history: { type: 'tool'|'stream'|'paste'|'page'|…,
+Sign-in and history: history: { type: 'tool'|'stream'|'video'|'clip'|'paste'|'thread'|'conversation'|'document'|'page'|…,
title, url, icon } is recorded for the signed-in user once auth resolves
title, url, icon } is recorded for the signed-in user once auth resolves
(cross-site "Recently used" / History on the Network). silentLogin:
(cross-site "Recently used" / History on the Network). silentLogin:
'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
'https://site/auth/login?silent=1&next={url}' — when nobody is signed in
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.201.0",
+ "version": "4.202.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.146.0",
"sdk": "openvibe-sdk v0.146.0",
"features": [
"features": [
+ "the shared field dropdown, one dialog/sheet/panel window, user cards, eight site groups, measured page ends and remade reveal (4.202.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
"the page-view beacon sends ?in=1 only for a signed-in browser and counts a guest at once, so a signed-in person is counted from presence and never also as a guest (4.191.0)",
"the mark at work is the O as eight dots with three bright ones going round, and it has a state of its own for an agent that is thinking or writing (data-state=think); a phone's Dock opens and closes exactly as the menu does and wears its close button in its place; a button, field, select or text box that nobody styled wears the theme instead of the browser's grey (4.186.0)",
"the mark at work is the O as eight dots with three bright ones going round, and it has a state of its own for an agent that is thinking or writing (data-state=think); a phone's Dock opens and closes exactly as the menu does and wears its close button in its place; a button, field, select or text box that nobody styled wears the theme instead of the browser's grey (4.186.0)",
"the menu's You tab: your home right under your name (a 52px bar, was 68) and the AI card; Most used is the apps themselves under their bar, each a link and a full touch target; Jump back in last, saying what it is. A phone's Dock opens out of the navbar's menu button: its close orb starts on that orb and settles into its x while the navbar's orb makes the same move underneath, and closing runs it back (4.185.0)",
"the menu's You tab: your home right under your name (a 52px bar, was 68) and the AI card; Most used is the apps themselves under their bar, each a link and a full touch target; Jump back in last, saying what it is. A phone's Dock opens out of the navbar's menu button: its close orb starts on that orb and settles into its x while the navbar's orb makes the same move underneath, and closing runs it back (4.185.0)",
@@ -13,6 +13,10 @@
@@ -13,6 +13,10 @@
* dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open. A link's icon is a
* dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open. A link's icon is a
* navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page)
* navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page)
* a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do.
* a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do.
+ *
+ * Plan 279: the bar does not sit on the layout viewport's bottom edge. bottom.js measures the visible viewport
+ * (--ovb-lift) and the bar moves up by it with `translate`, so the browser's toolbar sliding away or a keyboard
+ * never leaves the bar behind; its height is measured back for whatever docks above it (--ovb-bar, --ovb-dock).
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -43,7 +47,7 @@
@@ -43,7 +47,7 @@
@media (max-width: 760px) {
@media (max-width: 760px) {
html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); }
html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); }
html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); }
html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); }
- .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 9990; display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);
+ .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--ovb-z-bar, 9990); translate: 0 calc(-1 * var(--ovb-lift, 0px)); display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px);
background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px);
background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px);
border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease; }
border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease; }
.ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer;
.ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer;
@@ -140,6 +144,7 @@
@@ -140,6 +144,7 @@
document.body.appendChild(bar);
document.body.appendChild(bar);
barEl = bar;
barEl = bar;
if (de && de.classList) de.classList.add('ov-has-appbar');
if (de && de.classList) de.classList.add('ov-has-appbar');
+ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();
placePill(bar, true);
placePill(bar, true);
if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }
if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); }
// The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenever
// The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenever
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+/**
+ * openvibe-shared/bottom.js — the bottom of the screen, measured once for everything docked there.
+ *
+ * The owner, 2026-11-10 (plan 279): "That fixed bottom position is kind of buggy … as you scroll up and down,
+ * so let's make that nice and smooth and perfect across all sites." A phone's visible viewport is not its layout
+ * viewport: the browser's toolbar slides away, a keyboard opens, an orientation turns. A bar placed at bottom: 0
+ * of the layout viewport is then somewhere behind the thing the person is looking at. This file measures the one
+ * number every bottom-docked part needs and writes it where CSS can use it:
+ *
+ * --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport;
+ * the bar, the small player, the Dock's pill and the composer move up by it with `translate`
+ * (a paint, never layout).
+ * --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh.
+ * --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away).
+ * --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone.
+ * --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts.
+ * --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge.
+ *
+ * One stacking order, written here only (the bottom of the screen, low to high):
+ * 1. the app bar --ovb-z-bar 9990
+ * 2. the small player --ovb-z-mini 9991
+ * 3. the Dock's pill --ovb-z-pill 9995
+ * 4. the Dock's card --ovb-z-dock 9996 (a phone's Dock sheet is 10010: it covers the navbar)
+ * Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+).
+ *
+ * navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses
+ * these variables has its old value as the fallback, so a page whose bottom.js did not arrive keeps the bar it had.
+ *
+ * OpenVibeBottom.measure() measure now (a page that changed its own bottom calls it)
+ * OpenVibeBottom.lift() the current lift in px
+ * OpenVibeBottom.height() the visible viewport's height in px
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || !document.documentElement || root.OpenVibeBottom) return;
+ const html = document.documentElement;
+ let raf = 0;
+ let observer = null;
+ let resize = null;
+ let watched = [];
+
+ function styles() {
+ if (document.getElementById('ovb-styles')) return;
+ const st = document.createElement('style');
+ st.id = 'ovb-styles';
+ st.textContent = `:root {
+ --ovb-lift: 0px;
+ --ovb-bar: 0px;
+ --ovb-mini: 0px;
+ --ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px));
+ --ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px));
+ --ovb-z-bar: 9990;
+ --ovb-z-mini: 9991;
+ --ovb-z-pill: 9995;
+ --ovb-z-dock: 9996;
+}
+.ovnav-appbar, .ovmini, .ovdk-pill { will-change: translate; }`;
+ (document.head || html).appendChild(st);
+ }
+
+ const px = (n) => `${Math.round(n * 100) / 100}px`;
+
+ /** The height of an element that is actually on screen (a bar hidden under the page is not). */
+ function onScreen(el) {
+ if (!el) return 0;
+ const cs = getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden') return 0;
+ const r = el.getBoundingClientRect();
+ if (!r.height || r.bottom <= 0 || r.top >= root.innerHeight) return 0;
+ return r.height;
+ }
+
+ function measure() {
+ raf = 0;
+ const vv = root.visualViewport;
+ const vh = vv ? vv.height : root.innerHeight;
+ const top = vv ? vv.offsetTop : 0;
+ const lift = Math.max(0, root.innerHeight - (top + vh));
+ html.style.setProperty('--ovb-lift', px(lift));
+ // A visible height of 0 happens between two documents; keep the last real one.
+ if (vh > 0) html.style.setProperty('--ovb-vh', px(vh));
+ const bar = onScreen(document.querySelector('.ovnav-appbar'));
+ html.style.setProperty('--ovb-bar', px(bar));
+ let mini = 0;
+ try {
+ if (root.matchMedia && root.matchMedia('(max-width: 640px)').matches) {
+ const m = onScreen(document.querySelector('.ovmini:not(.is-leaving)'));
+ if (m) mini = m + 8; // its 8 px above the bar
+ }
+ } catch { /* no matchMedia: no full-width small player to stack */ }
+ html.style.setProperty('--ovb-mini', px(mini));
+ }
+
+ const later = () => { if (!raf) raf = requestAnimationFrame(measure); };
+
+ /** Watch the parts that are added to the page after this file, and their size changes. */
+ function watch() {
+ const parts = [...document.querySelectorAll('.ovnav-appbar, .ovmini')];
+ if (resize) { resize.disconnect(); watched = []; }
+ else if (typeof ResizeObserver === 'function') resize = new ResizeObserver(later);
+ if (!resize) return;
+ for (const el of parts) { try { resize.observe(el); watched.push(el); } catch { /* */ } }
+ measure();
+ }
+
+ function start() {
+ styles();
+ const vv = root.visualViewport;
+ if (vv && vv.addEventListener) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); }
+ root.addEventListener('resize', later);
+ root.addEventListener('orientationchange', later);
+ root.addEventListener('load', later);
+ document.addEventListener('visibilitychange', later);
+ if (typeof MutationObserver === 'function' && document.body) {
+ observer = new MutationObserver((records) => {
+ let parts = false;
+ for (const r of records) {
+ if (r.target && r.target.closest && r.target.closest('.ovnav-appbar, .ovmini')) { parts = true; break; }
+ for (const n of [...r.addedNodes, ...r.removedNodes]) {
+ if (n.nodeType === 1 && (n.matches('.ovnav-appbar, .ovmini') || n.querySelector('.ovnav-appbar, .ovmini'))) { parts = true; break; }
+ }
+ if (parts) break;
+ }
+ if (parts) watch();
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+ watch();
+ setTimeout(measure, 300); // fonts, a late app bar, a small player opened by the page
+ }
+
+ root.OpenVibeBottom = {
+ measure,
+ refresh: later,
+ lift: () => { const n = parseFloat(html.style.getPropertyValue('--ovb-lift')); return Number.isFinite(n) ? n : 0; },
+ height: () => { const vv = root.visualViewport; return vv ? vv.height : root.innerHeight; },
+ _stop: () => { if (observer) observer.disconnect(); if (resize) resize.disconnect(); },
+ };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
+ else start();
+ if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeBottom;
+})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected
// OpenVibe — the Code part (openvibe-shared 4.201.0). Ask 350, the owner: openvibe.codes "should be better interconnected
// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place
// and shared … so that you can do chat, codes, and agents all in the same place and in one area". The menu's Vibe place
// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:
// is Chat · Code · Agents, and the Dock has the same Code tab. dock.js loads this file the first time it opens:
-// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active }) → { refresh(), focus(), destroy() }
+// OpenVibeDockCode.mount(container, { token, frameBase, base, codes, active, take }) → { refresh(), focus(), destroy() }
+// take() → the words another part handed over (Vibe's "Build it in Code"), which go in the box.
//
//
// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:
// One box to start a coding task, the way openvibe.codes starts one (Create: a new website, game or app; OpenVibe:
// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,
// a change to OpenVibe itself; My code: a repository of yours), and your latest tasks under it, running ones first,
@@ -215,10 +216,13 @@
@@ -215,10 +216,13 @@
paint();
paint();
if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);
if (reachable && tasks && tasks.some((t) => /running|queued/.test(t.status)) && (!o.active || o.active())) timer = setTimeout(read, POLL_MS);
}
}
+ // What another part handed over (Vibe's Build it in Code) goes in the box, ready to start.
+ const take = () => { const t = typeof o.take === 'function' ? o.take() : ''; if (!t) return; text.value = t; text.dispatchEvent(new Event('input')); text.focus(); note.textContent = 'From your chat with Vibe. Start it, or change it first.'; };
paint();
paint();
read();
read();
+ take();
return {
return {
- refresh: read,
+ refresh: () => { take(); read(); },
focus: () => text.focus(),
focus: () => text.focus(),
destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },
destroy() { gone = true; clearTimeout(timer); container.replaceChildren(); container.classList.remove('ovdc'); },
};
};
@@ -206,7 +206,15 @@
@@ -206,7 +206,15 @@
} catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }
} catch { err.textContent = 'That picture could not be attached. Try again.'; err.hidden = false; }
finally { attach.disabled = false; file.value = ''; }
finally { attach.disabled = false; file.value = ''; }
});
});
- form.append(attach, file);
+ // 350: Build it in Code. What you wrote, else Vibe's last answer, goes to the Code part as a new task's words.
+ const build = iconButton('button', 'ovdk-attach ovdk-build', 'M8 7l-5 5 5 5M16 7l5 5-5 5', 'Build it in Code');
+ build.addEventListener('click', () => {
+ const last = [...vibe.msgs].reverse().find((m) => m.from !== 'you');
+ const text = box.value.trim() || (last && last.text) || '';
+ if (!text) { box.focus(); return; }
+ build.dispatchEvent(new CustomEvent('ov-dock:part', { bubbles: true, detail: { part: 'code', text: text.slice(0, 16000) } }));
+ });
+ form.append(attach, file, build);
let mic = null;
let mic = null;
if (TALK) {
if (TALK) {
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
mic = h('button', 'ovdk-mic'); mic.type = 'button'; mic.append(icon(G.mic));
@@ -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': '4eb6d94db445', 'dock-code.js': '7cc3b2f4b7ea', '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' };
+ const DOCK_REV = { 'dock-vibe.js': '1bfa19b01e8e', 'dock-code.js': '3dcbaca10bfb', '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' };
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);
@@ -106,7 +106,7 @@
@@ -106,7 +106,7 @@
const st = document.createElement('style');
const st = document.createElement('style');
st.id = 'ovdk-styles';
st.id = 'ovdk-styles';
st.textContent = `
st.textContent = `
- .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: 9995; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
+ .ovdk-pill { --t: var(--accent, #3b82f6); --bg: var(--bg-elevated, var(--bg-secondary, #252530)); position: fixed; z-index: var(--ovb-z-pill, 9995); right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(16px + var(--ovb-dock, calc(max(var(--ovnav-appbar-h, 0px), env(safe-area-inset-bottom, 0px)) + var(--ovb-mini, 0px)))); translate: 0 calc(-1 * var(--ovb-lift, 0px));
display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);
display: inline-flex; align-items: center; gap: 9px; height: 46px; padding: 0 18px 0 9px; border-radius: 99px; border: 1px solid var(--border, #333340); background: var(--bg); color: var(--text-primary, #e0e0e0);
font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }
font: inherit; font-size: 13.5px; font-weight: 700; letter-spacing: -.005em; box-shadow: 0 14px 34px -14px rgba(0,0,0,.6); cursor: pointer; -webkit-tap-highlight-color: transparent; animation: ovdk-in .3s cubic-bezier(.2,.9,.25,1.2); }
.ovdk-pill[hidden] { display: none; }
.ovdk-pill[hidden] { display: none; }
@@ -124,7 +124,7 @@
@@ -124,7 +124,7 @@
.ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }
.ovdk-pill:has(.ovdk-t:empty) { padding-right: 8px; }
@keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }
@keyframes ovdk-in { from { opacity: 0; transform: translateY(10px) scale(.9); } }
@keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }
@keyframes ovdk-ping { to { transform: scale(2.6); opacity: 0; } }
- .ovdk { --ovdk-gap: 16px; position: fixed; z-index: 9996; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
+ .ovdk { --ovdk-gap: 16px; position: fixed; z-index: var(--ovb-z-dock, 9996); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
right: max(var(--ovdk-gap), env(safe-area-inset-right, 0px)); bottom: calc(max(var(--ovdk-gap), env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px));
width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
width: ${DEF.width}px; height: ${DEF.height}px; max-width: calc(100vw - 32px); max-height: calc(100vh - 96px); max-height: calc(100dvh - 96px);
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+# The bottom of the screen (plan 279) and the page's end (plan 280)
+
+The owner, 2026-11-10:
+
+> "That fixed bottom position is kind of buggy … as you scroll up and down, so let's make that nice and smooth and
+> perfect across all sites."
+
+> "That huge wasted space … in between that and the footer … It's just waste."
+
+## One measured number
+
+A phone's visible viewport is not its layout viewport. The browser's toolbar slides away while the page scrolls, a
+keyboard opens, an orientation turns; a bar placed at `bottom: 0` of the layout viewport is then behind the very
+thing the person is looking at. `bottom.js` measures the difference and writes it where CSS can use it:
+
+| variable | meaning |
+|---|---|
+| `--ovb-lift` | px between the layout viewport's bottom and the visible (visual) viewport's bottom; every bottom-docked part moves up by it with `translate` (a paint, never layout) |
+| `--ovb-vh` | the visible viewport's height in px, measured — never a `100vh`/`100dvh` guess |
+| `--ovb-bar` | the app bar's height while it is on screen (0 when it is away) |
+| `--ovb-mini` | the small player's height plus its 8 px gap while it is the full-width bar on a phone |
+| `--ovb-base` | `max(--ovb-bar, env(safe-area-inset-bottom))`: where the first docked part starts |
+| `--ovb-dock` | `--ovb-base + --ovb-mini`: how far anything above all of them sits from the bottom edge |
+
+`navbar.js` loads `bottom.js` on every page before the bar, the Dock and the small player draw. Every rule that uses
+these variables keeps its old value as the fallback, so a page that did not get `bottom.js` keeps the bar it had.
+
+The parts move with `translate: 0 calc(-1 * var(--ovb-lift, 0px))`, never by changing `bottom` or `height` while the
+person scrolls; `--ovb-bar`/`--ovb-mini` are measured from the elements themselves by a `ResizeObserver`, so a bar
+that steps aside for the keyboard or a player that changes shape is followed without a guess.
+
+## One stacking order
+
+The bottom of the screen, low to high (these are the only z-indexes that belong to it; all of them live in
+`bottom.js`):
+
+| layer | class | z-index |
+|---|---|---|
+| the app bar | `.ovnav-appbar` | `--ovb-z-bar` (9990) |
+| the small player | `.ovmini` | `--ovb-z-mini` (9991) |
+| the Dock's pill | `.ovdk-pill` | `--ovb-z-pill` (9995) |
+| the Dock's card | `.ovdk` | `--ovb-z-dock` (9996) |
+
+A phone's Dock sheet (10010) covers the navbar; sheets (10060), the notification panel (99999) and toasts/notices
+(2147483000+) sit above all of these and do not belong to the bottom stack.
+
+## The page's end
+
+Content ends, then **32 px on a computer (24 on a phone)**, then the footer. That gap is the footer's own
+`margin-top` (`footer.js`); a site may not add its own bottom padding above it. The last block's bottom margin
+collapses with the footer's, so it does not add — but a container's `padding-bottom` does, and is removed in the
+site that has it.
+
+`scripts/audit-page-end.js` measures the gap on every site's home at 390 and 1280 and lists every page over the rule:
+
+```
+node scripts/audit-page-end.js
+node scripts/audit-page-end.js --footer-rule # with the Shared rule applied (what is left for the sites)
+node scripts/audit-page-end.js --hosts openvibe.wiki --width 390
+```
+
+## The check
+
+`test/bottom-chrome.test.js` drives a real headless Chrome at 360×740, 390×844 and 430×932. It scrolls up and down
+while a shimmed `visualViewport` opens and closes the toolbar (the layout viewport stays put, as a real toolbar's
+update reaches the visible viewport first), and fails when:
+
+- the app bar's bottom edge is more than 1 px from the visible viewport's bottom;
+- the small player is not 8 px above the bar, or the Dock's pill not 16 px above the small player;
+- any of the three overlaps another;
+- a gap jumps by more than 1 px between samples.
+
+```
+ov test test/bottom-chrome.test.js
+```
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+# One user card
+
+Load `/shared/ov-user.js` with the site's existing kit stylesheet. Loading the script has no side effects. The owner must enable Network's `user_card_enabled` setting before sites opt in.
+
+```js
+const mounted = OVUser.card(document.getElementById('person'), { username: 'Example' });
+await mounted.ready; // public summary, or null when unavailable
+await mounted.refresh();
+mounted.destroy();
+
+// Opt-in delegation, including dynamically added mentions and avatars:
+const unbind = OVUser.bind(document);
+// unbind() removes these hooks and closes its card.
+```
+
+```html
+<a href="https://openvibe.network/@Example">@Example</a>
+<button type="button" data-ov-user="Example" aria-label="Open Example's user card"><img src="/picture.png" alt=""></button>
+<a href="https://openvibe.network/@Example" data-ov-user="Example">Example</a>
+```
+
+The hook handles clicks, context menus and keyboard activation on marked elements (`data-ov-user`, or the existing `data-username`) and canonical `/@username` links on the current site, Network or Live. Use links/buttons for keyboard access; custom elements need `role="button"` and `tabindex="0"`. Modified clicks keep their native behavior. Links within the card keep navigating normally. Rebinding the same root replaces its old listeners. The default opening is a plain native dialog with Close/Escape and focus restoration; browsers without modal dialogs retain normal link navigation.
+
+The main session can supply its own opening surface without changing the read:
+
+```js
+OVUser.bind(document, { onOpen({ username, trigger }) {
+ // Open the designed surface, then mount OVUser.card(surfaceHost, { username }).
+} });
+```
+
+`card` fetches only `GET https://openvibe.network/api/v1/people/:username/card`, without credentials or browser caching, with an eight-second deadline. Optional `networkUrl` and `fetchImpl` configure a deployment/test read. Network coalesces and caches the aggregate for 30 seconds while rechecking privacy on each call. The component adds no cache. Remount/refresh/destroy abort prior reads and ignore late results. Disabled/unavailable reads show a profile link; missing fields never become zero, offline, or placeholders. Text is text, links/pictures must be HTTPS, and only kit classes/plain markup are used.
+
+The card shows the available name, handle, picture, live state, level, streak, badges count, followers, weekly sites and profile/channel links. Weekly sites are opt-in at Network. This hook does not automatically replace existing site menus or invoke relationship/moderation actions.
+
+## Next for the design (For Fable)
+
+Design the shared card surface and its placement, avatar/live treatment, hierarchy and responsive behavior; add the person's field-visibility controls and explicit weekly-sites opt-in. Adopt the hook in site mentions/avatars and compose site-specific actions using the existing menu registration API. This PR intentionally has no version bump or new CSS.
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+# OpenVibe.Shared 4.202.0 screenshots
+
+Captured from the branch's browser components in headless Chrome at 390 × 844 and 1280 × 900. The previews keep the component in context; the repository dropdown, eight-group footer, page end, Jump back in, composer settings, floating window and mention card use the branch's actual shared code.
+
+- `repository-field-{390,1280}.png` — typed repository search with grouped suggestions open.
+- `footer-eight-groups-{390,1280}.png` — the eight site groups in the footer.
+- `page-end-{390,1280}.png` — the bottom of a page on phone and computer.
+- `jump-back-in-{390,1280}.png` — recent destinations and the full-history link.
+- `composer-settings-{390,1280}.png` — settings as a phone sheet and a computer dialog.
+- `floating-panel-{390,1280}.png` — phone sheet and computer floating panel.
+- `mention-user-card-{390,1280}.png` — the card opened from a mention as a phone sheet and computer panel.
+- `reveal-entering-{390,1280}.png`, `reveal-settled-{390,1280}.png` — two frames of the remade reveal.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/composer-settings-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/floating-panel-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/footer-eight-groups-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/jump-back-in-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/mention-user-card-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/page-end-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/page-end-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/page-end-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/page-end-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/repository-field-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/repository-field-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/repository-field-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/repository-field-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-entering-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-1280.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-390.png differ
Binary files /dev/null and b/docs/screenshots/4.202.0/reveal-settled-390.png differ
@@ -38,6 +38,7 @@ const BROWSER = Object.freeze([
@@ -38,6 +38,7 @@ const BROWSER = Object.freeze([
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
+ 'ov-user.js', // public user-card read and opt-in mention/avatar hook; plain kit markup
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'brands.js', // brand logos (Simple Icons, CC0), fetched by ov-glyphs.js the first time a page shows one (4.72.0)
'brands.js', // brand logos (Simple Icons, CC0), fetched by ov-glyphs.js the first time a page shows one (4.72.0)
@@ -53,7 +54,9 @@ const BROWSER = Object.freeze([
@@ -53,7 +54,9 @@ const BROWSER = Object.freeze([
'ov-upload.js', // shared file picker, upload queue and ov-progress
'ov-upload.js', // shared file picker, upload queue and ov-progress
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
- 'ov-composer.js', // the one composer (4.201.0): files, / and @, voice, drafts, your settings; ov-thread.js loads it
+ 'ov-composer.js', // shared composer
+ 'ov-combo.js', // shared field dropdown
+ 'ov-window.js', // shared dialog, sheet, and panel
'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
@@ -75,6 +78,7 @@ const BROWSER = Object.freeze([
@@ -75,6 +78,7 @@ const BROWSER = Object.freeze([
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
+ 'bottom.js', // the bottom of the screen measured once (plan 279): --ovb-lift for the app bar, the small player and the Dock
'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
'appbar.js', // the phone app bar; navbar.js loads it only for a site that sets appBar
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit.js', // the OpenVibe Kit's builders in the page (for pages that draw themselves; with kit-web.js)
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
'kit-web.js', // the OpenVibe Kit's behaviour (copy, menus, Enter to send, confirms, sheets): all a server-rendered site loads
@@ -33,6 +33,11 @@
@@ -33,6 +33,11 @@
* Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
* Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
* hub, so the network behaves like one product instead of a ring of separate sites. It reads
* hub, so the network behaves like one product instead of a ring of separate sites. It reads
* the shared ov_token the same way navbar.js does and never blocks rendering on it.
* the shared ov_token the same way navbar.js does and never blocks rendering on it.
+ *
+ * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"):
+ * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that
+ * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is
+ * removed in that site (scripts/audit-page-end.js measures it on every home).
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
@@ -85,24 +90,23 @@
@@ -85,24 +90,23 @@
const REPORT_URL = 'https://openvibe.help/tickets/new';
const REPORT_URL = 'https://openvibe.help/tickets/new';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- // One place that knows what the network contains. Adding a property here gives every site
- // the link, with no edit anywhere else.
- const NETWORK = [
- { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
- { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
- { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
- { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
- { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
- { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
- { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
- { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
- { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
- { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
- { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
- { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
- { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
- { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
+ // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of
+ // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names
+ // and order when it answers, so the footer, the launcher and the home pages read the same list.
+ const GROUP_LINKS = [
+ { id: 'watch', name: 'Watch & stream' },
+ { id: 'talk', name: 'Talk & belong' },
+ { id: 'play', name: 'Play' },
+ { id: 'read', name: 'Read & ask' },
+ { id: 'shop', name: 'Shop & money' },
+ { id: 'tools', name: 'Free tools' },
+ { id: 'make', name: 'Make with AI' },
+ { id: 'build', name: 'For developers' },
];
];
+ const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`;
+ const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' }));
+ // Tool subdomains have no site of their own; brandFor names them "<Sub>.OpenVibe.Tools".
+ const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']);
const LEGAL = [
const LEGAL = [
{ key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
{ key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
@@ -189,9 +193,8 @@
@@ -189,9 +193,8 @@
if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';
if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream';
if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`;
if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`;
- const found = NETWORK.find(n => n.id === service);
if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
- if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
+ if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
return 'OpenVibe';
}
}
@@ -265,13 +268,18 @@
@@ -265,13 +268,18 @@
const service = c.service || detectService();
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
const brand = c.brandName || brandFor(service);
const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });
const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } });
- // Most used sites first when the Frame service has spoken; the built-in list otherwise.
- const network = frame && frame.nav.length
- ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
- : NETWORK.filter(n => n.id !== service);
+ // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups,
+ // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages.
+ const network = frame && Array.isArray(frame.groups) && frame.groups.length
+ ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' }))
+ : NETWORK;
+ // The ranked sites are still what the discover picks must not repeat.
+ const nav = frame && frame.nav.length
+ ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname))
+ : [];
const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline;
const popular = frame ? frame.footer.popular : [];
const popular = frame ? frame.footer.popular : [];
- const discover = frame ? frame.footer.discover.filter(d => !network.slice(0, 8).some(n => n.url === d.url)) : [];
+ const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
const year = new Date().getFullYear();
if (c.variant === 'compact') {
if (c.variant === 'compact') {
@@ -300,7 +308,10 @@
@@ -300,7 +308,10 @@
const fold = (label, heading, body, count) => `
const fold = (label, heading, body, count) => `
<nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary>
<nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary>
<div class="ovf-fold-body">${body}</div></details></nav>`;
<div class="ovf-fold-body">${body}</div></details></nav>`;
- const cols = (c.links || []).map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');
+ // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots
+ // showed two when a site passed empty groups).
+ const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length)
+ .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join('');
const networkLinks = network.slice(0, 8);
const networkLinks = network.slice(0, 8);
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}">
@@ -316,7 +327,7 @@
@@ -316,7 +327,7 @@
</div>
</div>
${cols}
${cols}
${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')}
${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')}
- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}
+ <a class="ovf-more" href="${NETWORK_URL}/sites" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''}
${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
<a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''}
<a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''}
${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
@@ -382,9 +393,9 @@
@@ -382,9 +393,9 @@
// ── Styles (injected once) ─────────────────────────────────
// ── Styles (injected once) ─────────────────────────────────
const CSS = `
const CSS = `
-.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);
+.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:32px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);
padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
-.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
+.ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
.ovf-inner{max-width:1240px;margin:0 auto}
.ovf-inner{max-width:1240px;margin:0 auto}
.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
@@ -491,6 +502,7 @@ a.ovf-tile:active{transform:scale(.96)}
@@ -491,6 +502,7 @@ a.ovf-tile:active{transform:scale(.96)}
@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
@keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}}
.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}
.ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
+@media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}}
@media (max-width:560px){
@media (max-width:560px){
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
.ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))}
@@ -51,7 +51,7 @@
@@ -51,7 +51,7 @@
--ovk-radius-sm: var(--ov-radius-1);
--ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
+ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
}
@@ -248,7 +248,7 @@ div.ovk-set-row { cursor: default; }
@@ -248,7 +248,7 @@ div.ovk-set-row { cursor: default; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
-.ovk-save { position: sticky; bottom: var(--ovk-bottom); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
+.ovk-save { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; margin: 0; padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0px)); background: linear-gradient(transparent, var(--ovk-bg) 35%); }
.ovk-save .ovk-btn { width: 100%; }
.ovk-save .ovk-btn { width: 100%; }
.ovk-form { display: grid; gap: 12px; margin: 0; }
.ovk-form { display: grid; gap: 12px; margin: 0; }
.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }
.ovk-form label { display: grid; gap: 5px; color: var(--ovk-muted); font-size: .9rem; }
@@ -272,7 +272,7 @@ div.ovk-set-row { cursor: default; }
@@ -272,7 +272,7 @@ div.ovk-set-row { cursor: default; }
.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }
.ovk-go { display: inline-grid; place-items: center; flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; background: var(--ovk-fill); color: var(--ovk-on-fill); cursor: pointer; }
.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }
.ovk-go .ovk-i { width: 18px; height: 18px; stroke-width: 2.4; }
.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }
.ovk-go--send { width: var(--ovk-tap); height: var(--ovk-tap); }
-.ovk-dock { position: sticky; bottom: var(--ovk-bottom); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
+.ovk-dock { position: sticky; bottom: var(--ovk-bottom); translate: 0 calc(-1 * var(--ovb-lift, 0px)); z-index: 30; display: flex; align-items: flex-end; gap: 6px; margin: 8px -16px 0; padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px)); background: color-mix(in srgb, var(--ovk-bg) 92%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }
.ovk-compose { flex: 1; min-width: 0; display: flex; align-items: flex-end; gap: 8px; margin: 0; }
.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }
.ovk-compose textarea { flex: 1; min-width: 0; min-height: var(--ovk-tap); max-height: 40vh; box-sizing: border-box; margin: 0; padding: 10px 16px; border-radius: 22px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 1rem; line-height: 1.4; resize: none; field-sizing: content; }
.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }
.ovk-compose textarea:focus-visible { outline: none; border-color: var(--ovk-accent); }
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
const st = document.createElement('style');
const st = document.createElement('style');
st.id = 'ov-mini-style';
st.id = 'ov-mini-style';
st.textContent = `
st.textContent = `
-.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: 9990; left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }
+.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: var(--ovb-z-mini, 9991); left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovb-bar, var(--ovnav-appbar-h, 0px))); translate: 0 calc(-1 * var(--ovb-lift, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }
.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }
.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }
.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }
.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }
@@ -82,7 +82,7 @@
@@ -82,7 +82,7 @@
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
@media (max-width: 640px) {
@media (max-width: 640px) {
/* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */
/* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */
- .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
+ .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovb-base, var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px)))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
.ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }
.ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }
.ovmini-back { display: none; }
.ovmini-back { display: none; }
.ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }
.ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }
@@ -181,6 +181,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -181,6 +181,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
video.muted = !!r.muted;
video.muted = !!r.muted;
arm(); source();
arm(); source();
document.body.appendChild(el);
document.body.appendChild(el);
+ if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();
root.addEventListener('pagehide', leave);
root.addEventListener('pagehide', leave);
document.addEventListener('play', other, true);
document.addEventListener('play', other, true);
paint();
paint();
@@ -196,7 +197,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
@@ -196,7 +197,7 @@ html[data-ov-motion=off] .ovmini-bar i { transition: none; }
el = null; video = null; live = false;
el = null; video = null; live = false;
if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }
if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }
if (!quiet) forget();
if (!quiet) forget();
- const off = () => node.remove();
+ const off = () => { node.remove(); if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); };
if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();
if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();
else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }
else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }
}
}
@@ -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': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '01b6fff110cd', 'dock.js': '0ebe750a2e4d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '7477f93fd775', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', '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': '8eec18660ff4', 'dock.js': '4031e67d411d', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', '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': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'cca1c50e1cce', '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.
@@ -2299,6 +2299,7 @@
@@ -2299,6 +2299,7 @@
_navEl = nav;
_navEl = nav;
paintSlots();
paintSlots();
watchKeyboard();
watchKeyboard();
+ loadBottom();
attachAvatarFallbacks(nav);
attachAvatarFallbacks(nav);
hookTop();
hookTop();
loadWalletChips();
loadWalletChips();
@@ -2307,6 +2308,14 @@
@@ -2307,6 +2308,14 @@
return nav;
return nav;
}
}
+ // bottom.js: the visible viewport for the bar, the small player, the Dock's pill and the composer.
+ let _bottomLoading = false;
+ function loadBottom() {
+ if (_bottomLoading || root.OpenVibeBottom) return;
+ _bottomLoading = true;
+ try { const sc = document.createElement('script'); sc.id = 'ovb-loader'; sc.src = siblingV('bottom.js'); sc.async = true; document.head.appendChild(sc); } catch { _bottomLoading = false; }
+ }
+
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
// ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ──
let _appBarLoading = false;
let _appBarLoading = false;
const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);
const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service);
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+/**
+ * ov-combo.js (openvibe-shared 4.202.0): one search-as-you-type dropdown for a field. Ask 348, the owner, on Codes'
+ * repository field (a browser datalist): "a completely custom drop-down system … that works a little more intelligently
+ * … async or dynamic fetching or search … configurable … You should be able to just create the new repository … through
+ * this UI". It looks like the one dropdown (ov-drop.js) and works with the keyboard and a screen reader (a combobox
+ * with a listbox).
+ *
+ * Without script the field is a plain input. With it:
+ * <input name="repo" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos">
+ * reads GET <source>?q=&limit=12 (same origin, the page's cookies) → { sections: [{ id, label, items: [{ label, url,
+ * host, private, sub }] }], create: { allowed, host, owner } }, as you type (160 ms after the last key, the one before
+ * aborted, answers kept a minute). Picking a row puts its url in the field. With create allowed, a last row offers
+ * "Create “name” on OpenVibe Git": POST <create> { name, private: true } → { url, label }, and the new one goes in.
+ * A site with its own data: OVCombo.enhance(input, { source: async (q, signal) => ({ sections }), create: async (name) => ({ url }) }).
+ * Events on the input: 'ov-combo:pick' { detail: item } after a pick or a create.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVCombo) return;
+
+ const svg = (inner) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
+ const HOST_IC = {
+ 'git.openvibe.codes': svg('<circle cx="7" cy="6" r="2.2"/><circle cx="7" cy="18" r="2.2"/><circle cx="17" cy="8" r="2.2"/><path d="M7 8.2v7.6M17 10.2c0 4-6 3-9.2 6"/>'),
+ 'github.com': svg('<path d="M9 19c-4 1.3-4-2-5.6-2.5M14.6 21v-3.3a2.9 2.9 0 0 0-.8-2.2c2.7-.3 5.6-1.3 5.6-6a4.6 4.6 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.2s-1-.3-3.4 1.3a11.5 11.5 0 0 0-6 0C6.2 2.8 5.2 3.1 5.2 3.1a4.3 4.3 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.8 9.5c0 4.6 2.8 5.7 5.5 6a2.9 2.9 0 0 0-.8 2.2V21"/>'),
+ recent: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
+ repo: svg('<path d="M5 4.5h11.5a1.5 1.5 0 0 1 1.5 1.5v13H6.5A1.5 1.5 0 0 1 5 17.5z"/><path d="M5 17.5A1.5 1.5 0 0 1 6.5 16H18"/>'),
+ plus: svg('<path d="M12 5v14M5 12h14"/>'),
+ lock: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><rect x="5.5" y="11" width="13" height="9" rx="2"/><path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3"/></svg>',
+ };
+ const CSS = `.ovcombo { position: fixed; z-index: 10030; box-sizing: border-box; max-height: min(60vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 6px; border-radius: 16px;
+ background: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, #111826)) 96%, transparent); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.42), 0 2px 6px rgba(0,0,0,.2);
+ -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); color: var(--text-primary, #e6edf7); font: 600 14.5px/1.25 var(--font, system-ui, -apple-system, 'Segoe UI', sans-serif);
+ transform-origin: 24px 0; animation: ovcombo-in .2s cubic-bezier(.2, 1.2, .3, 1) both; }
+.ovcombo.is-up { transform-origin: 24px 100%; }
+.ovcombo[hidden] { display: none; }
+.ovcombo-sec { margin: 0; padding: 8px 10px 5px; font-size: 11.5px; font-weight: 700; letter-spacing: .02em; color: var(--text-muted, #93a0b4); }
+.ovcombo-sec:not(:first-child) { margin-top: 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); padding-top: 10px; }
+.ovcombo-i { display: flex; align-items: center; gap: 11px; min-height: 46px; padding: 4px 12px 4px 10px; border-radius: 11px; cursor: pointer; }
+.ovcombo-i[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
+.ovcombo-i:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
+.ovcombo-i[aria-selected="true"]:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent); }
+.ovcombo-ic { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
+.ovcombo-ic svg { width: 18px; height: 18px; }
+.ovcombo-i.is-create .ovcombo-ic { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
+.ovcombo-t { flex: 1; min-width: 0; display: grid; gap: 2px; }
+.ovcombo-t b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovcombo-t b mark { background: none; color: var(--accent-light, var(--accent, #60a5fa)); }
+.ovcombo-t small { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; color: var(--text-muted, #93a0b4); overflow: hidden; white-space: nowrap; }
+.ovcombo-none { padding: 14px 12px; font-weight: 500; font-size: 13.5px; color: var(--text-muted, #93a0b4); }
+.ovcombo-skel { display: flex; align-items: center; gap: 11px; height: 46px; padding: 0 10px; }
+.ovcombo-skel i { flex: none; width: 30px; height: 30px; border-radius: 9px; }
+.ovcombo-skel b { height: 11px; border-radius: 6px; width: var(--w, 60%); }
+.ovcombo-skel i, .ovcombo-skel b { background: linear-gradient(90deg, color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent)); background-size: 200% 100%; animation: ovcombo-shine 1.1s linear infinite; }
+.ovcombo-err { padding: 10px 12px; font-size: 13px; font-weight: 500; color: var(--live-red, #f87171); }
+@keyframes ovcombo-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }
+@keyframes ovcombo-shine { to { background-position: -200% 0; } }
+html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, html[data-ov-motion=off] .ovcombo-skel b { animation: none; }`;
+
+ let pop = null, cur = null, uid = 0;
+ function styles() { if (document.getElementById('ovcombo-styles')) return; const st = document.createElement('style'); st.id = 'ovcombo-styles'; st.textContent = CSS; document.head.appendChild(st); }
+ function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
+ const nameOf = (q) => { const s = String(q || '').trim().replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, ''); const last = s.split('/').pop(); return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(last || '') ? last : ''; };
+ const ago = (t) => { const s = (Date.now() - new Date(t).getTime()) / 1000; if (!isFinite(s) || s < 0) return ''; return s < 3600 ? `${Math.max(1, Math.floor(s / 60))} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`; };
+
+ function enhance(input, opts) {
+ if (!input || input.__ovcombo) return input && input.__ovcombo;
+ styles();
+ const o = Object.assign({}, opts);
+ const src = o.source || input.getAttribute('data-ov-combo');
+ const createAt = o.create || input.getAttribute('data-ov-combo-create');
+ const listId = `ovcombo-${++uid}`;
+ input.setAttribute('role', 'combobox');
+ input.setAttribute('aria-autocomplete', 'list');
+ input.setAttribute('aria-expanded', 'false');
+ input.setAttribute('aria-controls', listId);
+ input.setAttribute('autocomplete', 'off');
+ input.removeAttribute('list');
+ const cache = new Map();
+ let rows = [], at = -1, ctl = null, timer = 0, lastQ = null, creating = false;
+
+ async function fetchQ(q) {
+ const hit = cache.get(q);
+ if (hit && Date.now() - hit.at < 60000) return hit.v;
+ if (ctl) ctl.abort();
+ ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
+ let v;
+ if (typeof src === 'function') v = await src(q, ctl && ctl.signal);
+ else {
+ const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '12');
+ const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: ctl && ctl.signal });
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ v = await r.json();
+ }
+ v = { sections: Array.isArray(v && v.sections) ? v.sections : [], create: (v && v.create) || null };
+ cache.set(q, { at: Date.now(), v });
+ return v;
+ }
+ function place() {
+ if (!pop || pop.hidden) return;
+ const r = input.getBoundingClientRect();
+ const vh = root.innerHeight, vw = root.innerWidth;
+ const below = vh - r.bottom - 12, above = r.top - 12;
+ const up = below < 220 && above > below;
+ pop.classList.toggle('is-up', up);
+ const w = Math.min(Math.max(r.width, 280), vw - 16);
+ pop.style.width = `${w}px`;
+ pop.style.left = `${Math.max(8, Math.min(r.left, vw - w - 8))}px`;
+ pop.style.maxHeight = `${Math.min(420, Math.max(160, up ? above : below))}px`;
+ if (up) { pop.style.top = ''; pop.style.bottom = `${vh - r.top + 6}px`; } else { pop.style.bottom = ''; pop.style.top = `${r.bottom + 6}px`; }
+ }
+ function open() {
+ if (!pop) { pop = el('div', 'ovcombo'); pop.setAttribute('role', 'listbox'); pop.hidden = true; document.body.appendChild(pop); pop.addEventListener('mousedown', (e) => e.preventDefault()); }
+ pop.id = listId;
+ pop.setAttribute('aria-label', input.getAttribute('aria-label') || input.placeholder || 'Choices');
+ cur = api;
+ pop.hidden = false;
+ input.setAttribute('aria-expanded', 'true');
+ place();
+ }
+ function close() {
+ // A closed list stays closed: the answer still on its way is dropped.
+ lastQ = null; clearTimeout(timer);
+ if (ctl) { ctl.abort(); ctl = null; }
+ if (cur === api && pop) { pop.hidden = true; pop.replaceChildren(); }
+ input.setAttribute('aria-expanded', 'false');
+ input.removeAttribute('aria-activedescendant');
+ rows = []; at = -1;
+ }
+ function mark(text, q) {
+ const b = el('b');
+ const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
+ if (i < 0) { b.textContent = text; return b; }
+ b.append(text.slice(0, i), el('mark', null, text.slice(i, i + q.length)), text.slice(i + q.length));
+ return b;
+ }
+ function select(i) {
+ at = i;
+ rows.forEach((r, k) => r.el.setAttribute('aria-selected', String(k === at)));
+ if (rows[at]) { input.setAttribute('aria-activedescendant', rows[at].el.id); rows[at].el.scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');
+ }
+ function row(item, q, kind) {
+ const d = el('div', `ovcombo-i${kind === 'create' ? ' is-create' : ''}`);
+ d.id = `${listId}-${rows.length}`;
+ d.setAttribute('role', 'option');
+ d.setAttribute('aria-selected', 'false');
+ const ic = el('span', 'ovcombo-ic');
+ ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.host] || (kind === 'recent' ? HOST_IC.recent : HOST_IC.repo);
+ const t = el('span', 'ovcombo-t');
+ t.appendChild(kind === 'create' ? el('b', null, item.label) : mark(String(item.label || item.url || ''), q));
+ const subBits = kind === 'create' ? [item.sub] : [item.host && item.host !== 'git.openvibe.codes' ? item.host : item.host ? 'OpenVibe Git' : null, item.sub || (item.updated_at ? ago(item.updated_at) : null)].filter(Boolean);
+ if (subBits.length || item.private) {
+ const s = el('small');
+ if (item.private && kind !== 'create') { const l = el('span'); l.innerHTML = HOST_IC.lock; l.setAttribute('title', 'Private'); s.appendChild(l); }
+ s.append(subBits.join(' · '));
+ t.appendChild(s);
+ }
+ d.append(ic, t);
+ const k = rows.length;
+ d.addEventListener('click', () => choose(k));
+ d.addEventListener('mousemove', () => { if (at !== k) select(k); });
+ rows.push({ el: d, item, kind });
+ return d;
+ }
+ function skeleton() {
+ open();
+ pop.replaceChildren();
+ ['62%', '48%', '70%'].forEach((w) => { const s = el('div', 'ovcombo-skel'); s.style.setProperty('--w', w); s.append(el('i'), el('b')); pop.appendChild(s); });
+ pop.setAttribute('aria-busy', 'true');
+ }
+ function paint(v, q) {
+ open();
+ pop.removeAttribute('aria-busy');
+ pop.replaceChildren(); rows = []; at = -1;
+ let any = false;
+ for (const sec of v.sections) {
+ const items = (sec.items || []).filter((x) => x && (x.url || x.label));
+ if (!items.length) continue;
+ any = true;
+ const h = el('p', 'ovcombo-sec', sec.label || ''); h.setAttribute('role', 'presentation');
+ pop.appendChild(h);
+ for (const it of items) pop.appendChild(row(it, q, sec.id === 'recent' ? 'recent' : 'item'));
+ }
+ const name = nameOf(q);
+ const c = v.create;
+ const exists = rows.some((r) => String(r.item.label || '').split('/').pop().toLowerCase() === name.toLowerCase());
+ if (createAt && c && c.allowed && name && !exists) {
+ if (any) { const h = el('p', 'ovcombo-sec', 'New'); h.setAttribute('role', 'presentation'); pop.appendChild(h); }
+ pop.appendChild(row({ label: `Create “${name}” on ${c.host === 'git.openvibe.codes' || !c.host ? 'OpenVibe Git' : c.host}`, sub: `Private, under ${c.owner ? '@' + c.owner : 'your account'}. You can share it later.`, name }, q, 'create'));
+ }
+ if (!rows.length) pop.appendChild(el('div', 'ovcombo-none', q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.'));
+ place();
+ if (rows.length && q) select(0);
+ }
+ async function look(q) {
+ lastQ = q;
+ if (!cache.has(q)) skeleton();
+ try {
+ const v = await fetchQ(q);
+ if (lastQ !== q || document.activeElement !== input) return;
+ paint(v, q);
+ } catch (e) {
+ if (e && e.name === 'AbortError') return;
+ if (lastQ !== q) return;
+ open(); pop.removeAttribute('aria-busy'); pop.replaceChildren(el('div', 'ovcombo-err', 'The list did not load. You can still paste an address.')); place();
+ }
+ }
+ async function choose(i) {
+ const r = rows[i]; if (!r || creating) return;
+ if (r.kind !== 'create') { put(r.item); return; }
+ creating = true;
+ r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;
+ try {
+ let made;
+ if (typeof createAt === 'function') made = await createAt(r.item.name);
+ else {
+ const res = await fetch(new URL(createAt, location.href).href, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ name: r.item.name, private: true }) });
+ made = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(made.message || made.error || `HTTP ${res.status}`);
+ }
+ cache.clear();
+ put({ label: made.label || r.item.name, url: made.url, host: 'git.openvibe.codes', created: true });
+ } catch (e) {
+ const s = r.el.querySelector('small') || r.el.querySelector('.ovcombo-t').appendChild(el('small'));
+ r.el.querySelector('b').textContent = `Create “${r.item.name}”`;
+ s.textContent = String((e && e.message) || 'It was not created.').slice(0, 160);
+ s.style.color = 'var(--live-red, #f87171)';
+ } finally { creating = false; }
+ }
+ function put(item) {
+ input.value = item.url || item.label || '';
+ close();
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ input.dispatchEvent(new CustomEvent('ov-combo:pick', { bubbles: true, detail: item }));
+ input.__ovcomboPicked = input.value;
+ }
+
+ input.addEventListener('focus', () => { if (input.__ovcomboPicked !== input.value || !input.value) look(input.value.trim()); });
+ input.addEventListener('input', () => { if (input.__ovcomboPicked === input.value) return; input.__ovcomboPicked = null; clearTimeout(timer); timer = setTimeout(() => look(input.value.trim()), 160); });
+ input.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== input) close(); }, 120));
+ input.addEventListener('keydown', (e) => {
+ const shown = pop && !pop.hidden && cur === api;
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (!shown) { look(input.value.trim()); return; }
+ if (!rows.length) return;
+ select(at < 0 ? (e.key === 'ArrowDown' ? 0 : rows.length - 1) : (at + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length);
+ } else if (e.key === 'Enter' && shown && at >= 0) { e.preventDefault(); e.stopPropagation(); choose(at); }
+ else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }
+ else if (e.key === 'Tab' && shown) close();
+ });
+ root.addEventListener('resize', place);
+ root.addEventListener('scroll', place, true);
+ const api = { input, open: () => look(input.value.trim()), close, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };
+ input.__ovcombo = api;
+ return api;
+ }
+
+ function scan(scope) { (scope || document).querySelectorAll('input[data-ov-combo]').forEach((i) => enhance(i)); }
+ root.OVCombo = { enhance, scan };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
+ document.addEventListener('ov:boost:load', () => scan());
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -27,6 +27,21 @@
@@ -27,6 +27,21 @@
if (root.OpenVibeComposer || typeof document === 'undefined') return;
if (root.OpenVibeComposer || typeof document === 'undefined') return;
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
+ // from beside this file; without it, a plain <dialog>.
+ const COMPOSER_REV = { 'ov-window.js': 'c882d11d85ff' };
+ const selfSrc = (document.currentScript && document.currentScript.src) || '';
+ function needWindow() {
+ return new Promise((ok) => {
+ if (root.OVWindow) return ok(root.OVWindow);
+ const m = /^(https?:\/\/[^?#]*\/)ov-composer(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
+ if (!m) return ok(null);
+ const sc = document.createElement('script');
+ sc.src = m[1] + 'ov-window.js' + (COMPOSER_REV['ov-window.js'] ? '?v=' + COMPOSER_REV['ov-window.js'] : '');
+ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
+ document.head.appendChild(sc);
+ });
+ }
const live = new Set();
const live = new Set();
const PREFS_KEY = 'ov:composer';
const PREFS_KEY = 'ov:composer';
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
const DEFAULTS = { send_key: 'enter', voice: true, density: 'comfy', snippets: [] };
@@ -207,8 +222,11 @@
@@ -207,8 +222,11 @@
const t = cd.getData('text/plain') || '';
const t = cd.getData('text/plain') || '';
const lines = t.split('\n').length;
const lines = t.split('\n').length;
if (t.length > 6000 || lines > 120) {
if (t.length > 6000 || lines > 120) {
+ // Only where the box takes a text file (or a mod uploads): a box for pictures keeps the paste as text.
+ const asFile = new File([t], 'pasted-text.txt', { type: 'text/plain' });
+ if (!uploader() && !accepts(asFile)) return;
e.preventDefault();
e.preventDefault();
- add([new File([t], 'pasted-text.txt', { type: 'text/plain' })], { lines, text: t });
+ add([asFile], { lines, text: t });
say('The long paste went in as a file: ↩ on it puts it back in the box.');
say('The long paste went in as a file: ↩ on it puts it back in the box.');
}
}
});
});
@@ -308,13 +326,15 @@
@@ -308,13 +326,15 @@
text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();
text.setRangeText(String(it.insert || ''), from, from, 'end'); fire(); text.focus();
}
}
let lookT = 0;
let lookT = 0;
- text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(look, trig && trig.kind === 'at' ? 140 : 0); });
+ text.addEventListener('input', () => { clearTimeout(lookT); lookT = setTimeout(() => { lookT = 0; look(); }, trig && trig.kind === 'at' ? 140 : 0); });
text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));
text.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== text) close(); }, 120));
text.setAttribute('aria-autocomplete', 'list');
text.setAttribute('aria-autocomplete', 'list');
// Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.
// Keys, before the thread's own: the list's arrows and Enter, what sends, and the marks.
form.addEventListener('keydown', (e) => {
form.addEventListener('keydown', (e) => {
if (e.target !== text) return;
if (e.target !== text) return;
+ // Enter right after the last letter: the slash list is read now, so "/settings" + Enter picks it.
+ if (e.key === 'Enter' && lookT) { clearTimeout(lookT); lookT = 0; const w = caretWord(); if (w && w.kind === 'slash') look(); }
if (!pop.hidden && items.length) {
if (!pop.hidden && items.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); at = (at + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; paintPop(); return; }
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); choose(at); return; }
@@ -419,26 +439,32 @@
@@ -419,26 +439,32 @@
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
let sheet = null;
let sheet = null;
- function openSettings() {
- if (!sheet) {
+ function openSettings(from) {
+ const opener = from || document.activeElement;
+ needWindow().then((W) => settingsIn(W, opener));
+ }
+ function settingsIn(W, opener) {
+ let win = null;
+ if (!W && !sheet) {
sheet = el('dialog', 'ovc-set');
sheet = el('dialog', 'ovc-set');
sheet.setAttribute('aria-label', 'Your composer');
sheet.setAttribute('aria-label', 'Your composer');
sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
document.body.appendChild(sheet);
document.body.appendChild(sheet);
}
}
const k = mac ? '⌘' : 'Ctrl';
const k = mac ? '⌘' : 'Ctrl';
- sheet.innerHTML = `<form method="dialog" class="ovc-set-f"><header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>
+ const host = W ? el('div', 'ovc-set ovc-set-in') : sheet;
+ host.innerHTML = `<form method="dialog" class="ovc-set-f">${W ? '' : `<header class="ovc-set-h"><b>Your composer</b><button class="ovt-btn" value="done" aria-label="Done">${svg(IC.x)}</button></header>`}
<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset>
<fieldset class="ovc-set-g"><legend>Send with</legend><label><input type="radio" name="send_key" value="enter"> Enter <small>(Shift+Enter for a new line)</small></label><label><input type="radio" name="send_key" value="mod-enter"> ${k}+Enter <small>(Enter for a new line)</small></label></fieldset>
<label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label>
<label class="ovc-set-c"><input type="checkbox" name="voice"> A microphone button${Speech ? '' : ' <small>(this browser cannot listen)</small>'}</label>
<label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label>
<label class="ovc-set-c"><input type="checkbox" name="compact"> A compact box</label>
<fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul>
<fieldset class="ovc-set-g"><legend>Your commands</legend><p class="ovc-set-p">Type / and the word, and its text goes in. Up to 50.</p><ul class="ovc-set-l"></ul>
<div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset>
<div class="ovc-set-add"><input name="trigger" placeholder="word" maxlength="30" pattern="[A-Za-z0-9_-]+" aria-label="The word after /"><textarea name="snippet" rows="2" maxlength="4000" placeholder="The text it puts in" aria-label="The text it puts in"></textarea><button type="button" class="ovc-set-btn" data-ovc-add>Add</button></div></fieldset>
<p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;
<p class="ovc-set-p">${synced ? 'Saved to your account: the same on every OpenVibe site.' : 'Saved in this browser.'}</p></form>`;
- const f = sheet.querySelector('form');
+ const f = host.querySelector('form');
f.send_key.value = prefs.send_key;
f.send_key.value = prefs.send_key;
f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;
f.voice.checked = !!prefs.voice; f.voice.disabled = !Speech;
f.compact.checked = prefs.density === 'compact';
f.compact.checked = prefs.density === 'compact';
- const list = sheet.querySelector('.ovc-set-l');
+ const list = host.querySelector('.ovc-set-l');
const paintList = () => {
const paintList = () => {
list.innerHTML = '';
list.innerHTML = '';
(prefs.snippets || []).forEach((s, i) => {
(prefs.snippets || []).forEach((s, i) => {
@@ -456,7 +482,7 @@
@@ -456,7 +482,7 @@
else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });
else if (e.target.name === 'voice') setPrefs({ voice: f.voice.checked });
else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });
else if (e.target.name === 'compact') setPrefs({ density: f.compact.checked ? 'compact' : 'comfy' });
});
});
- sheet.querySelector('[data-ovc-add]').addEventListener('click', () => {
+ host.querySelector('[data-ovc-add]').addEventListener('click', () => {
const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;
const w = f.trigger.value.trim().replace(/^\//, ''), t = f.snippet.value;
if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }
if (!/^[\w-]{1,30}$/.test(w)) { f.trigger.focus(); return; }
if (!t.trim()) { f.snippet.focus(); return; }
if (!t.trim()) { f.snippet.focus(); return; }
@@ -464,8 +490,10 @@
@@ -464,8 +490,10 @@
f.trigger.value = ''; f.snippet.value = '';
f.trigger.value = ''; f.snippet.value = '';
paintList(); f.trigger.focus();
paintList(); f.trigger.focus();
});
});
- if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', '');
- const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus();
+ f.addEventListener('submit', (e) => e.preventDefault());
+ if (W) win = W.open({ id: 'ov-composer-settings', title: 'Your composer', body: host, kind: 'dialog', size: 'sm', from: opener, focus: 'input[name=send_key]:checked' });
+ else { if (typeof sheet.showModal === 'function') sheet.showModal(); else sheet.setAttribute('open', ''); const first = f.querySelector('input[name=send_key]:checked'); if (first) first.focus(); }
+ return win;
}
}
function mod(m) {
function mod(m) {
@@ -4,7 +4,10 @@
@@ -4,7 +4,10 @@
* navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
* navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx.
*
*
* <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them
* <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them
- * <ul data-fx="reveal" data-fx-stagger="60">…</ul> its children rise in as they come on screen, 60 ms apart
+ * <section data-fx="reveal" data-fx-stagger="40">…</section>
+ * its heading rises 12 px (8 on a phone) out of a 4 px blur,
+ * a [data-fx-rule] draws from the left, and its cards follow in
+ * 40 ms steps (at most six; the rest together), once.
* <b data-fx="count">12,480</b> the number counts up the first time it is seen
* <b data-fx="count">12,480</b> the number counts up the first time it is seen
* <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed
* <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed
* OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
* OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal)
@@ -18,9 +21,17 @@
@@ -18,9 +21,17 @@
* --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
* --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a
* little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the
* little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the
* element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:
* element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion:
- * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a reveal
- * rises five, a tilt leans a third as far, a burst of confetti is a third the size, nothing overshoots. Only Off
- * (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
+ * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt
+ * leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short
+ * fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls.
+ *
+ * The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script
+ * runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is
+ * visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script
+ * knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6
+ * in the container; a rule is [data-fx-rule] (or an <hr>); cards are [data-fx-card]s, the container's own children,
+ * or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not
+ * one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still.
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -53,8 +64,30 @@
@@ -53,8 +64,30 @@
+ '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'
+ '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}'
+ '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
+ '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}'
+ '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
+ '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}'
- + '.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'
- + '.ovfx-in{opacity:1;transform:none;transition:opacity .5s var(--ov-ease-out),transform .6s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
+ // The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline
+ // plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS.
+ + '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}'
+ + '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}'
+ + '.ovfx-rule{transform-origin:left center}'
+ + '@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}}'
+ + '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}'
+ + '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}'
+ + '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}'
+ + '@supports (animation-timeline:view()){.ovfx-head.ovfx-scroll{animation:ovfxHeadRise linear backwards;animation-timeline:view();animation-range:entry 0% cover 25%}.ovfx-rule.ovfx-scroll{animation:ovfxRuleDraw linear backwards;animation-timeline:view();animation-range:entry 5% cover 35%}.ovfx-card.ovfx-scroll{animation:ovfxCardRise linear backwards;animation-timeline:view();animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)}.ovfx-fade.ovfx-scroll{animation:ovfxFadeIn linear backwards;animation-timeline:view();animation-range:entry 0% cover 15%}}'
+ + '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}'
+ + '.ovfx-head.ovfx-in{opacity:1;transform:none;filter:blur(0);transition:opacity .5s var(--ov-ease-out),transform .55s var(--ov-ease-spring),filter .45s var(--ov-ease-out)}'
+ + '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}'
+ + '.ovfx-card.ovfx-in{opacity:1;transform:none;transition:opacity .45s var(--ov-ease-out),transform .55s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}'
+ + '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}'
+ + '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}'
+ + '.ovfx-fade.ovfx-wait{opacity:0}'
+ + '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}'
+ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}'
+ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}'
+ + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}'
+ + '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}}'
+ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}'
+ + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade).ovfx-wait:not([data-fx-demo] *){opacity:1!important;transform:none!important;filter:none!important}'
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
+ '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}'
@@ -86,21 +119,100 @@
@@ -86,21 +119,100 @@
}
}
// ── Seen: reveal and count, once each, when they come on screen ──
// ── Seen: reveal and count, once each, when they come on screen ──
- function seen(el) {
- if (el.__ovfxReveal) {
- const kids = el.__ovfxReveal;
- kids.forEach((k) => { k.classList.add('ovfx-in'); k.classList.remove('ovfx-hide'); });
- root.setTimeout(() => kids.forEach((k) => { k.classList.remove('ovfx-in'); k.style.removeProperty('--ovfx-d'); }), 1400 + kids.length * 80);
- el.__ovfxReveal = null;
- }
- if (el.__ovfxCount) { const c = el.__ovfxCount; el.__ovfxCount = null; countUp(el, c); }
- }
- function observe(el) {
- if (!('IntersectionObserver' in root)) return seen(el);
- io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); seen(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
+ function observe(el, fn) {
+ if (!('IntersectionObserver' in root)) return fn(el);
+ io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); (e.target.__ovfxSeen || fn)(e.target); } }), { rootMargin: '0px 0px -8% 0px' });
+ el.__ovfxSeen = fn;
io.observe(el);
io.observe(el);
}
}
const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } };
+ function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } }
+
+ // ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it
+ // without any script on the main thread; the shared observer is the fallback. Only elements the script knows are
+ // below the fold ever get a start state, and every path has a way back to the finished state.
+ const HEAD_SEL = 'h1,h2,h3,h4,h5,h6';
+ const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]';
+ function findHead(el) {
+ if (el.hasAttribute('data-fx-self')) return el;
+ const marked = el.querySelector('[data-fx-heading]');
+ if (marked) return marked;
+ if (el.matches(HEAD_SEL)) return el;
+ for (const h of el.querySelectorAll(HEAD_SEL)) {
+ const card = h.closest(CARDISH);
+ if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading
+ let depth = 0, p = h;
+ while (p && p !== el) { p = p.parentElement; depth++; }
+ if (depth <= 3) return h;
+ }
+ return null;
+ }
+ function findRule(el, head) {
+ const marked = el.querySelector('[data-fx-rule]');
+ if (marked && marked !== head) return marked;
+ const hr = el.querySelector('hr');
+ return hr && hr !== head ? hr : null;
+ }
+ function findCards(el, head, rule) {
+ const sel = el.getAttribute('data-fx-cards');
+ if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } }
+ const marked = Array.from(el.querySelectorAll('[data-fx-card]'));
+ if (marked.length) return marked;
+ const out = [];
+ for (const k of Array.from(el.children)) {
+ if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue;
+ const cls = typeof k.className === 'string' ? k.className : '';
+ if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) {
+ const inner = Array.from(k.children);
+ if (inner.length) { out.push(...inner); continue; }
+ }
+ out.push(k);
+ }
+ return out;
+ }
+ let viewTimeline = null;
+ function hasViewTimeline() {
+ if (viewTimeline != null) return viewTimeline;
+ try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; }
+ return viewTimeline;
+ }
+ function showReveal(el) {
+ if (el.__ovfxShown) return;
+ el.__ovfxShown = true;
+ el.classList.add('ovfx-in');
+ const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0;
+ root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay);
+ }
+ function wireReveal(el) {
+ if (off(el)) return;
+ const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full';
+ const self = el.hasAttribute('data-fx-self');
+ const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule);
+ const parts = [];
+ if (head) parts.push([head, 'head', 0]);
+ if (rule) parts.push([rule, 'rule', 0]);
+ cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)]));
+ if (!parts.length) return;
+ // Never hide what someone can already see: a section below the fold is safe whole, one already on screen
+ // is checked part by part.
+ const live = below(el) ? parts : parts.filter(([k]) => below(k));
+ if (!live.length) return;
+ const timeline = hasViewTimeline();
+ if (!timeline && !('IntersectionObserver' in root)) return;
+ const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40));
+ const follow = head ? 140 : 0;
+ live.forEach(([k, kind, i]) => {
+ k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card');
+ if (timeline) {
+ k.classList.add('ovfx-scroll');
+ if (kind === 'card') k.style.setProperty('--ovfx-i', String(i));
+ } else {
+ k.classList.add('ovfx-wait');
+ if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`);
+ observe(k, showReveal);
+ }
+ });
+ }
function countUp(el, c) {
function countUp(el, c) {
const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1);
const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1);
@@ -188,16 +300,10 @@
@@ -188,16 +300,10 @@
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev));
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); }
if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });
if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); });
- if (n === 'reveal' && !off(el)) {
- const kids = el.children.length && el.getAttribute('data-fx-self') == null ? Array.from(el.children) : [el];
- if (!below(el)) continue; // already on screen when wired: never hide what someone can see
- const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 50));
- kids.forEach((k, i) => { k.classList.add('ovfx-hide'); k.style.setProperty('--ovfx-d', `${Math.min(i, 12) * gap}ms`); });
- el.__ovfxReveal = kids; observe(el);
- }
+ if (n === 'reveal') wireReveal(el);
if (n === 'count' && !off(el)) {
if (n === 'count' && !off(el)) {
const c = countOf(el);
const c = countOf(el);
- if (c && below(el)) { el.__ovfxCount = c; observe(el); } else if (c) { el.__ovfxCount = c; seen(el); }
+ if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); }
}
}
}
}
}
}
@@ -306,35 +306,52 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -306,35 +306,52 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
(o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
(o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
return byId;
return byId;
}
}
- // The network's groups, as folders for the sites past the first page (until the person arranges their home).
- const GROUP_FOLDERS = [['streams', 'Watch'], ['people', 'People'], ['make', 'Make'], ['answers', 'Read'], ['games', 'Play'], ['shop', 'Shop']];
+ // The network's eight groups, in the menu's own order (OpenVibe.Network frame/sites.js GROUPS, the owner's
+ // regrouping of 2026-11-10), as folders for the sites past the first page (until the person arranges their home).
+ const GROUP_FOLDERS = [['watch', 'Watch & stream'], ['talk', 'Talk & belong'], ['play', 'Play'], ['read', 'Read & ask'], ['shop', 'Shop & money'], ['tools', 'Free tools'], ['make', 'Make with AI'], ['build', 'For developers']];
+ // A group folder's id: `g-watch` …; Build keeps `build`, the id saved home screens already use.
+ const folderId = (g) => (g === 'build' ? 'build' : `g-${g}`);
+ // Saved before the regrouping: the old group ids map onto the new folders.
+ const FOLDER_ALIAS = { 'g-streams': 'g-watch', 'g-people': 'g-talk', 'g-games': 'g-play', 'g-answers': 'g-read' };
+ const normalizeFolder = (id) => (id.startsWith('folder:') ? `folder:${FOLDER_ALIAS[id.slice(7)] || id.slice(7)}` : id);
function topSites(o) {
function topSites(o) {
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id));
- const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);
- return sites.slice(0, Math.max(6, 12 - 2 - mineFirst.length));
+ const limit = Math.max(6, 12 - 2 - mineFirst.length);
+ // Leave at least one app in every group for its folder: a group that is all singles has no folder to open.
+ const left = new Map();
+ for (const a of o.apps || []) left.set(a.group, (left.get(a.group) || 0) + 1);
+ const sites = [];
+ for (const a of o.apps || []) {
+ if (a.group === 'build') continue;
+ if ((left.get(a.group) || 0) <= 1) continue;
+ left.set(a.group, left.get(a.group) - 1);
+ sites.push(a.id);
+ if (sites.length >= limit) break;
+ }
+ return sites;
}
}
function folderDefs(o) {
function folderDefs(o) {
const apps = o.apps || [];
const apps = o.apps || [];
const top = new Set(topSites(o));
const top = new Set(topSites(o));
- const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: `g-${g}`, name, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
- .filter((f) => f.apps.length >= 2);
+ const groups = GROUP_FOLDERS.map(([g, name]) => ({ id: folderId(g), name, group: g, apps: apps.filter((a) => a.group === g && !top.has(a.id)).map((a) => a.id), glyph: 'grid' }))
+ .filter((f) => f.apps.length);
return groups.concat([
return groups.concat([
{ id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
{ id: 'tools', name: 'Quick tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
- { id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
]).filter((f) => f.apps.length);
]).filter((f) => f.apps.length);
}
}
- /** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */
+ /** Until the person arranges it: their own places first (signed in), then the most used and popular sites, then
+ * one folder per group (the menu's eight, in its order) and the quick tools. */
function defaultHome(o) {
function defaultHome(o) {
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
- const groupFolders = folderDefs(o).filter((f) => f.id.startsWith('g-')).map((f) => `folder:${f.id}`);
- return mineFirst.concat(topSites(o), groupFolders, ['folder:tools', 'folder:build']);
+ const groupFolders = folderDefs(o).filter((f) => f.group).map((f) => `folder:${f.id}`);
+ return mineFirst.concat(topSites(o), groupFolders, ['folder:tools']);
}
}
function layoutOf(o) {
function layoutOf(o) {
const byId = universe(o);
const byId = universe(o);
const folders = folderDefs(o);
const folders = folderDefs(o);
const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));
const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));
const l = readLocal();
const l = readLocal();
- const home = (l ? l.home : defaultHome(o)).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
+ const home = (l ? l.home : defaultHome(o)).map(normalizeFolder).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
return { home, folders, arranged: !!l, byId };
return { home, folders, arranged: !!l, byId };
}
}
@@ -104,6 +104,8 @@
@@ -104,6 +104,8 @@
panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']]));
panel.append(row('text', 'Text size', [[100, 'Default'], [112, 'Large'], [125, 'Largest']]));
// Motion (4.183.0): Auto follows the device; Calm keeps every effect, smaller and slower; only Off stops them.
// Motion (4.183.0): Auto follows the device; Calm keeps every effect, smaller and slower; only Off stops them.
panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));
panel.append(row('motion', 'Motion', [['auto', 'Auto'], ['full', 'Full'], ['reduced', 'Calm'], ['off', 'Off']]));
+ // Windows (4.202.0, asks 304 and 318): every window at once (ov-window.js), glass, solid or outlined.
+ panel.append(row('window', 'Windows', [['glass', 'Glass'], ['solid', 'Solid'], ['outline', 'Outline']]));
}
}
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const signedIn = opts.signedIn != null ? !!opts.signedIn : /(?:^|; )ov_sso_hint=account/.test(document.cookie || '') || !!(root.localStorage && (() => { try { return root.localStorage.getItem('ov_token'); } catch (e) { return null; } })());
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
const named = showing ? showing.name : ((PICKS.find(([id]) => id === cur) || [0, cur])[1]);
@@ -24,9 +24,19 @@
@@ -24,9 +24,19 @@
// 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);
- // 4.201.0: the one composer (ov-composer.js: files, / and @, voice, drafts, your settings), from beside this file.
- const THREAD_REV = { 'ov-composer.js': 'b70e544155ac' };
+ const THREAD_REV = { 'ov-composer.js': 'bab301d99cb6', '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.
+ function loadCombo() {
+ if (root.OVCombo) { root.OVCombo.scan(); return; }
+ if (loadCombo.on || !document.querySelector('input[data-ov-combo]')) return;
+ loadCombo.on = true;
+ var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
+ var s = document.createElement('script');
+ s.async = true;
+ s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
+ document.head.appendChild(s);
+ }
function loadComposer() {
function loadComposer() {
if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }
if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }
if (loadComposer.on) return;
if (loadComposer.on) return;
@@ -243,6 +253,7 @@
@@ -243,6 +253,7 @@
root.OVThread = api;
root.OVThread = api;
function start() {
function start() {
+ loadCombo();
qa(document, '[data-ovt]').forEach(function (box) {
qa(document, '[data-ovt]').forEach(function (box) {
if (box.dataset.ovtReady) return;
if (box.dataset.ovtReady) return;
box.dataset.ovtReady = '1';
box.dataset.ovtReady = '1';
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
+/* The public user card (ask 287, the owner: "one user card … across all sites … level … live streams … like Discord, but
+ * way better"). Its look, 4.202.0: a banner from the person's own picture (blurred, tinted by their name's hue), the face
+ * with its ring (red while live), the name and @handle, Live now as the first action when they are, level / streak /
+ * badges / followers as chips, the sites they used this week, and the way to their profile and channel. Opened from a
+ * mention or a face (OVUser.bind), it sits in the one window (ov-window.js, a panel beside what was pressed; a sheet on
+ * a phone), loaded from beside this file.
+ * OVUser.card(host, { username }) -> { ready, refresh(), destroy() }
+ * Nothing mounts or intercepts clicks merely by loading this file. */
+(function (root, factory) {
+ 'use strict';
+ const api = factory(root);
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVUser = api;
+})(typeof window !== 'undefined' ? window : globalThis, function (root) {
+ 'use strict';
+ const NETWORK = 'https://openvibe.network';
+ const USER_REV = { 'ov-window.js': 'c882d11d85ff' };
+ 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%)); }
+.ovu-banner::before { content: ""; position: absolute; inset: -30%; background: var(--ovu-bg, none) center / cover; filter: blur(26px) saturate(1.5); opacity: .75; }
+.ovu-banner::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 30%, color-mix(in srgb, var(--ovw-bg, #111826) 55%, transparent)); }
+.ovu-head { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: end; gap: 12px; margin: -44px 16px 0; }
+.ovu-face { position: relative; display: grid; place-items: center; width: 76px; height: 76px; border-radius: 50%; overflow: hidden; background: hsl(var(--ovu-h, 220) 55% 40%); color: #fff; font-size: 30px; font-weight: 800; box-shadow: 0 0 0 4px var(--ovw-bg, var(--bg-secondary, #111826)); }
+.ovu-face img { width: 100%; height: 100%; object-fit: cover; }
+.ovu-face.is-live { box-shadow: 0 0 0 3px var(--live-red, #ef4444), 0 0 0 6px var(--ovw-bg, var(--bg-secondary, #111826)); }
+.ovu-names { min-width: 0; padding-bottom: 4px; }
+.ovu-name { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; font-weight: 800; letter-spacing: -.01em; }
+.ovu-handle { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 14px; }
+.ovu-state { margin: 0 16px; font-size: 13px; color: var(--text-muted, #8b98ad); }
+.ovu-live { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; margin: 0 16px; border-radius: 12px; background: var(--live-red, #ef4444); color: #fff; font-weight: 750; text-decoration: none; }
+.ovu-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: ovu-beat 1.4s ease-in-out infinite; }
+.ovu-live small { font-weight: 600; opacity: .85; }
+.ovu-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 16px; padding: 0; list-style: none; }
+.ovu-chips li { display: inline-flex; align-items: baseline; gap: 5px; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
+.ovu-chips b { font-size: 14px; color: var(--text-primary, #e6edf7); }
+.ovu-sec { margin: 0 16px; }
+.ovu-sec h3 { margin: 0 0 6px; font-size: 11.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #8b98ad); }
+.ovu-sites { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
+.ovu-sites a { display: inline-flex; align-items: center; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13px; font-weight: 600; }
+.ovu-sites a:hover { border-color: var(--accent, #3b82f6); }
+.ovu-acts { display: flex; gap: 8px; margin: 2px 16px 0; }
+.ovu-acts a { flex: 1; display: grid; place-items: center; min-height: 42px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: inherit; font-weight: 650; text-decoration: none; }
+.ovu-acts a:first-child { background: var(--accent, #3b82f6); color: var(--on-accent, #fff); }
+.ovu-acts a:hover { filter: brightness(1.1); }
+.ovu-wait { display: grid; gap: 10px; padding: 0 16px 18px; }
+.ovu-wait i { display: block; height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovu-shine 1.2s ease-in-out infinite; }
+.ovu-wait i:first-child { width: 55%; height: 18px; }
+.ovu-wait i:nth-child(2) { width: 35%; }
+.ovu-wait i:nth-child(3) { width: 80%; }
+.ovu-none { margin: 0 16px; color: var(--text-secondary, #a8b3c4); }
+@keyframes ovu-beat { 50% { opacity: .35; transform: scale(.75); } }
+@keyframes ovu-shine { 50% { opacity: .45; } }
+html[data-ov-motion=off] .ovu-live::before, html[data-ov-motion=off] .ovu-wait i { animation: none; }`;
+ function styles(doc) { if (doc.getElementById('ovu-styles')) return; const st = doc.createElement('style'); st.id = 'ovu-styles'; st.textContent = CSS; doc.head.appendChild(st); }
+ // The banner's hue: from the name, so a person's card always wears the same colour.
+ const hueOf = (name) => { let h = 0; for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; };
+ const compact = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
+ function needWindow() {
+ return new Promise((ok) => {
+ if (root.OVWindow) return ok(root.OVWindow);
+ const m = /^(https?:\/\/[^?#]*\/)ov-user(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
+ if (!m || typeof document === 'undefined') return ok(null);
+ const sc = document.createElement('script');
+ sc.src = m[1] + 'ov-window.js' + (USER_REV['ov-window.js'] ? '?v=' + USER_REV['ov-window.js'] : '');
+ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
+ document.head.appendChild(sc);
+ });
+ }
+ const mounts = new WeakMap();
+ const bindings = new WeakMap();
+ const usernameOf = value => typeof value === 'string' && /^[A-Za-z0-9_]{3,24}$/.test(value.replace(/^@/, '')) ? value.replace(/^@/, '') : null;
+ function safeUrl(value) {
+ try {
+ const u = new URL(value);
+ return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;
+ } catch { return null; }
+ }
+ function element(doc, tag, text, className) {
+ const el = doc.createElement(tag);
+ if (text !== undefined) el.textContent = String(text);
+ if (className) el.className = className;
+ return el;
+ }
+ function link(doc, label, url) {
+ const safe = safeUrl(url);
+ if (!safe) return null;
+ const a = element(doc, 'a', label, 'ovk-btn ovk-btn--quiet');
+ a.href = safe;
+ return a;
+ }
+ function render(section, person) {
+ const doc = section.ownerDocument;
+ section.replaceChildren();
+ section.setAttribute('aria-label', `User @${person.username}`);
+ section.style.setProperty('--ovu-h', String(hueOf(person.username)));
+ const avatar = safeUrl(person.avatar_url);
+ const banner = element(doc, 'div', undefined, 'ovu-banner');
+ if (avatar && !/["'()\\\s]/.test(avatar)) banner.style.setProperty('--ovu-bg', `url("${avatar}")`);
+ section.appendChild(banner);
+ const head = element(doc, 'div', undefined, 'ovu-head');
+ const live = person.live && person.live.now === true;
+ const face = element(doc, 'span', undefined, `ovu-face${live ? ' is-live' : ''}`);
+ if (avatar) { const img = doc.createElement('img'); img.src = avatar; img.alt = ''; img.decoding = 'async'; img.addEventListener('error', () => { img.remove(); face.textContent = person.username[0].toUpperCase(); }); face.appendChild(img); }
+ else face.textContent = person.username[0].toUpperCase();
+ const names = element(doc, 'div', undefined, 'ovu-names');
+ names.appendChild(element(doc, 'h2', typeof person.display_name === 'string' && person.display_name.trim() ? person.display_name : person.username, 'ovu-name'));
+ names.appendChild(element(doc, 'p', `@${person.username}`, 'ovu-handle'));
+ head.append(face, names);
+ section.appendChild(head);
+ if (live) {
+ const a = link(doc, 'Live now', person.live.url);
+ if (a) { a.className = 'ovu-live'; if (typeof person.live.where === 'string' && person.live.where) a.appendChild(element(doc, 'small', `on ${person.live.where}`)); section.appendChild(a); }
+ } else if (person.live && person.live.now === false) section.appendChild(element(doc, 'p', 'Not live right now', 'ovu-state'));
+ const chips = element(doc, 'ul', undefined, 'ovu-chips');
+ for (const [key, label] of [['level', 'Level'], ['streak', 'day streak'], ['badges_count', 'badges'], ['followers', 'followers']]) {
+ const value = person[key];
+ if (!Number.isSafeInteger(value) || value < 0) continue;
+ const li = element(doc, 'li');
+ if (key === 'level') { li.append(label + ' '); li.appendChild(element(doc, 'b', value)); }
+ else { li.appendChild(element(doc, 'b', compact(value))); li.append(` ${key === 'streak' && value === 1 ? 'day streak' : key === 'badges_count' && value === 1 ? 'badge' : key === 'followers' && value === 1 ? 'follower' : label}`); }
+ chips.appendChild(li);
+ }
+ if (chips.childNodes.length) section.appendChild(chips);
+ if (Array.isArray(person.active_sites)) {
+ const list = element(doc, 'ul', undefined, 'ovu-sites');
+ for (const site of person.active_sites) {
+ if (!site || typeof site.name !== 'string') continue;
+ const a = link(doc, site.name, site.url);
+ if (a) { a.className = ''; const li = element(doc, 'li'); li.appendChild(a); list.appendChild(li); }
+ }
+ if (list.childNodes.length) { const sec = element(doc, 'div', undefined, 'ovu-sec'); sec.append(element(doc, 'h3', 'This week'), list); section.appendChild(sec); }
+ }
+ const acts = element(doc, 'div', undefined, 'ovu-acts');
+ for (const [label, url] of [['Profile', person.profile_url], ['Channel', person.channel_url]]) {
+ const a = link(doc, label, url);
+ if (a) { a.className = ''; acts.appendChild(a); }
+ }
+ if (acts.childNodes.length) section.appendChild(acts);
+ }
+ function card(host, options = {}) {
+ const username = usernameOf(options.username);
+ if (!host?.ownerDocument || typeof host.replaceChildren !== 'function') throw new TypeError('OVUser.card: pass a host element');
+ if (!username) throw new TypeError('OVUser.card: use a username of 3–24 letters, digits or underscores');
+ mounts.get(host)?.destroy();
+ const doc = host.ownerDocument;
+ styles(doc);
+ const section = element(doc, 'section', undefined, 'ovu');
+ section.setAttribute('data-ov-user-card', '');
+ host.replaceChildren(section);
+ let generation = 0, controller, disposed = false;
+ const profile = `${NETWORK}/@${encodeURIComponent(username)}`;
+ const api = {
+ ready: null,
+ refresh() {
+ if (disposed) return Promise.resolve(null);
+ const ticket = ++generation;
+ controller?.abort();
+ const request = controller = new AbortController();
+ const timer = setTimeout(() => request.abort(), 8000);
+ const status = element(doc, 'div', undefined, 'ovu-wait'); status.setAttribute('role', 'status'); status.setAttribute('aria-label', 'Loading the profile');
+ status.append(element(doc, 'i'), element(doc, 'i'), element(doc, 'i'));
+ section.style.setProperty('--ovu-h', String(hueOf(username)));
+ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), status); section.setAttribute('aria-busy', 'true');
+ const fetcher = options.fetchImpl || ((...args) => root.fetch(...args));
+ const url = `${String(options.networkUrl || NETWORK).replace(/\/+$/, '')}/api/v1/people/${encodeURIComponent(username)}/card`;
+ api.ready = Promise.resolve().then(() => fetcher(url, { credentials: 'omit', cache: 'no-store',
+ headers: { Accept: 'application/json' }, signal: request.signal }))
+ .then(async response => {
+ if (!response.ok) throw new Error('Profile unavailable');
+ const person = await response.json();
+ if (!person || !usernameOf(person.username)) throw new Error('Profile unavailable');
+ if (!disposed && ticket === generation) render(section, person);
+ return person;
+ }).catch(() => {
+ if (!disposed && ticket === generation) {
+ const message = element(doc, 'p', 'The profile did not load.', 'ovu-none'); message.setAttribute('role', 'status');
+ const acts = element(doc, 'div', undefined, 'ovu-acts'); const a = link(doc, 'Open the profile', profile); if (a) { a.className = ''; acts.appendChild(a); }
+ section.replaceChildren(element(doc, 'div', undefined, 'ovu-banner'), message, acts);
+ }
+ return null;
+ }).finally(() => {
+ clearTimeout(timer);
+ if (!disposed && ticket === generation) section.removeAttribute('aria-busy');
+ });
+ return api.ready;
+ },
+ destroy() {
+ if (disposed) return;
+ disposed = true; generation++; controller?.abort();
+ section.remove();
+ if (mounts.get(host) === api) mounts.delete(host);
+ },
+ };
+ mounts.set(host, api);
+ api.refresh();
+ return api;
+ }
+ /** Delegated hook: marked mentions/avatars and profile links; call only when the site opts in. */
+ function bind(scope, options = {}) {
+ if (!scope?.addEventListener) throw new TypeError('OVUser.bind: pass a document or element');
+ bindings.get(scope)?.();
+ const doc = scope.ownerDocument || scope;
+ let close = null, disposed = false;
+ function open(username, trigger) {
+ close?.();
+ if (root.OVWindow || selfSrc) {
+ needWindow().then((W) => {
+ if (!W) { openDialog(username, trigger); return; }
+ const host = element(doc, 'div');
+ const mounted = card(host, { ...options, username });
+ let win = null;
+ const cleanup = () => { mounted.destroy(); if (win) { const w = win; win = null; w.close(); } if (close === cleanup) close = null; };
+ close = cleanup;
+ win = W.open({ id: 'ov-user-card', kind: 'panel', size: 'sm', bare: true, title: `@${username}`, body: host, from: trigger, onClose: () => { win = null; mounted.destroy(); if (close === cleanup) close = null; } });
+ });
+ return true;
+ }
+ return openDialog(username, trigger);
+ }
+ function openDialog(username, trigger) {
+ const dialog = element(doc, 'dialog');
+ if (typeof dialog.showModal !== 'function') return false;
+ dialog.setAttribute('aria-label', `User @${username}`);
+ const dismiss = element(doc, 'button', 'Close', 'ovk-btn ovk-btn--quiet'); dismiss.type = 'button';
+ const host = element(doc, 'div');
+ dialog.append(dismiss, host); doc.body.appendChild(dialog);
+ const mounted = card(host, { ...options, username });
+ const cleanup = () => {
+ mounted.destroy(); dialog.remove(); trigger.focus?.();
+ if (close === cleanup) close = null;
+ };
+ close = cleanup;
+ dismiss.addEventListener('click', cleanup);
+ dialog.addEventListener('cancel', cleanup);
+ dialog.addEventListener('close', cleanup);
+ dialog.addEventListener('click', event => { if (event.target === dialog) cleanup(); });
+ dialog.showModal();
+ return true;
+ }
+ function handle(event) {
+ if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
+ if (event.type === 'click' && event.button > 0) return;
+ const target = event.target?.closest?.('[data-ov-user], [data-username], a[href]');
+ if (!target || target.closest('[data-ov-user-card]')) return;
+ if (event.type === 'keydown' && (!['Enter', ' '].includes(event.key) || ['A', 'BUTTON'].includes(target.tagName) || event.repeat)) return;
+ let username = usernameOf(target.getAttribute('data-ov-user') || target.getAttribute('data-username'));
+ if (!username && target.tagName === 'A') {
+ try {
+ const base = doc.baseURI || NETWORK;
+ const url = new URL(target.getAttribute('href'), base);
+ if (!['https:', 'http:'].includes(url.protocol) || ![new URL(base).origin, NETWORK, 'https://openvibe.live'].includes(url.origin)) return;
+ const match = /^\/@([A-Za-z0-9_]{3,24})\/?$/.exec(url.pathname);
+ if (match) username = match[1];
+ } catch { return; }
+ }
+ if (!username) return;
+ if (typeof options.onOpen === 'function') {
+ event.preventDefault(); options.onOpen({ username, trigger: target, event });
+ } else if (open(username, target)) event.preventDefault();
+ }
+ for (const type of ['click', 'contextmenu', 'keydown']) scope.addEventListener(type, handle);
+ const cleanup = () => {
+ if (disposed) return;
+ disposed = true; close?.();
+ for (const type of ['click', 'contextmenu', 'keydown']) scope.removeEventListener(type, handle);
+ if (bindings.get(scope) === cleanup) bindings.delete(scope);
+ };
+ bindings.set(scope, cleanup);
+ return cleanup;
+ }
+ return { card, bind };
+});
@@ -54,3 +54,4 @@ html[data-ov-motion=reduced] .ov-wallet-body.is-first > * { animation: ov-wallet
@@ -54,3 +54,4 @@ html[data-ov-motion=reduced] .ov-wallet-body.is-first > * { animation: ov-wallet
html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off] .ov-wallet-wait { animation: none; }
html[data-ov-motion=off] .ov-wallet-body.is-first > *, html[data-ov-motion=off] .ov-wallet-wait { animation: none; }
@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }
@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ov-wallet-body.is-first > * { animation: ov-wallet-fade .2s ease both; animation-delay: 0s; } }
@keyframes ov-wallet-fade { from { opacity: 0; } }
@keyframes ov-wallet-fade { from { opacity: 0; } }
+.ov-wallet.ov-wallet-in{width:auto;max-height:none;border:0;border-radius:0;box-shadow:none;overflow:visible}.ovw:has(.ov-wallet-in){background:none;border:0}.ov-wallet-in{border-radius:inherit}.ov-wallet-in .ov-wallet-head{padding-right:64px}.ovw.is-sheet .ov-wallet-in .ov-wallet-head{padding-top:26px}.ov-wallet-in .ov-wallet-body{max-height:none;overflow:visible}.ovw-b:has(>.ov-wallet-in){padding:0}
@@ -31,6 +31,21 @@
@@ -31,6 +31,21 @@
const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };
const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };
if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });
if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });
+ // 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': 'c882d11d85ff' };
+ const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
+ function needWindow() {
+ return new Promise((ok) => {
+ if (root.OVWindow) return ok(root.OVWindow);
+ const m = /^(https?:\/\/[^?#]*\/)ov-wallet(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
+ if (!m) return ok(null);
+ const sc = document.createElement('script');
+ sc.src = m[1] + 'ov-window.js' + (WALLET_REV['ov-window.js'] ? '?v=' + WALLET_REV['ov-window.js'] : '');
+ sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
+ document.head.appendChild(sc);
+ });
+ }
async function open(options) {
async function open(options) {
close();
close();
const token = (options.getToken ? options.getToken() : options.token) || null;
const token = (options.getToken ? options.getToken() : options.token) || null;
@@ -41,7 +56,9 @@
@@ -41,7 +56,9 @@
// sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.
// sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.
const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;
const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;
const controller = new AbortController();
const controller = new AbortController();
- const dialog = el('dialog', 'ov-wallet');
+ const W = await needWindow();
+ let win = null;
+ const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
const header = el('header', 'ov-wallet-head'), heading = el('div');
const header = el('header', 'ov-wallet-head'), heading = el('div');
heading.append(el('h2', '', 'Wallet'));
heading.append(el('h2', '', 'Wallet'));
@@ -49,18 +66,20 @@
@@ -49,18 +66,20 @@
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
header.append(heading, exit);
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
- status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body);
+ status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body); if (W) exit.remove(); // the window's own × closes it
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
- const ownLock = !root.OVSurfaces;
+ const ownLock = !!W || !root.OVSurfaces; // in the window there is no <dialog> for surfaces.js to see
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [];
const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in
const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in
const wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; };
const wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; };
const shown = {}; // what each balance last showed, so a change counts from it
const shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const pending = new Map();
const session = { close() {
const session = { close() {
- if (disposed) return; disposed = true; controller.abort(); dialog.close(); dialog.remove();
+ if (disposed) return; disposed = true; controller.abort();
+ if (win) { const w = win; win = null; w.close(); } else if (dialog.close) dialog.close();
+ dialog.remove();
if (ownLock) document.documentElement.style.overflow = previousOverflow;
if (ownLock) document.documentElement.style.overflow = previousOverflow;
document.removeEventListener('openvibe-auth-changed', session.close);
document.removeEventListener('openvibe-auth-changed', session.close);
if (active === session) active = null;
if (active === session) active = null;
@@ -247,10 +266,12 @@
@@ -247,10 +266,12 @@
}
}
}
}
exit.addEventListener('click', session.close);
exit.addEventListener('click', session.close);
- dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
- dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
+ if (!W) dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
+ if (!W) dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); });
document.addEventListener('openvibe-auth-changed', session.close);
document.addEventListener('openvibe-auth-changed', session.close);
- document.body.append(dialog); dialog.showModal(); if (ownLock) document.documentElement.style.overflow = 'hidden'; exit.focus();
+ if (W) win = W.open({ id: 'ov-wallet', title: 'Wallet', bare: true, body: dialog, kind: 'dialog', size: 'lg', width: 860, from: previousFocus, onClose: () => { win = null; session.close(); } });
+ else { document.body.append(dialog); dialog.showModal(); exit.focus(); }
+ if (ownLock) document.documentElement.style.overflow = 'hidden';
// There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the
// There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the
// store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).
// store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).
const seed = kept();
const seed = kept();
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
+/**
+ * ov-window.js (openvibe-shared 4.202.0): one window for every little interface. Asks 304 and 318, the owner: "a system
+ * that's used for all the windows [in] harmony … people can make their own or … custom … everything … feels connected".
+ * One frame (a head with the title and ×, a body, a foot) in three kinds, the same motion, the same keys:
+ * dialog: centred on a computer; a sheet from the bottom on a phone
+ * sheet: a sheet from the bottom on every screen
+ * panel: floats beside what opened it on a computer, draggable by its head (where you leave it is kept per id);
+ * a sheet on a phone
+ * It grows out of the button that opened it (the navbar's pop motion), closes with ×, Escape, a press outside (a dialog
+ * or a sheet; a panel stays until closed, and the page under it stays usable) or a swipe down on a phone, keeps the focus inside while open, and gives it back to that button. Windows stack: Escape closes the
+ * top one. The look follows the person's theme and their window style, <html data-ov-window="glass|solid|outline">
+ * (OVWindow.setStyle), on every window at once.
+ *
+ * const w = OVWindow.open({ id, title, body: node | text, foot: node, kind: 'dialog', size: 'md', from: button,
+ * label, onClose, bare, width }); bare: the content draws its own head (the title is still
+ * read); width: px, for content that wants its own
+ * w.el, w.body, w.foot, w.setTitle(text), w.close()
+ * A site's content goes in as nodes (or text, set as text): the window never parses markup it is given.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVWindow) return;
+
+ 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; }
+.ovw-scrim.is-clear { background: transparent; }
+.ovw { position: fixed; z-index: var(--ovw-z, 10040); box-sizing: border-box; display: flex; flex-direction: column; max-height: min(86vh, 760px); color: var(--text-primary, #e6edf7);
+ font: 500 15px/1.45 var(--font, Inter, system-ui, -apple-system, 'Segoe UI', sans-serif); border-radius: 20px; overflow: hidden;
+ background: var(--ovw-bg); border: 1px solid var(--ovw-line); box-shadow: var(--ovw-shadow); -webkit-backdrop-filter: var(--ovw-blur); backdrop-filter: var(--ovw-blur);
+ transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ovw-in .28s cubic-bezier(.2, 1.2, .3, 1) both; }
+:root { --ovw-bg: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))) 92%, transparent); --ovw-line: var(--border, rgba(255, 255, 255, .12));
+ --ovw-shadow: 0 28px 70px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .2); --ovw-blur: blur(16px) saturate(1.3); }
+html[data-ov-window="solid"] { --ovw-bg: var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))); --ovw-blur: none; }
+html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blur: none; --ovw-line: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); --ovw-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), 0 18px 40px rgba(0, 0, 0, .35); }
+.ovw.is-out { animation: ovw-out .16s ease-in both; }
+.ovw-h { flex: none; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 8px 6px 18px; border-bottom: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
+.ovw-t { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 700; letter-spacing: -.01em; }
+.ovw-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; }
+.ovw-x:hover, .ovw-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); }
+.ovw-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.ovw-b { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 18px 18px; }
+.ovw-f { flex: none; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); }
+.ovw-f:empty { display: none; }
+.ovw.is-dialog { left: 50%; top: 50%; width: min(var(--ovw-w), calc(100vw - 32px)); transform: translate(-50%, -50%); animation-name: ovw-in-c; }
+.ovw.is-dialog.is-out { animation-name: ovw-out-c; }
+.ovw.is-panel { width: min(var(--ovw-w), calc(100vw - 24px)); }
+.ovw.is-panel .ovw-h { cursor: grab; touch-action: none; }
+.ovw.is-panel.is-dragging { animation: none; transition: none; box-shadow: 0 34px 90px rgba(0, 0, 0, .55); }
+.ovw.is-panel.is-dragging .ovw-h { cursor: grabbing; }
+.ovw.is-sheet { left: 0; right: 0; bottom: 0; width: auto; max-height: 92vh; margin: 0 auto; max-width: 720px; border-radius: 22px 22px 0 0; border-bottom: 0; animation: ovw-up .32s cubic-bezier(.2, 1, .3, 1) both; }
+.ovw.is-sheet.is-out { animation: ovw-down .2s ease-in both; }
+.ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; }
+.ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); }
+.ovw.is-sheet.is-dragging { animation: none; }
+.ovw { --ovw-w: 520px; }
+.ovw.is-sm { --ovw-w: 380px; }
+.ovw.is-lg { --ovw-w: 760px; }
+.ovw.is-full { --ovw-w: 1100px; }
+@keyframes ovw-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))) scale(.94); } }
+@keyframes ovw-out { to { opacity: 0; transform: scale(.96); } }
+@keyframes ovw-in-c { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px * var(--ov-move, 1))) scale(.95); } }
+@keyframes ovw-out-c { to { opacity: 0; transform: translate(-50%, -50%) scale(.97); } }
+@keyframes ovw-up { from { transform: translateY(100%); } }
+@keyframes ovw-down { to { transform: translateY(100%); } }
+@keyframes ovw-fade { from { opacity: 0; } }
+html[data-ov-motion=off] .ovw, html[data-ov-motion=off] .ovw-scrim { animation: none !important; }
+html.ovw-locked { overflow: hidden; }
+/* bare: the content carries its own head (a card's banner); the title is for screen readers, × floats on top, and
+ the top strip is still the handle (drag a panel, swipe a sheet). */
+.ovw.is-bare .ovw-h { position: absolute; left: 0; right: 0; top: 0; z-index: 3; min-height: 52px; padding: 6px; justify-content: flex-end; border: 0; background: none; }
+.ovw.is-bare .ovw-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.ovw.is-bare .ovw-x { width: 36px; height: 36px; border-radius: 50%; color: #fff; background: rgba(4, 8, 16, .42); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
+.ovw.is-bare .ovw-x:hover, .ovw.is-bare .ovw-x:focus-visible { background: rgba(4, 8, 16, .62); color: #fff; }
+.ovw.is-bare .ovw-b { padding: 0; }
+.ovw.is-bare.is-sheet .ovw-h { padding-top: 14px; }`;
+
+ 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); }
+ 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 */ } } };
+ function put(parent, v) { if (v == null) return; if (typeof v === 'string') parent.textContent = v; else parent.appendChild(v); }
+
+ function open(o = {}) {
+ styles();
+ const kind0 = ['dialog', 'sheet', 'panel'].includes(o.kind) ? o.kind : 'dialog';
+ const kind = phone() ? 'sheet' : kind0;
+ const id = o.id || `ovw-${++uid}`;
+ const opener = o.from || document.activeElement;
+ const modal = o.modal !== false && kind !== 'panel';
+ const z = 10040 + stack.length * 2;
+ const scrim = document.createElement('div');
+ scrim.className = `ovw-scrim${modal ? '' : ' is-clear'}`;
+ scrim.style.setProperty('--ovw-z', String(z));
+ const el = document.createElement('section');
+ el.className = `ovw is-${kind} is-${['sm', 'md', 'lg', 'full'].includes(o.size) ? o.size : 'md'}${o.bare ? ' is-bare' : ''}`;
+ el.style.setProperty('--ovw-z', String(z + 1));
+ if (Number(o.width) > 0) el.style.setProperty('--ovw-w', `${Math.round(Number(o.width))}px`);
+ el.setAttribute('role', 'dialog');
+ if (modal) el.setAttribute('aria-modal', 'true');
+ el.dataset.ovWindow = id;
+ const head = document.createElement('header'); head.className = 'ovw-h';
+ const title = document.createElement('h2'); title.className = 'ovw-t'; title.id = `${id}-t`; title.textContent = o.title || '';
+ const x = document.createElement('button'); x.type = 'button'; x.className = 'ovw-x'; x.setAttribute('aria-label', 'Close');
+ x.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
+ head.append(title, x);
+ const body = document.createElement('div'); body.className = 'ovw-b'; put(body, o.body);
+ const foot = document.createElement('footer'); foot.className = 'ovw-f'; put(foot, o.foot);
+ el.append(head, body, foot);
+ if (o.title) el.setAttribute('aria-labelledby', title.id); else el.setAttribute('aria-label', o.label || 'Window');
+ if (modal) document.body.append(scrim, el); else document.body.append(el);
+ 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.
+ if (kind === 'panel') {
+ const saved = o.id ? place.get(o.id) : null;
+ const r = el.getBoundingClientRect(), vw = root.innerWidth, vh = 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`;
+ }
+ if (opener && opener.getBoundingClientRect && kind !== 'sheet') {
+ const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect();
+ el.style.setProperty('--ov-pop-x', `${Math.round(f.left + f.width / 2 - r.left)}px`);
+ el.style.setProperty('--ov-pop-y', `${Math.round(f.top + f.height / 2 - r.top)}px`);
+ }
+
+ let closed = false;
+ const w = {
+ id, el, body, foot,
+ setTitle(t) { title.textContent = t || ''; },
+ close(why) {
+ if (closed) return; closed = true;
+ 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(); };
+ if (document.documentElement.getAttribute('data-ov-motion') === 'off') gone();
+ else { el.classList.add('is-out'); scrim.style.opacity = '0'; scrim.style.transition = 'opacity .16s'; let done = false; const end = () => { if (!done) { done = true; gone(); } }; el.addEventListener('animationend', end, { once: true }); setTimeout(end, 260); }
+ if (opener && opener.focus && document.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch { /* */ } }
+ if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } }
+ },
+ modal,
+ };
+ stack.push(w);
+ x.addEventListener('click', () => w.close('x'));
+ if (modal) scrim.addEventListener('pointerdown', () => w.close('outside'));
+ el.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') {
+ const f = focusables(el); if (!f.length) return;
+ const first = f[0], last = f[f.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ }
+ });
+
+ // A panel moves by its head on a computer; a sheet goes down when swiped down on a phone.
+ if (kind === 'panel' || 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;
+ let moved = false;
+ el.classList.add('is-dragging');
+ try { head.setPointerCapture(e.pointerId); } catch { /* */ }
+ const move = (ev) => {
+ const dx = ev.clientX - sx, dy = ev.clientY - sy;
+ if (!moved && Math.hypot(dx, dy) < 4) return;
+ moved = true;
+ if (kind === 'panel') {
+ el.style.left = `${Math.max(8, Math.min(r.left + dx, root.innerWidth - r.width - 8))}px`;
+ el.style.top = `${Math.max(8, Math.min(r.top + dy, root.innerHeight - 56))}px`;
+ } else el.style.transform = `translateY(${Math.max(0, dy)}px)`;
+ };
+ const up = (ev) => {
+ head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up);
+ el.classList.remove('is-dragging');
+ if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, { x: Math.round(b.left), y: Math.round(b.top) }); } return; }
+ const dy = ev.clientY - sy;
+ if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe');
+ else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); }
+ };
+ head.addEventListener('pointermove', move); head.addEventListener('pointerup', up); head.addEventListener('pointercancel', up);
+ });
+ }
+
+ const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0];
+ (first || x).focus({ preventScroll: true });
+ return w;
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return;
+ e.preventDefault();
+ stack[stack.length - 1].close('escape');
+ });
+
+ // The style is the person's display setting (theme-loader: saved, synced to their account, painted at boot as
+ // <html data-ov-window>); setStyle saves through it, or straight onto the page where the theme loader is absent.
+ function setStyle(name) {
+ const v = STYLES.includes(name) ? name : 'glass';
+ const T = root.OpenVibeThemeLoader;
+ if (T && T.display && typeof T.display.set === 'function') { try { T.display.set({ window: v }); } catch { /* */ } }
+ if (v === 'glass') document.documentElement.removeAttribute('data-ov-window'); else document.documentElement.setAttribute('data-ov-window', v);
+ return v;
+ }
+
+ root.OVWindow = { open, setStyle, styles: STYLES.slice(), top: () => stack[stack.length - 1] || null, closeAll: () => [...stack].reverse().forEach((w) => w.close('all')) };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.201.0",
+ "version": "4.202.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.201.0",
+ "version": "4.202.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.201.0",
+ "version": "4.202.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": {
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
"./assets": "./assets.js",
"./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
+ "./bottom": "./bottom.js",
"./browser-harness": "./browser-harness.js",
"./browser-harness": "./browser-harness.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
"./cache-policy": "./cache-policy.js",
"./cache-policy": "./cache-policy.js",
@@ -127,6 +128,7 @@
@@ -127,6 +128,7 @@
"./trace": "./trace.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
+ "./ov-user": "./ov-user.js",
"./user-card": "./user-card.js",
"./user-card": "./user-card.js",
"./web-runtime": "./web-runtime.js",
"./web-runtime": "./web-runtime.js",
"./route-transition": "./route-transition.js",
"./route-transition": "./route-transition.js",
@@ -138,6 +140,8 @@
@@ -138,6 +140,8 @@
"./thread": "./thread.js",
"./thread": "./thread.js",
"./messages": "./messages.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
"./ov-thread": "./ov-thread.js",
+ "./ov-window": "./ov-window.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",
"./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
@@ -164,6 +168,7 @@
@@ -164,6 +168,7 @@
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
+ "audit:page-end": "node scripts/audit-page-end.js",
"create-site": "node scripts/create-site.js",
"create-site": "node scripts/create-site.js",
"size": "node scripts/size-report.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
"build:icons": "python3 scripts/build-nav-icons.py",
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
--ovk-radius-sm: var(--ov-radius-1);
--ovk-radius-sm: var(--ov-radius-1);
--ovk-tap: 44px;
--ovk-tap: 44px;
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
--ovk-top: var(--ovnav-real-h, 52px); /* the navbar's height: sticky things stop under it */
- --ovk-bottom: var(--ovnav-appbar-h, 0px); /* the app bar's height on phones (navbar.js sets it) */
+ --ovk-bottom: var(--ovb-dock, var(--ovnav-appbar-h, 0px)); /* the app bar (and a small player above it) on phones, measured by bottom.js */
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
--ovk-font: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
}
}
/* Shape personalities, as in kit.css but at zero specificity (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners
/* Shape personalities, as in kit.css but at zero specificity (plan L45: "Paper is quiet and square, Neon glows and bounces"). A quiet look has square corners
@@ -21,26 +21,26 @@
@@ -21,26 +21,26 @@
'use strict';
'use strict';
const HOODS = Object.freeze({
const HOODS = Object.freeze({
- streams: 'Streams & places', games: 'Games & laughs', answers: 'News & answers',
- make: 'Make & build', shop: 'Shop & swap', people: 'People & you', build: 'For developers',
+ watch: 'Watch & stream', talk: 'Talk & belong', play: 'Play', read: 'Read & ask',
+ shop: 'Shop & money', tools: 'Free tools', make: 'Make with AI', build: 'For developers',
});
});
// id, name in menus, host, neighbourhood, OKLCH hue, dark accent, light accent, hero line (≤ 8 words), kit icon, motif
// id, name in menus, host, neighbourhood, OKLCH hue, dark accent, light accent, hero line (≤ 8 words), kit icon, motif
const ROWS = [
const ROWS = [
- ['fun', 'Fun', 'openvibe.fun', 'games', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],
+ ['fun', 'Fun', 'openvibe.fun', 'play', 350, '#F47DB9', '#A42C71', 'Something to do, alone, together, or live', 'spark', 'a city map where idea pins pop up and a quest card flips'],
['shoes', 'Shoes', 'open.shoes', 'shop', 25, '#FF7E76', '#B02A2D', 'Every shoe, every market, the right price', 'shoe', 'a shoe drawn as one line, price tags from several stores sliding into one all-in price'],
['shoes', 'Shoes', 'open.shoes', 'shop', 25, '#FF7E76', '#B02A2D', 'Every shoe, every market, the right price', 'shoe', 'a shoe drawn as one line, price tags from several stores sliding into one all-in price'],
- ['tours', 'Tours', 'openvibe.tours', 'streams', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],
- ['today', 'Today', 'openvibe.today', 'answers', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],
- ['meme', 'Meme', 'openvibe.meme', 'games', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],
- ['domains', 'Domains', 'openvibe.domains', 'make', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],
- ['casino', 'Casino', 'openvibe.casino', 'games', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],
- ['exchange', 'Exchange', 'openvibe.exchange', 'shop', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],
- ['cam', 'Cam', 'openvibe.cam', 'streams', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],
- ['surf', 'Surf', 'openvibe.surf', 'games', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],
- ['report', 'Report', 'openvibe.report', 'answers', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],
+ ['tours', 'Tours', 'openvibe.tours', 'watch', 50, '#F98942', '#AA3900', 'Go anywhere: fly, stay, walk, watch', 'map', 'a plane\'s dotted arc landing on a map, then the walking route drawing itself'],
+ ['today', 'Today', 'openvibe.today', 'read', 75, '#E49900', '#994E00', 'Everything happening for you, today', 'calendar', 'the day\'s ring with events as dots and "now" sweeping; the brief\'s lines typing in'],
+ ['meme', 'Meme', 'openvibe.meme', 'play', 100, '#C3AC00', '#7B6100', 'The internet\'s meme database, rebuilt with AI', 'image', 'a family tree of memes growing branch by branch, stickers fanning out'],
+ ['domains', 'Domains', 'openvibe.domains', 'tools', 125, '#94BB3B', '#4D7100', 'Name it, price it, run it', 'globe', 'a name typing itself while extensions cycle, prices settling into a sorted list'],
+ ['casino', 'Casino', 'openvibe.casino', 'play', 150, '#51C672', '#007C25', 'Learn it, play it, never pay it', 'game', 'a card fan and chips, with a probability curve drawing beneath'],
+ ['exchange', 'Exchange', 'openvibe.exchange', 'build', 175, '#00CAA5', '#007F5E', 'Trade what people make', 'swap', 'two arrows looping an item between makers, and a passport card stamping "safe"'],
+ ['cam', 'Cam', 'openvibe.cam', 'watch', 200, '#00C7D2', '#007C89', 'Your camera, the world\'s cams, face to face', 'video', 'a shutter iris opening onto a grid of live cams, a flash, the photo flying to a gallery'],
+ ['surf', 'Surf', 'openvibe.surf', 'play', 225, '#00BEF6', '#0072AB', 'Ride maps, surf the web, surf what\'s on', 'wave', 'a first-person surf run: ramps, speed lines, the timer ticking'],
+ ['report', 'Report', 'openvibe.report', 'read', 250, '#50B0FF', '#0064BE', 'Every number on OpenVibe, honest', 'chart', 'live counters ticking, a line chart drawing itself, the after-show card sliding in'],
['website', 'Website', 'openvibe.website', 'make', 275, '#8EA0FF', '#4B53C2', 'Serious sites, no login needed', 'computer', 'a browser frame assembling a real site from blocks while a cursor types the prompt'],
['website', 'Website', 'openvibe.website', 'make', 275, '#8EA0FF', '#4B53C2', 'Serious sites, no login needed', 'computer', 'a browser frame assembling a real site from blocks while a cursor types the prompt'],
- ['vision', 'Vision', 'openvibe.vision', 'answers', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],
- ['design', 'Design', 'openvibe.design', 'make', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],
+ ['vision', 'Vision', 'openvibe.vision', 'read', 300, '#BB91FF', '#7444B4', 'See with the best model for the job', 'eye', 'an aperture with two models\' boxes racing on the same image, cost and time ticking'],
+ ['design', 'Design', 'openvibe.design', 'tools', 325, '#DD84E2', '#913797', 'Make it yours, find the best AI designer', 'palette', 'the theme orb: drag it and the whole page recolours live; an A/B vote flips'],
];
];
const ROOMS = Object.freeze(Object.fromEntries(ROWS.map(([id, name, host, hood, hue, dark, light, line, icon, motif]) =>
const ROOMS = Object.freeze(Object.fromEntries(ROWS.map(([id, name, host, hood, hue, dark, light, line, icon, motif]) =>
[id, Object.freeze({ id, name, host, hood, hue, dark, light, line, icon, motif })])));
[id, Object.freeze({ id, name, host, hood, hue, dark, light, line, icon, motif })])));
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+'use strict';
+// openvibe-shared/scripts/audit-page-end — plan 280 ("That huge wasted space … in between that and the footer …
+// It's just waste"): measure the gap between the last thing on a site's home page and its footer, at 390 and 1280,
+// and list every site over the rule: content ends, then 32 px on a computer (24 on a phone), then the footer.
+//
+// node scripts/audit-page-end.js every site, 390 and 1280
+// node scripts/audit-page-end.js --footer-rule with the Shared rule (32/24) applied, to see what is left
+// node scripts/audit-page-end.js --hosts openvibe.wiki,openvibe.games --width 390
+// node scripts/audit-page-end.js --json research/page-end.json --strict
+//
+// A gap is the top of the footer minus the bottom of the lowest visible piece of content before it (text a person
+// reads, or a picture/video), in the page's own coordinates. A site's bottom padding, a spacer or a margin on the
+// last block shows up here; the footer's own margin-top is the Shared rule and is not site dead space.
+// Real Chrome through ../browser-harness (CHROME_BIN or the usual paths). Reads only: no site is changed.
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const h = require('../browser-harness');
+
+// The first-party homes (the Network's own zones), the same ring the footer and the menu list.
+const SITES = [
+ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',
+ 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news',
+ 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals',
+ 'openvibe.coupons', 'openre.stream', 'open.shoes', 'openvibe.today', 'openvibe.vision', 'openvibe.domains',
+ 'openvibe.report', 'openvibe.meme', 'openvibe.casino', 'openvibe.surf', 'openvibe.fun', 'openvibe.tours',
+ 'openvibe.exchange', 'openvibe.cam', 'openvibe.design',
+];
+const RULE = { computer: 32, phone: 24 };
+
+const args = process.argv.slice(2);
+const flag = (name) => args.includes(`--${name}`);
+const value = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; };
+const widths = (value('width', '') ? [Number(value('width'))] : [1280, 390]).filter((n) => Number.isFinite(n) && n > 0);
+const hosts = (value('hosts', '') ? value('hosts', '').split(',') : SITES).map((s) => s.trim()).filter(Boolean);
+const footerRule = flag('footer-rule');
+const strict = flag('strict');
+const out = value('json', '');
+const quiet = flag('quiet');
+
+// What the page answers: where the footer starts, the lowest visible content before it, and the site's own padding
+// around the end of the page (the context that says where the dead space comes from).
+const MEASURE = `(() => {
+ const toFixed = (n) => Math.round(n * 10) / 10;
+ const footers = [...document.querySelectorAll('footer, #ov-footer')].filter((f) => {
+ const r = f.getBoundingClientRect();
+ return r.height > 0 && getComputedStyle(f).display !== 'none' && getComputedStyle(f).visibility !== 'hidden';
+ });
+ if (!footers.length) return { footer: false };
+ const footer = footers[footers.length - 1];
+ const footerTop = footer.getBoundingClientRect().top + scrollY;
+ let last = 0, who = '';
+ for (const el of document.body.querySelectorAll('*')) {
+ if (el.closest('footer, #ov-footer, nav, .openvibe-navbar, .ovnav-appbar, .ovmini, .ovdk, .ovdk-pill, script, style, link, meta, noscript, template, [aria-hidden="true"]')) continue;
+ const cs = getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed' || cs.position === 'sticky') continue;
+ const r = el.getBoundingClientRect();
+ if (!r.width || !r.height) continue;
+ const bottom = r.bottom + scrollY;
+ if (bottom > footerTop + 1) continue;
+ const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
+ const media = el.matches('img,picture,video,canvas') || [...el.children].some((c) => c.matches('img,picture,video,canvas'));
+ if (!ownText && !media) continue;
+ if (bottom > last) { last = bottom; who = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/)[0] : ''); }
+ }
+ const main = document.querySelector('main') || document.body;
+ return {
+ footer: true, gap: toFixed(Math.max(0, footerTop - last)), who,
+ footerTop: toFixed(footerTop), lastBottom: toFixed(last),
+ footerMargin: getComputedStyle(footer).marginTop,
+ mainPad: getComputedStyle(main).paddingBottom,
+ bodyPad: getComputedStyle(document.body).paddingBottom,
+ scrollHeight: document.documentElement.scrollHeight,
+ };
+})()`;
+
+const ink = (on) => (process.stdout.isTTY ? (s) => `\u001b[${on}m${s}\u001b[0m` : (s) => s);
+const dim = ink(2), bold = ink(1), red = ink(31);
+
+(async () => {
+ if (!h.findChrome()) {
+ console.log('audit-page-end: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+ }
+ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-page-end-')) });
+ const rows = [];
+ try {
+ for (const host of hosts) {
+ for (const width of widths) {
+ const rule = width <= 760 ? RULE.phone : RULE.computer;
+ const page = await h.openPage(browser, await browser.newContext(), { width, height: width <= 760 ? 844 : 900 });
+ let row;
+ try {
+ await page.goto(`https://${host}/`, { maxMs: 25000, quietMs: 700 });
+ if (footerRule) await page.evaluate(`(() => { const s = document.createElement('style'); s.textContent = ${JSON.stringify('@media (max-width: 760px) { footer.ovf, #ov-footer { margin-top: 24px !important; } } @media (min-width: 761px) { footer.ovf, #ov-footer { margin-top: 32px !important; } }')}; document.head.appendChild(s); })()`);
+ const m = await page.evaluate(MEASURE);
+ row = { host, width, rule, ...m, over: m.footer ? Math.max(0, m.gap - rule) : null };
+ } catch (e) {
+ row = { host, width, rule, error: e.message.slice(0, 120) };
+ }
+ await page.close();
+ rows.push(row);
+ if (!quiet) {
+ if (row.error) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('unreachable')} ${dim(row.error)}`);
+ else if (!row.footer) console.log(`${host.padEnd(20)} ${String(width).padStart(4)} ${red('no footer found')}`);
+ else {
+ const over = row.gap > rule + 1;
+ console.log(`${host.padEnd(20)} ${String(width).padStart(4)} gap ${String(row.gap).padStart(6)}px rule ${String(rule).padStart(2)}px ${over ? red(`+${row.over}px over`) : bold('ok')} ${dim(`last ${row.who || '?'} · main pad ${row.mainPad} · footer margin ${row.footerMargin}`)}`);
+ }
+ }
+ }
+ }
+ } finally { await browser.close(); }
+ if (out) { fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); fs.writeFileSync(out, JSON.stringify({ generatedAt: new Date().toISOString(), footerRule, widths, rows }, null, 1)); console.log(`audit-page-end: wrote ${out}`); }
+ const measured = rows.filter((r) => r.footer && !r.error);
+ const over = measured.filter((r) => r.gap > r.rule + 1);
+ console.log(`audit-page-end: ${measured.length}/${rows.length} measured, ${over.length} over the rule${footerRule ? ' (Shared rule applied)' : ''}${over.length ? ': ' + [...new Set(over.map((r) => r.host))].join(', ') : ''}`);
+ if (strict && over.length) process.exit(1);
+})().catch((e) => { console.error('audit-page-end:', e.stack || e.message); process.exit(2); });
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
+ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'surfaces.js', 'notification-ui.js', 'history.js', 'bottom.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js', 'nav-slots.js',
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
// 4.173.0: the wallet sheet (4.170.0) went out without ?v=, so a fix to it reached phones up to a day late.
'ov-wallet.js', 'ov-wallet.css',
'ov-wallet.js', 'ov-wallet.css',
// 4.189.0: the balances pill's icons and effects.
// 4.189.0: the balances pill's icons and effects.
@@ -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'] },
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'] },
'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'] }, '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'] }, '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)) {
@@ -291,6 +291,8 @@
@@ -291,6 +291,8 @@
.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
+/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
+@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }
.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
.ovs-tabs[hidden] { display: none; }
.ovs-tabs[hidden] { display: none; }
@@ -306,45 +306,107 @@ const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj
@@ -306,45 +306,107 @@ const jsonLd = (obj) => `<script type="application/ld+json">${JSON.stringify(obj
const faqJsonLd = (items) => jsonLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]).map(([q, a]) => ({ '@type': 'Question', name: String(q), acceptedAnswer: { '@type': 'Answer', text: String(a) } })) });
const faqJsonLd = (items) => jsonLd({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: (items || []).filter((q) => Array.isArray(q) && q[0] && q[1]).map(([q, a]) => ({ '@type': 'Question', name: String(q), acceptedAnswer: { '@type': 'Answer', text: String(a) } })) });
/**
/**
- * Where each product leads next on the network: real, crawlable links with descriptive text, chosen per product so a
- * reader (and a search engine) sees how the sites fit together, not one long list of every domain. [to, what, href].
+ * Where each home page leads next on the network: the other places in the same menu group, in the group's own order
+ * (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of 2026-11-10), so "More on OpenVibe" shows the
+ * same neighbourhood the launcher and the footer show. Up to four. [to, what, href]; `to` is the name and icon the
+ * card shows (Email is `email`, OpenRestream `openre`, Studio `studio`, Locker `locker`).
*/
*/
-const RELATED = {
- live: [['openre', 'Stream to every platform at once', 'https://openre.stream/'], ['video', 'Watch past streams and clips', 'https://openvibe.video/'], ['chat', 'Chat rooms and DMs', 'https://openvibe.chat/'], ['tips', 'Tips and alerts for your stream', 'https://openvibe.tips/']],
- openre: [['live', 'Go live from your browser', 'https://openvibe.live/'], ['video', 'Keep your broadcasts', 'https://openvibe.video/'], ['chat', 'One chat for every platform', 'https://openvibe.chat/']],
- video: [['live', 'Live streams now', 'https://openvibe.live/'], ['pics', 'Pictures, fast and open', 'https://openvibe.pics/'], ['tools', 'Convert and trim media', 'https://openvibe.tools/video-tools']],
- media: [['video', 'Videos, clips and VODs', 'https://openvibe.video/'], ['pics', 'Share pictures', 'https://openvibe.pics/'], ['download', 'Your files, anywhere', 'https://openvibe.download/']],
- chat: [['live', 'Live streams with chat', 'https://openvibe.live/'], ['actor', 'Agents that work while you are away', 'https://openvibe.actor/'], ['community', 'Posts and pastes', 'https://openvibe.community/']],
- tools: [['codes', 'Build apps with AI', 'https://openvibe.codes/'], ['services', 'APIs and SDK for developers', 'https://openvibe.services/'], ['actor', 'Hand a task to an agent', 'https://openvibe.actor/'], ['space', 'Host a site or an app', 'https://openvibe.space/']],
- codes: [['run', 'Run jobs on your own machine', 'https://openvibe.run/'], ['services', 'The SDK and APIs', 'https://openvibe.services/'], ['space', 'Publish what you build', 'https://openvibe.space/']],
- actor: [['tools', 'Hundreds of free tools', 'https://openvibe.tools/'], ['watch', 'Alerts when something changes', 'https://openvibe.watch/'], ['bot', 'Drive a robot from your browser', 'https://openvibe.bot/'], ['codes', 'Coding agents', 'https://openvibe.codes/']],
- services: [['tools', 'Tools with open APIs', 'https://openvibe.tools/developers'], ['space', 'Host apps on the network', 'https://openvibe.space/'], ['network', 'Sign-in for your app', 'https://openvibe.network/']],
- space: [['codes', 'Build it with AI', 'https://openvibe.codes/'], ['services', 'The SDK your app calls', 'https://openvibe.services/'], ['host', 'Hosting, compared', 'https://openvibe.host/']],
- run: [['codes', 'Coding agents', 'https://openvibe.codes/'], ['bot', 'Robots and machines', 'https://openvibe.bot/'], ['actor', 'Agents', 'https://openvibe.actor/']],
- watch: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['work', 'Job alerts', 'https://openvibe.work/'], ['actor', 'An agent to act on alerts', 'https://openvibe.actor/']],
- bot: [['live', 'Stream your robot', 'https://openvibe.live/'], ['actor', 'Let an agent drive', 'https://openvibe.actor/'], ['run', 'Your machines', 'https://openvibe.run/']],
- work: [['watch', 'Alerts for new jobs', 'https://openvibe.watch/'], ['actor', 'An agent on your side', 'https://openvibe.actor/'], ['rent', 'Find a place to live', 'https://openvibe.rent/']],
- rent: [['homes', 'Homes for sale', 'https://openvibe.homes/'], ['work', 'Jobs nearby', 'https://openvibe.work/'], ['food', 'Food near you', 'https://openvibe.food/']],
- homes: [['rent', 'Rentals', 'https://openvibe.rent/'], ['deals', 'Deals', 'https://openvibe.deals/'], ['help', 'Help nearby', 'https://openvibe.help/']],
- food: [['help', 'Help nearby', 'https://openvibe.help/'], ['deals', 'Grocery deals', 'https://openvibe.deals/'], ['coupons', 'Coupons that work', 'https://openvibe.coupons/']],
- deals: [['coupons', 'Coupons that work', 'https://openvibe.coupons/'], ['reviews', 'Reviews with receipts', 'https://openvibe.reviews/'], ['watch', 'Price alerts', 'https://openvibe.watch/']],
- coupons: [['deals', 'Deals worth sharing', 'https://openvibe.deals/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['trade', 'Markets and watchlists', 'https://openvibe.trade/']],
- reviews: [['deals', 'Deals', 'https://openvibe.deals/'], ['coupons', 'Coupons', 'https://openvibe.coupons/'], ['host', 'Hosting reviews', 'https://openvibe.host/']],
- trade: [['news', 'News, sourced', 'https://openvibe.news/'], ['watch', 'Market alerts', 'https://openvibe.watch/'], ['deals', 'Deals', 'https://openvibe.deals/']],
- news: [['wiki', 'Knowledge, with sources', 'https://openvibe.wiki/'], ['blog', 'Long-form writing', 'https://openvibe.blog/'], ['community', 'Discuss it', 'https://openvibe.community/']],
- blog: [['news', 'News', 'https://openvibe.news/'], ['wiki', 'Wiki', 'https://openvibe.wiki/'], ['community', 'Community', 'https://openvibe.community/']],
- wiki: [['news', 'News', 'https://openvibe.news/'], ['help', 'Help', 'https://openvibe.help/'], ['blog', 'Blog', 'https://openvibe.blog/']],
- community: [['chat', 'Chat', 'https://openvibe.chat/'], ['blog', 'Blog', 'https://openvibe.blog/'], ['live', 'Live', 'https://openvibe.live/']],
- help: [['food', 'Food near you', 'https://openvibe.food/'], ['wiki', 'Guides', 'https://openvibe.wiki/'], ['community', 'Ask the community', 'https://openvibe.community/']],
- games: [['quest', 'Quests and rewards', 'https://openvibe.quest/'], ['live', 'Stream your game', 'https://openvibe.live/'], ['vip', 'Memberships', 'https://openvibe.vip/']],
- quest: [['games', 'Browser games', 'https://openvibe.games/'], ['vip', 'Memberships', 'https://openvibe.vip/'], ['tips', 'Tips', 'https://openvibe.tips/']],
- tips: [['live', 'Go live', 'https://openvibe.live/'], ['vip', 'Memberships for creators', 'https://openvibe.vip/'], ['openre', 'Restream', 'https://openre.stream/']],
- vip: [['tips', 'Tips and paid messages', 'https://openvibe.tips/'], ['live', 'Live', 'https://openvibe.live/'], ['games', 'Games', 'https://openvibe.games/']],
- host: [['space', 'Host on OpenVibe', 'https://openvibe.space/'], ['reviews', 'Reviews', 'https://openvibe.reviews/'], ['tools', 'Network tools', 'https://openvibe.tools/network-tools']],
- pics: [['video', 'Videos', 'https://openvibe.video/'], ['tools', 'Image tools', 'https://openvibe.tools/image-tools'], ['community', 'Community', 'https://openvibe.community/']],
- network: [['live', 'Live', 'https://openvibe.live/'], ['tools', 'Tools', 'https://openvibe.tools/'], ['chat', 'Chat', 'https://openvibe.chat/'], ['actor', 'Agents', 'https://openvibe.actor/']],
+const GROUPS = {
+ watch: [
+ ['live', 'Go live, watch streams', 'https://openvibe.live/'],
+ ['video', 'Clips and past streams', 'https://openvibe.video/'],
+ ['pics', 'Share pictures', 'https://openvibe.pics/'],
+ ['download', 'Your files', 'https://openvibe.download/'],
+ ['openre', 'Stream everywhere', 'https://openre.stream/'],
+ ['cam', 'Your camera, in private', 'https://openvibe.cam/'],
+ ['tours', 'Cities, routes, stays', 'https://openvibe.tours/'],
+ ],
+ talk: [
+ ['community', 'Posts and pastes', 'https://openvibe.community/'],
+ ['chat', 'Chat with anyone', 'https://openvibe.chat/'],
+ ['tips', 'Support streamers', 'https://openvibe.tips/'],
+ ['vip', 'Back your creators', 'https://openvibe.vip/'],
+ ['email', 'Masks and inboxes', 'https://openvibe.email/'],
+ ['help', 'Help nearby', 'https://openvibe.help/'],
+ ['network', 'Your account', 'https://openvibe.network/'],
+ ],
+ play: [
+ ['games', 'Play in your browser', 'https://openvibe.games/'],
+ ['quest', 'Earn badges', 'https://openvibe.quest/'],
+ ['surf', 'Surf maps and the web', 'https://openvibe.surf/'],
+ ['fun', 'Something to do', 'https://openvibe.fun/'],
+ ['meme', 'Memes with receipts', 'https://openvibe.meme/'],
+ ['casino', 'Odds shown, chips free', 'https://openvibe.casino/'],
+ ['inventory', 'Your items', 'https://openvibe.my/inventory'],
+ ],
+ read: [
+ ['news', 'Sourced, not spun', 'https://openvibe.news/'],
+ ['today', 'Your schedule', 'https://openvibe.today/'],
+ ['report', 'Your numbers, honest', 'https://openvibe.report/'],
+ ['search', 'Search everything', 'https://search.openvibe.network/'],
+ ['wiki', 'Facts with sources', 'https://openvibe.wiki/'],
+ ['blog', 'Write long-form', 'https://openvibe.blog/'],
+ ['vision', 'Ask a picture', 'https://openvibe.vision/'],
+ ],
+ shop: [
+ ['deals', 'Deals people share', 'https://openvibe.deals/'],
+ ['coupons', 'Codes that work', 'https://openvibe.coupons/'],
+ ['shoes', 'Every shoe, every price', 'https://open.shoes/'],
+ ['reviews', 'Honest reviews', 'https://openvibe.reviews/'],
+ ['trade', 'Watch the markets', 'https://openvibe.trade/'],
+ ['rent', 'Places to rent', 'https://openvibe.rent/'],
+ ['work', 'Find a job', 'https://openvibe.work/'],
+ ],
+ tools: [
+ ['tools', 'Every online tool', 'https://openvibe.tools/'],
+ ['design', 'Try themes live', 'https://openvibe.design/'],
+ ['domains', 'Names and prices', 'https://openvibe.domains/'],
+ ['host', 'Compare hosting', 'https://openvibe.host/'],
+ ['studio', 'Edit photos in your browser', 'https://openvibe.studio/'],
+ ['locker', 'Files only you can open', 'https://openvibe.locker/'],
+ ['food', 'Food near you', 'https://openvibe.food/'],
+ ],
+ make: [
+ ['space', 'Host a site or app', 'https://openvibe.space/'],
+ ['codes', 'Code with any agent', 'https://openvibe.codes/'],
+ ['bot', 'Drive a robot', 'https://openvibe.bot/'],
+ ['actor', 'The agent engine', 'https://openvibe.actor/'],
+ ['computer', 'Your computer, your way', 'https://openvibe.computer/'],
+ ['connect', 'Share a port with Link', 'https://connect.openvibe.link/'],
+ ],
+ build: [
+ ['services', 'APIs, SDK and keys', 'https://openvibe.services/'],
+ ['exchange', 'AI models, one account', 'https://openvibe.exchange/'],
+ ['run', 'Run jobs anywhere', 'https://openvibe.run/'],
+ ['zone', 'S3 storage', 'https://openvibe.zone/'],
+ ['events', 'Event streams', 'https://openvibe.events/'],
+ ['watch', 'Know when it changes', 'https://openvibe.watch/'],
+ ['git', 'Host and browse code', 'https://git.openvibe.codes/'],
+ ],
};
};
-const DEFAULT_RELATED = RELATED.network;
+// The service names home pages pass that are not the card's own name (Video is media-hub, Media is its API,
+// Restream's service is openre, Email's mail, Studio's image, Locker's lock, Homes is Rent's section, Bench Report's).
+const RELATED_ALIAS = {
+ media: 'video', 'media-hub': 'video', video: 'video', stream: 'openre', openre: 'openre', restream: 'openre',
+ mail: 'email', email: 'email', image: 'studio', studio: 'studio', lock: 'locker', locker: 'locker',
+ homes: 'rent', bench: 'report',
+};
+const RELATED_ALL = Object.entries(GROUPS).flatMap(([group, members]) => members.map(([to, what, href]) => ({ group, to, what, href })));
+const RELATED_BY_ID = new Map(RELATED_ALL.map((x) => [x.to, x]));
+function relatedFor(key) {
+ const id = RELATED_ALIAS[key] || key;
+ const here = RELATED_BY_ID.get(id);
+ if (!here) return null;
+ return RELATED_ALL.filter((x) => x.group === here.group && x.to !== id).slice(0, 4).map((x) => [x.to, x.what, x.href]);
+}
+const RELATED = {};
+for (const x of RELATED_ALL) RELATED[x.to] = relatedFor(x.to);
+for (const [alias, id] of Object.entries(RELATED_ALIAS)) if (RELATED_BY_ID.has(id)) RELATED[alias] = relatedFor(id);
+// A product without its own list shows one place from each of the first four groups, in the groups' order.
+const DEFAULT_RELATED = ['live', 'community', 'games', 'news'].map((id) => {
+ const x = RELATED_BY_ID.get(id);
+ return [x.to, x.what, x.href];
+});
/**
/**
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
* "Part of OpenVibe": up to four related products for this one (RELATED, or the caller's own items in the same
@@ -676,4 +738,4 @@ html{overflow-x:clip}
@@ -676,4 +738,4 @@ html{overflow-x:clip}
}
}
`;
`;
-module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, CSS, STYLESHEET: 'showcase.css', esc };
+module.exports = { NETWORK_JS, icon, room, hero, features, steps, code, demo, fold, compare, pricing, limits, stories, stats, cards, spotlight, faq, network, apps, nextSteps, cta, jsonLd, faqJsonLd, RELATED, DEFAULT_RELATED, CSS, STYLESHEET: 'showcase.css', esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+// The bottom of a phone (plan 279, the owner: "That fixed bottom position is kind of buggy … as you scroll up and
+// down, so let's make that nice and smooth and perfect across all sites"). A real headless Chrome at 360×740,
+// 390×844 and 430×932, with the browser's toolbar sliding away under the page:
+//
+// • the toolbar is emulated with a visualViewport whose height changes while the layout viewport stays put (the
+// shim below; a real toolbar can update the visible viewport before the layout one, which is where a bar at
+// bottom: 0 is left behind);
+// • the page scrolls up and down through four positions and the toolbar opens and closes;
+// • the app bar's bottom edge stays on the visible viewport's bottom (within 1 px), the small player stays 8 px
+// above the bar and the Dock's pill 16 px above the small player, and none of them overlap;
+// • the gaps do not jump by more than 1 px between samples.
+// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('bottom-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const file = (res, name, type) => {
+ const p = path.join(ROOT, name);
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
+ res.end(fs.existsSync(p) ? fs.readFileSync(p) : ''); // the baseline (origin/main) has no bottom.js: the page still loads
+};
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bottom</title>
+<style>body{margin:0;background:#0b1120;color:#e6edf7;font:15px system-ui;min-height:2600px}main{padding:20px 16px 64px}p{height:38px;margin:0 0 14px}</style></head>
+<body><main><h1>Bottom</h1>${'<p>The bar, the small player and the Dock all take their place from one measured number.</p>'.repeat(40)}</main>
+<script src="/bottom.js"></script>
+<script src="/appbar.js"></script>
+<script src="/mini.js"></script>
+<script src="/dock.js"></script>
+<script>
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+OpenVibeAppBar.render({
+ config: { appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Games', href: '/games', icon: 'fa-gamepad' }, { label: 'Tools', href: '/tools', icon: 'fa-wrench' }] },
+ links: [], activePage: null, nav: null,
+ navIcon: () => '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="7" fill="currentColor"/></svg>',
+ escapeAttr: esc, escapeHtml: esc, flatLinks: () => [],
+});
+OpenVibeMini.open({ page: '/', src: '/clip.webm', title: 'The harbour at night', by: 'Kim', t: 0, dur: 60, rate: 1, vol: 0, muted: true, playing: false });
+OpenVibeDock.mount({ apiBase: location.origin, token: 'tok', user: { id: 7, username: 'ada', display_name: 'Ada' } });
+</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/clip.webm') return file(res, 'test/fixtures/ov-sync-clock.webm', 'video/webm');
+ if (u.pathname === '/bottom.js' || u.pathname === '/appbar.js' || u.pathname === '/mini.js' || u.pathname === '/dock.js') return file(res, u.pathname.slice(1), 'application/javascript');
+ if (u.pathname === '/api/v1/me/dock') {
+ const body = { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 }, sources_partial: false };
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify(body));
+ }
+ res.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' });
+ res.end(PAGE);
+});
+
+// A visual viewport the test can shrink without resizing the layout viewport: the toolbar sliding over the page.
+const SHIM = `(() => {
+ let vh = innerHeight, top = 0;
+ const vv = new EventTarget();
+ Object.defineProperties(vv, {
+ height: { get: () => vh }, width: { get: () => innerWidth },
+ offsetTop: { get: () => top }, offsetLeft: { get: () => 0 },
+ pageTop: { get: () => top + scrollY }, pageLeft: { get: () => 0 }, scale: { get: () => 1 },
+ });
+ Object.defineProperty(window, 'visualViewport', { configurable: true, get: () => vv });
+ window.__ovvv = {
+ set(height, offset) { vh = height; top = offset || 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },
+ reset() { vh = innerHeight; top = 0; vv.dispatchEvent(new Event('resize')); vv.dispatchEvent(new Event('scroll')); },
+ };
+})();`;
+
+const MEASURE = `(() => {
+ const px = (n) => Math.round(n * 100) / 100;
+ const vv = visualViewport;
+ const visibleBottom = vv.offsetTop + vv.height;
+ const box = (sel) => {
+ const e = document.querySelector(sel);
+ if (!e) return null;
+ const cs = getComputedStyle(e);
+ if (e.hidden || cs.display === 'none' || cs.visibility === 'hidden') return null;
+ const r = e.getBoundingClientRect();
+ if (!r.width || !r.height) return null;
+ return { t: px(r.top), b: px(r.bottom), l: px(r.left), r: px(r.right), w: px(r.width), h: px(r.height) };
+ };
+ const bar = box('.ovnav-appbar'), mini = box('.ovmini:not(.is-leaving)'), pill = box('#ovdk-pill');
+ const area = (a, b) => (!a || !b) ? 0 : px(Math.max(0, Math.min(a.r, b.r) - Math.max(a.l, b.l)) * Math.max(0, Math.min(a.b, b.b) - Math.max(a.t, b.t)));
+ return {
+ visibleBottom: px(visibleBottom), innerHeight, lift: px(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--ovb-lift')) || 0),
+ bar, mini, pill,
+ gaps: {
+ bar: bar && px(visibleBottom - bar.b),
+ mini: (bar && mini) ? px(bar.t - mini.b) : null,
+ pill: (mini && pill) ? px(mini.t - pill.b) : null,
+ },
+ overlap: { barMini: area(bar, mini), barPill: area(bar, pill), miniPill: area(mini, pill) },
+ };
+})()`;
+
+const SIZES = [[360, 740], [390, 844], [430, 932]];
+const SCROLLS = [0, 900, 1800, 500];
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-bottom-')) });
+ const report = [];
+ let failed = 0;
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ for (const [width, height] of SIZES) {
+ const page = await h.openPage(browser, await browser.newContext(), { width, height });
+ try {
+ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: SHIM });
+ await page.goto(`${base}/`, { maxMs: 15000, quietMs: 600 });
+ const until = async (expr, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return true; await wait(80); } return false; };
+ assert.ok(await until(`!!document.querySelector('.ovnav-appbar') && !!document.querySelector('.ovmini:not(.is-leaving)') && !!document.querySelector('#ovdk-pill') && !document.querySelector('#ovdk-pill').hidden`), `${width}x${height}: the bar, the small player and the Dock's pill are on the page`);
+ await wait(450); // the small player's entrance and the Dock's first read
+ const samples = [];
+ for (const [i, y] of SCROLLS.entries()) {
+ for (const [label, visible] of [['toolbar-shown', height - 56], ['toolbar-collapsed', height], ['toolbar-half', height - 28], ['toolbar-collapsed', height]]) {
+ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, ${y}); 0`);
+ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');
+ await wait(90);
+ const m = await page.evaluate(MEASURE);
+ m.y = y; m.toolbar = label; m.i = i;
+ samples.push(m);
+ }
+ }
+ // The toolbar sliding while the page is still: the same samples, for the jump.
+ for (const visible of [height, height - 56, height - 56, height]) {
+ await page.evaluate(`__ovvv.set(${visible}); scrollTo(0, 900); 0`);
+ await page.evaluate('new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))');
+ await wait(90);
+ samples.push({ ...(await page.evaluate(MEASURE)), y: 900, toolbar: 'slide', i: 'slide' });
+ }
+
+ const failures = [];
+ const check = (ok, text) => { if (!ok) failures.push(text); };
+ for (const m of samples) {
+ const at = `${m.y}px/${m.toolbar}`;
+ check(!!m.bar, `${at}: the app bar is on the page`);
+ check(!!m.mini, `${at}: the small player is on the page`);
+ check(!!m.pill, `${at}: the Dock's pill is on the page`);
+ if (!m.bar || !m.mini || !m.pill) continue;
+ check(Math.abs(m.gaps.bar) <= 1, `${at}: the app bar's bottom is ${m.gaps.bar}px from the visible bottom`);
+ check(Math.abs(m.overlap.barMini) === 0, `${at}: the bar and the small player overlap by ${m.overlap.barMini}px²`);
+ check(Math.abs(m.overlap.barPill) === 0, `${at}: the bar and the Dock overlap by ${m.overlap.barPill}px²`);
+ check(Math.abs(m.overlap.miniPill) === 0, `${at}: the small player and the Dock overlap by ${m.overlap.miniPill}px²`);
+ check(Math.abs(m.gaps.mini - 8) <= 1, `${at}: the small player sits ${m.gaps.mini}px above the bar (8)`);
+ check(Math.abs(m.gaps.pill - 16) <= 1, `${at}: the Dock sits ${m.gaps.pill}px above the small player (16)`);
+ }
+ const spread = (key) => { const v = samples.map((m) => m.gaps[key]).filter((n) => typeof n === 'number'); return v.length ? Math.max(...v) - Math.min(...v) : 0; };
+ check(spread('bar') <= 1, `${width}x${height}: the bar jumps ${spread('bar')}px between samples`);
+ check(spread('mini') <= 1, `${width}x${height}: the small player jumps ${spread('mini')}px between samples`);
+ check(spread('pill') <= 1, `${width}x${height}: the Dock jumps ${spread('pill')}px between samples`);
+ const barGaps = samples.map((m) => m.gaps.bar);
+ const miniGaps = samples.map((m) => m.gaps.mini);
+ const pillGaps = samples.map((m) => m.gaps.pill);
+ report.push({
+ size: `${width}x${height}`, samples: samples.length,
+ bar: [Math.min(...barGaps), Math.max(...barGaps)],
+ mini: [Math.min(...miniGaps), Math.max(...miniGaps)],
+ pill: [Math.min(...pillGaps), Math.max(...pillGaps)],
+ overlap: Math.max(...samples.map((m) => m.overlap.barMini + m.overlap.barPill + m.overlap.miniPill)),
+ });
+ if (failures.length) {
+ failed++;
+ console.error(`${width}x${height}: ${failures.length} failure(s)`);
+ for (const f of failures.slice(0, 6)) console.error(` - ${f}`);
+ }
+ } finally { await page.close(); }
+ }
+ } finally { await browser.close(); }
+ for (const r of report) console.log(`bottom-chrome ${r.size}: ${r.samples} samples, bar gap ${r.bar[0]}..${r.bar[1]}px, mini ${r.mini[0]}..${r.mini[1]}px, pill ${r.pill[0]}..${r.pill[1]}px, overlap ${r.overlap}px²`);
+ if (failed || report.length !== SIZES.length) { console.error(`bottom-chrome: FAILED at ${failed} of ${SIZES.length} sizes`); process.exit(1); }
+ console.log('bottom-chrome: the bar, the small player and the Dock hold the bottom at every size and toolbar state');
+ server.close();
+})().catch((e) => { console.error('bottom-chrome:', e.stack || e.message); try { server.close(); } catch { /* */ } process.exit(1); });
@@ -82,7 +82,15 @@ p.L.display.set({ icons: 'auto' }, { local: true });
@@ -82,7 +82,15 @@ p.L.display.set({ icons: 'auto' }, { local: true });
assert.strictEqual(p.html.getAttribute('data-ov-icons'), null);
assert.strictEqual(p.html.getAttribute('data-ov-icons'), null);
p = load({ store: new Map([['ov_display', JSON.stringify({ icons: 'line' })]]) });
p = load({ store: new Map([['ov_display', JSON.stringify({ icons: 'line' })]]) });
assert.strictEqual(p.html.getAttribute('data-ov-icons'), 'line', 'a saved preference paints on the next page');
assert.strictEqual(p.html.getAttribute('data-ov-icons'), 'line', 'a saved preference paints on the next page');
-assert.ok(/'data-ov-icons'\]/.test(SRC) || SRC.includes("'data-ov-oled', 'data-ov-icons']"), 'the preference travels with the look (PAINT_ATTRS)');
+assert.ok(/'data-ov-icons'[,\]]/.test(SRC) && /const PAINT_ATTRS = \[[^\]]*'data-ov-icons'/.test(SRC), 'the preference travels with the look (PAINT_ATTRS)');
+// Windows (4.202.0, asks 304 and 318): the person's window style is a display setting too, painted at boot.
+p.L.display.set({ window: 'outline' }, { local: true });
+assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline');
+p.L.display.set({ window: 'neon' }, { local: true });
+assert.strictEqual(p.html.getAttribute('data-ov-window'), 'outline', 'an unknown window style changes nothing');
+p.L.display.set({ window: 'glass' }, { local: true });
+assert.strictEqual(p.html.getAttribute('data-ov-window'), null, 'glass (the default) paints nothing');
+assert.ok(/const PAINT_ATTRS = \[[^\]]*'data-ov-window'/.test(SRC), 'the window style travels with the look');
const glyphCss = require('fs').readFileSync(require('path').join(__dirname, '..', 'ov-glyphs.js'), 'utf8');
const glyphCss = require('fs').readFileSync(require('path').join(__dirname, '..', 'ov-glyphs.js'), 'utf8');
assert.ok(glyphCss.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the glyphs read the preference');
assert.ok(glyphCss.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the glyphs read the preference');
assert.ok(glyphCss.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'the person\'s preference wins over an area\'s switch point');
assert.ok(glyphCss.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'the person\'s preference wins over an area\'s switch point');
@@ -41,7 +41,7 @@ const server = http.createServer((req, res) => {
@@ -41,7 +41,7 @@ const server = http.createServer((req, res) => {
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#111;color:#eee;font-family:sans-serif">
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#111;color:#eee;font-family:sans-serif">
<div id="part" style="width:380px"></div><script src="/dock-code.js"></script>
<div id="part" style="width:380px"></div><script src="/dock-code.js"></script>
<script>window.signedIn = ${u.searchParams.get('out') ? 'false' : 'true'};
<script>window.signedIn = ${u.searchParams.get('out') ? 'false' : 'true'};
-window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true });</script>`);
+window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: () => (window.signedIn ? 'tok_abc' : null), frameBase: location.origin + '/api/frame', codes: location.origin + '/codes', active: () => true, take: () => { const t = window.handed || ''; window.handed = ''; return t; } });</script>`);
});
});
(async () => {
(async () => {
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
@@ -82,6 +82,14 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
@@ -82,6 +82,14 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
assert.deepEqual(b, { text: '', note: 'Started. Follow it on openvibe.codes', link: 'https://openvibe.codes/tasks/t3' });
assert.deepEqual(b, { text: '', note: 'Started. Follow it on openvibe.codes', link: 'https://openvibe.codes/tasks/t3' });
assert.equal((await rows())[0].s, 'Waiting', 'a queued task says it is waiting');
assert.equal((await rows())[0].s, 'Waiting', 'a queued task says it is waiting');
+ // Vibe's Build it in Code: the words another part hands over go in the box, ready to start.
+ await page.evaluate(`window.handed = 'Build a goal bar for my sub goal'; window.part.refresh()`);
+ b = await page.evaluate(`({ text: document.querySelector('.ovdc-text').value, note: document.querySelector('.ovdc-note').textContent, focused: document.activeElement === document.querySelector('.ovdc-text'), go: document.querySelector('.ovdc-go').disabled })`);
+ assert.deepEqual(b, { text: 'Build a goal bar for my sub goal', note: 'From your chat with Vibe. Start it, or change it first.', focused: true, go: false }, 'handed-over words are in the box, ready to start (My code keeps its repository)');
+ await page.evaluate(`document.querySelector('[data-mode="create"]').click()`);
+ assert.equal(await page.evaluate(`document.querySelector('.ovdc-go').disabled`), false);
+ await page.evaluate(`(() => { const t = document.querySelector('.ovdc-text'); t.value = ''; t.dispatchEvent(new Event('input')); })()`);
+
// Stop.
// Stop.
await page.evaluate(`[...document.querySelectorAll('.ovdc-task')].find((li) => li.textContent.includes('goal bar')).querySelector('.ovdc-stop').click()`);
await page.evaluate(`[...document.querySelectorAll('.ovdc-task')].find((li) => li.textContent.includes('goal bar')).querySelector('.ovdc-stop').click()`);
assert.ok(await until(`[...document.querySelectorAll('.ovdc-task')].some((li) => li.textContent.includes('goal bar') && li.textContent.includes('Stopped'))`), 'Stop stops it');
assert.ok(await until(`[...document.querySelectorAll('.ovdc-task')].some((li) => li.textContent.includes('goal bar') && li.textContent.includes('Stopped'))`), 'Stop stops it');
@@ -21,7 +21,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -21,7 +21,7 @@ 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 = 21040; // 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 = 21100; // 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'];
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'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
@@ -38,4 +38,12 @@ assert.ok(phone.length < css.length, 'the phone block exists');
@@ -38,4 +38,12 @@ assert.ok(phone.length < css.length, 'the phone block exists');
assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);
assert.match(phone, /\.ovf-fold>summary\{pointer-events:auto;cursor:pointer;[^}]*min-height:48px\}/);
assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);
assert.match(phone, /\.ovf-fold\[open\]>summary::after\{transform:/);
+// An untitled or empty group is never a fold (4.202.0's screenshots showed two "0 links" folds with no heading).
+{
+ const empty = footer.buildHTML({ service: 'live', variant: 'full', links: [{ heading: '', items: [] }, { heading: 'Nothing', items: [] }, { heading: '', items: [{ name: 'X', url: '/x' }] }, ...links] });
+ assert.ok(!/<h3><\/h3>/.test(empty), 'no fold without a heading');
+ assert.ok(!/>0 links</.test(empty), 'no fold with no links');
+ assert.strictEqual((empty.match(/<details class="ovf-fold"/g) || []).length, folds.length, 'only the real columns are folds');
+}
+
console.log('footer-folds: all checks passed');
console.log('footer-folds: all checks passed');
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
-// ov-fx.js (4.66.0, plan L45): the network's effects by data-fx. Each name wires its class or listener; a reveal hides
-// only what is below the fold and shows it when it comes on screen; a number counts up and ends on its own text;
-// under reduced motion or Calm nothing is hidden, nothing counts and no confetti falls; markup added later is wired;
-// the navbar loads it only for a page that asks. linkedom with a fake IntersectionObserver and matchMedia.
+// ov-fx.js (4.66.0, plan L45; the reveal remade for 278): the network's effects by data-fx. Each name wires its class
+// or listener; a reveal gives its heading, rule and cards a start state only when they are below the fold and then
+// shows them once — with the scroll timeline where view() exists (no script on the main thread while scrolling) and
+// one shared IntersectionObserver otherwise. Calm keeps a short fade; Off shows everything at once. linkedom with a
+// fake IntersectionObserver, matchMedia and CSS.supports.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -10,29 +11,32 @@ const { parseHTML } = require('linkedom');
@@ -10,29 +11,32 @@ const { parseHTML } = require('linkedom');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-fx.js'), 'utf8');
-function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
+function setup({ reduce = false, calm = false, off = false, timeline = false, html = '' } = {}) {
const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${off ? ' data-ov-motion="off"' : calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);
const { window, document, MutationObserver } = parseHTML(`<!doctype html><html${off ? ' data-ov-motion="off"' : calm ? ' data-ov-motion="reduced"' : ''}><head></head><body>${html}</body></html>`);
const observed = [];
const observed = [];
class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }
class IO { constructor(cb) { this.cb = cb; } observe(el) { observed.push(el); } unobserve() {} fire(el) { this.cb([{ target: el, isIntersecting: true }]); } }
let io = null;
let io = null;
const frames = [];
const frames = [];
+ const timers = [];
window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };
window.IntersectionObserver = class extends IO { constructor(cb) { super(cb); io = this; } };
window.MutationObserver = MutationObserver;
window.MutationObserver = MutationObserver;
+ window.CSS = { supports: (q) => !!timeline && /animation-timeline/.test(q) };
window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });
window.matchMedia = (q) => ({ matches: /reduce/.test(q) ? reduce : /hover/.test(q) });
window.innerHeight = 800;
window.innerHeight = 800;
window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
window.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
window.cancelAnimationFrame = () => {};
window.cancelAnimationFrame = () => {};
- window.setTimeout = (fn) => { fn(); return 0; };
+ window.setTimeout = (fn) => { timers.push(fn); return timers.length; };
window.performance = { now: () => 0 };
window.performance = { now: () => 0 };
for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });
for (const el of document.querySelectorAll('[data-top]')) el.getBoundingClientRect = () => ({ top: Number(el.getAttribute('data-top')), left: 0, width: 100, height: 40 });
window.OVFx = undefined; // linkedom windows share their expando properties
window.OVFx = undefined; // linkedom windows share their expando properties
new Function('window', SRC)(window);
new Function('window', SRC)(window);
const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };
const run = (t) => { let n = 0; while (frames.length && n++ < 500) frames.shift()(t); };
- return { window, document, io: () => io, observed, run };
+ const flush = () => { let n = 0; while (timers.length && n++ < 500) timers.shift()(); };
+ return { window, document, io: () => io, observed, run, flush };
}
}
(async () => {
(async () => {
- // ── names wire their classes; the CSS carries the motion tokens and the reduced-motion guards ──
+ // ── names wire their classes; the CSS carries the motion tokens and the Off/Calm guards ──
{
{
const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });
const t = setup({ html: '<a id="a" data-fx="glint lift press glow">x</a><span id="s" data-fx="shimmer"></span><b id="r" data-fx="ripple">r</b>' });
const a = t.document.getElementById('a');
const a = t.document.getElementById('a');
@@ -43,7 +47,7 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
@@ -43,7 +47,7 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-move:\.35;--ov-dur-1:\.15s;[^}]*--ov-ease-spring:var\(--ov-ease-out\)\}/, 'Calm: a third of the distance, the calm lengths, no overshoot; never zero');
assert.match(css, /html\[data-ov-motion=reduced\]\{--ov-move:\.35;--ov-dur-1:\.15s;[^}]*--ov-ease-spring:var\(--ov-ease-out\)\}/, 'Calm: a third of the distance, the calm lengths, no overshoot; never zero');
assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{html:not\(\[data-ov-motion\]\)\{--ov-move:\.35;/, 'the same when the device asks and no loader has spoken');
assert.match(css, /@media \(prefers-reduced-motion:reduce\)\{html:not\(\[data-ov-motion\]\)\{--ov-move:\.35;/, 'the same when the device asks and no loader has spoken');
assert.match(css, /html\[data-ov-motion=off\]\{--ov-move:0;--ov-dur-1:0s/, 'only Off zeroes them');
assert.match(css, /html\[data-ov-motion=off\]\{--ov-move:0;--ov-dur-1:0s/, 'only Off zeroes them');
- assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-hide{opacity:0;transform:translateY(calc(14px * var(--ov-move,1)))}'), 'distances are multiplied by --ov-move');
+ assert.ok(css.includes('.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)))') && css.includes('.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px))'), 'distances are multiplied by --ov-move');
assert.ok(css.includes('html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}'), 'Calm keeps the glint, fainter and slower');
assert.ok(css.includes('html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}'), 'Calm keeps the glint, fainter and slower');
assert.ok(css.includes('html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'motion off stops the glint, outside a demo scope');
assert.ok(css.includes('html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}'), 'motion off stops the glint, outside a demo scope');
assert.ok(!/data-ov-motion=reduced\][^{]*\{[^}]*(animation|transition):none/.test(css), 'nothing is stopped under Calm');
assert.ok(!/data-ov-motion=reduced\][^{]*\{[^}]*(animation|transition):none/.test(css), 'nothing is stopped under Calm');
@@ -57,47 +61,110 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
@@ -57,47 +61,110 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.ok(css.indexOf('html[data-ov-motion=reduced]{--ov-move:.35') > css.indexOf('html:is([data-theme=neon-tokyo]'), 'Calm comes last, so it wins over any feel');
assert.ok(css.indexOf('html[data-ov-motion=reduced]{--ov-move:.35') > css.indexOf('html:is([data-theme=neon-tokyo]'), 'Calm comes last, so it wins over any feel');
}
}
- // ── reveal: below the fold it waits for the screen; on screen it is never hidden ──
+ // ── the reveal's CSS (278): heading out of a blur, rule from the left, six card steps, scroll timeline first ──
{
{
- const t = setup({ html: '<ul id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400"><li>a</li><li>b</li><li>c</li></ul><ul id="top" data-fx="reveal" data-top="100"><li>x</li></ul>' });
+ const t = setup();
+ const css = t.document.getElementById('ov-fx-css').textContent;
+ assert.ok(css.includes('--ovfx-rise:12px') && css.includes('@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}'), '12 px on a computer, 8 on a phone');
+ assert.ok(css.includes('@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}'), 'the heading rises and sharpens from 4 px of blur');
+ assert.ok(css.includes('@keyframes ovfxRuleDraw{from{transform:scaleX(0)'), 'the rule is drawn, not faded in');
+ assert.ok(css.includes('.ovfx-rule{transform-origin:left center}'), 'from the left');
+ assert.ok(css.includes('@supports (animation-timeline:view())'), 'the scroll timeline, where the browser has it');
+ assert.ok(css.includes('animation-timeline:view();animation-range:entry 0% cover 25%'), 'the heading is driven by the scroll position');
+ assert.ok(css.includes('animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)'), 'the cards step by index');
+ assert.ok(css.includes('animation:ovfxHeadRise linear backwards'), 'backwards fill: once past the range the page\'s own styles are back, and nothing is filled forward');
+ assert.ok(!/\.ovfx-head\{[^}]*opacity:0/.test(css), 'content is visible by default; only a start state the script or @supports adds hides it');
+ assert.ok(css.includes('html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn'), 'Calm keeps only the fade');
+ assert.ok(css.includes('html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important'), 'Off removes the motion');
+ }
+
+ // ── reveal: below the fold it waits for the screen; heading, rule and cards step; on screen it is never hidden ──
+ {
+ const html = `<section id="low" data-fx="reveal" data-fx-stagger="40" data-top="1400">
+ <h2 id="h" data-top="1400">Live Now</h2><span id="r" data-fx-rule data-top="1400"></span>
+ <div class="grid" id="grid">${[1, 2, 3, 4, 5, 6, 7].map((n) => `<a class="card" data-top="${1500 + n}">${n}</a>`).join('')}</div>
+ </section><section id="top" data-fx="reveal" data-top="100"><h2 data-top="100">x</h2><p data-top="100">y</p></section>`;
+ const t = setup({ html });
const low = t.document.getElementById('low'), top = t.document.getElementById('top');
const low = t.document.getElementById('low'), top = t.document.getElementById('top');
- assert.ok([...low.children].every((k) => k.classList.contains('ovfx-hide')), 'below the fold: hidden until seen');
- assert.strictEqual(low.children[2].style.getPropertyValue('--ovfx-d'), '80ms', 'staggered');
- assert.ok(![...top.children].some((k) => k.classList.contains('ovfx-hide')), 'on screen when wired: never hidden');
- assert.ok(t.observed.includes(low) && !t.observed.includes(top));
- t.io().fire(low);
- assert.ok([...low.children].every((k) => !k.classList.contains('ovfx-hide')), 'seen: shown');
+ const head = t.document.getElementById('h'), rule = t.document.getElementById('r');
+ const cards = [...t.document.querySelectorAll('#grid > a')];
+ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-wait'), 'the heading has a start state');
+ assert.ok(rule.classList.contains('ovfx-rule') && rule.classList.contains('ovfx-wait'), 'the rule has one too');
+ assert.ok(cards.every((k) => k.classList.contains('ovfx-card') && k.classList.contains('ovfx-wait')), 'so do the cards');
+ assert.ok(!t.document.getElementById('grid').classList.contains('ovfx-card'), 'the grid itself is not a card');
+ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-d')), ['140ms', '180ms', '220ms', '260ms', '300ms', '340ms', '340ms'], '40 ms apart after the heading, at most six, the rest together');
+ assert.strictEqual(t.observed.filter((x) => x === low).length, 0, 'the section is not observed as a whole: its parts are');
+ assert.strictEqual(t.observed.length, 9, 'heading, rule and seven cards wait with the one observer');
+ assert.ok(t.io() instanceof t.window.IntersectionObserver, 'one shared observer');
+ assert.ok(![...top.querySelectorAll('*')].some((k) => k.classList.contains('ovfx-wait')), 'on screen when wired: never hidden');
+ t.io().fire(head);
+ assert.ok(head.classList.contains('ovfx-in'), 'seen: the transition runs');
+ t.flush();
+ assert.ok(!head.classList.contains('ovfx-wait') && !head.classList.contains('ovfx-in'), 'and the start and transition classes are gone, so the page own styles are back');
+ t.io().fire(cards[2]);
+ assert.ok(cards[2].classList.contains('ovfx-in'), 'a card runs its own transition');
}
}
- // ── count: from 0 up to the number, ending on its own text ──
+ // ── a section already on screen only waits for the parts that are below it ──
{
{
- const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });
- const n = t.document.getElementById('n');
- t.io().fire(n);
- t.run(100);
- assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');
- assert.match(n.textContent, / viewers$/, 'keeps its words');
- t.run(5000);
- assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');
- t.run(5000);
- assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');
- assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');
+ const t = setup({ html: '<section data-fx="reveal" data-top="100"><h2 id="h" data-top="100">top</h2><a id="c" data-top="1400">card</a></section>' });
+ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
+ assert.ok(!h.classList.contains('ovfx-head'), 'the heading someone can see is left alone');
+ assert.ok(c.classList.contains('ovfx-card') && c.classList.contains('ovfx-wait'), 'the card below the fold waits');
+ assert.deepStrictEqual(t.observed, [c], 'only that card is observed');
+ }
+
+ // ── data-fx-self reveals the element itself, not its children ──
+ {
+ const t = setup({ html: '<h2 id="h" data-fx="reveal" data-fx-self data-top="1400">Title</h2>' });
+ const h = t.document.getElementById('h');
+ assert.ok(h.classList.contains('ovfx-head') && h.classList.contains('ovfx-wait'), 'the element itself has the start state');
+ assert.strictEqual(t.observed.length, 1, 'one element waits for the screen');
+ }
+
+ // ── a rule only when the section has one; cards can be named when the markup is not a list or grid ──
+ {
+ const t = setup({ html: '<div data-fx="reveal" data-fx-cards=".pick" data-top="1400"><h2 data-top="1400">x</h2><p data-top="1400">lede</p><div><a class="pick" data-top="1400">1</a><a class="pick" data-top="1400">2</a></div></div>' });
+ assert.strictEqual(t.document.querySelector('.ovfx-rule'), null, 'no rule: nothing is drawn');
+ assert.deepStrictEqual([...t.document.querySelectorAll('.pick')].map((k) => k.classList.contains('ovfx-card')), [true, true], 'the named cards are the cards');
+ assert.ok(!t.document.querySelector('p').classList.contains('ovfx-card'), 'the lede is not a card when the cards are named');
+ }
+
+ // ── with the scroll timeline the browser drives it: no observer and no script while scrolling ──
+ {
+ const t = setup({ timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><div class="grid">' + [1, 2, 3, 4, 5, 6, 7].map((n) => `<a data-top="${1500 + n}">${n}</a>`).join('') + '</div></section>' });
+ const head = t.document.getElementById('h'), cards = [...t.document.querySelectorAll('.grid > a')];
+ assert.ok(head.classList.contains('ovfx-head') && head.classList.contains('ovfx-scroll'), 'the heading rides the scroll timeline');
+ assert.ok(!head.classList.contains('ovfx-wait'), 'no start class: the keyframes hold the start state');
+ assert.deepStrictEqual(cards.map((k) => k.style.getPropertyValue('--ovfx-i')), ['0', '1', '2', '3', '4', '5', '5'], 'six steps; the rest share the last');
+ assert.strictEqual(t.observed.length, 0, 'no observer is used, so no script runs on the main thread while scrolling');
}
}
- // ── Calm (the setting, or the device asking for less): everything still plays, a third as far ──
+ // ── Calm (the setting, or the device asking for less): the reveal keeps only a short fade ──
for (const opts of [{ reduce: true }, { calm: true }]) {
for (const opts of [{ reduce: true }, { calm: true }]) {
- const t = setup({ ...opts, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });
- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 1, 'the reveal waits to play');
- assert.strictEqual(t.observed.length, 2, 'the reveal and the number both wait for the screen');
- assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance');
+ const t = setup({ ...opts, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><span data-fx-rule data-top="1400"></span><a id="c" data-top="1400">card</a></section>' });
+ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
+ assert.ok(h.classList.contains('ovfx-fade') && !h.classList.contains('ovfx-head'), 'the heading only fades');
+ assert.ok(c.classList.contains('ovfx-fade') && !c.classList.contains('ovfx-card'), 'the card only fades');
+ assert.strictEqual(h.style.getPropertyValue('--ovfx-d'), '', 'no rise or stagger delay');
+ assert.strictEqual(t.observed.length, 3, 'the fade still waits for the screen');
+ assert.strictEqual(t.window.OVFx.move(), 0.35, 'a third of the distance for everything else');
+ }
+
+ // ── the same fade on the scroll timeline when the browser has it ──
+ {
+ const t = setup({ reduce: true, timeline: true, html: '<section data-fx="reveal" data-top="1400"><h2 id="h" data-top="1400">x</h2><a id="c" data-top="1400">card</a></section>' });
+ const h = t.document.getElementById('h'), c = t.document.getElementById('c');
+ assert.ok(h.classList.contains('ovfx-fade') && h.classList.contains('ovfx-scroll'), 'the heading fades on the scroll timeline');
+ assert.ok(c.classList.contains('ovfx-fade') && c.classList.contains('ovfx-scroll'), 'the card too');
+ assert.strictEqual(t.observed.length, 0, 'still no observer');
}
}
- assert.strictEqual(setup().window.OVFx.move(), 1, 'full motion: the whole distance');
// ── motion off: nothing hidden, nothing counted, no confetti ──
// ── motion off: nothing hidden, nothing counted, no confetti ──
{
{
- const t = setup({ off: true, reduce: true, html: '<ul data-fx="reveal" data-top="1400"><li>a</li></ul><b id="n" data-fx="count" data-top="1400">900</b>' });
+ const t = setup({ off: true, reduce: true, html: '<section data-fx="reveal" data-top="1400"><h2 data-top="1400">x</h2><a data-top="1400">card</a></section><b id="n" data-fx="count" data-top="1400">900</b>' });
assert.strictEqual(t.window.OVFx.move(), 0);
assert.strictEqual(t.window.OVFx.move(), 0);
- assert.strictEqual(t.document.querySelectorAll('.ovfx-hide').length, 0, 'nothing hidden');
+ assert.strictEqual(t.document.querySelectorAll('.ovfx-wait,.ovfx-fade,.ovfx-head,.ovfx-card').length, 0, 'nothing hidden and nothing staged');
assert.strictEqual(t.observed.length, 0, 'nothing waits to count');
assert.strictEqual(t.observed.length, 0, 'nothing waits to count');
assert.strictEqual(t.document.getElementById('n').textContent, '900');
assert.strictEqual(t.document.getElementById('n').textContent, '900');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'no confetti');
@@ -105,12 +172,27 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
@@ -105,12 +172,27 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
// ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──
// ── the demo scope: a page that shows motion may play it inside [data-fx-demo], when the person asks ──
{
{
- const t = setup({ off: true, html: '<div data-fx-demo><ul id="in" data-fx="reveal" data-top="1400"><li>a</li></ul></div><ul id="out" data-fx="reveal" data-top="1400"><li>b</li></ul>' });
- assert.strictEqual(t.document.querySelectorAll('#in .ovfx-hide').length, 1, 'inside the scope the reveal waits to play');
- assert.strictEqual(t.document.querySelectorAll('#out .ovfx-hide').length, 0, 'outside it nothing is hidden: the setting holds for the rest of the page');
+ const t = setup({ off: true, timeline: true, html: '<div data-fx-demo><section id="in" data-fx="reveal" data-top="1400"><h2 data-top="1400">a</h2></section></div><section id="out" data-fx="reveal" data-top="1400"><h2 data-top="1400">b</h2></section>' });
+ assert.ok(t.document.querySelector('#in h2').classList.contains('ovfx-scroll'), 'inside the scope the reveal plays in full');
+ assert.strictEqual(t.document.querySelectorAll('#out .ovfx-scroll').length, 0, 'outside it the setting holds');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');
assert.strictEqual(t.window.OVFx.confetti({ x: 1, y: 1 }), false, 'confetti with no source element stays off');
}
}
+ // ── count: from 0 up to the number, ending on its own text ──
+ {
+ const t = setup({ html: '<b id="n" data-fx="count" data-top="1200">12,480 viewers</b><b id="m" data-fx="count" data-top="10">€3.50</b><b id="w" data-fx="count" data-top="1200">soon</b>' });
+ const n = t.document.getElementById('n');
+ t.io().fire(n);
+ t.run(100);
+ assert.notStrictEqual(n.textContent, '12,480 viewers', 'counting');
+ assert.match(n.textContent, / viewers$/, 'keeps its words');
+ t.run(5000);
+ assert.strictEqual(n.textContent, '12,480 viewers', 'ends exactly on its own text');
+ t.run(5000);
+ assert.strictEqual(t.document.getElementById('m').textContent, '€3.50', 'decimals end exact too');
+ assert.strictEqual(t.document.getElementById('w').textContent, 'soon', 'not a number: left alone');
+ }
+
// ── markup added later is wired ──
// ── markup added later is wired ──
{
{
const t = setup();
const t = setup();
@@ -129,5 +211,5 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
@@ -129,5 +211,5 @@ function setup({ reduce = false, calm = false, off = false, html = '' } = {}) {
assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');
assert.ok(require('../files').BROWSER.includes('ov-fx.js'), 'served');
}
}
- console.log('fx: names wire their effects; reveal waits below the fold and never hides what is on screen; numbers end on their own text; Calm plays everything a third as far; only motion off keeps still; later markup wired; loaded only on request');
+ console.log('fx: names wire their effects; the reveal stages a heading out of a blur, its rule from the left and six card steps, on the scroll timeline or one observer; Calm fades; only motion off keeps still; later markup wired; loaded only on request');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -97,8 +97,8 @@ const server = http.createServer((req, res) => {
@@ -97,8 +97,8 @@ const server = http.createServer((req, res) => {
await openTab(p, 'you');
await openTab(p, 'you');
assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');
assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');
const first = await order(p);
const first = await order(p);
- assert.strictEqual(first.length, 12, `twelve on Home by default (${first})`);
- assert.deepStrictEqual(first.slice(-2), ['folder:tools', 'folder:build'], 'Tools and Build are folders at the end');
+ assert.strictEqual(first.length, 13, `thirteen on Home by default (${first})`);
+ assert.deepStrictEqual(first.slice(-3), ['folder:g-play', 'folder:build', 'folder:tools'], 'the final group, Build and Quick tools folders stay at the end');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
@@ -132,7 +132,7 @@ const server = http.createServer((req, res) => {
@@ -132,7 +132,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
const removed = await order(p);
const removed = await order(p);
- assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');
+ assert.ok(!removed.includes(keyed[0]) && removed.length === 12, '− takes it off Home');
// Done, then All apps puts it back.
// Done, then All apps puts it back.
await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);
await p.evaluate(`document.querySelector('.ovh-allbtn').click(), true`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+// The one dropdown for a field (ov-combo.js, 4.202.0; ask 348) in a real headless Chrome against a stub source: the
+// field becomes a combobox (no datalist); focus shows the sections, typing asks again (the earlier answer is not
+// painted over the newer), the match is marked, the arrows and Enter pick and the address goes in; a name nobody has
+// offers "Create … on OpenVibe Git", which posts the name and puts the new address in; a source that fails says so and
+// the field still takes a pasted address; Escape closes; the list stays on screen near the bottom (it opens upward).
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-combo-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const REPOS = [
+ { label: 'goosely/goal-bar', url: 'https://git.openvibe.codes/goosely/goal-bar.git', host: 'git.openvibe.codes', private: true },
+ { label: 'goosely/site', url: 'https://git.openvibe.codes/goosely/site.git', host: 'git.openvibe.codes' },
+ { label: 'goosely/overlay-kit', url: 'https://github.com/goosely/overlay-kit.git', host: 'github.com' },
+];
+const asked = [];
+let failing = false;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/ov-combo.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-combo.js'))); }
+ if (u.pathname === '/repos') {
+ const q = (u.searchParams.get('q') || '').toLowerCase();
+ asked.push(q);
+ const answer = () => {
+ if (failing) { res.writeHead(500); return res.end(); }
+ const m = (r) => !q || r.label.toLowerCase().includes(q);
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ sections: [{ id: 'recent', label: 'Recent', items: REPOS.slice(0, 1).filter(m) }, { id: 'git', label: 'On OpenVibe Git', items: REPOS.slice(1, 2).filter(m) }, { id: 'github', label: 'On GitHub', items: REPOS.slice(2).filter(m) }], create: { allowed: true, host: 'git.openvibe.codes', owner: 'goosely' } }));
+ };
+ // "go" answers late: a later query must not be painted over by it.
+ return q === 'go' ? setTimeout(answer, 600) : answer();
+ }
+ if (u.pathname === '/create' && req.method === 'POST') {
+ let body = ''; req.on('data', (c) => { body += c; });
+ req.on('end', () => { const b = JSON.parse(body); res.writeHead(201, { 'content-type': 'application/json' }); res.end(JSON.stringify({ url: `https://git.openvibe.codes/goosely/${b.name}.git`, label: `goosely/${b.name}`, private: b.private })); });
+ return;
+ }
+ 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;background:#111;color:#eee;font-family:sans-serif">
+<form id="f" style="padding:40px 16px"><input id="repo" name="repo" list="old" aria-label="Repository" style="width:100%;height:40px" data-ov-combo="/repos" data-ov-combo-create="/create"><datalist id="old"></datalist></form>
+<input id="low" data-ov-combo="/repos" aria-label="Low" style="position:fixed;bottom:8px;left:16px;width:300px;height:36px">
+<script>window.picks = []; document.addEventListener('ov-combo:pick', (e) => window.picks.push(e.detail.url));</script><script src="/ov-combo.js"></script>`);
+});
+(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-combo-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });
+ await page.goto(base + '/', { maxMs: 5000 });
+ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
+ const key = async (k) => { const codes = { Enter: 13, ArrowDown: 40, ArrowUp: 38, Escape: 27 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k] }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };
+ const view = () => page.evaluate(`(() => { const p = document.querySelector('.ovcombo'); if (!p || p.hidden) return null; return { secs: [...p.querySelectorAll('.ovcombo-sec')].map((x) => x.textContent), rows: [...p.querySelectorAll('.ovcombo-i b')].map((x) => x.textContent), sel: [...p.querySelectorAll('.ovcombo-i')].findIndex((x) => x.getAttribute('aria-selected') === 'true'), marks: [...p.querySelectorAll('.ovcombo-i b mark')].map((x) => x.textContent), none: (p.querySelector('.ovcombo-none, .ovcombo-err') || {}).textContent || '' }; })()`);
+
+ const a = await page.evaluate(`({ role: repo.getAttribute('role'), list: repo.getAttribute('list'), exp: repo.getAttribute('aria-expanded'), ac: repo.getAttribute('autocomplete') })`);
+ assert.deepEqual(a, { role: 'combobox', list: null, exp: 'false', ac: 'off' }, 'a combobox, the datalist gone');
+
+ await page.evaluate(`repo.focus()`);
+ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden]) .ovcombo-i')`), 'focus shows the list');
+ let v = await view();
+ assert.deepEqual(v.secs, ['Recent', 'On OpenVibe Git', 'On GitHub']);
+ assert.deepEqual(v.rows, ['goosely/goal-bar', 'goosely/site', 'goosely/overlay-kit']);
+ assert.equal(await page.evaluate(`repo.getAttribute('aria-expanded')`), 'true');
+
+ // A slow answer for "go" must not paint over the answer for "goal".
+ await page.send('Input.insertText', { text: 'go' });
+ await wait(250);
+ await page.send('Input.insertText', { text: 'al' });
+ assert.ok(await until(`(() => { const p = document.querySelector('.ovcombo'); return p && !p.hidden && p.querySelectorAll('.ovcombo-i').length && [...p.querySelectorAll('.ovcombo-i b')][0].textContent === 'goosely/goal-bar'; })()`));
+ await wait(700);
+ v = await view();
+ assert.deepEqual(v.rows, ['goosely/goal-bar', 'Create “goal” on OpenVibe Git'], 'only what matches "goal", and a Create row');
+ assert.deepEqual(v.marks, ['goal'], 'the match is marked');
+ assert.equal(v.sel, 0, 'the first row is ready for Enter');
+ await key('Enter');
+ assert.equal(await page.evaluate('repo.value'), 'https://git.openvibe.codes/goosely/goal-bar.git', 'Enter put the address in');
+ assert.equal(await view(), null, 'and closed the list');
+ assert.deepEqual(await page.evaluate('window.picks'), ['https://git.openvibe.codes/goosely/goal-bar.git']);
+
+ // A new name: Create … on OpenVibe Git.
+ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);
+ await page.send('Input.insertText', { text: 'sub-goal-bar' });
+ assert.ok(await until(`[...document.querySelectorAll('.ovcombo .ovcombo-i b')].some((b) => b.textContent === 'Create “sub-goal-bar” on OpenVibe Git')`), 'a name nobody has can be created');
+ v = await view();
+ assert.match(await page.evaluate(`document.querySelector('.ovcombo .is-create small').textContent`), /Private, under @goosely/);
+ await key('ArrowDown');
+ await key('ArrowUp');
+ await key('Enter');
+ assert.ok(await until(`repo.value === 'https://git.openvibe.codes/goosely/sub-goal-bar.git'`), 'the new repository went in');
+ assert.equal(await page.evaluate('window.picks.length'), 2);
+
+ // Escape closes; ArrowDown opens again.
+ await key('ArrowDown');
+ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden])')`));
+ await key('Escape');
+ assert.equal(await view(), null, 'Escape closes it');
+
+ // A source that fails says so; a pasted address still stands.
+ failing = true;
+ await page.evaluate(`(() => { repo.value = ''; repo.__ovcomboPicked = null; })()`);
+ await page.send('Input.insertText', { text: 'zz' });
+ assert.ok(await until(`/did not load/.test((document.querySelector('.ovcombo-err') || {}).textContent || '')`), 'a failed source says the list did not load');
+ failing = false;
+ assert.equal(await page.evaluate('repo.value'), 'zz');
+
+ // Near the bottom, it opens upward and stays on screen.
+ await page.evaluate(`document.getElementById('low').focus()`);
+ assert.ok(await until(`!!document.querySelector('.ovcombo.is-up:not([hidden]) .ovcombo-i')`), 'it opens upward near the bottom');
+ const r = await page.evaluate(`(() => { const b = document.querySelector('.ovcombo').getBoundingClientRect(); return { top: b.top, bottom: b.bottom, left: b.left, right: b.right, vw: innerWidth, vh: innerHeight }; })()`);
+ assert.ok(r.top >= 0 && r.bottom <= r.vh && r.left >= 8 && r.right <= r.vw - 8, `on screen ${JSON.stringify(r)}`);
+ await page.close();
+ console.log('ov-combo-chrome: a combobox (no datalist), sections, a slow answer not painted over a newer one, marks, Enter picks, Create on OpenVibe Git, Escape, a failed source, upward near the bottom');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -22,7 +22,7 @@ const form = thread.composer({
@@ -22,7 +22,7 @@ const form = thread.composer({
});
});
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = req.url.split('?')[0];
const u = req.url.split('?')[0];
- const file = { '/ov-composer.js': 'application/javascript', '/thread.css': 'text/css' }[u];
+ const file = { '/ov-composer.js': 'application/javascript', '/ov-window.js': 'application/javascript', '/thread.css': 'text/css' }[u];
if (file) { res.writeHead(200, { 'content-type': file }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
if (file) { res.writeHead(200, { 'content-type': file }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
if (u === '/send') { res.writeHead(204); return res.end(); }
if (u === '/send') { res.writeHead(204); return res.end(); }
res.writeHead(200, { 'content-type': 'text/html' });
res.writeHead(200, { 'content-type': 'text/html' });
@@ -89,6 +89,12 @@ const server = http.createServer((req, res) => {
@@ -89,6 +89,12 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('#c1 [data-ovc-back]').click()`);
await page.evaluate(`document.querySelector('#c1 [data-ovc-back]').click()`);
assert.ok((await val()).startsWith('line 0\nline 1'), '↩ puts it back in the box');
assert.ok((await val()).startsWith('line 0\nline 1'), '↩ puts it back in the box');
assert.equal(await page.evaluate(`document.querySelectorAll('#c1 .ovc-file').length`), 0);
assert.equal(await page.evaluate(`document.querySelectorAll('#c1 .ovc-file').length`), 0);
+ // A box that takes only pictures keeps a long paste as text (it is not turned into a file it would refuse).
+ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.dataset.acc = i.getAttribute('accept'); i.setAttribute('accept', 'image/*'); })()`);
+ f = await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); const dt = new DataTransfer(); dt.setData('text/plain', 'x\\n'.repeat(200));
+ const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); t.dispatchEvent(ev); return { prevented: ev.defaultPrevented, chips: document.querySelectorAll('#c1 .ovc-file').length }; })()`);
+ assert.deepEqual(f, { prevented: false, chips: 0 }, 'a pictures-only box lets the long paste in as text');
+ await page.evaluate(`(() => { const i = document.querySelector('#c1 input[type=file]'); i.setAttribute('accept', i.dataset.acc); })()`);
await clear();
await clear();
// "/": the site's commands, then settings; the arrows and Enter pick one, and Enter does not send.
// "/": the site's commands, then settings; the arrows and Enter pick one, and Enter does not send.
@@ -179,12 +185,13 @@ const server = http.createServer((req, res) => {
@@ -179,12 +185,13 @@ const server = http.createServer((req, res) => {
await type('/settings');
await type('/settings');
await until(`!document.querySelector('#c1 .ovc-pop').hidden`);
await until(`!document.querySelector('#c1 .ovc-pop').hidden`);
await key('Enter');
await key('Enter');
- assert.ok(await until(`!!document.querySelector('dialog.ovc-set[open]')`), '/settings opens your composer settings');
- f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);
+ assert.ok(await until(`!!document.querySelector('.ovw .ovc-set-in')`), '/settings opens your composer settings, in the one window (ov-window.js, loaded from beside the composer)');
+ assert.equal(await page.evaluate(`document.querySelector('.ovw .ovw-t').textContent`), 'Your composer');
+ f = await page.evaluate(`({ mine: [...document.querySelectorAll('.ovw .ovc-set-l b')].map((b) => b.textContent), box: document.querySelector('#c1 textarea').value })`);
assert.deepEqual(f, { mine: ['/sig'], box: '' }, 'the sheet lists your commands; the /settings word left the box');
assert.deepEqual(f, { mine: ['/sig'], box: '' }, 'the sheet lists your commands; the /settings word left the box');
// The count near the limit (600: it shows from 200 left).
// The count near the limit (600: it shows from 200 left).
- await page.evaluate(`document.querySelector('dialog.ovc-set').close()`);
+ await page.evaluate(`OVWindow.closeAll()`);
await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); t.value = 'x'.repeat(590); t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
await page.evaluate(`(() => { const t = document.querySelector('#c1 textarea'); t.value = 'x'.repeat(590); t.dispatchEvent(new Event('input', { bubbles: true })); })()`);
f = await page.evaluate(`({ shown: !document.querySelector('#c1 .ovc-count').hidden, text: document.querySelector('#c1 .ovc-count').textContent, low: document.querySelector('#c1 .ovc-count').classList.contains('is-low') })`);
f = await page.evaluate(`({ shown: !document.querySelector('#c1 .ovc-count').hidden, text: document.querySelector('#c1 .ovc-count').textContent, low: document.querySelector('#c1 .ovc-count').classList.contains('is-low') })`);
assert.deepEqual(f, { shown: true, text: '10 left', low: true });
assert.deepEqual(f, { shown: true, text: '10 left', low: true });
@@ -24,7 +24,17 @@ global.window = undefined;
@@ -24,7 +24,17 @@ global.window = undefined;
const sandbox = {};
const sandbox = {};
new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});
new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});
const H = sandbox.OpenVibeHome;
const H = sandbox.OpenVibeHome;
-const apps = ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot'].map((id) => ({ id, group: 'play' })).concat([{ id: 'ai', group: 'build' }]);
-assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot', 'folder:tools', 'folder:build'], 'signed out: ten popular places and the two folders');
+const apps = [
+ ...['live', 'video', 'pics', 'stream', 'cam'].map((id) => ({ id, group: 'watch' })),
+ ...['community', 'chat', 'tips', 'mail'].map((id) => ({ id, group: 'talk' })),
+ ...['games', 'quest', 'surf', 'fun'].map((id) => ({ id, group: 'play' })),
+ ...['news', 'today', 'report', 'wiki'].map((id) => ({ id, group: 'read' })),
+ ...['deals', 'coupons', 'rent', 'work'].map((id) => ({ id, group: 'shop' })),
+ ...['tools', 'design', 'domains', 'host'].map((id) => ({ id, group: 'tools' })),
+ ...['space', 'codes', 'bot', 'actor'].map((id) => ({ id, group: 'make' })),
+ ...['services', 'exchange', 'run'].map((id) => ({ id, group: 'build' })),
+];
+// Ten popular singles (one left in every group for its folder), then the menu's eight groups in its order.
+assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'video', 'pics', 'stream', 'community', 'chat', 'tips', 'games', 'quest', 'surf', 'folder:g-watch', 'folder:g-talk', 'folder:g-play', 'folder:g-read', 'folder:g-shop', 'folder:g-tools', 'folder:g-make', 'folder:build', 'folder:tools'], 'signed out: ten popular places and one folder per group, in the menu\'s order');
assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');
assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');
console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');
console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+const assert = require('assert');
+const { parseHTML } = require('linkedom');
+const OVUser = require('../ov-user');
+const { document, window } = parseHTML('<html><body><div id="host"></div></body></html>');
+const host = document.getElementById('host');
+const person = { username: 'Example', display_name: '<img src=x onerror=alert(1)>', avatar_url: 'https://openvibe.network/avatar/Example?s=160', profile_url: 'https://openvibe.network/@Example', channel_url: 'https://openvibe.live/@Example', level: 4, streak: 3, badges_count: 0, followers: 12, live: { now: true, site: 'live', url: 'https://openvibe.live/@Example' }, active_sites: [{ id: 'live', name: '<Live>', url: 'https://openvibe.live' }] };
+const event = (type, extras = {}) => { const e = new window.Event(type, { bubbles: true, cancelable: true }); Object.assign(e, extras); return e; };
+(async () => {
+ let body = person, calls = [];
+ const fetchImpl = async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => body }; };
+ const mounted = OVUser.card(host, { username: '@Example', fetchImpl });
+ await mounted.ready;
+ assert.strictEqual(calls.length, 1);
+ assert.strictEqual(calls[0].url, 'https://openvibe.network/api/v1/people/Example/card');
+ assert.strictEqual(calls[0].options.credentials, 'omit');
+ assert.strictEqual(calls[0].options.cache, 'no-store');
+ assert.ok(['Level 4', '3 day streak', '0 badges', '12 followers'].every((t) => host.textContent.includes(t)), 'the numbers as chips (4.202.0 card)');
+ assert.ok(host.textContent.includes('Live now') && host.textContent.includes('<Live>'));
+ assert.strictEqual(host.querySelectorAll('img').length, 1, 'display name is text, never markup');
+ assert.strictEqual(host.querySelector('img').getAttribute('onerror'), null);
+ assert.strictEqual(host.querySelectorAll('style').length, 0, 'plain kit markup only');
+ body = { username: 'Example', profile_url: person.profile_url, level: null, streak: null, badges_count: null, followers: null, avatar_url: 'javascript:alert(1)', channel_url: 'javascript:alert(1)' };
+ await mounted.refresh();
+ for (const label of ['Level', 'Streak', 'Badges', 'Followers', 'Offline', 'Live now', 'This week']) assert.ok(!host.textContent.includes(label), label + ' stays absent');
+ assert.strictEqual(host.querySelector('img'), null);
+ assert.strictEqual(host.querySelector('dl'), null);
+ assert.strictEqual(host.querySelectorAll('a').length, 1, 'unsafe links omitted');
+ mounted.destroy(); assert.strictEqual(host.childNodes.length, 0);
+ assert.throws(() => OVUser.card(host, { username: '../bad' }), TypeError);
+
+ let resolveOld, oldSignal;
+ const old = OVUser.card(host, { username: 'OldName', fetchImpl: (_url, options) => { oldSignal = options.signal; return new Promise(resolve => { resolveOld = resolve; }); } });
+ await Promise.resolve();
+ body = { username: 'NewName', profile_url: 'https://openvibe.network/@NewName' };
+ const newer = OVUser.card(host, { username: 'NewName', fetchImpl });
+ await newer.ready;
+ assert.ok(oldSignal.aborted);
+ resolveOld({ ok: true, json: async () => person }); await old.ready;
+ assert.ok(host.textContent.includes('@NewName') && !host.textContent.includes('@Example'), 'late response cannot replace a newer mount');
+ newer.destroy();
+ const unavailable = OVUser.card(host, { username: 'Example', fetchImpl: async () => ({ ok: false, status: 503 }) });
+ assert.strictEqual(await unavailable.ready, null);
+ assert.ok(host.textContent.includes('The profile did not load.') && host.querySelector('a').href === person.profile_url);
+ unavailable.destroy();
+
+ const mention = document.createElement('a'); mention.href = person.profile_url; mention.textContent = '@Example'; document.body.appendChild(mention);
+ const avatar = document.createElement('button'); avatar.setAttribute('data-ov-user', 'Example'); avatar.appendChild(document.createElement('img')); document.body.appendChild(avatar);
+ const marked = document.createElement('span'); marked.setAttribute('data-username', 'Example'); marked.setAttribute('role', 'button'); marked.tabIndex = 0; document.body.appendChild(marked);
+ const foreign = document.createElement('a'); foreign.href = 'https://example.org/@Example'; document.body.appendChild(foreign);
+ const opened = [];
+ let unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });
+ mention.dispatchEvent(event('click')); avatar.firstChild.dispatchEvent(event('click')); marked.dispatchEvent(event('keydown', { key: 'Enter' }));
+ assert.deepStrictEqual(opened, ['Example', 'Example', 'Example']);
+ mention.dispatchEvent(event('click', { ctrlKey: true })); foreign.dispatchEvent(event('click'));
+ assert.strictEqual(opened.length, 3, 'modified and foreign links keep their native behavior');
+ body = person;
+ const inCard = OVUser.card(host, { username: 'Example', fetchImpl }); await inCard.ready;
+ host.querySelector('a').dispatchEvent(event('click'));
+ assert.strictEqual(opened.length, 3, 'links inside the card remain navigation');
+ inCard.destroy();
+ unbind = OVUser.bind(document, { onOpen: value => opened.push(value.username) });
+ mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4, 'rebinding does not duplicate listeners');
+ unbind(); mention.dispatchEvent(event('click')); assert.strictEqual(opened.length, 4);
+
+ // Native dialog opening/closing and request disposal through the default hook.
+ const create = document.createElement.bind(document);
+ document.createElement = name => { const el = create(name); if (name === 'dialog') el.showModal = () => el.setAttribute('open', ''); return el; };
+ unbind = OVUser.bind(document, { fetchImpl });
+ mention.dispatchEvent(event('click'));
+ await new Promise(resolve => setImmediate(resolve));
+ const dialog = document.querySelector('dialog');
+ assert.ok(dialog?.hasAttribute('open'));
+ assert.ok(dialog.textContent.includes('@Example'));
+ dialog.querySelector('button').dispatchEvent(event('click'));
+ assert.strictEqual(document.querySelector('dialog'), null);
+ unbind();
+ assert.ok(require('../files').BROWSER.includes('ov-user.js'));
+ assert.strictEqual(require('../package.json').exports['./ov-user'], './ov-user.js');
+ console.log('ov-user: single read, safe kit markup, absent fields, cancellation, stale-response protection, mention/avatar/keyboard hooks and dialog lifecycle passed');
+})().catch(e => { console.error(e); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+// One window for every little interface (ov-window.js, 4.202.0; asks 304 and 318) in a real headless Chrome: a dialog
+// grows out of its button, is named by its title, keeps the focus inside, closes with Escape and gives the focus back;
+// windows stack and Escape closes the top one; a panel floats beside its button with no scrim, moves by its head, and
+// opens again where it was left; on a phone every kind is a sheet that a swipe down closes; the window style is one
+// attribute for every window; content given as text is set as text.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-window-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-window.js')) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-window.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">
+<script src="/ov-window.js"></script>
+<script>
+window.shut = [];
+window.mk = (o) => { const f = document.createElement('div'); const a = document.createElement('input'); a.id = 'first'; const b = document.createElement('button'); b.id = 'last'; b.textContent = 'OK'; f.append(a, b); return OVWindow.open(Object.assign({ title: 'Your wallet', body: f, onClose: (why) => window.shut.push(why) }, o)); };
+</script>`);
+});
+(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-window-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const key = async (page, k, shift) => { const codes = { Escape: 27, Tab: 9 }; const ev = { key: k, code: k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], modifiers: shift ? 8 : 0 }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 1280, height: 800 });
+ await page.goto(base, { maxMs: 5000 });
+ await page.evaluate(`document.getElementById('open').focus(); window.w1 = mk({ from: document.getElementById('open'), id: 'wallet' })`);
+ await wait(400);
+ let v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { kind: el.className, role: el.getAttribute('role'), modal: el.getAttribute('aria-modal'), named: document.getElementById(el.getAttribute('aria-labelledby')).textContent, focus: document.activeElement.id, scrim: !!document.querySelector('.ovw-scrim'), cx: Math.round(r.left + r.width / 2 - document.documentElement.clientWidth / 2), locked: document.documentElement.classList.contains('ovw-locked'), popX: el.style.getPropertyValue('--ov-pop-x') }; })()`);
+ assert.match(v.kind, /is-dialog/);
+ assert.deepEqual([v.role, v.modal, v.named, v.focus, v.scrim, v.locked], ['dialog', 'true', 'Your wallet', 'first', true, true], 'a named modal dialog, the focus on its first field, the page behind locked');
+ assert.ok(Math.abs(v.cx) < 3, `centred on a computer (${v.cx})`);
+ assert.ok(v.popX !== '', 'it grows out of its button');
+ // The focus stays inside: Tab from the last control goes to the first (×), Shift+Tab from the first to the last.
+ await page.evaluate(`document.getElementById('last').focus()`); await key(page, 'Tab');
+ assert.equal(await page.evaluate(`document.activeElement.className`), 'ovw-x', 'Tab wraps to the first control (×)');
+ await key(page, 'Tab', true);
+ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'Shift+Tab wraps to the last');
+ // A second window on top: Escape closes the top one only.
+ await page.evaluate(`window.w2 = mk({ title: 'Send', from: document.getElementById('last') })`);
+ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 2);
+ await key(page, 'Escape'); await wait(350);
+ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw:not(.is-out)').length`), 1, 'Escape closed the top window');
+ assert.equal(await page.evaluate(`document.activeElement.id`), 'last', 'the focus went back to what opened it');
+ await key(page, 'Escape'); await wait(350);
+ v = await page.evaluate(`({ n: document.querySelectorAll('.ovw').length, scrims: document.querySelectorAll('.ovw-scrim').length, focus: document.activeElement.id, locked: document.documentElement.classList.contains('ovw-locked'), shut: window.shut })`);
+ assert.deepEqual(v, { n: 0, scrims: 0, focus: 'open', locked: false, shut: ['escape', 'escape'] }, 'both gone, the page free, the focus back on Open');
+ // A press outside a dialog closes it.
+ await page.evaluate(`mk({})`); await wait(80);
+ await page.evaluate(`document.querySelector('.ovw-scrim').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`); await wait(350);
+ assert.equal(await page.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a press outside closes a dialog');
+ // Text is text.
+ await page.evaluate(`OVWindow.open({ title: '<b>x</b>', body: '<img src=x onerror=alert(1)>' })`);
+ v = await page.evaluate(`({ t: document.querySelector('.ovw-t').textContent, imgs: document.querySelectorAll('.ovw img').length })`);
+ assert.deepEqual(v, { t: '<b>x</b>', imgs: 0 }, 'markup given as text is shown as text');
+ await page.evaluate('OVWindow.closeAll()'); await wait(300);
+
+ // A panel: beside its button, no scrim, the page usable, moved by its head, opened again where it was left.
+ await page.evaluate(`window.p1 = mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
+ await wait(400);
+ v = await page.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(), b = document.getElementById('pan').getBoundingClientRect(); return { scrim: !!document.querySelector('.ovw-scrim'), modal: el.getAttribute('aria-modal'), below: Math.round(r.top - b.bottom), left: Math.round(r.left - b.left), locked: document.documentElement.classList.contains('ovw-locked') }; })()`);
+ assert.deepEqual(v, { scrim: false, modal: null, below: 8, left: 0, locked: false }, 'a panel sits under its button, with no scrim, and the page stays usable');
+ const hb = await page.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 40, y: r.top + 20 }; })()`);
+ await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: hb.x, y: hb.y, button: 'left', clickCount: 1 });
+ await page.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: hb.x + 60, y: hb.y + 30, button: 'left' });
+ await 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 });
+ const moved = await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`);
+ 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(`mk({ kind: 'panel', id: 'dock-test', title: 'Dock', from: document.getElementById('pan') })`);
+ await wait(400);
+ assert.deepEqual(await page.evaluate(`(() => { const r = document.querySelector('.ovw').getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top) }; })()`), moved, 'and it opens there again');
+ await page.evaluate('OVWindow.closeAll()'); await wait(300);
+
+ // The window style: one attribute, every window.
+ assert.equal(await page.evaluate(`OVWindow.setStyle('outline')`), 'outline');
+ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), 'outline');
+ assert.equal(await page.evaluate(`OVWindow.setStyle('nonsense')`), 'glass', 'an unknown style is the default');
+ assert.equal(await page.evaluate(`document.documentElement.getAttribute('data-ov-window')`), null);
+ await page.close();
+
+ // A phone: every kind is a sheet; a swipe down on its head closes it.
+ const ph = await h.openPage(browser, await browser.newContext(), { width: 390, height: 800, mobile: true });
+ await ph.goto(base, { maxMs: 5000 });
+ await ph.evaluate(`mk({ kind: 'panel', title: 'Dock' })`); await wait(400);
+ v = await ph.evaluate(`(() => { const el = document.querySelector('.ovw'); const r = el.getBoundingClientRect(); return { sheet: el.classList.contains('is-sheet'), bottom: Math.round(innerHeight - r.bottom), width: Math.round(r.width) }; })()`);
+ assert.deepEqual(v, { sheet: true, bottom: 0, width: 390 }, 'a sheet across the bottom of a phone');
+ const sh = await ph.evaluate(`(() => { const r = document.querySelector('.ovw-h').getBoundingClientRect(); return { x: r.left + 120, y: r.top + 20 }; })()`);
+ await ph.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sh.x, y: sh.y, button: 'left', clickCount: 1 });
+ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 80, button: 'left' });
+ await ph.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: sh.x, y: sh.y + 260, button: 'left' });
+ await ph.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sh.x, y: sh.y + 260, button: 'left', clickCount: 1 });
+ await wait(400);
+ assert.equal(await ph.evaluate(`document.querySelectorAll('.ovw').length`), 0, 'a swipe down closes the sheet');
+ assert.deepEqual(await ph.evaluate('window.shut'), ['swipe']);
+ await ph.close();
+ console.log('ov-window-chrome: a named modal dialog from its button, focus kept and given back, stacked Escape, outside press, text as text, a panel beside its button that keeps its place, one window style, a phone sheet closed by a swipe');
+ } 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,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+// The owner's one site list (2026-11-10, plan ask 274): the same eight groups, in the same order, in the footer's
+// Network column and fallback, the rooms' neighbourhoods, the You tab's folders and each home page's "More on OpenVibe".
+// OpenVibe.Network frame/sites.js GROUPS is the list; these are its consumers in Shared, checked against it.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const footer = require('../footer');
+const rooms = require('../rooms');
+const showcase = require('../showcase');
+
+const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];
+const IDS = ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'];
+// The tile ids as the home cards show them (Email, Restream, Studio and Locker keep their names).
+const MEMBERS = [
+ ['live', 'video', 'pics', 'download', 'openre', 'cam', 'tours'],
+ ['community', 'chat', 'tips', 'vip', 'email', 'help', 'network'],
+ ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'],
+ ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'],
+ ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'],
+ ['tools', 'design', 'domains', 'host', 'studio', 'locker', 'food'],
+ ['space', 'codes', 'bot', 'actor', 'computer', 'connect'],
+ ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'],
+];
+
+// The footer's fallback is the eight groups, in order, each linking to its /sites section.
+assert.deepStrictEqual(footer.NETWORK.map((x) => x.name), LABELS, 'footer fallback: the eight labels in order');
+assert.deepStrictEqual(footer.NETWORK.map((x) => x.url), IDS.map((id) => `https://openvibe.network/sites#group-${id}`), 'footer fallback: one link per group');
+// The rooms' neighbourhoods repeat the same ids and labels.
+assert.deepStrictEqual(Object.keys(rooms.HOODS), IDS, 'rooms: the eight ids in order');
+assert.deepStrictEqual(Object.values(rooms.HOODS), LABELS, 'rooms: the eight labels in order');
+for (const r of rooms.IDS) assert.ok(rooms.HOODS[rooms.get(r).hood], `${r}: a current neighbourhood`);
+
+// The You tab's folders are the same eight groups, in order (Build keeps the `build` id saved home screens use).
+const homeSrc = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');
+const gf = /const GROUP_FOLDERS = (\[\[[\s\S]*?\]\]);/.exec(homeSrc);
+assert.ok(gf, 'ov-home has GROUP_FOLDERS');
+const folders = JSON.parse(gf[1].replace(/'/g, '"'));
+assert.deepStrictEqual(folders.map((f) => f[0]), IDS, 'You folders: the eight ids in order');
+assert.deepStrictEqual(folders.map((f) => f[1]), LABELS, 'You folders: the eight labels in order');
+
+// "More on OpenVibe": each home page leads to the next places in its own group, in the group's order.
+const ALIAS = { media: 'video', 'media-hub': 'video', stream: 'openre', restream: 'openre', mail: 'email', image: 'studio', lock: 'locker', homes: 'rent', bench: 'report' };
+const groupOf = (id) => MEMBERS.findIndex((list) => list.includes(ALIAS[id] || id));
+for (const list of MEMBERS) for (const id of list) assert.ok(showcase.RELATED[id], `${id}: has a "More on OpenVibe" list`);
+for (const list of MEMBERS) for (const id of list) {
+ const g = groupOf(id);
+ const expected = MEMBERS[g].filter((x) => x !== id).slice(0, 4);
+ assert.deepStrictEqual(showcase.RELATED[id].map((x) => x[0]), expected, `${id}: its group's next places, in group order`);
+}
+for (const [alias, id] of Object.entries(ALIAS)) {
+ assert.ok(showcase.RELATED[alias], `${alias}: an alias for ${id}`);
+ assert.ok(!showcase.RELATED[alias].some((x) => x[0] === id), `${alias}: never links to itself`);
+ assert.ok(showcase.RELATED[alias].every((x) => groupOf(x[0]) === groupOf(id)), `${alias}: only its own group`);
+}
+assert.deepStrictEqual(showcase.DEFAULT_RELATED.map((x) => x[0]), ['live', 'community', 'games', 'news'], 'a product without a list: one place from each of the first four groups, in order');
+
+// The footer's live column uses the Frame's own groups (network column), not a head-list of ranked sites.
+const footerSrc = fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8');
+assert.ok(/frame\.groups\.map\(g => \(\{ name: g\.name, url: groupHref\(g\.id\)/.test(footerSrc), 'the live footer reads the Frame groups');
+assert.ok(/const network = frame && Array\.isArray\(frame\.groups\)/.test(footerSrc), 'and falls back to the eight only when the Frame is away');
+console.log('site groups: footer, rooms, You folders and home pages show the same eight groups in the menu\'s order');
@@ -18,7 +18,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -18,7 +18,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await p.goto(base, { maxMs: 5000 });
await p.goto(base, { maxMs: 5000 });
assert(!p.state.urls.some((u) => u.includes('ov-wallet.js')), 'sheet is lazy');
assert(!p.state.urls.some((u) => u.includes('ov-wallet.js')), 'sheet is lazy');
await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);
await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);
- const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&d.open), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
+ // 4.202.0: the wallet opens in the one window, which grows in (.28 s): measure once it has settled.
+ { const end = Date.now() + 4000; while (Date.now() < end && !(await p.evaluate(`(() => { const w = document.querySelector('.ovw'); return !w || (w.getAnimations ? w.getAnimations().every((a) => a.playState !== 'running') : true); })()`))) await wait(50); }
+ const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&(d.open||d.closest('.ovw'))), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));
assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');
assert(metrics.text.includes('for the quest Publish a video'), 'a money reason is a sentence, not a key');
@@ -353,7 +353,7 @@
@@ -353,7 +353,7 @@
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// ── Display settings, one record for every OpenVibe site: display.get() → DISPLAY_OPTS' values; display.set(patch)
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
// applies here and in other tabs and saves to the account. <html data-ov-*> and --ov-text-scale before first paint.
- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'] };
+ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'full', 'reduced', 'off'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'], contrast: ['normal', 'more'], font: ['default', 'readable'], colors: ['default', 'cb-safe'], oled: ['off', 'on'], icons: ['auto', 'line', 'solid'], window: ['glass', 'solid', 'outline'] };
// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
// Status names some sites use (--error, --error-text, --ok, --warn, --accent-text) that no theme sets: they resolve to the theme's
// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
// readable status colours instead of each site's fallback literal (pale on light themes). :where() keeps them
// below any site's own value (4.86.0).
// below any site's own value (4.86.0).
@@ -371,7 +371,7 @@
@@ -371,7 +371,7 @@
+ 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
+ 'html[data-ov-colors=cb-safe]{--success:#56b4e9!important;--success-text:#7cc6ef!important;--warning:#f0e442!important;--warning-text:#f0e442!important;--danger:#e8743b!important;--danger-text:#f0985f!important}'
+ 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
+ 'html[data-ov-colors=cb-safe][data-theme-mode=light]{--success:#0072b2!important;--success-text:#005a8c!important;--warning:#e69f00!important;--warning-text:#8a6100!important;--danger:#d55e00!important;--danger-text:#a84400!important}'
+ 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
+ 'html[data-ov-oled=on][data-theme-mode=dark]{--bg-primary:#000!important;--bg-secondary:#050608!important;--bg-tertiary:#0b0d12!important;--bg-card:#0a0c10!important;--bg-input:#0b0d12!important;--bg-hover:#12151c!important}';
- const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'] };
+ const COMFORT_ATTRS = { contrast: ['data-ov-contrast', 'normal'], font: ['data-ov-font', 'default'], colors: ['data-ov-colors', 'default'], oled: ['data-ov-oled', 'off'], icons: ['data-ov-icons', 'auto'], window: ['data-ov-window', 'glass'] };
// ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting
// ── Motion (4.183.0; the owner, 2026-11-10: "still have full motion but actually just reduce it instead of getting
// rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),
// rid of it"). auto follows the device, full plays everything, reduced is Calm (every effect, smaller and slower),
// off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving
// off alone stops motion. <html data-ov-motion=full|reduced|off> is the one signal; --ov-move is what a moving
@@ -404,7 +404,7 @@
@@ -404,7 +404,7 @@
document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
document.addEventListener('animationstart', function (e) { if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') paceLoops(e.target); }, true);
}
}
function readDisplay() {
function readDisplay() {
- const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto' };
+ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual', contrast: 'normal', font: 'default', colors: 'default', oled: 'off', icons: 'auto', window: 'glass' };
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
return out;
}
}
@@ -546,7 +546,7 @@
@@ -546,7 +546,7 @@
// page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
// page, only colours pass; the next site keeps it when newer and drops it from the address before first paint.
const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
const OV_HOST = /(^|\.)(openvibe\.[a-z]{2,12}|openre\.stream)$/i;
const PAINT_KEY = 'ov_paint';
const PAINT_KEY = 'ov_paint';
- const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons'];
+ const PAINT_ATTRS = ['data-theme', 'data-theme-mode', 'data-ov-season', 'data-ov-season-mark', 'data-ov-motion', 'data-ov-text', 'data-ov-contrast', 'data-ov-font', 'data-ov-colors', 'data-ov-oled', 'data-ov-icons', 'data-ov-window'];
const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
const SAFE_VALUE = /^(#[0-9a-f]{3,8}|rgba?\([0-9.,\s%]+\)|[0-9.,\s]+|light|dark|(-?[0-9.]+(px)?\s+){2,4}rgba?\([0-9.,\s%]+\))$/i;
const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
const SAFE_ATTR = /^[a-z0-9-]{1,40}$/;
function seasonUntil(s) {
function seasonUntil(s) {
@@ -355,6 +355,8 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
@@ -355,6 +355,8 @@ html.ovt-sheet-open, html.ovt-sheet-open body { overflow: hidden; }
html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data-ov-motion=off] .ovc-mic { animation: none; }
.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
.ovc-set { width: min(460px, calc(100vw - 24px)); max-height: min(640px, calc(100vh - 32px)); padding: 0; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .2)); border-radius: 20px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 30px 80px rgba(0, 0, 0, .5); }
.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
.ovc-set::backdrop { background: rgba(2, 6, 14, .55); }
+.ovc-set.ovc-set-in { width: auto; max-height: none; border: 0; border-radius: 0; background: none; box-shadow: none; }
+.ovc-set-in .ovc-set-f { padding: 0; }
.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
.ovc-set-f { display: grid; gap: 14px; padding: 6px 18px 18px; }
.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
.ovc-set-h { display: flex; align-items: center; justify-content: space-between; margin: 0 -8px 0 0; font-size: 17px; }
.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }
.ovc-set-g { margin: 0; padding: 0; border: 0; display: grid; gap: 6px; }