openvibers/openvibe.shared Commits
openvibe-shared 4.229.0: Back to the menu, menu addresses, one search field for every site (#511)
Changes
@@@ -1,5 +1,18 @@# Changelog++## 4.229.0 — 2026-11-12++++- **Back brings you back to the menu** (the owner, 11-12: "fix the history back button so it properly brings you back to this GUI rather than the page behind it"). Leaving through a link in the menu marks where it was on its history entry (its place, its door, its scroll); Back to that page opens the menu again there, whether the page comes back from the browser's cache, loads again, or a site swaps it in place (Boost). It reuses that entry, so Forward still works, and closing it then leaves the page as it was. Only for a Back or Forward within half an hour.++- **Links are on their way before the click**: anywhere on the page (the bar, the menu, a footer, a card), a finger landing on or a pointer resting on a link to another OpenVibe site opens its connection at once; this site's pages were already prefetched by the bar's speculation rules.++- **All apps has one search** (the owner, 11-12: "confusing"): no second box under the menu's pill. The pill reads "Find among 64 apps" and filters the apps in place; the view's head is its name, its count and a hint, coming in with the view. Back on Home, the pill searches OpenVibe again.++- **The close x has no white ring**: the ring that leaves it as the menu opens is invisible at rest, so where animations are off (a phone's "Remove animations") it no longer stays as a white circle.++- **The Feed never shows a card twice**: a card id already on the list is skipped (Network names a Deals burst once, as of its #887).++- **Feed cards with no person wear their site's app icon** (design rule 142), 40 px beside 40 px faces, the glyph filling the square (the owner, 11-12: "icons to increase size").++- **Any button can open a place in the menu** (the owner, 11-12: "other tools and buttons can open specific areas of the menu"). A menu address is a place, or place/part: `you/notifications`, `you/apps` (All apps), `you/recent`, `feed`, `here/live`, or a door (`vibe`, `chats`, `agent`, `code`, `tasks`). Any element with `data-ov-menu="you/notifications"` opens it on a click, on any page with the bar; a link with an address still opens elsewhere with a modifier key. A page address ending `#ov-menu=you/notifications` opens it as the page loads (on any OpenVibe site), and a same-page link to it works too; the hash leaves the address. `OpenVibeNavbar.openMenu(address)` and `OpenVibeNavbar.menuAddress(address)` take the same addresses; OpenVibeHome's `show('drawer')` opens All apps.++- **One search field for every site: `ov-searchbox.js`** (the owner, 11-12: "improve the search field … autocomplete, dropdown, animated effects … a shared, universal, reusable search input component"). `<input name="q" data-ov-searchbox="/api/…/suggest" data-ov-examples="streams|clips">` in a form becomes a pill: the search mark, the field, a clear button and a "/" key hint on a computer; its edge glows with focus. The placeholder walks through the examples while idle (Calm slower, Off never). Suggestions come from the source in OVCombo's shape, with icons (search, recent, page, person, live, video, place) or a small picture; while empty, the person's recent searches here. The first suggestion that starts with the typed words shows the rest as a ghost: Tab or → takes it. Enter searches the typed words (the form's action) or the chosen row's page; "/" anywhere puts the cursor in it; Escape closes, then clears. `OVSearchBox.mount(input, { source, examples, scope, recent, onSearch })`; a cancelable `ov-search` event lets a page search in place.++- **OVCombo grows hooks** for anything that is not a value picker: `pick`, `enter`, `onRows`, `selectFirst: false`, `none`, `limit`, `cacheMs`, `anchor`; rows take `icon` or `image`; the skeleton no longer flashes over rows already showing while a quick answer comes.++- The navbar's budget is 52 KB brotli (51.6 KB with the menu addresses).++## 4.228.0 — 2026-11-12- **A page's sections as tabs on a phone** (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking huge … at least on mobile"). `showcase.parts({ name, items: [{ id, label, count, html }], front })` shows one part at a time with a tab bar (its name and count) at 900 px and under, 600 for a front page; every part stays in the HTML (crawlers, AI readers); radios and `:has()`, no script; the arrow keys move between parts; wide screens and browsers without `:has()` stack them. `ov-parts.css` is the same look alone, for a page that does not load showcase.css (Live's home).@@@ -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.228.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.229.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.228.0",++ "version": "4.229.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)",@@@ -61,6 +61,7 @@ const BROWSER = Object.freeze(['ov-run.js', // 4.224.0: the one run thread (OpenVibeRun), loaded beside the navbar'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)'ov-combo.js', // shared field dropdown++ 'ov-searchbox.js', // the one search field (4.229.0): pill, suggestions, recent searches, ghost completion; its list is ov-combo'ov-window.js', // shared dialog, sheet, and panel'agent-sheet.js', // an agent's sheet in the one window (4.211.0)'ov-float.js', // the move engine behind a PiP or a panel: dock/float, tuck, size, place (4.208.0)@@@ -610,7 +610,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': 'c96584376da1', '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': '146282739c06', 'dock.js': '9f15df28050c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '69a80757ed04', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'ac7107f5d733' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', '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': 'ef289c4550b1', 'dock.js': '9f15df28050c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'b11405559831', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'ac7107f5d733' };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.@@@ -1363,6 +1363,52 @@status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });let _menuStatus = null;/** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */++ /** 4.229.0: Back to a page the menu was left from opens the menu again, where it was (its place, its door, its++ * scroll), whether the page comes back from the browser's cache, loads again, or is swapped in place. Only for a++ * Back or Forward, and only within half an hour of leaving. */++ function restoreMenu(ev) {++ let st = null;++ try { st = history.state; } catch { return; }++ const b = st && st.ovsBack;++ if (!b || typeof b !== 'object' || !(Date.now() - Number(b.at || 0) < 30 * 60000)) return;++ let nav = null;++ try { nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; } catch { nav = null; }++ if (!(ev && ev.persisted) && !(nav && nav.type === 'back_forward')) return;++ if (menuOpen()) return;++ const tab = /^(here|feed|you)$/.test(String(b.tab || '')) ? b.tab : 'here';++ const door = /^(vibe|chats|agent|code|tasks)$/.test(String(b.door || '')) ? b.door : undefined;++ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];++ openSearch('', tab, orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, door, { restore: { scroll: Math.max(0, Number(b.scroll) || 0) } });++ }++ /** 4.229.0 (the owner, 11-12: "other tools and buttons can open specific areas of the menu"): a menu address is++ * place[/part]. Places: here, feed, you, and the doors vibe, chats, agent, code, tasks. Parts: You's notifications,++ * recent, apps (All apps), feeds, display; this site's live, places, tools, all. A part alone names its place++ * ("notifications" is you/notifications). Anything else opens the menu on this site. */++ const MENU_PARTS = { here: /^(live|places|tools|all|discover|news)$/, you: /^(notifications|recent|apps|feeds|display|home)$/, feed: /^$/ };++ function menuAddress(addr) {++ const [a = '', b = ''] = String(addr || '').trim().toLowerCase().replace(/^#?ov-menu=/, '').split('/');++ if (/^(vibe|chats|agent|code|tasks)$/.test(a)) return { door: a };++ if (MENU_PARTS.you.test(a)) return { tab: 'you', part: a };++ if (MENU_PARTS.here.test(a)) return { tab: 'here', part: a };++ const tab = /^(here|feed|you)$/.test(a) ? a : 'here';++ return { tab, part: MENU_PARTS[tab].test(b) && b ? b : null };++ }++ function openMenuAt(addr, fromEl) {++ const m = menuAddress(addr);++ if (m.door) return openMenuOn(m.door);++ openSearch('', m.tab, fromEl && fromEl.getBoundingClientRect ? fromEl : null, m.part);++ }++ /** An address in the page's own: #ov-menu=you/notifications (alone, or after other hash words with &). It opens++ * as the page loads or when a link on the page changes the hash, and leaves the address so Back is the page. */++ function menuFromHash() {++ let h = '';++ try { h = root.location.hash || ''; } catch { return; }++ const m = /(?:^#|&)ov-menu=([a-z/-]{1,40})(?=&|$)/.exec(h);++ if (!m) return;++ const rest = h.replace(m[0], '').replace(/^#&/, '#');++ try { history.replaceState(history.state, '', root.location.pathname + root.location.search + (rest === '#' ? '' : rest)); } catch { /* the address keeps it */ }++ openMenuAt(m[1]);++ }function openMenuOn(id) {if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];@@@ -2375,7 +2421,7 @@if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);}/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */-- function openSearch(q, tab, fromEl, section, door) {++ function openSearch(q, tab, fromEl, section, door, extra) {// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));// The menu drops from the button that opened it (the orb, or the burger), under the bar.@@@ -2389,7 +2435,7 @@if (orb) orb.classList.remove('is-busy');if (!root.OpenVibeSearch) return;if (_navEl) _navEl.classList.remove('ovnav-hide');-- root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,++ root.OpenVibeSearch.open({ ...(extra || {}), service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },@@@ -2602,6 +2648,26 @@document.head.appendChild(sr);} catch { /* an old browser: links load as they always did */ }}++ // 4.229.0 (the owner, 11-12: "when you click links and pages and other domains within the menu or navigation it++ // loads async dynamically and is highly optimized"): a link to another OpenVibe site warms up as a finger lands on++ // it or a pointer rests on it (its connection opens: DNS and TLS while the press is still down). This site's++ // pages are already fetched ahead by the speculation rules above; browsers do not prefetch another site's page++ // for a person signed in there. Anywhere on the page: the bar, the menu, a footer, a card. At most 24 origins.++ const warmed = new Set();++ const OV_HOST = /(^|\.)(openvibe\.[a-z]+|openre\.stream|open\.shoes)$/;++ const warm = (a) => {++ let u; try { u = new URL(a.getAttribute('href'), location.href); } catch { return; }++ if (u.protocol !== 'https:' || u.origin === location.origin || warmed.has(u.origin) || warmed.size >= 24 || !OV_HOST.test(u.hostname)) return;++ warmed.add(u.origin);++ try { const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; document.head.appendChild(l); } catch { /* the click connects */ }++ };++ document.addEventListener('pointerdown', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (a) warm(a); }, { capture: true, passive: true });++ let hoverTimer = 0;++ document.addEventListener('pointerover', (e) => {++ if (e.pointerType === 'touch') return;++ const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return;++ clearTimeout(hoverTimer); hoverTimer = setTimeout(() => warm(a), 90);++ }, { passive: true });// A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.document.addEventListener('click', (e) => {if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;@@@ -2612,7 +2678,19 @@setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say}, true);// Back from the browser's page cache: whatever was loading is done.-- window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });++ window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) { busy(false); restoreMenu(ev); } });++ document.addEventListener('openvibe-menu-restore', () => restoreMenu({ persisted: true }));++ setTimeout(() => restoreMenu(null), 0);++ setTimeout(menuFromHash, 0);++ window.addEventListener('hashchange', menuFromHash);++ document.addEventListener('click', (e) => {++ const t = e.target && e.target.closest ? e.target.closest('[data-ov-menu]') : null;++ if (!t || e.defaultPrevented || e.button !== 0) return;++ // A link that also has an address keeps the browser's own ways to open it elsewhere (a new tab, a window).++ if (t.matches('a[href]') && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) return;++ e.preventDefault();++ openMenuAt(t.getAttribute('data-ov-menu'), t);++ });}// ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──// A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for@@@ -2695,7 +2773,6 @@/** Open the search panel (search.js), with an optional query already in it. */openSearch(q, tab) { openSearch(q, tab); },-- openMenu(tab) { openSearch('', tab || 'you'); },openSwitcher() { openSwitcher(); },/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */@@@ -2792,8 +2869,11 @@/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },-- /** The menu at a place (4.223.0). */-- openMenu(p) { if (/^(vibe|chats|agent|code|tasks)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },++ /** The menu at an address: a place, or place/part ('you/notifications', 'you/apps', 'vibe'; 4.223.0, parts 4.229.0).++ * The same address works as data-ov-menu="…" on any element and as #ov-menu=… at the end of a page's address. */++ openMenu(p, fromEl) { openMenuAt(p || 'you', fromEl); },++ /** What an address opens: { door } or { tab, part } (for a site's own button that wants to show where it goes). */++ menuAddress,pop: popFrom,unpop,askAgent(text) { askAgent(text); },@@@ -13,6 +13,16 @@* "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.++ *++ * 4.229.0, for the one search field (ov-searchbox.js) and anything else that is not a value picker:++ * pick(item, q) a chosen row goes here instead of into the field (a search goes to the row's page);++ * enter(q) Enter with no row chosen (a search searches what was typed); the list closes;++ * onRows(items, q) the rows just drawn, in order (the search field's ghost reads the first);++ * selectFirst false: the first row is not chosen ahead of the arrows (Enter is the typed text);++ * none(q) the words when nothing matches; limit, the rows asked of a URL source (12);++ * cacheMs how long an answer is kept per query (60000; 0 asks every time: the caller keeps its own);++ * anchor the element the list lines up under (the search field's pill), else the field.++ * A row may carry icon (search, recent, page, person, live, video, place) or image (a small picture's URL).*/(function (root) {'use strict';@@@ -23,6 +33,12 @@'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"/>'),++ search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'),++ page: svg('<path d="M7 3.5h7l4 4V20a.5.5 0 0 1-.5.5h-10A.5.5 0 0 1 7 20z"/><path d="M14 3.5V8h4"/>'),++ person: svg('<circle cx="12" cy="8.5" r="3.5"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>'),++ live: svg('<circle cx="12" cy="12" r="2.2"/><path d="M8.1 8.1a5.5 5.5 0 0 0 0 7.8M15.9 8.1a5.5 5.5 0 0 1 0 7.8M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/>'),++ video: svg('<rect x="3.5" y="6" width="12.5" height="12" rx="2"/><path d="m16 10.5 4.5-2.5v8l-4.5-2.5"/>'),++ place: svg('<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"/><circle cx="12" cy="10" r="2.3"/>'),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>',@@@ -41,6 +57,7 @@.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-ic img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }.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; }@@@ -68,6 +85,8 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htmconst o = Object.assign({}, opts);const src = o.source || input.getAttribute('data-ov-combo');const createAt = o.create || input.getAttribute('data-ov-combo-create');++ const limit = Math.max(1, Math.min(25, Number(o.limit) || 12));++ const keepMs = o.cacheMs == null ? 60000 : Math.max(0, Number(o.cacheMs) || 0);const listId = `ovcombo-${++uid}`;input.setAttribute('role', 'combobox');input.setAttribute('aria-autocomplete', 'list');@@@ -80,13 +99,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htmasync function fetchQ(q) {const hit = cache.get(q);-- if (hit && Date.now() - hit.at < 60000) return hit.v;++ if (hit && Date.now() - hit.at < keepMs) 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 u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', String(limit));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();@@@ -97,7 +116,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm}function place() {if (!pop || pop.hidden) return;-- const r = input.getBoundingClientRect();++ const r = (o.anchor && o.anchor.isConnected ? o.anchor : 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;@@@ -144,7 +163,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htmd.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);++ if (kind !== 'create' && item.image && /^(https?:\/\/|\/)/.test(String(item.image))) {++ const im = el('img'); im.alt = ''; im.loading = 'lazy'; im.decoding = 'async'; im.referrerPolicy = 'no-referrer'; im.src = String(item.image);++ ic.appendChild(im);++ } else ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.icon] || 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);@@@ -187,18 +209,26 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htmif (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.'));++ if (!rows.length) {++ const words = typeof o.none === 'function' ? o.none(q) : q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.';++ if (words) pop.appendChild(el('div', 'ovcombo-none', words)); else { close(); return; }++ }place();-- if (rows.length && q) select(0);++ if (rows.length && q && o.selectFirst !== false) select(0);++ if (typeof o.onRows === 'function') { try { o.onRows(rows.map((r) => r.item), q); } catch { /* the caller's */ } }}async function look(q) {lastQ = q;-- if (!cache.has(q)) skeleton();++ const hit = cache.get(q);++ // Rows already showing stay while a quick answer comes (no flash of the skeleton on every key).++ const sk = hit && Date.now() - hit.at < keepMs ? 0 : setTimeout(() => { if (lastQ === q) skeleton(); }, rows.length ? 180 : 0);try {const v = await fetchQ(q);++ clearTimeout(sk);if (lastQ !== q || document.activeElement !== input) return;paint(v, q);} catch (e) {++ clearTimeout(sk);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();@@@ -206,7 +236,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm}async function choose(i) {const r = rows[i]; if (!r || creating) return;-- if (r.kind !== 'create') { put(r.item); return; }++ if (r.kind !== 'create') {++ if (typeof o.pick === 'function') { const q = input.value.trim(); close(); o.pick(r.item, q); return; }++ put(r.item); return;++ }creating = true;r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;try {@@@ -239,6 +272,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htminput.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) => {++ if (e.defaultPrevented) return; // a field built on this one handled the key (the search field's Tab)const shown = pop && !pop.hidden && cur === api;if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {e.preventDefault();@@@ -246,12 +280,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htmif (!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 === 'Enter' && typeof o.enter === 'function' && !e.isComposing) { e.preventDefault(); const q = input.value.trim(); close(); o.enter(q); }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); } };++ const api = { input, open: () => look(input.value.trim()), close, get shown() { return !!(pop && !pop.hidden && cur === api); }, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };input.__ovcombo = api;return api;}@@@ -128,6 +128,13 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { aborder-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }.ovh-head b { flex: 1; font-size: 16px; }++/* 4.229.0: a place's head inside You (All apps, a folder): its name, its count and a quiet hint, coming in with the view. */++.ovh-head-t { flex: 1; min-width: 0; display: grid; gap: 1px; animation: ovh-head-in .28s cubic-bezier(.2, .8, .2, 1) both; }++.ovh-head-t b { display: inline-flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e6edf7); }++.ovh-head-n { min-width: 24px; padding: 0 8px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #93c5fd); font-size: 12.5px; font-weight: 800; line-height: 22px; text-align: center; font-variant-numeric: tabular-nums; }++.ovh-head-t small { font-size: 12.5px; color: var(--text-muted, #8a96a8); }++@keyframes ovh-head-in { from { opacity: 0; transform: translateX(-6px); } }++html[data-ov-motion=off] .ovh-head-t { animation: none; }.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; }@@@ -595,6 +602,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;}// ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────++ /** 4.229.0: a card with no person behind it (Deals, Community, Report) wears its site's app icon (design rule 142),++ * 40 px like a person's face beside it, the glyph filling it (the owner, 11-12: "icons to increase size"). */++ function appFace(site) {++ const s = h('span', 'ov-appicon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', '40'); s.setAttribute('aria-hidden', 'true');++ return s;++ }function siteIcon(site, size) {const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');return s;@@@ -1083,10 +1096,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;function paintDrawer() {el.textContent = '';-- const head = backHead('All apps');-- const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;-- head.querySelector('b').replaceWith(field);++ // 4.229.0 (the owner, 11-12: "confusing"): one search. The menu's own pill reads "Find among 64 apps" and++ // filters this list as you type (OpenVibeHome's filter()); the head is the place's name and its count.++ const head = backHead('All apps', { count: L.byId.size, hint: typeof o.onView === 'function' ? 'Type above to find one' : '' });el.append(head);++ let field = null;++ if (typeof o.onView !== 'function') { // a host without the menu's pill keeps the box of its own++ field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;++ head.append(field);++ }const body = h('div');el.append(body);const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();@@@ -1125,16 +1143,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;});icons();};-- field.addEventListener('input', () => { state.filter = field.value; draw(); });++ if (field) field.addEventListener('input', () => { state.filter = field.value; draw(); });++ drawerDraw = draw;draw();}++ let drawerDraw = null;-- function backHead(title) {++ function backHead(title, { count = null, hint = '' } = {}) {const head = h('div', 'ovh-head');const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');-- back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });-- head.append(back, h('b', '', title));++ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; state.filter = ''; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });++ const name = h('div', 'ovh-head-t');++ const b = h('b', '', title);++ if (count != null) b.append(h('span', 'ovh-head-n', String(count)));++ name.append(b);++ if (hint) name.append(h('small', '', hint));++ head.append(back, name);return head;}function sec(text, glyph, count) {@@@ -1146,7 +1171,11 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;return s;}-- function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }++ function paint() {++ // The menu hears which view shows, so its pill can search inside it (All apps) or go back to searching OpenVibe.++ if (typeof o.onView === 'function') { try { o.onView(state.view, { apps: L.byId.size }); } catch { /* the menu's own */ } }++ if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome();++ }paint();// Network's copy wins when it answers and differs.pullRemote(o).then((r) => {@@@ -1160,6 +1189,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;});return {repaint: paint,++ /** 4.229.0: which view shows ('home', 'drawer' for All apps, 'folder'), and the pill's words for All apps. */++ get view() { return state.view; },++ apps: () => L.byId.size,++ filter(q) { state.filter = String(q || ''); if (state.view === 'drawer' && drawerDraw) drawerDraw(); },++ /** Show 'drawer' (All apps) or 'home' from outside: a menu address (you/apps) opens All apps. */++ show(view) { if (view !== 'drawer' && view !== 'home') return; state.view = view; state.folder = null; state.filter = ''; paint(); },/** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },};@@@ -1498,7 +1533,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;const face = h('span', cls || 'ovfd-face');const av = c.actor && safe(c.actor.avatar);if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }-- else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 22)); }++ else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();return face;}@@@ -1597,7 +1632,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;const av = c.actor && safe(c.actor.avatar);// No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }-- else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }++ else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();const who = h('span', 'ovfd-who');const meta = h('small', '');@@@ -1745,6 +1780,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;const sentinel = h('div', 'ovfd-more');el.append(examples, resultHead, chips, list, sentinel);let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';++ // 4.229.0: a card already on the list is never drawn again (the owner, 11-12: "112 new deals" three times, one++ // per page of a Deals poll; Network now names a burst once, and this keeps any repeat off the screen).++ const shownIds = new Set();let query = '', searchSeq = 0;const loaded = [];const feedBase = `${o.frameBase || FRAME}/feed`;@@@ -1801,7 +1839,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;async function more(first) {if (busy || done) return;busy = true; const my = ++seq;-- if (first) { list.textContent = ''; skeletons(); } else skeletons();++ if (first) { list.textContent = ''; shownIds.clear(); skeletons(); } else skeletons();const j = await loadFeed(o, kind, cursor);if (my !== seq) return;clearSkels();@@@ -1821,7 +1859,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;// (no time of their own) sit between, under the day they were woven into.const day = (x) => { const t = Date.parse(String((x && x.at) || '')); return Number.isFinite(t) ? new Date(t).toDateString() : ''; };const mark = (x) => { const d = day(x); if (!d || d === lastDay) return; flush(); lastDay = d; list.append(dayLine(Date.parse(String(x.at)))); };-- j.items.forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });++ j.items.filter((x) => x && x.id && !shownIds.has(x.id) && shownIds.add(x.id)).forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });flush();if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }cursor = j.next_cursor || null;@@@ -1879,9 +1917,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;.ovfd-card{display:grid;gap:8px;padding:10px;border-radius:16px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none;min-width:0}@media (hover:hover){.ovfd-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}.ovfd-head{display:flex;align-items:center;gap:8px;min-width:0}--.ovfd-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}++.ovfd-face{flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:15px}.ovfd-face img{width:100%;height:100%;object-fit:cover}--.ovfd-face.is-site{border-radius:10px;background:linear-gradient(150deg,hsl(var(--ovh-h,215) 72% 58% / .34),hsl(var(--ovh-h,215) 70% 38% / .18))}.ovfd-face.is-site .ov-icon{color:hsl(var(--ovh-h,215) 90% 82%)}++.ovfd-face.is-site{border-radius:30%;background:none;overflow:visible}.ovfd-face.is-site>.ov-appicon{--ova-size:40px}.ovfd-who{min-width:0;display:grid}.ovfd-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovfd-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}nnew file mode 100644@@@ -0,0 +1,267 @@++/**++ * ov-searchbox.js (openvibe-shared 4.229.0): one search field for every site. The owner, 11-12: "improve the search++ * field … autocomplete, dropdown, animated effects … a shared, universal, reusable search input component across all++ * sites". Its list is the one dropdown (ov-combo.js, loaded from beside this file), so it reads and moves like every++ * other list on OpenVibe.++ *++ * <form action="/search" role="search">++ * <input name="q" data-ov-searchbox="/api/search/suggest" data-ov-examples="streams|clips|people">++ * </form>++ *++ * Without script it is the site's own form. With it:++ * - a pill: the search mark, the field, a clear button and, on a computer, a "/" key hint; its edge glows with focus;++ * - the placeholder walks through the examples ("Search streams", "Search clips"…) while the field is empty and idle++ * (Calm walks slower; motion Off keeps the first);++ * - as you type, suggestions from the source: GET <source>?q=&limit=8 → { sections: [{ id, label, items: [{ label, url,++ * sub, icon, image }] }] } (OVCombo's shape; same origin, the page's cookies); while the field is empty, the person's++ * recent searches here (this browser only; data-ov-recent="off" keeps none);++ * - the first suggestion that starts with what was typed shows the rest as a ghost: Tab, or → at the end, takes it;++ * - Enter searches what was typed: the form's action with ?<name>=…, or the row the arrows chose (its url); "/" anywhere++ * on the page (outside another field) puts the cursor in the first one; Esc closes the list, then clears the field.++ *++ * A site with its own data or its own search (an SPA):++ * OVSearchBox.mount(input, { source: async (q, signal) => ({ sections }), examples: ['streams', 'clips'], scope,++ * recent: true, onSearch(q, item) { … return false to stop the page from moving } }) → { input, set(q), focus(), destroy() }++ * Events on the input: 'ov-search' { detail: { q, item } }, cancelable: a page that searches in place calls preventDefault.++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OVSearchBox) return;++++ // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.++ const SEARCHBOX_REV = { 'ov-combo.js': '8001a3b19c2f' };++ const self = document.currentScript && document.currentScript.src;++ const base = (self && /^(.*\/)ov-searchbox\.js/.exec(self)) ? /^(.*\/)ov-searchbox\.js/.exec(self)[1] : 'https://openvibe.network/shared/';++++ const svg = (inner, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;++ const IC = { search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'), clear: svg('<path d="M7 7l10 10M17 7 7 17"/>', 16) };++ const CSS = `.ovsb { position: relative; display: flex; align-items: center; gap: 4px; box-sizing: border-box; min-height: 44px; padding: 0 6px 0 12px; border-radius: 999px;++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); color: var(--text-primary, #e6edf7);++ transition: border-color .18s ease, box-shadow .25s ease, background-color .18s ease; }++.ovsb:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); }++.ovsb.is-focus { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, var(--bg-secondary, #111826));++ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), 0 8px 26px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }++.ovsb-mark { flex: none; display: grid; place-items: center; width: 24px; height: 24px; color: var(--text-muted, #93a0b4); transition: color .18s ease, transform .3s cubic-bezier(.2, 1.4, .4, 1); }++.ovsb.is-focus .ovsb-mark { color: var(--accent-light, var(--accent, #60a5fa)); transform: scale(1.08) rotate(-8deg); }++.ovsb.is-busy .ovsb-mark { animation: ovsb-look 1s ease-in-out infinite; }++.ovsb-field { position: relative; flex: 1; min-width: 0; display: grid; }++.ovsb-field > input, .ovsb-ghost { grid-area: 1 / 1; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 10px 4px; border: 0; font: inherit; font-size: 16px; line-height: 22px; letter-spacing: inherit; }++.ovsb-field > input { position: relative; z-index: 1; background: transparent; color: inherit; outline: none; -webkit-appearance: none; appearance: none; }++.ovsb-field > input::-webkit-search-cancel-button, .ovsb-field > input::-webkit-search-decoration { -webkit-appearance: none; }++.ovsb-field > input::placeholder { color: var(--text-muted, #93a0b4); opacity: 1; transition: opacity .22s ease, transform .22s ease; }++.ovsb.is-swap .ovsb-field > input::placeholder { opacity: 0; }++.ovsb-ghost { pointer-events: none; overflow: hidden; white-space: pre; color: transparent; }++.ovsb-ghost i { font-style: normal; color: var(--text-muted, #93a0b4); opacity: .7; }++.ovsb-clear, .ovsb-key { flex: none; display: grid; place-items: center; }++.ovsb-clear { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; animation: ovsb-pop .18s cubic-bezier(.2, 1.4, .4, 1) both; }++.ovsb-clear:hover, .ovsb-clear:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); outline: none; }++.ovsb-clear[hidden], .ovsb-key[hidden] { display: none; }++.ovsb-key { min-width: 22px; height: 22px; margin-right: 6px; padding: 0 6px; box-sizing: border-box; border-radius: 6px; border: 1px solid var(--border, rgba(255,255,255,.16));++ font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted, #93a0b4); }++@media (hover: none), (pointer: coarse) { .ovsb-key { display: none; } }++@keyframes ovsb-pop { from { opacity: 0; transform: scale(.6); } }++@keyframes ovsb-look { 50% { transform: translateX(2px) rotate(-14deg); } }++html[data-ov-motion=calm] .ovsb, html[data-ov-motion=calm] .ovsb-mark { transition-duration: .3s; }++html[data-ov-motion=off] .ovsb, html[data-ov-motion=off] .ovsb-mark, html[data-ov-motion=off] .ovsb-field > input::placeholder { transition: none; }++html[data-ov-motion=off] .ovsb-clear, html[data-ov-motion=off] .ovsb.is-busy .ovsb-mark { animation: none; }++html[data-ov-motion=off] .ovsb.is-focus .ovsb-mark { transform: none; }`;++++ function styles() { if (document.getElementById('ovsb-styles')) return; const st = document.createElement('style'); st.id = 'ovsb-styles'; st.textContent = CSS; document.head.appendChild(st); }++ function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }++ const motion = () => document.documentElement.getAttribute('data-ov-motion') || '';++ const editable = (t) => !!(t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)));++++ let comboWait = null;++ function combo() {++ if (root.OVCombo) return Promise.resolve(root.OVCombo);++ if (comboWait) return comboWait;++ comboWait = new Promise((resolve) => {++ let s = document.querySelector('script[data-ov-combo-loader]');++ if (!s) {++ s = document.createElement('script');++ s.src = `${base}ov-combo.js${SEARCHBOX_REV['ov-combo.js'] ? `?v=${SEARCHBOX_REV['ov-combo.js']}` : ''}`;++ s.async = true; s.setAttribute('data-ov-combo-loader', '');++ document.head.appendChild(s);++ }++ s.addEventListener('load', () => resolve(root.OVCombo || null));++ s.addEventListener('error', () => resolve(null));++ });++ return comboWait;++ }++++ const mounted = [];++++ function mount(input, opts) {++ if (!input || input.__ovsb) return input && input.__ovsb;++ styles();++ const o = Object.assign({}, opts);++ const form = input.form || null;++ const name = input.name || 'q';++ const srcAttr = input.getAttribute('data-ov-searchbox');++ const source = o.source || (srcAttr && srcAttr !== 'true' && srcAttr !== '' ? srcAttr : null);++ const examples = (o.examples || String(input.getAttribute('data-ov-examples') || '').split('|')).map((x) => String(x).trim()).filter(Boolean).slice(0, 12);++ const keepRecent = o.recent !== false && input.getAttribute('data-ov-recent') !== 'off';++ const scope = String(o.scope || input.getAttribute('data-ov-scope') || location.host).slice(0, 80);++ const key = `ov-sb:${scope}`;++ const words = input.getAttribute('placeholder') || 'Search';++++ // The pill around the field (the field itself stays where the page put it: its form, its name, its value).++ const pill = el('div', 'ovsb');++ const mark = el('span', 'ovsb-mark'); mark.innerHTML = IC.search;++ const fieldBox = el('span', 'ovsb-field');++ const ghost = el('span', 'ovsb-ghost'); ghost.setAttribute('aria-hidden', 'true');++ const clear = el('button', 'ovsb-clear'); clear.type = 'button'; clear.innerHTML = IC.clear; clear.setAttribute('aria-label', 'Clear the search'); clear.hidden = !input.value;++ const hint = el('kbd', 'ovsb-key'); hint.textContent = '/'; hint.title = 'Press / to search';++ input.parentNode.insertBefore(pill, input);++ fieldBox.append(input, ghost);++ pill.append(mark, fieldBox, clear, hint);++ if (input.type !== 'search') { try { input.type = 'search'; } catch { /* the type it had */ } }++ input.setAttribute('autocomplete', 'off');++ input.setAttribute('enterkeyhint', 'search');++ input.setAttribute('spellcheck', 'false');++ if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', words);++ if (form && !form.getAttribute('role')) form.setAttribute('role', 'search');++++ // Recent searches, in this browser, per site (or per scope a site names).++ function recents() { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x).slice(0, 8) : []; } catch { return []; } }++ function remember(q) {++ if (!keepRecent || !q) return;++ try { localStorage.setItem(key, JSON.stringify([q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8))); } catch { /* private mode */ }++ }++ function searchUrl(q) {++ const action = form ? (form.getAttribute('action') || location.pathname) : (o.action || '/search');++ const u = new URL(action, location.href);++ u.searchParams.set(name, q);++ return u.href;++ }++++ // What the list shows: recent searches while empty; the source's answer while typing (recent ones that match lead).++ const answers = new Map(); // the source's answers, a minute each (the recent searches are read fresh every time)++ async function list(q, signal) {++ const mine = recents();++ if (!q) return { sections: mine.length ? [{ id: 'recent', label: 'Recent searches', items: mine.map((x) => ({ label: x, icon: 'recent', recent: true })) }] : [] };++ const lq = q.toLowerCase();++ const again = mine.filter((x) => x.toLowerCase().startsWith(lq) && x.toLowerCase() !== lq).slice(0, 3).map((x) => ({ label: x, icon: 'recent', recent: true }));++ let sections = [];++ const kept = answers.get(q);++ const fresh = !!(kept && Date.now() - kept.at < 60000);++ if (fresh) sections = kept.sections;++ else if (typeof source === 'function') sections = ((await source(q, signal)) || {}).sections || [];++ else if (source) {++ const u = new URL(source, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '8');++ pill.classList.add('is-busy');++ try {++ const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal });++ if (r.ok) sections = ((await r.json()) || {}).sections || [];++ } finally { pill.classList.remove('is-busy'); }++ }++ if (source && !fresh) { answers.set(q, { at: Date.now(), sections }); if (answers.size > 60) answers.delete(answers.keys().next().value); }++ return { sections: [...(again.length ? [{ id: 'recent', label: 'Recent searches', items: again }] : []), ...(Array.isArray(sections) ? sections : [])] };++ }++++ // The ghost: the rest of the first suggestion that starts with what was typed.++ let rest = '';++ function paintGhost() {++ const v = input.value;++ ghost.textContent = '';++ if (!rest || !v || document.activeElement !== input || input.selectionEnd !== v.length) return;++ ghost.append(v, Object.assign(el('i'), { textContent: rest }));++ }++ function suggest(items, q) {++ const lq = q.toLowerCase();++ const first = q && items.map((it) => String(it.label || '')).find((l) => l.toLowerCase().startsWith(lq) && l.length > q.length);++ rest = first ? first.slice(q.length) : '';++ paintGhost();++ }++ function takeGhost() {++ if (!rest) return false;++ input.value += rest; rest = ''; paintGhost();++ input.dispatchEvent(new Event('input', { bubbles: true }));++ return true;++ }++++ function go(q, item) {++ q = String(q || '').trim();++ if (!q && !(item && item.url)) return;++ if (q && !(item && item.url && !item.recent)) remember(q);++ const ev = new CustomEvent('ov-search', { bubbles: true, cancelable: true, detail: { q, item: item || null } });++ const stay = input.dispatchEvent(ev) === false || (typeof o.onSearch === 'function' && o.onSearch(q, item || null) === false);++ if (stay) return;++ location.assign(item && item.url ? new URL(item.url, location.href).href : searchUrl(q));++ }++++ // The examples walk: one every 2.8 s (5 s in Calm), only while the field is empty, idle and on screen.++ let ex = 0, walk = 0;++ function setWords(i) { input.setAttribute('placeholder', examples.length ? `Search ${examples[i % examples.length]}` : words); }++ function walkOn() {++ clearInterval(walk);++ if (examples.length < 2 || motion() === 'off') return;++ walk = setInterval(() => {++ if (input.value || document.activeElement === input || document.hidden) return;++ pill.classList.add('is-swap');++ setTimeout(() => { ex++; setWords(ex); pill.classList.remove('is-swap'); }, 220);++ }, motion() === 'calm' ? 5000 : 2800);++ }++ if (examples.length) setWords(0);++ walkOn();++++ let comboApi = null;++ combo().then((C) => {++ if (!C || !input.isConnected) return;++ comboApi = C.enhance(input, {++ source: list, limit: 8, selectFirst: false, cacheMs: 0, anchor: pill,++ none: (q) => (q && source ? `Press Enter to search for “${q}”` : ''),++ pick: (item, q) => { if (item.recent) { input.value = item.label; go(item.label, null); } else go(q || item.label, item); },++ enter: (q) => go(q, null),++ onRows: suggest,++ });++ });++++ input.addEventListener('focus', () => { pill.classList.add('is-focus'); hint.hidden = true; });++ input.addEventListener('blur', () => { pill.classList.remove('is-focus'); hint.hidden = !!input.value; rest = ''; paintGhost(); });++ input.addEventListener('input', () => { clear.hidden = !input.value; hint.hidden = !!input.value || document.activeElement === input; rest = rest && input.value ? rest : ''; paintGhost(); });++ input.addEventListener('keydown', (e) => {++ if ((e.key === 'Tab' && !e.shiftKey && rest) || (e.key === 'ArrowRight' && rest && input.selectionEnd === input.value.length)) {++ if (takeGhost()) e.preventDefault();++ } else if (e.key === 'Escape' && !(comboApi && comboApi.shown) && input.value) {++ e.preventDefault(); input.value = ''; clear.hidden = true; rest = ''; paintGhost();++ input.dispatchEvent(new Event('input', { bubbles: true }));++ } else if (e.key === 'Enter' && !comboApi) { e.preventDefault(); go(input.value, null); }++ });++ input.addEventListener('keyup', paintGhost);++ input.addEventListener('click', paintGhost);++ clear.addEventListener('click', () => { input.value = ''; clear.hidden = true; rest = ''; paintGhost(); input.focus(); input.dispatchEvent(new Event('input', { bubbles: true })); });++ if (form) form.addEventListener('submit', (e) => { e.preventDefault(); go(input.value, null); });++ pill.addEventListener('mousedown', (e) => { if (e.target === pill || e.target === mark) { e.preventDefault(); input.focus(); } });++++ const api = {++ input,++ set(q) { input.value = String(q || ''); clear.hidden = !input.value; rest = ''; paintGhost(); },++ focus() { input.focus(); },++ destroy() {++ clearInterval(walk);++ if (comboApi) comboApi.destroy();++ pill.parentNode.insertBefore(input, pill); pill.remove();++ input.__ovsb = null;++ const i = mounted.indexOf(api); if (i >= 0) mounted.splice(i, 1);++ },++ };++ input.__ovsb = api;++ mounted.push(api);++ return api;++ }++++ // "/" puts the cursor in the page's first search field that shows (never while typing somewhere else).++ document.addEventListener('keydown', (e) => {++ if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || editable(e.target)) return;++ const box = mounted.find((m) => m.input.isConnected && m.input.getClientRects().length);++ if (!box) return;++ e.preventDefault();++ box.focus();++ });++++ function scan(scope) { (scope || document).querySelectorAll('input[data-ov-searchbox]').forEach((i) => mount(i)); }++ root.OVSearchBox = { mount, scan };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();++ document.addEventListener('ov:boost:load', () => scan());++})(typeof window !== 'undefined' ? window : globalThis);@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': 'ac7107f5d733', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': 'ac7107f5d733', 'ov-combo.js': '8001a3b19c2f' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.228.0",++ "version": "4.229.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.228.0",++ "version": "4.229.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.228.0",++ "version": "4.229.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -150,6 +150,7 @@"./agent-sheet": "./agent-sheet.js","./ov-float": "./ov-float.js","./ov-combo": "./ov-combo.js",++ "./ov-searchbox": "./ov-searchbox.js","./ov-composer": "./ov-composer.js","./ov-run": "./ov-run.js","./ov-capture": "./ov-capture.js",@@@ -32,7 +32,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ovconst OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },'ov-arrange.js': { name: 'ARRANGE_CORE_REV', files: ['ov-arrange-core.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-capture.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.js'] } };++ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-searchbox.js': { name: 'SEARCHBOX_REV', files: ['ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js', 'ov-capture.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.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)) {@@@ -526,7 +526,9 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }.ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }.ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }/* A ring leaves the orb as its sheet opens (4.186.0; the Dock's sheet does the same). */-- .ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }++ /* 4.229.0: the ring is invisible at rest, so where animations are off (a phone's "Remove animations", reduced motion)++ it never stays as a white circle around the x (the owner, 11-12: "why is it white"). */++ .ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; opacity: 0; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }/* 4.213.0: when the pressed button flew to its tab, the close turns in at the pill once it has left (a quarter turn,@@@ -692,6 +694,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }}}const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();++ /** 4.229.0 (the owner, 11-12: "fix the history back button so it properly brings you back to this GUI"): the menu's++ * entry remembers where it was when a link took the person away, and Back reopens it there (navbar.js restoreMenu). */++ function markBack() {++ try {++ const back = { tab, door: door || null, scroll: listEl ? Math.round(listEl.scrollTop) : 0, at: Date.now() };++ history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false, ovsBack: back }), '');++ } catch { /* a page that cannot keep state: Back shows the page */ }++ }function remember(it) {if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }const q = input && input.value.trim();@@@ -798,12 +808,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {const H = root.OpenVibeHome; if (!H) return;const kept = homeViews[g.as];-- if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }++ if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); if (g.as === 'home') drawerIfAsked(kept.api); return; }const li = document.createElement('li'); li.setAttribute('role', 'presentation');let api = null;try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }homeViews[g.as] = { li, api };listEl.appendChild(li);++ if (g.as === 'home') drawerIfAsked(api);return;}if (g.as === 'route') {@@@ -1604,6 +1615,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }}const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);/** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */++ /** 4.229.0: You's home tells the menu which view shows: All apps makes the pill its finder ("Find among 64 apps"),++ * and leaving it gives the pill back to searching OpenVibe. */++ function homeView(view, info) {++ if (!input || tab !== 'you') return;++ if (view === 'drawer') { input.placeholder = `Find among ${(info && info.apps) || ''} apps`.replace(' ', ' '); input.value = ''; return; }++ if (/^Find among /.test(input.placeholder)) { input.placeholder = `Search ${here()}, or ask Vibe`; input.value = ''; }++ }function homeOpts() {const { all, ranked, f } = basis();const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);@@@ -1620,7 +1638,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }const seenB = new Set();const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 60).map(backRow) : [];const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];-- return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],++ return { onView: homeView, apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,// You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },@@@ -1668,6 +1686,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }el.classList.toggle('ovs-typing', !!q);if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }if (door === 'vibe') return;++ // All apps open under You: the pill finds an app there, in place (4.229.0; one search, not a second box).++ const homeApi = homeViews.home && homeViews.home.api;++ if (tab === 'you' && homeApi && homeApi.view === 'drawer') {++ tabsEl.hidden = false;++ input.placeholder = `Find among ${homeApi.apps()} apps`;++ homeApi.filter(q);++ return;++ }if (tab === 'feed' && homeLib()) {tabsEl.hidden = false;input.placeholder = 'Search the feed';@@@ -1950,6 +1976,11 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }fly(from, to, { fromFace: flyIdle });}++ function drawerIfAsked(api) {++ if (!wantDrawer || !api || typeof api.show !== 'function') return;++ wantDrawer = false;++ try { api.show('drawer'); } catch { /* the home screen stays */ }++ }function open(opts = {}) {homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opensif (typeof document === 'undefined') return;@@@ -1975,7 +2006,16 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }tickAgo();// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one// history entry; Back pops it and only closes the menu.-- try { if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; } } catch { pushed = false; }++ try {++ if (opts.restore) { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: true, ovsBack: null }), ''); pushed = true; }++ else if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; }++ } catch { pushed = false; }++ if (opts.restore && opts.restore.scroll > 0) {++ // Its parts arrive a moment later (the home screen, the wall): the scroll is put back as they land.++ const want = opts.restore.scroll;++ const put = () => { if (listEl && listEl.scrollTop < want) listEl.scrollTop = want; };++ requestAnimationFrame(put); setTimeout(put, 250); setTimeout(put, 700);++ }// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.const at = opts.anchor;el.classList.toggle('ovs-anchored', !!at);@@@ -2015,7 +2055,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }paintGhost();render(input.value);if (flyFrom && !input.value) requestAnimationFrame(flyIn);-- if (opts.section) setTimeout(() => { const section = listEl.querySelector(`[data-sec="${opts.section}"]`); if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); }, 80);++ // All apps is a view of You's home screen: it opens as the home screen arrives (4.229.0, you/apps).++ wantDrawer = opts.section === 'apps';++ const sec = opts.section === 'apps' ? 'home' : opts.section;++ if (sec) setTimeout(() => { const section = listEl.querySelector(`[data-sec="${sec}"]`); if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); }, 80);// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);@@@ -2031,7 +2074,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }}// ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened// again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).-- let pushed = false, popping = false, surface = null, ownBack = false;++ let pushed = false, popping = false, surface = null, ownBack = false, wantDrawer = false;++ if (typeof window !== 'undefined') window.addEventListener('popstate', () => {++ if (ownBack) return;++ if (!(el && el.classList.contains('is-open'))) {++ try { if (history.state && history.state.ovsBack) document.dispatchEvent(new CustomEvent('openvibe-menu-restore')); } catch { /* */ }++ }++ });if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });function close({ navigating = false } = {}) {// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).@@@ -2041,7 +2090,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }if (view === 'results' && rtabsEl) leaveResults();leaveDoor();if (!el || closing) return;-- if (navigating) pushed = false;++ if (navigating) { pushed = false; markBack(); }// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++// Menu addresses (4.229.0; the owner, 11-12: "other tools and buttons can open specific areas of the menu"), in a real++// headless Chrome: any element with data-ov-menu="you/notifications" opens the menu there on a click; a page address++// ending #ov-menu=… opens it as the page loads and the hash goes; OpenVibeNavbar.menuAddress reads an address the same++// way. A link with an address still opens elsewhere with a modifier key. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const ROOT = path.join(__dirname, '..');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('menu-address-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };++const LINKS = [{ label: 'Home', href: '/' }, { label: 'Other page', href: '/other' }];++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Addr</title><link rel="icon" href="data:,">++<script>++ const realFetch = window.fetch;++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>++<main><button id="bell" type="button" data-ov-menu="you/notifications">Notifications</button> <a id="feed" href="/feed-page" data-ov-menu="feed">Feed</a> <a id="hashlink" href="#ov-menu=you">You</a></main>++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false }); });</script>++</body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css))$/.exec(u.pathname);++ if (m) {++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }++ res.writeHead(200, { 'content-type': m[1].endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));++ }++ if (u.pathname === '/feed-page') { res.writeHead(204); return res.end(); }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);++});++++(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-addr-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { let v = null; try { v = await p.evaluate(expr); } catch { v = null; } if (v || Date.now() > end) return v; await wait(50); } };++ const openTab = `(document.querySelector('.ovs.is-open .ovs-tab[data-tab][aria-selected="true"]') || {}).getAttribute ? document.querySelector('.ovs.is-open .ovs-tab[data-tab][aria-selected="true"]').getAttribute('data-tab') : ''`;++ const closeMenu = `(window.OpenVibeSearch && OpenVibeSearch.close && OpenVibeSearch.close(), true)`;++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });++++ // An address in the page's own address opens the menu as it loads, and the hash goes.++ await p.goto(`${base}/#ov-menu=you/notifications`, { maxMs: 5000 });++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 8000), '#ov-menu opens the menu as the page loads');++ assert.strictEqual(await until(p, openTab), 'you', 'on You');++ assert.strictEqual(await p.evaluate('location.hash'), '', 'and the address no longer carries it');++ await p.evaluate(closeMenu);++ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');++++ // The API reads addresses the same way.++ const read = await p.evaluate(`JSON.stringify(['you/notifications', 'notifications', 'vibe', 'you/apps', 'feed', 'nonsense', 'here/live'].map((a) => OpenVibeNavbar.menuAddress(a)))`);++ assert.deepStrictEqual(JSON.parse(read), [++ { tab: 'you', part: 'notifications' }, { tab: 'you', part: 'notifications' }, { door: 'vibe' }, { tab: 'you', part: 'apps' },++ { tab: 'feed', part: null }, { tab: 'here', part: null }, { tab: 'here', part: 'live' },++ ]);++++ // A button with an address opens the menu there.++ await p.evaluate(`document.getElementById('bell').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'data-ov-menu opens the menu');++ assert.strictEqual(await until(p, openTab), 'you', 'you/notifications opens You');++ await p.evaluate(closeMenu);++ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');++++ // A link with an address opens the menu instead of following its href…++ await p.evaluate(`document.getElementById('feed').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'a link with data-ov-menu opens the menu');++ assert.strictEqual(await until(p, openTab), 'feed', 'on the Feed');++ assert.strictEqual(await p.evaluate('location.pathname'), '/', 'and stays on the page');++ await p.evaluate(closeMenu);++ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');++ // …but a modified click is the browser's (a new tab), and the menu stays shut.++ const prevented = await p.evaluate(`(() => { const e = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }); let seen = null; document.addEventListener('click', (x) => { seen = x.defaultPrevented; x.preventDefault(); }, { once: true }); document.getElementById('feed').dispatchEvent(e); return seen; })()`);++ // Not prevented means the bar's handler returned before it opened anything (it prevents first, then opens).++ assert.strictEqual(prevented, false, 'a ctrl-click on a link with an address is left to the browser, and opens no menu');++++ // A plain link to #ov-menu=… on the same page opens it too (hashchange).++ await p.evaluate(`document.getElementById('hashlink').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'a #ov-menu link on the page opens the menu');++ assert.strictEqual(await until(p, openTab), 'you', 'on You');++ await p.close();++ console.log('menu-address-chrome: #ov-menu on load and on the page, data-ov-menu on a button and a link, modifier clicks left alone, menuAddress');++ } 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,107 @@++'use strict';++// Back to the menu (4.229.0; the owner, 11-12: "fix the history back button so it properly brings you back to this GUI++// rather than the page behind it"), in a real headless Chrome: a page left from the menu, then Back, opens the menu again++// where it was, on the same history entry (Forward still works); closing it then leaves the page as it was; a site's++// in-place move (Boost) comes back the same way. Built on menu-navigate-chrome's page.++//++// Changing pages from the menu (owner, 11-09: "when you switch pages… it doesn't update the navigation that's highlighted…++// the menu should animate closed when you change pages… a progress indicator at the very top… a glow"), in a real++// headless Chrome:++// • a page chosen in the menu closes it, and Back is not spent on the menu during the move (a site's in-place move,++// like Boost's, keeps its own history entry);++// • a menu still open when a page is swapped in place marks the new page;++// • a link that leaves the page shows the top bar at once, while the next page loads; a click a page handled itself++// (defaultPrevented) draws nothing.++// Skipped without Chrome.++const assert = require('assert');++const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const ROOT = path.join(__dirname, '..');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('menu-back-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };++const LINKS = [{ label: 'Home', href: '/' }, { label: 'Camera', href: '/camera' }, { label: 'Other page', href: '/other' }];++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Nav</title><link rel="icon" href="data:,">++<script>++ const realFetch = window.fetch;++ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));++ // A stand-in for Boost: same-site links to /camera are swapped in place.++ document.addEventListener('click', (e) => {++ const a = e.target.closest && e.target.closest('a[href]');++ if (!a || new URL(a.href).pathname !== '/camera') return;++ e.preventDefault();++ history.pushState({}, '', '/camera');++ document.dispatchEvent(new CustomEvent('ov:boost:load'));++ });++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div><main><a id="plain" href="/stay">A plain link</a> <a id="handled" href="/elsewhere">Handled</a></main>++<script>document.getElementById('handled').addEventListener('click', (e) => e.preventDefault());++document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false });++ setTimeout(function () { window.addEventListener('click', function (e) { if (e.target && e.target.id === 'plain') { window.__left = true; e.preventDefault(); } }); }, 0); });</script>++</body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);++ if (m) {++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }++ res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));++ }++ if (u.pathname === '/stay') { res.writeHead(204); return res.end(); } // no content: the browser stays on the page++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);++});++++(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-back-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { let v = null; try { v = await p.evaluate(expr); } catch { v = null; } if (v || Date.now() > end) return v; await wait(50); } };++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });++ await p.goto(`${base}/`, { maxMs: 5000 });++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');++++ // Leave from the menu by a page that loads: Back opens the menu again.++ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened');++ await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Other page').click(), true`);++ assert.ok(await until(p, `location.pathname === '/other' && !!document.getElementById('openvibe-burger')`, 8000), 'the other page loaded');++ await p.evaluate(`history.back(), true`);++ assert.ok(await until(p, `location.pathname === '/' && !!document.querySelector('.ovs.is-open')`, 8000), 'Back opens the menu again, not the page behind it');++ assert.strictEqual(await p.evaluate(`!!(history.state && history.state.ovsMenu)`), true, 'on its own history entry');++ assert.strictEqual(await p.evaluate(`!!(history.state && history.state.ovsBack)`), false, 'and the mark is used up');++ // Closing it now leaves the page as it was (one Back, taken by the menu).++ await p.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs.is-open .ovs-orb, .ovs.is-open .ovs-x') || { click() {} }).click(), true`);++ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');++ await eventually(async () => assert.strictEqual(await p.evaluate('location.pathname'), '/', 'still on the page it was left from'));++ // Forward still leads to the page that was left to.++ await p.evaluate(`history.forward(), true`);++ assert.ok(await until(p, `location.pathname === '/other'`, 8000) || await until(p, `location.pathname === '/'`, 1000), 'Forward is not lost');++++ // A move in place (Boost): Back on the same page opens the menu again too.++ await p.goto(`${base}/`, { maxMs: 5000 });++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up again');++ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened');++ await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);++ assert.ok(await until(p, `location.pathname === '/camera' && !document.querySelector('.ovs.is-open')`, 3000), 'moved in place, the menu closed');++ await p.evaluate(`history.back(), true`);++ assert.ok(await until(p, `location.pathname === '/' && !!document.querySelector('.ovs.is-open')`, 5000), 'Back opens the menu again on the same page');++ await p.close();++ console.log('menu-back-chrome: Back to the menu after a page load and after an in-place move, its own entry, close and Forward');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -147,10 +147,20 @@ const server = http.createServer((req, res) => {// 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`);-- assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');++ assert.ok(await until(p, `!!document.querySelector('.ovh-head-t b') && document.querySelector('.ovh-head-t b').textContent.startsWith('All apps')`), 'the drawer opened, named, with its count');++ // 4.229.0: one search. No second box: the menu's own pill finds an app in All apps.++ assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-filter').length`), 0, 'no second search box');++ assert.match(await p.evaluate(`document.querySelector('.ovs-input').placeholder`), /^Find among \d+ apps$/, 'the pill says what it finds now');++ const allN = await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`);++ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'zzzz-no-such-app'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`), 0, 'typing in the pill filters the apps'));++ assert.ok(await p.evaluate(`!!document.querySelector('.ovh-head-t')`), 'and the apps stay in place (not search results)');++ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`), allN, 'clearing it shows them all'));await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);await p.evaluate(`document.querySelector('.ovh-back').click(), true`);assert.ok((await order(p)).includes(keyed[0]), 'All apps put it back on Home');++ assert.doesNotMatch(await p.evaluate(`document.querySelector('.ovs-input').placeholder`), /^Find among/, 'back on Home, the pill searches OpenVibe again');// The Tools folder: tools with their own icons, and the count of all.await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');const ROOT = path.join(__dirname, '..');const NAVBAR_PATH = path.join(ROOT, 'navbar.js');const ICONS = require('../nav-icons');--const NAVBAR_BROTLI_BUDGET = 50.5 * 1024; // 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)++const NAVBAR_BROTLI_BUDGET = 52 * 1024; // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)// ── size budget and generated block ──────────────────────────{nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++// The one search field (ov-searchbox.js, 4.229.0; the owner, 11-12: "autocomplete, dropdown, animated effects … a shared,++// universal, reusable search input component") in a real headless Chrome against a stub source: the field becomes a pill++// that loads the one dropdown from beside itself; the placeholder names the first example; typing asks the source and the++// first suggestion's rest shows as a ghost that Tab takes; Enter searches the typed words (a cancelable ov-search), the++// arrows and Enter go to a row's page; the search is remembered and shows while the field is empty; "/" puts the cursor++// in it; Escape closes the list, then clears; the clear button empties it.++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-searchbox-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const ITEMS = [++ { label: 'streams today', url: '/s/today', icon: 'live', sub: '12 live now' },++ { label: 'stream keys', url: '/s/keys', icon: 'page' },++];++const asked = [];++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const file = /^\/shared\/(ov-searchbox|ov-combo)\.js$/.exec(u.pathname);++ if (file) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, `${file[1]}.js`))); }++ if (u.pathname === '/suggest') {++ const q = (u.searchParams.get('q') || '').toLowerCase();++ asked.push([q, u.searchParams.get('limit')]);++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ sections: [{ id: 'top', label: 'Top', items: ITEMS.filter((x) => x.label.includes(q)) }] }));++ }++ 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" action="/search" style="padding:40px 16px"><input id="q" name="q" placeholder="Search" data-ov-searchbox="/suggest" data-ov-examples="streams|clips"></form>++<p id="else">Elsewhere</p>++<script>window.searches = []; document.addEventListener('ov-search', (e) => { window.searches.push({ q: e.detail.q, url: e.detail.item && e.detail.item.url }); e.preventDefault(); });</script>++<script src="/shared/ov-searchbox.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-searchbox-'));++ 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, extra = {}) => { const codes = { Enter: 13, ArrowDown: 40, Escape: 27, Tab: 9, '/': 191 }; const ev = { key: k, code: k === '/' ? 'Slash' : k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], ...extra }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };++++ assert.ok(await until(`!!(document.querySelector('.ovsb #q') && window.OVCombo)`), 'the field sits in the pill, and the one dropdown loaded from beside it');++ assert.equal(await page.evaluate(`document.getElementById('q').placeholder`), 'Search streams', 'the first example names the placeholder');++ assert.equal(await page.evaluate(`document.getElementById('q').getAttribute('role')`), 'combobox');++++ // "/" from the page puts the cursor in it.++ await page.evaluate(`document.activeElement && document.activeElement.blur(), true`);++ await key('/', { text: '/' });++ assert.ok(await until(`document.activeElement && document.activeElement.id === 'q'`), '"/" focuses the search field');++ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'and types no slash');++ assert.ok(await page.evaluate(`document.querySelector('.ovsb').classList.contains('is-focus')`), 'the pill shows its focus');++++ // Typing asks the source; the first match's rest is a ghost; Tab takes it.++ await page.send('Input.insertText', { text: 'str' });++ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].length === 2`), 'two suggestions');++ assert.deepEqual(asked.at(-1), ['str', '8']);++ assert.ok(await until(`document.querySelector('.ovsb-ghost i') && document.querySelector('.ovsb-ghost i').textContent === 'eams today'`), 'the ghost shows the rest of the first suggestion');++ assert.equal(await page.evaluate(`document.querySelector('.ovcombo-i[aria-selected="true"]')`), null, 'no row is chosen ahead of the arrows');++ await key('Tab');++ assert.equal(await page.evaluate(`document.getElementById('q').value`), 'streams today', 'Tab takes the ghost');++ assert.ok(await until(`document.activeElement && document.activeElement.id === 'q'`), 'and the cursor stays');++++ // Enter searches the typed words.++ await key('Enter');++ assert.ok(await until(`window.searches.length === 1`), 'Enter searches');++ assert.deepEqual(await page.evaluate(`window.searches[0]`), { q: 'streams today', url: null });++++ // The arrows and Enter go to a row's page.++ await page.evaluate(`(() => { const i = document.getElementById('q'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await page.send('Input.insertText', { text: 'key' });++ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].some((r) => r.textContent.includes('stream keys'))`), 'stream keys suggested');++ await key('ArrowDown');++ await key('Enter');++ assert.ok(await until(`window.searches.length === 2`), 'a chosen row searches');++ assert.deepEqual(await page.evaluate(`window.searches[1]`), { q: 'key', url: '/s/keys' });++++ // Escape closes the list, then clears the field.++ await page.evaluate(`document.getElementById('q').focus(), true`);++ await page.send('Input.insertText', { text: 's' });++ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden]) .ovcombo-i')`), 'the list is open');++ await key('Escape');++ assert.ok(await until(`!document.querySelector('.ovcombo:not([hidden])')`), 'Escape closes the list');++ assert.notEqual(await page.evaluate(`document.getElementById('q').value`), '', 'and keeps the words');++ await key('Escape');++ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'a second Escape clears');++++ // The search is remembered: the empty field shows it.++ await page.evaluate(`document.getElementById('else').click(), document.activeElement.blur(), true`);++ await page.evaluate(`document.getElementById('q').focus(), true`);++ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-sec')].some((s) => s.textContent === 'Recent searches') && [...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].some((r) => r.textContent.includes('streams today'))`), 'recent searches while empty');++++ // The clear button.++ await page.send('Input.insertText', { text: 'abc' });++ assert.equal(await page.evaluate(`document.querySelector('.ovsb-clear').hidden`), false, 'clear shows with words');++ await page.evaluate(`document.querySelector('.ovsb-clear').click(), true`);++ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'clear empties the field');++ assert.equal(await page.evaluate(`document.querySelector('.ovsb-clear').hidden`), true);++++ // No page overflow at phone width.++ assert.ok(await page.evaluate(`document.documentElement.scrollWidth <= innerWidth`), 'no sideways scroll at 390 px');++ await page.close();++ console.log('ov-searchbox-chrome: the pill, the dropdown from beside it, an example placeholder, "/", the ghost and Tab, Enter, a chosen row, Escape twice, recent searches, clear');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,5 +1,18 @@
@@ -1,5 +1,18 @@
# Changelog
# Changelog
+## 4.229.0 — 2026-11-12
+
+- **Back brings you back to the menu** (the owner, 11-12: "fix the history back button so it properly brings you back to this GUI rather than the page behind it"). Leaving through a link in the menu marks where it was on its history entry (its place, its door, its scroll); Back to that page opens the menu again there, whether the page comes back from the browser's cache, loads again, or a site swaps it in place (Boost). It reuses that entry, so Forward still works, and closing it then leaves the page as it was. Only for a Back or Forward within half an hour.
+- **Links are on their way before the click**: anywhere on the page (the bar, the menu, a footer, a card), a finger landing on or a pointer resting on a link to another OpenVibe site opens its connection at once; this site's pages were already prefetched by the bar's speculation rules.
+- **All apps has one search** (the owner, 11-12: "confusing"): no second box under the menu's pill. The pill reads "Find among 64 apps" and filters the apps in place; the view's head is its name, its count and a hint, coming in with the view. Back on Home, the pill searches OpenVibe again.
+- **The close x has no white ring**: the ring that leaves it as the menu opens is invisible at rest, so where animations are off (a phone's "Remove animations") it no longer stays as a white circle.
+- **The Feed never shows a card twice**: a card id already on the list is skipped (Network names a Deals burst once, as of its #887).
+- **Feed cards with no person wear their site's app icon** (design rule 142), 40 px beside 40 px faces, the glyph filling the square (the owner, 11-12: "icons to increase size").
+- **Any button can open a place in the menu** (the owner, 11-12: "other tools and buttons can open specific areas of the menu"). A menu address is a place, or place/part: `you/notifications`, `you/apps` (All apps), `you/recent`, `feed`, `here/live`, or a door (`vibe`, `chats`, `agent`, `code`, `tasks`). Any element with `data-ov-menu="you/notifications"` opens it on a click, on any page with the bar; a link with an address still opens elsewhere with a modifier key. A page address ending `#ov-menu=you/notifications` opens it as the page loads (on any OpenVibe site), and a same-page link to it works too; the hash leaves the address. `OpenVibeNavbar.openMenu(address)` and `OpenVibeNavbar.menuAddress(address)` take the same addresses; OpenVibeHome's `show('drawer')` opens All apps.
+- **One search field for every site: `ov-searchbox.js`** (the owner, 11-12: "improve the search field … autocomplete, dropdown, animated effects … a shared, universal, reusable search input component"). `<input name="q" data-ov-searchbox="/api/…/suggest" data-ov-examples="streams|clips">` in a form becomes a pill: the search mark, the field, a clear button and a "/" key hint on a computer; its edge glows with focus. The placeholder walks through the examples while idle (Calm slower, Off never). Suggestions come from the source in OVCombo's shape, with icons (search, recent, page, person, live, video, place) or a small picture; while empty, the person's recent searches here. The first suggestion that starts with the typed words shows the rest as a ghost: Tab or → takes it. Enter searches the typed words (the form's action) or the chosen row's page; "/" anywhere puts the cursor in it; Escape closes, then clears. `OVSearchBox.mount(input, { source, examples, scope, recent, onSearch })`; a cancelable `ov-search` event lets a page search in place.
+- **OVCombo grows hooks** for anything that is not a value picker: `pick`, `enter`, `onRows`, `selectFirst: false`, `none`, `limit`, `cacheMs`, `anchor`; rows take `icon` or `image`; the skeleton no longer flashes over rows already showing while a quick answer comes.
+- The navbar's budget is 52 KB brotli (51.6 KB with the menu addresses).
+
## 4.228.0 — 2026-11-12
## 4.228.0 — 2026-11-12
- **A page's sections as tabs on a phone** (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking huge … at least on mobile"). `showcase.parts({ name, items: [{ id, label, count, html }], front })` shows one part at a time with a tab bar (its name and count) at 900 px and under, 600 for a front page; every part stays in the HTML (crawlers, AI readers); radios and `:has()`, no script; the arrow keys move between parts; wide screens and browsers without `:has()` stack them. `ov-parts.css` is the same look alone, for a page that does not load showcase.css (Live's home).
- **A page's sections as tabs on a phone** (the owner, 11-11: "consolidate these into tabs, self-contained tabs, so the page isn't so freaking huge … at least on mobile"). `showcase.parts({ name, items: [{ id, label, count, html }], front })` shows one part at a time with a tab bar (its name and count) at 900 px and under, 600 for a front page; every part stays in the HTML (crawlers, AI readers); radios and `:has()`, no script; the arrow keys move between parts; wide screens and browsers without `:has()` stack them. `ov-parts.css` is the same look alone, for a page that does not load showcase.css (Live's home).
@@ -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.228.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.229.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.228.0",
+ "version": "4.229.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)",
@@ -61,6 +61,7 @@ const BROWSER = Object.freeze([
@@ -61,6 +61,7 @@ const BROWSER = Object.freeze([
'ov-run.js', // 4.224.0: the one run thread (OpenVibeRun), loaded beside the navbar
'ov-run.js', // 4.224.0: the one run thread (OpenVibeRun), loaded beside the navbar
'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-combo.js', // shared field dropdown
'ov-combo.js', // shared field dropdown
+ 'ov-searchbox.js', // the one search field (4.229.0): pill, suggestions, recent searches, ghost completion; its list is ov-combo
'ov-window.js', // shared dialog, sheet, and panel
'ov-window.js', // shared dialog, sheet, and panel
'agent-sheet.js', // an agent's sheet in the one window (4.211.0)
'agent-sheet.js', // an agent's sheet in the one window (4.211.0)
'ov-float.js', // the move engine behind a PiP or a panel: dock/float, tuck, size, place (4.208.0)
'ov-float.js', // the move engine behind a PiP or a panel: dock/float, tuck, size, place (4.208.0)
@@ -610,7 +610,7 @@
@@ -610,7 +610,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': 'c96584376da1', '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': '146282739c06', 'dock.js': '9f15df28050c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '69a80757ed04', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'ac7107f5d733' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', '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': 'ef289c4550b1', 'dock.js': '9f15df28050c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '3a88a5ba2565', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'b11405559831', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'ac7107f5d733' };
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.
@@ -1363,6 +1363,52 @@
@@ -1363,6 +1363,52 @@
status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });
status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } });
let _menuStatus = null;
let _menuStatus = null;
/** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */
/** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */
+ /** 4.229.0: Back to a page the menu was left from opens the menu again, where it was (its place, its door, its
+ * scroll), whether the page comes back from the browser's cache, loads again, or is swapped in place. Only for a
+ * Back or Forward, and only within half an hour of leaving. */
+ function restoreMenu(ev) {
+ let st = null;
+ try { st = history.state; } catch { return; }
+ const b = st && st.ovsBack;
+ if (!b || typeof b !== 'object' || !(Date.now() - Number(b.at || 0) < 30 * 60000)) return;
+ let nav = null;
+ try { nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; } catch { nav = null; }
+ if (!(ev && ev.persisted) && !(nav && nav.type === 'back_forward')) return;
+ if (menuOpen()) return;
+ const tab = /^(here|feed|you)$/.test(String(b.tab || '')) ? b.tab : 'here';
+ const door = /^(vibe|chats|agent|code|tasks)$/.test(String(b.door || '')) ? b.door : undefined;
+ const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
+ openSearch('', tab, orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, door, { restore: { scroll: Math.max(0, Number(b.scroll) || 0) } });
+ }
+ /** 4.229.0 (the owner, 11-12: "other tools and buttons can open specific areas of the menu"): a menu address is
+ * place[/part]. Places: here, feed, you, and the doors vibe, chats, agent, code, tasks. Parts: You's notifications,
+ * recent, apps (All apps), feeds, display; this site's live, places, tools, all. A part alone names its place
+ * ("notifications" is you/notifications). Anything else opens the menu on this site. */
+ const MENU_PARTS = { here: /^(live|places|tools|all|discover|news)$/, you: /^(notifications|recent|apps|feeds|display|home)$/, feed: /^$/ };
+ function menuAddress(addr) {
+ const [a = '', b = ''] = String(addr || '').trim().toLowerCase().replace(/^#?ov-menu=/, '').split('/');
+ if (/^(vibe|chats|agent|code|tasks)$/.test(a)) return { door: a };
+ if (MENU_PARTS.you.test(a)) return { tab: 'you', part: a };
+ if (MENU_PARTS.here.test(a)) return { tab: 'here', part: a };
+ const tab = /^(here|feed|you)$/.test(a) ? a : 'here';
+ return { tab, part: MENU_PARTS[tab].test(b) && b ? b : null };
+ }
+ function openMenuAt(addr, fromEl) {
+ const m = menuAddress(addr);
+ if (m.door) return openMenuOn(m.door);
+ openSearch('', m.tab, fromEl && fromEl.getBoundingClientRect ? fromEl : null, m.part);
+ }
+ /** An address in the page's own: #ov-menu=you/notifications (alone, or after other hash words with &). It opens
+ * as the page loads or when a link on the page changes the hash, and leaves the address so Back is the page. */
+ function menuFromHash() {
+ let h = '';
+ try { h = root.location.hash || ''; } catch { return; }
+ const m = /(?:^#|&)ov-menu=([a-z/-]{1,40})(?=&|$)/.exec(h);
+ if (!m) return;
+ const rest = h.replace(m[0], '').replace(/^#&/, '#');
+ try { history.replaceState(history.state, '', root.location.pathname + root.location.search + (rest === '#' ? '' : rest)); } catch { /* the address keeps it */ }
+ openMenuAt(m[1]);
+ }
function openMenuOn(id) {
function openMenuOn(id) {
if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;
if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return;
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : [];
@@ -2375,7 +2421,7 @@
@@ -2375,7 +2421,7 @@
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000);
}
}
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
/** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
- function openSearch(q, tab, fromEl, section, door) {
+ function openSearch(q, tab, fromEl, section, door, extra) {
// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).
// The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0).
const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));
const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn'));
// The menu drops from the button that opened it (the orb, or the burger), under the bar.
// The menu drops from the button that opened it (the orb, or the burger), under the bar.
@@ -2389,7 +2435,7 @@
@@ -2389,7 +2435,7 @@
if (orb) orb.classList.remove('is-busy');
if (orb) orb.classList.remove('is-busy');
if (!root.OpenVibeSearch) return;
if (!root.OpenVibeSearch) return;
if (_navEl) _navEl.classList.remove('ovnav-hide');
if (_navEl) _navEl.classList.remove('ovnav-hide');
- root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
+ root.OpenVibeSearch.open({ ...(extra || {}), service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
@@ -2602,6 +2648,26 @@
@@ -2602,6 +2648,26 @@
document.head.appendChild(sr);
document.head.appendChild(sr);
} catch { /* an old browser: links load as they always did */ }
} catch { /* an old browser: links load as they always did */ }
}
}
+ // 4.229.0 (the owner, 11-12: "when you click links and pages and other domains within the menu or navigation it
+ // loads async dynamically and is highly optimized"): a link to another OpenVibe site warms up as a finger lands on
+ // it or a pointer rests on it (its connection opens: DNS and TLS while the press is still down). This site's
+ // pages are already fetched ahead by the speculation rules above; browsers do not prefetch another site's page
+ // for a person signed in there. Anywhere on the page: the bar, the menu, a footer, a card. At most 24 origins.
+ const warmed = new Set();
+ const OV_HOST = /(^|\.)(openvibe\.[a-z]+|openre\.stream|open\.shoes)$/;
+ const warm = (a) => {
+ let u; try { u = new URL(a.getAttribute('href'), location.href); } catch { return; }
+ if (u.protocol !== 'https:' || u.origin === location.origin || warmed.has(u.origin) || warmed.size >= 24 || !OV_HOST.test(u.hostname)) return;
+ warmed.add(u.origin);
+ try { const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; document.head.appendChild(l); } catch { /* the click connects */ }
+ };
+ document.addEventListener('pointerdown', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (a) warm(a); }, { capture: true, passive: true });
+ let hoverTimer = 0;
+ document.addEventListener('pointerover', (e) => {
+ if (e.pointerType === 'touch') return;
+ const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return;
+ clearTimeout(hoverTimer); hoverTimer = setTimeout(() => warm(a), 90);
+ }, { passive: true });
// A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.
// A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over.
document.addEventListener('click', (e) => {
document.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
@@ -2612,7 +2678,19 @@
@@ -2612,7 +2678,19 @@
setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say
setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say
}, true);
}, true);
// Back from the browser's page cache: whatever was loading is done.
// Back from the browser's page cache: whatever was loading is done.
- window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) busy(false); });
+ window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) { busy(false); restoreMenu(ev); } });
+ document.addEventListener('openvibe-menu-restore', () => restoreMenu({ persisted: true }));
+ setTimeout(() => restoreMenu(null), 0);
+ setTimeout(menuFromHash, 0);
+ window.addEventListener('hashchange', menuFromHash);
+ document.addEventListener('click', (e) => {
+ const t = e.target && e.target.closest ? e.target.closest('[data-ov-menu]') : null;
+ if (!t || e.defaultPrevented || e.button !== 0) return;
+ // A link that also has an address keeps the browser's own ways to open it elsewhere (a new tab, a window).
+ if (t.matches('a[href]') && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) return;
+ e.preventDefault();
+ openMenuAt(t.getAttribute('data-ov-menu'), t);
+ });
}
}
// ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──
// ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ──
// A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for
// A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for
@@ -2695,7 +2773,6 @@
@@ -2695,7 +2773,6 @@
/** Open the search panel (search.js), with an optional query already in it. */
/** Open the search panel (search.js), with an optional query already in it. */
openSearch(q, tab) { openSearch(q, tab); },
openSearch(q, tab) { openSearch(q, tab); },
- openMenu(tab) { openSearch('', tab || 'you'); },
openSwitcher() { openSwitcher(); },
openSwitcher() { openSwitcher(); },
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
@@ -2792,8 +2869,11 @@
@@ -2792,8 +2869,11 @@
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
- /** The menu at a place (4.223.0). */
- openMenu(p) { if (/^(vibe|chats|agent|code|tasks)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },
+ /** The menu at an address: a place, or place/part ('you/notifications', 'you/apps', 'vibe'; 4.223.0, parts 4.229.0).
+ * The same address works as data-ov-menu="…" on any element and as #ov-menu=… at the end of a page's address. */
+ openMenu(p, fromEl) { openMenuAt(p || 'you', fromEl); },
+ /** What an address opens: { door } or { tab, part } (for a site's own button that wants to show where it goes). */
+ menuAddress,
pop: popFrom,
pop: popFrom,
unpop,
unpop,
askAgent(text) { askAgent(text); },
askAgent(text) { askAgent(text); },
@@ -13,6 +13,16 @@
@@ -13,6 +13,16 @@
* "Create “name” on OpenVibe Git": POST <create> { name, private: true } → { url, label }, and the new one goes in.
* "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 }) }).
* 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.
* Events on the input: 'ov-combo:pick' { detail: item } after a pick or a create.
+ *
+ * 4.229.0, for the one search field (ov-searchbox.js) and anything else that is not a value picker:
+ * pick(item, q) a chosen row goes here instead of into the field (a search goes to the row's page);
+ * enter(q) Enter with no row chosen (a search searches what was typed); the list closes;
+ * onRows(items, q) the rows just drawn, in order (the search field's ghost reads the first);
+ * selectFirst false: the first row is not chosen ahead of the arrows (Enter is the typed text);
+ * none(q) the words when nothing matches; limit, the rows asked of a URL source (12);
+ * cacheMs how long an answer is kept per query (60000; 0 asks every time: the caller keeps its own);
+ * anchor the element the list lines up under (the search field's pill), else the field.
+ * A row may carry icon (search, recent, page, person, live, video, place) or image (a small picture's URL).
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -23,6 +33,12 @@
@@ -23,6 +33,12 @@
'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"/>'),
'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"/>'),
'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"/>'),
recent: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
+ search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'),
+ page: svg('<path d="M7 3.5h7l4 4V20a.5.5 0 0 1-.5.5h-10A.5.5 0 0 1 7 20z"/><path d="M14 3.5V8h4"/>'),
+ person: svg('<circle cx="12" cy="8.5" r="3.5"/><path d="M5.5 20a6.5 6.5 0 0 1 13 0"/>'),
+ live: svg('<circle cx="12" cy="12" r="2.2"/><path d="M8.1 8.1a5.5 5.5 0 0 0 0 7.8M15.9 8.1a5.5 5.5 0 0 1 0 7.8M5.3 5.3a9.5 9.5 0 0 0 0 13.4M18.7 5.3a9.5 9.5 0 0 1 0 13.4"/>'),
+ video: svg('<rect x="3.5" y="6" width="12.5" height="12" rx="2"/><path d="m16 10.5 4.5-2.5v8l-4.5-2.5"/>'),
+ place: svg('<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"/><circle cx="12" cy="10" r="2.3"/>'),
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"/>'),
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"/>'),
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>',
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>',
@@ -41,6 +57,7 @@
@@ -41,6 +57,7 @@
.ovcombo-i[aria-selected="true"]:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 20%, 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 { 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-ic svg { width: 18px; height: 18px; }
+.ovcombo-ic img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.ovcombo-i.is-create .ovcombo-ic { color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
.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 { 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 { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -68,6 +85,8 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -68,6 +85,8 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
const o = Object.assign({}, opts);
const o = Object.assign({}, opts);
const src = o.source || input.getAttribute('data-ov-combo');
const src = o.source || input.getAttribute('data-ov-combo');
const createAt = o.create || input.getAttribute('data-ov-combo-create');
const createAt = o.create || input.getAttribute('data-ov-combo-create');
+ const limit = Math.max(1, Math.min(25, Number(o.limit) || 12));
+ const keepMs = o.cacheMs == null ? 60000 : Math.max(0, Number(o.cacheMs) || 0);
const listId = `ovcombo-${++uid}`;
const listId = `ovcombo-${++uid}`;
input.setAttribute('role', 'combobox');
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-autocomplete', 'list');
@@ -80,13 +99,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -80,13 +99,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
async function fetchQ(q) {
async function fetchQ(q) {
const hit = cache.get(q);
const hit = cache.get(q);
- if (hit && Date.now() - hit.at < 60000) return hit.v;
+ if (hit && Date.now() - hit.at < keepMs) return hit.v;
if (ctl) ctl.abort();
if (ctl) ctl.abort();
ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
let v;
let v;
if (typeof src === 'function') v = await src(q, ctl && ctl.signal);
if (typeof src === 'function') v = await src(q, ctl && ctl.signal);
else {
else {
- const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '12');
+ const u = new URL(src, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', String(limit));
const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal: ctl && ctl.signal });
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}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
v = await r.json();
v = await r.json();
@@ -97,7 +116,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -97,7 +116,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
}
}
function place() {
function place() {
if (!pop || pop.hidden) return;
if (!pop || pop.hidden) return;
- const r = input.getBoundingClientRect();
+ const r = (o.anchor && o.anchor.isConnected ? o.anchor : input).getBoundingClientRect();
const vh = root.innerHeight, vw = root.innerWidth;
const vh = root.innerHeight, vw = root.innerWidth;
const below = vh - r.bottom - 12, above = r.top - 12;
const below = vh - r.bottom - 12, above = r.top - 12;
const up = below < 220 && above > below;
const up = below < 220 && above > below;
@@ -144,7 +163,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -144,7 +163,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
d.setAttribute('role', 'option');
d.setAttribute('role', 'option');
d.setAttribute('aria-selected', 'false');
d.setAttribute('aria-selected', 'false');
const ic = el('span', 'ovcombo-ic');
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);
+ if (kind !== 'create' && item.image && /^(https?:\/\/|\/)/.test(String(item.image))) {
+ const im = el('img'); im.alt = ''; im.loading = 'lazy'; im.decoding = 'async'; im.referrerPolicy = 'no-referrer'; im.src = String(item.image);
+ ic.appendChild(im);
+ } else ic.innerHTML = kind === 'create' ? HOST_IC.plus : HOST_IC[item.icon] || HOST_IC[item.host] || (kind === 'recent' ? HOST_IC.recent : HOST_IC.repo);
const t = el('span', 'ovcombo-t');
const t = el('span', 'ovcombo-t');
t.appendChild(kind === 'create' ? el('b', null, item.label) : mark(String(item.label || item.url || ''), q));
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);
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);
@@ -187,18 +209,26 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -187,18 +209,26 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
if (any) { const h = el('p', 'ovcombo-sec', 'New'); h.setAttribute('role', 'presentation'); pop.appendChild(h); }
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'));
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.'));
+ if (!rows.length) {
+ const words = typeof o.none === 'function' ? o.none(q) : q ? `Nothing matches “${q}”. A full address works too.` : 'Nothing here yet. Paste an address, or type a name.';
+ if (words) pop.appendChild(el('div', 'ovcombo-none', words)); else { close(); return; }
+ }
place();
place();
- if (rows.length && q) select(0);
+ if (rows.length && q && o.selectFirst !== false) select(0);
+ if (typeof o.onRows === 'function') { try { o.onRows(rows.map((r) => r.item), q); } catch { /* the caller's */ } }
}
}
async function look(q) {
async function look(q) {
lastQ = q;
lastQ = q;
- if (!cache.has(q)) skeleton();
+ const hit = cache.get(q);
+ // Rows already showing stay while a quick answer comes (no flash of the skeleton on every key).
+ const sk = hit && Date.now() - hit.at < keepMs ? 0 : setTimeout(() => { if (lastQ === q) skeleton(); }, rows.length ? 180 : 0);
try {
try {
const v = await fetchQ(q);
const v = await fetchQ(q);
+ clearTimeout(sk);
if (lastQ !== q || document.activeElement !== input) return;
if (lastQ !== q || document.activeElement !== input) return;
paint(v, q);
paint(v, q);
} catch (e) {
} catch (e) {
+ clearTimeout(sk);
if (e && e.name === 'AbortError') return;
if (e && e.name === 'AbortError') return;
if (lastQ !== q) 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();
open(); pop.removeAttribute('aria-busy'); pop.replaceChildren(el('div', 'ovcombo-err', 'The list did not load. You can still paste an address.')); place();
@@ -206,7 +236,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -206,7 +236,10 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
}
}
async function choose(i) {
async function choose(i) {
const r = rows[i]; if (!r || creating) return;
const r = rows[i]; if (!r || creating) return;
- if (r.kind !== 'create') { put(r.item); return; }
+ if (r.kind !== 'create') {
+ if (typeof o.pick === 'function') { const q = input.value.trim(); close(); o.pick(r.item, q); return; }
+ put(r.item); return;
+ }
creating = true;
creating = true;
r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;
r.el.querySelector('b').textContent = `Creating “${r.item.name}”…`;
try {
try {
@@ -239,6 +272,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -239,6 +272,7 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
input.addEventListener('input', () => { if (input.__ovcomboPicked === input.value) return; input.__ovcomboPicked = null; clearTimeout(timer); timer = setTimeout(() => look(input.value.trim()), 160); });
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('blur', () => setTimeout(() => { if (document.activeElement !== input) close(); }, 120));
input.addEventListener('keydown', (e) => {
input.addEventListener('keydown', (e) => {
+ if (e.defaultPrevented) return; // a field built on this one handled the key (the search field's Tab)
const shown = pop && !pop.hidden && cur === api;
const shown = pop && !pop.hidden && cur === api;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.preventDefault();
@@ -246,12 +280,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
@@ -246,12 +280,13 @@ html[data-ov-motion=off] .ovcombo, html[data-ov-motion=off] .ovcombo-skel i, htm
if (!rows.length) 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);
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 === 'Enter' && shown && at >= 0) { e.preventDefault(); e.stopPropagation(); choose(at); }
+ else if (e.key === 'Enter' && typeof o.enter === 'function' && !e.isComposing) { e.preventDefault(); const q = input.value.trim(); close(); o.enter(q); }
else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === 'Escape' && shown) { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === 'Tab' && shown) close();
else if (e.key === 'Tab' && shown) close();
});
});
root.addEventListener('resize', place);
root.addEventListener('resize', place);
root.addEventListener('scroll', place, true);
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); } };
+ const api = { input, open: () => look(input.value.trim()), close, get shown() { return !!(pop && !pop.hidden && cur === api); }, destroy() { close(); input.__ovcombo = null; root.removeEventListener('resize', place); root.removeEventListener('scroll', place, true); } };
input.__ovcombo = api;
input.__ovcombo = api;
return api;
return api;
}
}
@@ -128,6 +128,13 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
@@ -128,6 +128,13 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
.ovh-head b { flex: 1; font-size: 16px; }
.ovh-head b { flex: 1; font-size: 16px; }
+/* 4.229.0: a place's head inside You (All apps, a folder): its name, its count and a quiet hint, coming in with the view. */
+.ovh-head-t { flex: 1; min-width: 0; display: grid; gap: 1px; animation: ovh-head-in .28s cubic-bezier(.2, .8, .2, 1) both; }
+.ovh-head-t b { display: inline-flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 800; letter-spacing: -.01em; color: var(--text-primary, #e6edf7); }
+.ovh-head-n { min-width: 24px; padding: 0 8px; box-sizing: border-box; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #93c5fd); font-size: 12.5px; font-weight: 800; line-height: 22px; text-align: center; font-variant-numeric: tabular-nums; }
+.ovh-head-t small { font-size: 12.5px; color: var(--text-muted, #8a96a8); }
+@keyframes ovh-head-in { from { opacity: 0; transform: translateX(-6px); } }
+html[data-ov-motion=off] .ovh-head-t { animation: none; }
.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; }
background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; }
@@ -595,6 +602,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -595,6 +602,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
}
}
// ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────
// ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────
+ /** 4.229.0: a card with no person behind it (Deals, Community, Report) wears its site's app icon (design rule 142),
+ * 40 px like a person's face beside it, the glyph filling it (the owner, 11-12: "icons to increase size"). */
+ function appFace(site) {
+ const s = h('span', 'ov-appicon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', '40'); s.setAttribute('aria-hidden', 'true');
+ return s;
+ }
function siteIcon(site, size) {
function siteIcon(site, size) {
const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
return s;
return s;
@@ -1083,10 +1096,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1083,10 +1096,15 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
function paintDrawer() {
function paintDrawer() {
el.textContent = '';
el.textContent = '';
- const head = backHead('All apps');
- const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
- head.querySelector('b').replaceWith(field);
+ // 4.229.0 (the owner, 11-12: "confusing"): one search. The menu's own pill reads "Find among 64 apps" and
+ // filters this list as you type (OpenVibeHome's filter()); the head is the place's name and its count.
+ const head = backHead('All apps', { count: L.byId.size, hint: typeof o.onView === 'function' ? 'Type above to find one' : '' });
el.append(head);
el.append(head);
+ let field = null;
+ if (typeof o.onView !== 'function') { // a host without the menu's pill keeps the box of its own
+ field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = `Find among ${L.byId.size} apps`; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
+ head.append(field);
+ }
const body = h('div');
const body = h('div');
el.append(body);
el.append(body);
const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
@@ -1125,16 +1143,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1125,16 +1143,23 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
});
});
icons();
icons();
};
};
- field.addEventListener('input', () => { state.filter = field.value; draw(); });
+ if (field) field.addEventListener('input', () => { state.filter = field.value; draw(); });
+ drawerDraw = draw;
draw();
draw();
}
}
+ let drawerDraw = null;
- function backHead(title) {
+ function backHead(title, { count = null, hint = '' } = {}) {
const head = h('div', 'ovh-head');
const head = h('div', 'ovh-head');
const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));
const back = h('button', 'ovh-back'); back.type = 'button'; back.append(svgIcon('back'));
back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');
back.setAttribute('aria-label', 'Back to your home'); back.setAttribute('data-ov-tip', 'Back to your home');
- back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
- head.append(back, h('b', '', title));
+ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; state.filter = ''; paint(); const b = el.querySelector('.ovh-allbtn'); if (b) b.focus(); });
+ const name = h('div', 'ovh-head-t');
+ const b = h('b', '', title);
+ if (count != null) b.append(h('span', 'ovh-head-n', String(count)));
+ name.append(b);
+ if (hint) name.append(h('small', '', hint));
+ head.append(back, name);
return head;
return head;
}
}
function sec(text, glyph, count) {
function sec(text, glyph, count) {
@@ -1146,7 +1171,11 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1146,7 +1171,11 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
return s;
return s;
}
}
- function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }
+ function paint() {
+ // The menu hears which view shows, so its pill can search inside it (All apps) or go back to searching OpenVibe.
+ if (typeof o.onView === 'function') { try { o.onView(state.view, { apps: L.byId.size }); } catch { /* the menu's own */ } }
+ if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome();
+ }
paint();
paint();
// Network's copy wins when it answers and differs.
// Network's copy wins when it answers and differs.
pullRemote(o).then((r) => {
pullRemote(o).then((r) => {
@@ -1160,6 +1189,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1160,6 +1189,12 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
});
});
return {
return {
repaint: paint,
repaint: paint,
+ /** 4.229.0: which view shows ('home', 'drawer' for All apps, 'folder'), and the pill's words for All apps. */
+ get view() { return state.view; },
+ apps: () => L.byId.size,
+ filter(q) { state.filter = String(q || ''); if (state.view === 'drawer' && drawerDraw) drawerDraw(); },
+ /** Show 'drawer' (All apps) or 'home' from outside: a menu address (you/apps) opens All apps. */
+ show(view) { if (view !== 'drawer' && view !== 'home') return; state.view = view; state.folder = null; state.filter = ''; paint(); },
/** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */
/** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */
update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },
update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },
};
};
@@ -1498,7 +1533,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1498,7 +1533,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const face = h('span', cls || 'ovfd-face');
const face = h('span', cls || 'ovfd-face');
const av = c.actor && safe(c.actor.avatar);
const av = c.actor && safe(c.actor.avatar);
if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
- else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 22)); }
+ else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
return face;
return face;
}
}
@@ -1597,7 +1632,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1597,7 +1632,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const av = c.actor && safe(c.actor.avatar);
const av = c.actor && safe(c.actor.avatar);
// No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".
// No person behind it (the network's trending pictures): the site's icon and the card's label, not a "?".
if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
if (av && /^https:/.test(av)) { const img = h('img'); img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; img.src = av; face.append(img); }
- else if (!c.actor) { face.classList.add('is-site'); face.style.setProperty('--ovh-h', String(hueOf(c.site || 'ov'))); face.append(siteIcon(c.site || 'ov', 20)); }
+ else if (!c.actor) { face.classList.add('is-site'); face.append(appFace(c.site || 'ov')); }
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
else face.textContent = String(c.actor.name || '?').charAt(0).toUpperCase();
const who = h('span', 'ovfd-who');
const who = h('span', 'ovfd-who');
const meta = h('small', '');
const meta = h('small', '');
@@ -1745,6 +1780,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1745,6 +1780,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
const sentinel = h('div', 'ovfd-more');
const sentinel = h('div', 'ovfd-more');
el.append(examples, resultHead, chips, list, sentinel);
el.append(examples, resultHead, chips, list, sentinel);
let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
+ // 4.229.0: a card already on the list is never drawn again (the owner, 11-12: "112 new deals" three times, one
+ // per page of a Deals poll; Network now names a burst once, and this keeps any repeat off the screen).
+ const shownIds = new Set();
let query = '', searchSeq = 0;
let query = '', searchSeq = 0;
const loaded = [];
const loaded = [];
const feedBase = `${o.frameBase || FRAME}/feed`;
const feedBase = `${o.frameBase || FRAME}/feed`;
@@ -1801,7 +1839,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1801,7 +1839,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
async function more(first) {
async function more(first) {
if (busy || done) return;
if (busy || done) return;
busy = true; const my = ++seq;
busy = true; const my = ++seq;
- if (first) { list.textContent = ''; skeletons(); } else skeletons();
+ if (first) { list.textContent = ''; shownIds.clear(); skeletons(); } else skeletons();
const j = await loadFeed(o, kind, cursor);
const j = await loadFeed(o, kind, cursor);
if (my !== seq) return;
if (my !== seq) return;
clearSkels();
clearSkels();
@@ -1821,7 +1859,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1821,7 +1859,7 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
// (no time of their own) sit between, under the day they were woven into.
// (no time of their own) sit between, under the day they were woven into.
const day = (x) => { const t = Date.parse(String((x && x.at) || '')); return Number.isFinite(t) ? new Date(t).toDateString() : ''; };
const day = (x) => { const t = Date.parse(String((x && x.at) || '')); return Number.isFinite(t) ? new Date(t).toDateString() : ''; };
const mark = (x) => { const d = day(x); if (!d || d === lastDay) return; flush(); lastDay = d; list.append(dayLine(Date.parse(String(x.at)))); };
const mark = (x) => { const d = day(x); if (!d || d === lastDay) return; flush(); lastDay = d; list.append(dayLine(Date.parse(String(x.at)))); };
- j.items.forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });
+ j.items.filter((x) => x && x.id && !shownIds.has(x.id) && shownIds.add(x.id)).forEach((x) => { mark(x); if (past(x) && !kind) { run.push(x); return; } flush(); const c = feedCard(x); if (c) list.append(c); });
flush();
flush();
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
cursor = j.next_cursor || null;
@@ -1879,9 +1917,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -1879,9 +1917,9 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
.ovfd-card{display:grid;gap:8px;padding:10px;border-radius:16px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none;min-width:0}
.ovfd-card{display:grid;gap:8px;padding:10px;border-radius:16px;background:color-mix(in srgb,var(--text-primary,#e8ecf3) 4%,transparent);box-shadow:inset 0 0 0 1px var(--border,rgba(255,255,255,.08));color:inherit;text-decoration:none;min-width:0}
@media (hover:hover){.ovfd-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}
@media (hover:hover){.ovfd-card:hover{background:color-mix(in srgb,var(--text-primary,#e8ecf3) 7%,transparent)}}
.ovfd-head{display:flex;align-items:center;gap:8px;min-width:0}
.ovfd-head{display:flex;align-items:center;gap:8px;min-width:0}
-.ovfd-face{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:13px}
+.ovfd-face{flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:50%;overflow:hidden;background:var(--accent,#3b82f6);color:#fff;font-weight:800;font-size:15px}
.ovfd-face img{width:100%;height:100%;object-fit:cover}
.ovfd-face img{width:100%;height:100%;object-fit:cover}
-.ovfd-face.is-site{border-radius:10px;background:linear-gradient(150deg,hsl(var(--ovh-h,215) 72% 58% / .34),hsl(var(--ovh-h,215) 70% 38% / .18))}.ovfd-face.is-site .ov-icon{color:hsl(var(--ovh-h,215) 90% 82%)}
+.ovfd-face.is-site{border-radius:30%;background:none;overflow:visible}.ovfd-face.is-site>.ov-appicon{--ova-size:40px}
.ovfd-who{min-width:0;display:grid}
.ovfd-who{min-width:0;display:grid}
.ovfd-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ovfd-who b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ovfd-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
.ovfd-who small{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted,#93a0b4)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+/**
+ * ov-searchbox.js (openvibe-shared 4.229.0): one search field for every site. The owner, 11-12: "improve the search
+ * field … autocomplete, dropdown, animated effects … a shared, universal, reusable search input component across all
+ * sites". Its list is the one dropdown (ov-combo.js, loaded from beside this file), so it reads and moves like every
+ * other list on OpenVibe.
+ *
+ * <form action="/search" role="search">
+ * <input name="q" data-ov-searchbox="/api/search/suggest" data-ov-examples="streams|clips|people">
+ * </form>
+ *
+ * Without script it is the site's own form. With it:
+ * - a pill: the search mark, the field, a clear button and, on a computer, a "/" key hint; its edge glows with focus;
+ * - the placeholder walks through the examples ("Search streams", "Search clips"…) while the field is empty and idle
+ * (Calm walks slower; motion Off keeps the first);
+ * - as you type, suggestions from the source: GET <source>?q=&limit=8 → { sections: [{ id, label, items: [{ label, url,
+ * sub, icon, image }] }] } (OVCombo's shape; same origin, the page's cookies); while the field is empty, the person's
+ * recent searches here (this browser only; data-ov-recent="off" keeps none);
+ * - the first suggestion that starts with what was typed shows the rest as a ghost: Tab, or → at the end, takes it;
+ * - Enter searches what was typed: the form's action with ?<name>=…, or the row the arrows chose (its url); "/" anywhere
+ * on the page (outside another field) puts the cursor in the first one; Esc closes the list, then clears the field.
+ *
+ * A site with its own data or its own search (an SPA):
+ * OVSearchBox.mount(input, { source: async (q, signal) => ({ sections }), examples: ['streams', 'clips'], scope,
+ * recent: true, onSearch(q, item) { … return false to stop the page from moving } }) → { input, set(q), focus(), destroy() }
+ * Events on the input: 'ov-search' { detail: { q, item } }, cancelable: a page that searches in place calls preventDefault.
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OVSearchBox) return;
+
+ // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
+ const SEARCHBOX_REV = { 'ov-combo.js': '8001a3b19c2f' };
+ const self = document.currentScript && document.currentScript.src;
+ const base = (self && /^(.*\/)ov-searchbox\.js/.exec(self)) ? /^(.*\/)ov-searchbox\.js/.exec(self)[1] : 'https://openvibe.network/shared/';
+
+ const svg = (inner, w = 18) => `<svg viewBox="0 0 24 24" width="${w}" height="${w}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
+ const IC = { search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>'), clear: svg('<path d="M7 7l10 10M17 7 7 17"/>', 16) };
+ const CSS = `.ovsb { position: relative; display: flex; align-items: center; gap: 4px; box-sizing: border-box; min-height: 44px; padding: 0 6px 0 12px; border-radius: 999px;
+ background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); color: var(--text-primary, #e6edf7);
+ transition: border-color .18s ease, box-shadow .25s ease, background-color .18s ease; }
+.ovsb:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, var(--border, rgba(255,255,255,.12))); }
+.ovsb.is-focus { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, var(--bg-secondary, #111826));
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), 0 8px 26px color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); }
+.ovsb-mark { flex: none; display: grid; place-items: center; width: 24px; height: 24px; color: var(--text-muted, #93a0b4); transition: color .18s ease, transform .3s cubic-bezier(.2, 1.4, .4, 1); }
+.ovsb.is-focus .ovsb-mark { color: var(--accent-light, var(--accent, #60a5fa)); transform: scale(1.08) rotate(-8deg); }
+.ovsb.is-busy .ovsb-mark { animation: ovsb-look 1s ease-in-out infinite; }
+.ovsb-field { position: relative; flex: 1; min-width: 0; display: grid; }
+.ovsb-field > input, .ovsb-ghost { grid-area: 1 / 1; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 10px 4px; border: 0; font: inherit; font-size: 16px; line-height: 22px; letter-spacing: inherit; }
+.ovsb-field > input { position: relative; z-index: 1; background: transparent; color: inherit; outline: none; -webkit-appearance: none; appearance: none; }
+.ovsb-field > input::-webkit-search-cancel-button, .ovsb-field > input::-webkit-search-decoration { -webkit-appearance: none; }
+.ovsb-field > input::placeholder { color: var(--text-muted, #93a0b4); opacity: 1; transition: opacity .22s ease, transform .22s ease; }
+.ovsb.is-swap .ovsb-field > input::placeholder { opacity: 0; }
+.ovsb-ghost { pointer-events: none; overflow: hidden; white-space: pre; color: transparent; }
+.ovsb-ghost i { font-style: normal; color: var(--text-muted, #93a0b4); opacity: .7; }
+.ovsb-clear, .ovsb-key { flex: none; display: grid; place-items: center; }
+.ovsb-clear { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; animation: ovsb-pop .18s cubic-bezier(.2, 1.4, .4, 1) both; }
+.ovsb-clear:hover, .ovsb-clear:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); outline: none; }
+.ovsb-clear[hidden], .ovsb-key[hidden] { display: none; }
+.ovsb-key { min-width: 22px; height: 22px; margin-right: 6px; padding: 0 6px; box-sizing: border-box; border-radius: 6px; border: 1px solid var(--border, rgba(255,255,255,.16));
+ font: 700 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text-muted, #93a0b4); }
+@media (hover: none), (pointer: coarse) { .ovsb-key { display: none; } }
+@keyframes ovsb-pop { from { opacity: 0; transform: scale(.6); } }
+@keyframes ovsb-look { 50% { transform: translateX(2px) rotate(-14deg); } }
+html[data-ov-motion=calm] .ovsb, html[data-ov-motion=calm] .ovsb-mark { transition-duration: .3s; }
+html[data-ov-motion=off] .ovsb, html[data-ov-motion=off] .ovsb-mark, html[data-ov-motion=off] .ovsb-field > input::placeholder { transition: none; }
+html[data-ov-motion=off] .ovsb-clear, html[data-ov-motion=off] .ovsb.is-busy .ovsb-mark { animation: none; }
+html[data-ov-motion=off] .ovsb.is-focus .ovsb-mark { transform: none; }`;
+
+ function styles() { if (document.getElementById('ovsb-styles')) return; const st = document.createElement('style'); st.id = 'ovsb-styles'; st.textContent = CSS; document.head.appendChild(st); }
+ function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }
+ const motion = () => document.documentElement.getAttribute('data-ov-motion') || '';
+ const editable = (t) => !!(t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)));
+
+ let comboWait = null;
+ function combo() {
+ if (root.OVCombo) return Promise.resolve(root.OVCombo);
+ if (comboWait) return comboWait;
+ comboWait = new Promise((resolve) => {
+ let s = document.querySelector('script[data-ov-combo-loader]');
+ if (!s) {
+ s = document.createElement('script');
+ s.src = `${base}ov-combo.js${SEARCHBOX_REV['ov-combo.js'] ? `?v=${SEARCHBOX_REV['ov-combo.js']}` : ''}`;
+ s.async = true; s.setAttribute('data-ov-combo-loader', '');
+ document.head.appendChild(s);
+ }
+ s.addEventListener('load', () => resolve(root.OVCombo || null));
+ s.addEventListener('error', () => resolve(null));
+ });
+ return comboWait;
+ }
+
+ const mounted = [];
+
+ function mount(input, opts) {
+ if (!input || input.__ovsb) return input && input.__ovsb;
+ styles();
+ const o = Object.assign({}, opts);
+ const form = input.form || null;
+ const name = input.name || 'q';
+ const srcAttr = input.getAttribute('data-ov-searchbox');
+ const source = o.source || (srcAttr && srcAttr !== 'true' && srcAttr !== '' ? srcAttr : null);
+ const examples = (o.examples || String(input.getAttribute('data-ov-examples') || '').split('|')).map((x) => String(x).trim()).filter(Boolean).slice(0, 12);
+ const keepRecent = o.recent !== false && input.getAttribute('data-ov-recent') !== 'off';
+ const scope = String(o.scope || input.getAttribute('data-ov-scope') || location.host).slice(0, 80);
+ const key = `ov-sb:${scope}`;
+ const words = input.getAttribute('placeholder') || 'Search';
+
+ // The pill around the field (the field itself stays where the page put it: its form, its name, its value).
+ const pill = el('div', 'ovsb');
+ const mark = el('span', 'ovsb-mark'); mark.innerHTML = IC.search;
+ const fieldBox = el('span', 'ovsb-field');
+ const ghost = el('span', 'ovsb-ghost'); ghost.setAttribute('aria-hidden', 'true');
+ const clear = el('button', 'ovsb-clear'); clear.type = 'button'; clear.innerHTML = IC.clear; clear.setAttribute('aria-label', 'Clear the search'); clear.hidden = !input.value;
+ const hint = el('kbd', 'ovsb-key'); hint.textContent = '/'; hint.title = 'Press / to search';
+ input.parentNode.insertBefore(pill, input);
+ fieldBox.append(input, ghost);
+ pill.append(mark, fieldBox, clear, hint);
+ if (input.type !== 'search') { try { input.type = 'search'; } catch { /* the type it had */ } }
+ input.setAttribute('autocomplete', 'off');
+ input.setAttribute('enterkeyhint', 'search');
+ input.setAttribute('spellcheck', 'false');
+ if (!input.getAttribute('aria-label')) input.setAttribute('aria-label', words);
+ if (form && !form.getAttribute('role')) form.setAttribute('role', 'search');
+
+ // Recent searches, in this browser, per site (or per scope a site names).
+ function recents() { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x).slice(0, 8) : []; } catch { return []; } }
+ function remember(q) {
+ if (!keepRecent || !q) return;
+ try { localStorage.setItem(key, JSON.stringify([q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8))); } catch { /* private mode */ }
+ }
+ function searchUrl(q) {
+ const action = form ? (form.getAttribute('action') || location.pathname) : (o.action || '/search');
+ const u = new URL(action, location.href);
+ u.searchParams.set(name, q);
+ return u.href;
+ }
+
+ // What the list shows: recent searches while empty; the source's answer while typing (recent ones that match lead).
+ const answers = new Map(); // the source's answers, a minute each (the recent searches are read fresh every time)
+ async function list(q, signal) {
+ const mine = recents();
+ if (!q) return { sections: mine.length ? [{ id: 'recent', label: 'Recent searches', items: mine.map((x) => ({ label: x, icon: 'recent', recent: true })) }] : [] };
+ const lq = q.toLowerCase();
+ const again = mine.filter((x) => x.toLowerCase().startsWith(lq) && x.toLowerCase() !== lq).slice(0, 3).map((x) => ({ label: x, icon: 'recent', recent: true }));
+ let sections = [];
+ const kept = answers.get(q);
+ const fresh = !!(kept && Date.now() - kept.at < 60000);
+ if (fresh) sections = kept.sections;
+ else if (typeof source === 'function') sections = ((await source(q, signal)) || {}).sections || [];
+ else if (source) {
+ const u = new URL(source, location.href); u.searchParams.set('q', q); u.searchParams.set('limit', '8');
+ pill.classList.add('is-busy');
+ try {
+ const r = await fetch(u.href, { credentials: 'same-origin', headers: { accept: 'application/json' }, signal });
+ if (r.ok) sections = ((await r.json()) || {}).sections || [];
+ } finally { pill.classList.remove('is-busy'); }
+ }
+ if (source && !fresh) { answers.set(q, { at: Date.now(), sections }); if (answers.size > 60) answers.delete(answers.keys().next().value); }
+ return { sections: [...(again.length ? [{ id: 'recent', label: 'Recent searches', items: again }] : []), ...(Array.isArray(sections) ? sections : [])] };
+ }
+
+ // The ghost: the rest of the first suggestion that starts with what was typed.
+ let rest = '';
+ function paintGhost() {
+ const v = input.value;
+ ghost.textContent = '';
+ if (!rest || !v || document.activeElement !== input || input.selectionEnd !== v.length) return;
+ ghost.append(v, Object.assign(el('i'), { textContent: rest }));
+ }
+ function suggest(items, q) {
+ const lq = q.toLowerCase();
+ const first = q && items.map((it) => String(it.label || '')).find((l) => l.toLowerCase().startsWith(lq) && l.length > q.length);
+ rest = first ? first.slice(q.length) : '';
+ paintGhost();
+ }
+ function takeGhost() {
+ if (!rest) return false;
+ input.value += rest; rest = ''; paintGhost();
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ return true;
+ }
+
+ function go(q, item) {
+ q = String(q || '').trim();
+ if (!q && !(item && item.url)) return;
+ if (q && !(item && item.url && !item.recent)) remember(q);
+ const ev = new CustomEvent('ov-search', { bubbles: true, cancelable: true, detail: { q, item: item || null } });
+ const stay = input.dispatchEvent(ev) === false || (typeof o.onSearch === 'function' && o.onSearch(q, item || null) === false);
+ if (stay) return;
+ location.assign(item && item.url ? new URL(item.url, location.href).href : searchUrl(q));
+ }
+
+ // The examples walk: one every 2.8 s (5 s in Calm), only while the field is empty, idle and on screen.
+ let ex = 0, walk = 0;
+ function setWords(i) { input.setAttribute('placeholder', examples.length ? `Search ${examples[i % examples.length]}` : words); }
+ function walkOn() {
+ clearInterval(walk);
+ if (examples.length < 2 || motion() === 'off') return;
+ walk = setInterval(() => {
+ if (input.value || document.activeElement === input || document.hidden) return;
+ pill.classList.add('is-swap');
+ setTimeout(() => { ex++; setWords(ex); pill.classList.remove('is-swap'); }, 220);
+ }, motion() === 'calm' ? 5000 : 2800);
+ }
+ if (examples.length) setWords(0);
+ walkOn();
+
+ let comboApi = null;
+ combo().then((C) => {
+ if (!C || !input.isConnected) return;
+ comboApi = C.enhance(input, {
+ source: list, limit: 8, selectFirst: false, cacheMs: 0, anchor: pill,
+ none: (q) => (q && source ? `Press Enter to search for “${q}”` : ''),
+ pick: (item, q) => { if (item.recent) { input.value = item.label; go(item.label, null); } else go(q || item.label, item); },
+ enter: (q) => go(q, null),
+ onRows: suggest,
+ });
+ });
+
+ input.addEventListener('focus', () => { pill.classList.add('is-focus'); hint.hidden = true; });
+ input.addEventListener('blur', () => { pill.classList.remove('is-focus'); hint.hidden = !!input.value; rest = ''; paintGhost(); });
+ input.addEventListener('input', () => { clear.hidden = !input.value; hint.hidden = !!input.value || document.activeElement === input; rest = rest && input.value ? rest : ''; paintGhost(); });
+ input.addEventListener('keydown', (e) => {
+ if ((e.key === 'Tab' && !e.shiftKey && rest) || (e.key === 'ArrowRight' && rest && input.selectionEnd === input.value.length)) {
+ if (takeGhost()) e.preventDefault();
+ } else if (e.key === 'Escape' && !(comboApi && comboApi.shown) && input.value) {
+ e.preventDefault(); input.value = ''; clear.hidden = true; rest = ''; paintGhost();
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ } else if (e.key === 'Enter' && !comboApi) { e.preventDefault(); go(input.value, null); }
+ });
+ input.addEventListener('keyup', paintGhost);
+ input.addEventListener('click', paintGhost);
+ clear.addEventListener('click', () => { input.value = ''; clear.hidden = true; rest = ''; paintGhost(); input.focus(); input.dispatchEvent(new Event('input', { bubbles: true })); });
+ if (form) form.addEventListener('submit', (e) => { e.preventDefault(); go(input.value, null); });
+ pill.addEventListener('mousedown', (e) => { if (e.target === pill || e.target === mark) { e.preventDefault(); input.focus(); } });
+
+ const api = {
+ input,
+ set(q) { input.value = String(q || ''); clear.hidden = !input.value; rest = ''; paintGhost(); },
+ focus() { input.focus(); },
+ destroy() {
+ clearInterval(walk);
+ if (comboApi) comboApi.destroy();
+ pill.parentNode.insertBefore(input, pill); pill.remove();
+ input.__ovsb = null;
+ const i = mounted.indexOf(api); if (i >= 0) mounted.splice(i, 1);
+ },
+ };
+ input.__ovsb = api;
+ mounted.push(api);
+ return api;
+ }
+
+ // "/" puts the cursor in the page's first search field that shows (never while typing somewhere else).
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || editable(e.target)) return;
+ const box = mounted.find((m) => m.input.isConnected && m.input.getClientRects().length);
+ if (!box) return;
+ e.preventDefault();
+ box.focus();
+ });
+
+ function scan(scope) { (scope || document).querySelectorAll('input[data-ov-searchbox]').forEach((i) => mount(i)); }
+ root.OVSearchBox = { mount, scan };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scan(), { once: true }); else scan();
+ document.addEventListener('ov:boost:load', () => scan());
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': 'ac7107f5d733', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': 'ac7107f5d733', 'ov-combo.js': '8001a3b19c2f' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.228.0",
+ "version": "4.229.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.228.0",
+ "version": "4.229.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.228.0",
+ "version": "4.229.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": {
@@ -150,6 +150,7 @@
@@ -150,6 +150,7 @@
"./agent-sheet": "./agent-sheet.js",
"./agent-sheet": "./agent-sheet.js",
"./ov-float": "./ov-float.js",
"./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-combo": "./ov-combo.js",
+ "./ov-searchbox": "./ov-searchbox.js",
"./ov-composer": "./ov-composer.js",
"./ov-composer": "./ov-composer.js",
"./ov-run": "./ov-run.js",
"./ov-run": "./ov-run.js",
"./ov-capture": "./ov-capture.js",
"./ov-capture": "./ov-capture.js",
@@ -32,7 +32,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -32,7 +32,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'ov-arrange.js': { name: 'ARRANGE_CORE_REV', files: ['ov-arrange-core.js'] },
'ov-arrange.js': { name: 'ARRANGE_CORE_REV', files: ['ov-arrange-core.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-capture.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.js'] } };
+ 'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'ov-thread.js': { name: 'THREAD_REV', files: ['ov-composer.js', 'ov-combo.js'] }, 'ov-searchbox.js': { name: 'SEARCHBOX_REV', files: ['ov-combo.js'] }, 'ov-composer.js': { name: 'COMPOSER_REV', files: ['ov-window.js', 'ov-capture.js'] }, 'ov-user.js': { name: 'USER_REV', files: ['ov-window.js'] }, 'ov-wallet.js': { name: 'WALLET_REV', files: ['ov-window.js'] }, 'ov-window.js': { name: 'WINDOW_REV', files: ['ov-float.js'] }, 'dock-vibe.js': { name: 'VIBE_REV', files: ['ov-composer.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] }, 'dock-actor.js': { name: 'ACTOR_REV', files: ['ov-window.js', 'agent-sheet.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)) {
@@ -526,7 +526,9 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -526,7 +526,9 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
.ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }
.ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }
.ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }
.ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }
/* A ring leaves the orb as its sheet opens (4.186.0; the Dock's sheet does the same). */
/* A ring leaves the orb as its sheet opens (4.186.0; the Dock's sheet does the same). */
- .ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
+ /* 4.229.0: the ring is invisible at rest, so where animations are off (a phone's "Remove animations", reduced motion)
+ it never stays as a white circle around the x (the owner, 11-12: "why is it white"). */
+ .ovs.ovs-anchored .ovs-orb.is-x::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--ovo-a); pointer-events: none; opacity: 0; animation: ovs-ring .6s cubic-bezier(.2,.8,.2,1) .1s both; }
@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
@keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
.ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
/* 4.213.0: when the pressed button flew to its tab, the close turns in at the pill once it has left (a quarter turn,
/* 4.213.0: when the pressed button flew to its tab, the close turns in at the pill once it has left (a quarter turn,
@@ -692,6 +694,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -692,6 +694,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
}
}
}
}
const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
+ /** 4.229.0 (the owner, 11-12: "fix the history back button so it properly brings you back to this GUI"): the menu's
+ * entry remembers where it was when a link took the person away, and Back reopens it there (navbar.js restoreMenu). */
+ function markBack() {
+ try {
+ const back = { tab, door: door || null, scroll: listEl ? Math.round(listEl.scrollTop) : 0, at: Date.now() };
+ history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false, ovsBack: back }), '');
+ } catch { /* a page that cannot keep state: Back shows the page */ }
+ }
function remember(it) {
function remember(it) {
if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }
if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }
const q = input && input.value.trim();
const q = input && input.value.trim();
@@ -798,12 +808,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -798,12 +808,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {
if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {
const H = root.OpenVibeHome; if (!H) return;
const H = root.OpenVibeHome; if (!H) return;
const kept = homeViews[g.as];
const kept = homeViews[g.as];
- if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }
+ if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); if (g.as === 'home') drawerIfAsked(kept.api); return; }
const li = document.createElement('li'); li.setAttribute('role', 'presentation');
const li = document.createElement('li'); li.setAttribute('role', 'presentation');
let api = null;
let api = null;
try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }
try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }
homeViews[g.as] = { li, api };
homeViews[g.as] = { li, api };
listEl.appendChild(li);
listEl.appendChild(li);
+ if (g.as === 'home') drawerIfAsked(api);
return;
return;
}
}
if (g.as === 'route') {
if (g.as === 'route') {
@@ -1604,6 +1615,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -1604,6 +1615,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
}
}
const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
/** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */
/** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */
+ /** 4.229.0: You's home tells the menu which view shows: All apps makes the pill its finder ("Find among 64 apps"),
+ * and leaving it gives the pill back to searching OpenVibe. */
+ function homeView(view, info) {
+ if (!input || tab !== 'you') return;
+ if (view === 'drawer') { input.placeholder = `Find among ${(info && info.apps) || ''} apps`.replace(' ', ' '); input.value = ''; return; }
+ if (/^Find among /.test(input.placeholder)) { input.placeholder = `Search ${here()}, or ask Vibe`; input.value = ''; }
+ }
function homeOpts() {
function homeOpts() {
const { all, ranked, f } = basis();
const { all, ranked, f } = basis();
const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);
const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);
@@ -1620,7 +1638,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -1620,7 +1638,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
const seenB = new Set();
const seenB = new Set();
const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 60).map(backRow) : [];
const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 60).map(backRow) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
- return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
+ return { onView: homeView, apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,
token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,
// You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.
// You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.
notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },
notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },
@@ -1668,6 +1686,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -1668,6 +1686,14 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
if (door === 'vibe') return;
if (door === 'vibe') return;
+ // All apps open under You: the pill finds an app there, in place (4.229.0; one search, not a second box).
+ const homeApi = homeViews.home && homeViews.home.api;
+ if (tab === 'you' && homeApi && homeApi.view === 'drawer') {
+ tabsEl.hidden = false;
+ input.placeholder = `Find among ${homeApi.apps()} apps`;
+ homeApi.filter(q);
+ return;
+ }
if (tab === 'feed' && homeLib()) {
if (tab === 'feed' && homeLib()) {
tabsEl.hidden = false;
tabsEl.hidden = false;
input.placeholder = 'Search the feed';
input.placeholder = 'Search the feed';
@@ -1950,6 +1976,11 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -1950,6 +1976,11 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
fly(from, to, { fromFace: flyIdle });
fly(from, to, { fromFace: flyIdle });
}
}
+ function drawerIfAsked(api) {
+ if (!wantDrawer || !api || typeof api.show !== 'function') return;
+ wantDrawer = false;
+ try { api.show('drawer'); } catch { /* the home screen stays */ }
+ }
function open(opts = {}) {
function open(opts = {}) {
homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
@@ -1975,7 +2006,16 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -1975,7 +2006,16 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
tickAgo();
tickAgo();
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// history entry; Back pops it and only closes the menu.
// history entry; Back pops it and only closes the menu.
- try { if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; } } catch { pushed = false; }
+ try {
+ if (opts.restore) { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: true, ovsBack: null }), ''); pushed = true; }
+ else if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; }
+ } catch { pushed = false; }
+ if (opts.restore && opts.restore.scroll > 0) {
+ // Its parts arrive a moment later (the home screen, the wall): the scroll is put back as they land.
+ const want = opts.restore.scroll;
+ const put = () => { if (listEl && listEl.scrollTop < want) listEl.scrollTop = want; };
+ requestAnimationFrame(put); setTimeout(put, 250); setTimeout(put, 700);
+ }
// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
const at = opts.anchor;
const at = opts.anchor;
el.classList.toggle('ovs-anchored', !!at);
el.classList.toggle('ovs-anchored', !!at);
@@ -2015,7 +2055,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -2015,7 +2055,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
paintGhost();
paintGhost();
render(input.value);
render(input.value);
if (flyFrom && !input.value) requestAnimationFrame(flyIn);
if (flyFrom && !input.value) requestAnimationFrame(flyIn);
- if (opts.section) setTimeout(() => { const section = listEl.querySelector(`[data-sec="${opts.section}"]`); if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); }, 80);
+ // All apps is a view of You's home screen: it opens as the home screen arrives (4.229.0, you/apps).
+ wantDrawer = opts.section === 'apps';
+ const sec = opts.section === 'apps' ? 'home' : opts.section;
+ if (sec) setTimeout(() => { const section = listEl.querySelector(`[data-sec="${sec}"]`); if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); }, 80);
// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
// On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
// 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
@@ -2031,7 +2074,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -2031,7 +2074,13 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
}
}
// ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened
// ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened
// again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).
// again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).
- let pushed = false, popping = false, surface = null, ownBack = false;
+ let pushed = false, popping = false, surface = null, ownBack = false, wantDrawer = false;
+ if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
+ if (ownBack) return;
+ if (!(el && el.classList.contains('is-open'))) {
+ try { if (history.state && history.state.ovsBack) document.dispatchEvent(new CustomEvent('openvibe-menu-restore')); } catch { /* */ }
+ }
+ });
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (ownBack) { ownBack = false; return; } if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
function close({ navigating = false } = {}) {
function close({ navigating = false } = {}) {
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
// The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
@@ -2041,7 +2090,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -2041,7 +2090,7 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
leaveDoor();
leaveDoor();
if (!el || closing) return;
if (!el || closing) return;
- if (navigating) pushed = false;
+ if (navigating) { pushed = false; markBack(); }
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }
if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
// A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+// Menu addresses (4.229.0; the owner, 11-12: "other tools and buttons can open specific areas of the menu"), in a real
+// headless Chrome: any element with data-ov-menu="you/notifications" opens the menu there on a click; a page address
+// ending #ov-menu=… opens it as the page loads and the hash goes; OpenVibeNavbar.menuAddress reads an address the same
+// way. A link with an address still opens elsewhere with a modifier key. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const ROOT = path.join(__dirname, '..');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('menu-address-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };
+const LINKS = [{ label: 'Home', href: '/' }, { label: 'Other page', href: '/other' }];
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Addr</title><link rel="icon" href="data:,">
+<script>
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
+<main><button id="bell" type="button" data-ov-menu="you/notifications">Notifications</button> <a id="feed" href="/feed-page" data-ov-menu="feed">Feed</a> <a id="hashlink" href="#ov-menu=you">You</a></main>
+<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false }); });</script>
+</body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css))$/.exec(u.pathname);
+ if (m) {
+ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'content-type': m[1].endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
+ }
+ if (u.pathname === '/feed-page') { res.writeHead(204); return res.end(); }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);
+});
+
+(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-addr-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { let v = null; try { v = await p.evaluate(expr); } catch { v = null; } if (v || Date.now() > end) return v; await wait(50); } };
+ const openTab = `(document.querySelector('.ovs.is-open .ovs-tab[data-tab][aria-selected="true"]') || {}).getAttribute ? document.querySelector('.ovs.is-open .ovs-tab[data-tab][aria-selected="true"]').getAttribute('data-tab') : ''`;
+ const closeMenu = `(window.OpenVibeSearch && OpenVibeSearch.close && OpenVibeSearch.close(), true)`;
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
+
+ // An address in the page's own address opens the menu as it loads, and the hash goes.
+ await p.goto(`${base}/#ov-menu=you/notifications`, { maxMs: 5000 });
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 8000), '#ov-menu opens the menu as the page loads');
+ assert.strictEqual(await until(p, openTab), 'you', 'on You');
+ assert.strictEqual(await p.evaluate('location.hash'), '', 'and the address no longer carries it');
+ await p.evaluate(closeMenu);
+ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');
+
+ // The API reads addresses the same way.
+ const read = await p.evaluate(`JSON.stringify(['you/notifications', 'notifications', 'vibe', 'you/apps', 'feed', 'nonsense', 'here/live'].map((a) => OpenVibeNavbar.menuAddress(a)))`);
+ assert.deepStrictEqual(JSON.parse(read), [
+ { tab: 'you', part: 'notifications' }, { tab: 'you', part: 'notifications' }, { door: 'vibe' }, { tab: 'you', part: 'apps' },
+ { tab: 'feed', part: null }, { tab: 'here', part: null }, { tab: 'here', part: 'live' },
+ ]);
+
+ // A button with an address opens the menu there.
+ await p.evaluate(`document.getElementById('bell').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'data-ov-menu opens the menu');
+ assert.strictEqual(await until(p, openTab), 'you', 'you/notifications opens You');
+ await p.evaluate(closeMenu);
+ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');
+
+ // A link with an address opens the menu instead of following its href…
+ await p.evaluate(`document.getElementById('feed').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'a link with data-ov-menu opens the menu');
+ assert.strictEqual(await until(p, openTab), 'feed', 'on the Feed');
+ assert.strictEqual(await p.evaluate('location.pathname'), '/', 'and stays on the page');
+ await p.evaluate(closeMenu);
+ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');
+ // …but a modified click is the browser's (a new tab), and the menu stays shut.
+ const prevented = await p.evaluate(`(() => { const e = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }); let seen = null; document.addEventListener('click', (x) => { seen = x.defaultPrevented; x.preventDefault(); }, { once: true }); document.getElementById('feed').dispatchEvent(e); return seen; })()`);
+ // Not prevented means the bar's handler returned before it opened anything (it prevents first, then opens).
+ assert.strictEqual(prevented, false, 'a ctrl-click on a link with an address is left to the browser, and opens no menu');
+
+ // A plain link to #ov-menu=… on the same page opens it too (hashchange).
+ await p.evaluate(`document.getElementById('hashlink').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open')`, 5000), 'a #ov-menu link on the page opens the menu');
+ assert.strictEqual(await until(p, openTab), 'you', 'on You');
+ await p.close();
+ console.log('menu-address-chrome: #ov-menu on load and on the page, data-ov-menu on a button and a link, modifier clicks left alone, menuAddress');
+ } 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,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+// Back to the menu (4.229.0; the owner, 11-12: "fix the history back button so it properly brings you back to this GUI
+// rather than the page behind it"), in a real headless Chrome: a page left from the menu, then Back, opens the menu again
+// where it was, on the same history entry (Forward still works); closing it then leaves the page as it was; a site's
+// in-place move (Boost) comes back the same way. Built on menu-navigate-chrome's page.
+//
+// Changing pages from the menu (owner, 11-09: "when you switch pages… it doesn't update the navigation that's highlighted…
+// the menu should animate closed when you change pages… a progress indicator at the very top… a glow"), in a real
+// headless Chrome:
+// • a page chosen in the menu closes it, and Back is not spent on the menu during the move (a site's in-place move,
+// like Boost's, keeps its own history entry);
+// • a menu still open when a page is swapped in place marks the new page;
+// • a link that leaves the page shows the top bar at once, while the next page loads; a click a page handled itself
+// (defaultPrevented) draws nothing.
+// Skipped without Chrome.
+const assert = require('assert');
+const { eventually } = require('../browser-harness'); // a check retries until the page is there (5 s ceiling)
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const ROOT = path.join(__dirname, '..');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('menu-back-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const FRAME = { nav: [{ id: 'chat', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', group: 'talk' }], groups: [{ id: 'talk', name: 'Talk' }], soon: [], footer: {} };
+const LINKS = [{ label: 'Home', href: '/' }, { label: 'Camera', href: '/camera' }, { label: 'Other page', href: '/other' }];
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Nav</title><link rel="icon" href="data:,">
+<script>
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame?') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : String(u).startsWith('https://') ? Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) : realFetch(u, o));
+ // A stand-in for Boost: same-site links to /camera are swapped in place.
+ document.addEventListener('click', (e) => {
+ const a = e.target.closest && e.target.closest('a[href]');
+ if (!a || new URL(a.href).pathname !== '/camera') return;
+ e.preventDefault();
+ history.pushState({}, '', '/camera');
+ document.dispatchEvent(new CustomEvent('ov:boost:load'));
+ });
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div><main><a id="plain" href="/stay">A plain link</a> <a id="handled" href="/elsewhere">Handled</a></main>
+<script>document.getElementById('handled').addEventListener('click', (e) => e.preventDefault());
+document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: ${JSON.stringify(LINKS)}, countViews: false, notifications: false });
+ setTimeout(function () { window.addEventListener('click', function (e) { if (e.target && e.target.id === 'plain') { window.__left = true; e.preventDefault(); } }); }, 0); });</script>
+</body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
+ if (m) {
+ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
+ }
+ if (u.pathname === '/stay') { res.writeHead(204); return res.end(); } // no content: the browser stays on the page
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE);
+});
+
+(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-back-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, ms = 5000) => { const end = Date.now() + ms; for (;;) { let v = null; try { v = await p.evaluate(expr); } catch { v = null; } if (v || Date.now() > end) return v; await wait(50); } };
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
+
+ // Leave from the menu by a page that loads: Back opens the menu again.
+ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened');
+ await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Other page').click(), true`);
+ assert.ok(await until(p, `location.pathname === '/other' && !!document.getElementById('openvibe-burger')`, 8000), 'the other page loaded');
+ await p.evaluate(`history.back(), true`);
+ assert.ok(await until(p, `location.pathname === '/' && !!document.querySelector('.ovs.is-open')`, 8000), 'Back opens the menu again, not the page behind it');
+ assert.strictEqual(await p.evaluate(`!!(history.state && history.state.ovsMenu)`), true, 'on its own history entry');
+ assert.strictEqual(await p.evaluate(`!!(history.state && history.state.ovsBack)`), false, 'and the mark is used up');
+ // Closing it now leaves the page as it was (one Back, taken by the menu).
+ await p.evaluate(`(document.querySelector('.ovs.is-open [data-close], .ovs.is-open .ovs-orb, .ovs.is-open .ovs-x') || { click() {} }).click(), true`);
+ assert.ok(await until(p, `!document.querySelector('.ovs.is-open')`, 3000), 'it closes');
+ await eventually(async () => assert.strictEqual(await p.evaluate('location.pathname'), '/', 'still on the page it was left from'));
+ // Forward still leads to the page that was left to.
+ await p.evaluate(`history.forward(), true`);
+ assert.ok(await until(p, `location.pathname === '/other'`, 8000) || await until(p, `location.pathname === '/'`, 1000), 'Forward is not lost');
+
+ // A move in place (Boost): Back on the same page opens the menu again too.
+ await p.goto(`${base}/`, { maxMs: 5000 });
+ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up again');
+ await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-links')`), 'the menu opened');
+ await p.evaluate(`[...document.querySelectorAll('.ovs-links .ovs-link')].find((a) => a.textContent === 'Camera').click(), true`);
+ assert.ok(await until(p, `location.pathname === '/camera' && !document.querySelector('.ovs.is-open')`, 3000), 'moved in place, the menu closed');
+ await p.evaluate(`history.back(), true`);
+ assert.ok(await until(p, `location.pathname === '/' && !!document.querySelector('.ovs.is-open')`, 5000), 'Back opens the menu again on the same page');
+ await p.close();
+ console.log('menu-back-chrome: Back to the menu after a page load and after an in-place move, its own entry, close and Forward');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -147,10 +147,20 @@ const server = http.createServer((req, res) => {
@@ -147,10 +147,20 @@ const server = http.createServer((req, res) => {
// 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`);
- assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');
+ assert.ok(await until(p, `!!document.querySelector('.ovh-head-t b') && document.querySelector('.ovh-head-t b').textContent.startsWith('All apps')`), 'the drawer opened, named, with its count');
+ // 4.229.0: one search. No second box: the menu's own pill finds an app in All apps.
+ assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-filter').length`), 0, 'no second search box');
+ assert.match(await p.evaluate(`document.querySelector('.ovs-input').placeholder`), /^Find among \d+ apps$/, 'the pill says what it finds now');
+ const allN = await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`);
+ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'zzzz-no-such-app'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`), 0, 'typing in the pill filters the apps'));
+ assert.ok(await p.evaluate(`!!document.querySelector('.ovh-head-t')`), 'and the apps stay in place (not search results)');
+ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await eventually(async () => assert.strictEqual(await p.evaluate(`document.querySelectorAll('.ovh-grid .ovh-app').length`), allN, 'clearing it shows them all'));
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);
await p.evaluate(`document.querySelector('.ovh-back').click(), true`);
await p.evaluate(`document.querySelector('.ovh-back').click(), true`);
assert.ok((await order(p)).includes(keyed[0]), 'All apps put it back on Home');
assert.ok((await order(p)).includes(keyed[0]), 'All apps put it back on Home');
+ assert.doesNotMatch(await p.evaluate(`document.querySelector('.ovs-input').placeholder`), /^Find among/, 'back on Home, the pill searches OpenVibe again');
// The Tools folder: tools with their own icons, and the count of all.
// The Tools folder: tools with their own icons, and the count of all.
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);
await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
const ICONS = require('../nav-icons');
-const NAVBAR_BROTLI_BUDGET = 50.5 * 1024; // 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
+const NAVBAR_BROTLI_BUDGET = 52 * 1024; // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
// ── size budget and generated block ──────────────────────────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+// The one search field (ov-searchbox.js, 4.229.0; the owner, 11-12: "autocomplete, dropdown, animated effects … a shared,
+// universal, reusable search input component") in a real headless Chrome against a stub source: the field becomes a pill
+// that loads the one dropdown from beside itself; the placeholder names the first example; typing asks the source and the
+// first suggestion's rest shows as a ghost that Tab takes; Enter searches the typed words (a cancelable ov-search), the
+// arrows and Enter go to a row's page; the search is remembered and shows while the field is empty; "/" puts the cursor
+// in it; Escape closes the list, then clears; the clear button empties it.
+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-searchbox-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const ITEMS = [
+ { label: 'streams today', url: '/s/today', icon: 'live', sub: '12 live now' },
+ { label: 'stream keys', url: '/s/keys', icon: 'page' },
+];
+const asked = [];
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const file = /^\/shared\/(ov-searchbox|ov-combo)\.js$/.exec(u.pathname);
+ if (file) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, `${file[1]}.js`))); }
+ if (u.pathname === '/suggest') {
+ const q = (u.searchParams.get('q') || '').toLowerCase();
+ asked.push([q, u.searchParams.get('limit')]);
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ sections: [{ id: 'top', label: 'Top', items: ITEMS.filter((x) => x.label.includes(q)) }] }));
+ }
+ 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" action="/search" style="padding:40px 16px"><input id="q" name="q" placeholder="Search" data-ov-searchbox="/suggest" data-ov-examples="streams|clips"></form>
+<p id="else">Elsewhere</p>
+<script>window.searches = []; document.addEventListener('ov-search', (e) => { window.searches.push({ q: e.detail.q, url: e.detail.item && e.detail.item.url }); e.preventDefault(); });</script>
+<script src="/shared/ov-searchbox.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-searchbox-'));
+ 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, extra = {}) => { const codes = { Enter: 13, ArrowDown: 40, Escape: 27, Tab: 9, '/': 191 }; const ev = { key: k, code: k === '/' ? 'Slash' : k, windowsVirtualKeyCode: codes[k], nativeVirtualKeyCode: codes[k], ...extra }; await page.send('Input.dispatchKeyEvent', { type: 'keyDown', ...ev }); await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...ev }); };
+
+ assert.ok(await until(`!!(document.querySelector('.ovsb #q') && window.OVCombo)`), 'the field sits in the pill, and the one dropdown loaded from beside it');
+ assert.equal(await page.evaluate(`document.getElementById('q').placeholder`), 'Search streams', 'the first example names the placeholder');
+ assert.equal(await page.evaluate(`document.getElementById('q').getAttribute('role')`), 'combobox');
+
+ // "/" from the page puts the cursor in it.
+ await page.evaluate(`document.activeElement && document.activeElement.blur(), true`);
+ await key('/', { text: '/' });
+ assert.ok(await until(`document.activeElement && document.activeElement.id === 'q'`), '"/" focuses the search field');
+ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'and types no slash');
+ assert.ok(await page.evaluate(`document.querySelector('.ovsb').classList.contains('is-focus')`), 'the pill shows its focus');
+
+ // Typing asks the source; the first match's rest is a ghost; Tab takes it.
+ await page.send('Input.insertText', { text: 'str' });
+ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].length === 2`), 'two suggestions');
+ assert.deepEqual(asked.at(-1), ['str', '8']);
+ assert.ok(await until(`document.querySelector('.ovsb-ghost i') && document.querySelector('.ovsb-ghost i').textContent === 'eams today'`), 'the ghost shows the rest of the first suggestion');
+ assert.equal(await page.evaluate(`document.querySelector('.ovcombo-i[aria-selected="true"]')`), null, 'no row is chosen ahead of the arrows');
+ await key('Tab');
+ assert.equal(await page.evaluate(`document.getElementById('q').value`), 'streams today', 'Tab takes the ghost');
+ assert.ok(await until(`document.activeElement && document.activeElement.id === 'q'`), 'and the cursor stays');
+
+ // Enter searches the typed words.
+ await key('Enter');
+ assert.ok(await until(`window.searches.length === 1`), 'Enter searches');
+ assert.deepEqual(await page.evaluate(`window.searches[0]`), { q: 'streams today', url: null });
+
+ // The arrows and Enter go to a row's page.
+ await page.evaluate(`(() => { const i = document.getElementById('q'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await page.send('Input.insertText', { text: 'key' });
+ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].some((r) => r.textContent.includes('stream keys'))`), 'stream keys suggested');
+ await key('ArrowDown');
+ await key('Enter');
+ assert.ok(await until(`window.searches.length === 2`), 'a chosen row searches');
+ assert.deepEqual(await page.evaluate(`window.searches[1]`), { q: 'key', url: '/s/keys' });
+
+ // Escape closes the list, then clears the field.
+ await page.evaluate(`document.getElementById('q').focus(), true`);
+ await page.send('Input.insertText', { text: 's' });
+ assert.ok(await until(`!!document.querySelector('.ovcombo:not([hidden]) .ovcombo-i')`), 'the list is open');
+ await key('Escape');
+ assert.ok(await until(`!document.querySelector('.ovcombo:not([hidden])')`), 'Escape closes the list');
+ assert.notEqual(await page.evaluate(`document.getElementById('q').value`), '', 'and keeps the words');
+ await key('Escape');
+ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'a second Escape clears');
+
+ // The search is remembered: the empty field shows it.
+ await page.evaluate(`document.getElementById('else').click(), document.activeElement.blur(), true`);
+ await page.evaluate(`document.getElementById('q').focus(), true`);
+ assert.ok(await until(`[...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-sec')].some((s) => s.textContent === 'Recent searches') && [...document.querySelectorAll('.ovcombo:not([hidden]) .ovcombo-i')].some((r) => r.textContent.includes('streams today'))`), 'recent searches while empty');
+
+ // The clear button.
+ await page.send('Input.insertText', { text: 'abc' });
+ assert.equal(await page.evaluate(`document.querySelector('.ovsb-clear').hidden`), false, 'clear shows with words');
+ await page.evaluate(`document.querySelector('.ovsb-clear').click(), true`);
+ assert.equal(await page.evaluate(`document.getElementById('q').value`), '', 'clear empties the field');
+ assert.equal(await page.evaluate(`document.querySelector('.ovsb-clear').hidden`), true);
+
+ // No page overflow at phone width.
+ assert.ok(await page.evaluate(`document.documentElement.scrollWidth <= innerWidth`), 'no sideways scroll at 390 px');
+ await page.close();
+ console.log('ov-searchbox-chrome: the pill, the dropdown from beside it, an example placeholder, "/", the ghost and Tab, Enter, a chosen row, Escape twice, recent searches, clear');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });