openvibers/openvibe.shared search.js

search.js

main 183.1 KB · 2214 lines · javascript

1/**
2 * openvibe-shared/search.js — the OpenVibe menu: search, every site and every tool in one panel, opened by the navbar's
3 * magnifier, "/" or Ctrl+K (4.23.0). The navbar loads this file the first time someone asks.
4 *
5 * It used to be two panels side by side, the launcher (the dots: every site) and search (the magnifier), which "kind
6 * of accomplish the same exact thing" (the owner, 2026-10-28: "combine… what are the most important things… a better
7 * hierarchy… use this user's history… input history and autocomplete… to give them better results… maybe use tabs").
8 *
9 * Before typing, three tabs:
10 * For you the places this person went lately anywhere on OpenVibe (their Network history, signed in), this site's
11 * pages, their recent searches, and their sites (most used by them, else most used by everyone: the Frame's
12 * analytics ranking).
13 * Sites every OpenVibe site by what people come to do (the Frame's groups), the one you are on marked.
14 * Tools the tool families and the most used tools (OpenVibe.Tools' catalog).
15 * Typing makes one list: sites and tools to go to, this site's results and pages, the person's own history, the rest of
16 * OpenVibe.Search (sites they use most first), their recent searches, and "search everywhere" / "Ask Vibe". The box
17 * completes what they type from their own searches, history and the site names (Tab or → takes it).
18 *
19 * Enter shows every result in the panel (4.29.0, the Search Fabric, plan 2026-10-28 §4.5), unless a row was picked with the
20 * arrows or the text is a place's exact name (live, tools): tabs All · OpenVibe · Web · Video · News · Products (· Yours,
21 * signed in). OpenVibe's results come at once (Network's GET /api/frame/query, scopes network[,mine], depth quick); the
22 * web's arrive on their own (scope web) with skeleton rows meanwhile, each saying where it came from ("via Wikipedia").
23 * A line at the end says what was searched and how long it took. The person's token goes along, so their own results
24 * and the paid web providers are theirs; the query is never written into the page as markup.
25 *
26 * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, tab, frame, token, apiBase, user,
27 * suggestUrl, icons }) navbar.js calls it
28 * OpenVibeSearch.close()
29 * OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box
30 * (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the
31 * page's connect-src allows it; both answer any origin)
32 *
33 * Suggestions come through OpenVibe.Network's GET /api/frame/suggest (OpenVibe.Search, kept 30 s): every site's
34 * Content-Security-Policy lets the navbar reach openvibe.network. History is GET /api/history (the person's own token,
35 * read once a panel opening, kept two minutes). Text is always set with textContent; links are https or same-site paths
36 * only. Recent searches live in localStorage (ov_search_recent, eight at most) and can be cleared from the panel.
37 */
38(function (root) {
39 'use strict';
40 const API = 'https://search.openvibe.network';
41 const SUGGEST = 'https://openvibe.network/api/frame/suggest';
42 const CATALOG_URL = 'https://openvibe.network/api/catalog.json';
43 const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
44 const RECENT_KEY = 'ov_search_recent';
45 const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
46 const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
47 // Until the Frame answers (or when it cannot): the network's main sites and the tool families.
48 const FALLBACK_SITES = [
49 { id: 'live', name: 'Live', desc: 'Go live, watch streams', icon: 'live', url: 'https://openvibe.live/' },
50 { id: 'chat', name: 'Chat', desc: 'Rooms, servers and messages', icon: 'chat', url: 'https://openvibe.chat/' },
51 { id: 'games', name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
52 { id: 'tools', name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
53 { id: 'community', name: 'Community', desc: 'Forum, pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
54 { id: 'network', name: 'Network', desc: 'Your account and themes', icon: 'network', url: 'https://openvibe.network/' },
55 ];
56 const FALLBACK_FAMILIES = [
57 { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
58 { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
59 { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
60 { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
61 { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
62 { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
63 { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
64 ];
65 // The menu's three tabs (the owner, 11-08: "we should have tabs at the top … a feed tab … remember whichever tab the
66 // user last had open … the main tab should just show links that exist on that site, and under that the full list
67 // of sites"). The first is labelled with this site's own name.
68 const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
69 // When the Feed was last looked at, for its "new since" count (11-09, the owner: "if there's anything new in the feed
70 // since last time you've looked… a badge").
71 const FEED_SEEN = 'ovs-feed-seen';
72 const FEED_COUNT = 'ovs-feed-count';
73 const feedSeen = () => { try { return Number(localStorage.getItem(FEED_SEEN)) || 0; } catch { return 0; } };
74 function paintFeedDot(count) {
75 const orb = document.getElementById('openvibe-burger'); if (!orb) return;
76 let dot = orb.querySelector('.ovs-feed-dot');
77 if (!dot) { dot = document.createElement('i'); dot.className = 'ovs-feed-dot'; dot.setAttribute('aria-hidden', 'true'); orb.appendChild(dot); }
78 dot.hidden = !(Number(count) > 0);
79 }
80 function paintFeedTabCount(count) {
81 if (!el || !el.classList.contains('is-open') || !tabsEl) return;
82 const b = tabsEl.querySelector('[data-tab="feed"]'); if (!b) return;
83 const old = b.querySelector('.ovs-tabnew'); if (old) old.remove();
84 const n = Number(count) || 0;
85 if (n) { const badge = document.createElement('b'); badge.className = 'ovs-tabnew'; badge.textContent = n > 9 ? '9+' : String(n); badge.setAttribute('aria-label', `${n} new since you last looked`); (b.querySelector('.ovs-tab-ic') || b).appendChild(badge); }
86 }
87 let feedChannel;
88 try { if (typeof BroadcastChannel === 'function') { feedChannel = new BroadcastChannel('openvibe-feed-seen'); feedChannel.onmessage = (e) => { const n = Number(e.data && e.data.count) || 0; paintFeedDot(n); paintFeedTabCount(n); }; } } catch { feedChannel = null; }
89 if (typeof window !== 'undefined') window.addEventListener('storage', (e) => { if (e.key === FEED_COUNT) { paintFeedDot(e.newValue); paintFeedTabCount(e.newValue); } if (e.key === FEED_SEEN) { paintFeedDot(0); paintFeedTabCount(0); } });
90 const markFeedSeen = () => {
91 try { localStorage.setItem(FEED_SEEN, String(Date.now())); } catch { /* signed out private window */ }
92 try { localStorage.setItem(FEED_COUNT, '0'); } catch { /* */ }
93 paintFeedDot(0); paintFeedTabCount(0);
94 try { feedChannel?.postMessage({ type: 'seen', count: 0, at: feedSeen() }); } catch { /* */ }
95 };
96 let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
97 let tab = 'here', mine = null, catalog = null, completion = '';
98 // The results view (4.29.0): Enter shows the Search Fabric's answer here, by scope, instead of leaving the page.
99 const RTABS = [['all', 'All'], ['openvibe', 'OpenVibe'], ['web', 'Web'], ['video', 'Video'], ['news', 'News'], ['products', 'Products'], ['mine', 'Yours']];
100 const PROVIDER = { open_index: 'OpenVibe\'s index', deepseek: 'DeepSeek', wikipedia: 'Wikipedia', brave: 'Brave', exa: 'Exa', tavily: 'Tavily' };
101 let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;
102 // A place of the Dock shown inside the menu (a phone, 312 step 2): which one, its mount, and AI's part.
103 let door = null, doorEl = null, doorHost = null, doorMount = null, runView = null, runFilter = 'all';
104 let actsEl = null, actsObs = null, actsTick = 0;
105
106 const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
107 function siteOf(url) {
108 try {
109 const h = new URL(url).hostname.replace(/^www\./, '');
110 if (SITE[h]) return SITE[h];
111 const m = /^(?:([a-z0-9-]+)\.)?openvibe\.([a-z]+)$/.exec(h);
112 if (m) return m[1] ? m[1][0].toUpperCase() + m[1].slice(1) : m[2][0].toUpperCase() + m[2].slice(1);
113 return h;
114 } catch { return ''; }
115 }
116 const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
117 const recent = {
118 get() { try { return (JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') || []).filter((x) => typeof x === 'string').slice(0, 8); } catch { return []; } },
119 add(q) { try { const list = [q, ...recent.get().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } },
120 clear() { try { localStorage.removeItem(RECENT_KEY); } catch { /* */ } },
121 };
122
123 async function suggest(q, { owner = null, limit = 8, signal, url = SUGGEST } = {}) {
124 const u = new URL(url || SUGGEST);
125 u.searchParams.set('q', String(q).slice(0, 100));
126 if (owner) u.searchParams.set('owner', owner);
127 u.searchParams.set('limit', String(limit));
128 const res = await fetch(u, { signal, credentials: 'omit', headers: { accept: 'application/json' } });
129 if (!res.ok) return [];
130 const d = await res.json();
131 return (d.suggestions || []).map((s) => ({ owner: s.owner, type: s.type, id: s.id, title: String(s.title || '').slice(0, 160), url: safeUrl(s.canonical_url), site: siteOf(s.canonical_url) })).filter((s) => s.url && s.title);
132 }
133
134 // ── What the panel knows ─────────────────────────────────
135 /** Every open site from the Frame (most used across OpenVibe first), else the main ones. */
136 function sites() {
137 const f = ctx.frame;
138 if (f && Array.isArray(f.nav) && f.nav.length) return f.nav.map((n) => ({ id: n.id, name: n.name, desc: String(n.tagline || '').replace(/\.\s*$/, ''), icon: n.icon || n.id, url: safeUrl(n.url), group: n.group })).filter((s) => s.url);
139 return FALLBACK_SITES;
140 }
141 /** The site this page belongs to: the longest matching host (play.openvibe.network is Play, not Network). */
142 // The site this page is on: the longest matching address, and a site that lives under a path (openvibe.my/inventory)
143 // only on that path.
144 function hereSite(list) {
145 const h = location.hostname.toLowerCase(), at = location.pathname || '/'; let best = null, score = 0;
146 for (const s of list) {
147 let u; try { u = new URL(s.url); } catch { continue; }
148 const sh = u.hostname, sp = u.pathname.replace(/\/+$/, '');
149 if (!sh || !(h === sh || h.endsWith(`.${sh}`))) continue;
150 if (sp && at !== sp && !at.startsWith(`${sp}/`)) continue;
151 const n = sh.length * 1000 + sp.length + 1;
152 if (n > score) { best = s; score = n; }
153 }
154 return best;
155 }
156 /** The person's own use of each site (their history's counts), for "your sites" and for ranking results. */
157 const useOf = (id) => { const s = mine && mine.services.find((x) => x.service === id); return s ? s.count : 0; };
158
159 /** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */
160 async function loadHistory() {
161 if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;
162 const key = `ov_hist2:${ctx.user.id || ctx.user.username || ''}`;
163 try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }
164 try {
165 const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
166 if (!r.ok) return null;
167 const j = await r.json();
168 const data = {
169 items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: utcOf(h.updated_at) })).filter((h) => h.url && h.title),
170 services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),
171 };
172 try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
173 return data;
174 } catch { return null; }
175 }
176 /** OpenVibe.Tools' catalog (families and tools), kept 30 minutes; the same copy the navbar used to keep. */
177 async function loadCatalog() {
178 try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
179 try {
180 const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
181 const j = await r.json();
182 const ok = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
183 const data = {
184 families: (j.families || []).slice(0, 12).map((f) => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: ok(f.url) })).filter((f) => f.name && f.url),
185 tools: (j.tools || []).slice(0, 400).map((t) => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: ok(t.url), go: t.hosts && t.hosts.short ? ok(`https://${t.hosts.short}/`) : null,
186 hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter((h) => typeof h === 'string' && h), id: String(t.id || ''),
187 desc: String(t.tagline || '').slice(0, 120), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter((t) => t.name && t.url),
188 };
189 try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
190 return data;
191 } catch { return null; }
192 }
193
194 // Times that keep up while the menu is open (once a minute; nothing runs while it is closed).
195 let agoTimer = 0;
196 function tickAgo() {
197 clearInterval(agoTimer);
198 agoTimer = setInterval(() => {
199 if (!el || !el.classList.contains('is-open')) { clearInterval(agoTimer); agoTimer = 0; return; }
200 el.querySelectorAll('[data-at]').forEach((m) => { const t = ago(m.dataset.at); if (t) m.textContent = [m.dataset.pre, t].filter(Boolean).join(' · '); });
201 }, 60000);
202 }
203 /** A time without a zone is UTC (Network's history and older APIs send "YYYY-MM-DD HH:MM:SS"; a browser reads that as
204 * local time, and west of UTC every visit looked hours ahead: "just now" for all of them, 11-12). */
205 function utcOf(t) { const v = String(t || ''); return /^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v; }
206 function ago(iso) {
207 const t = Date.parse(utcOf(iso)); if (!isFinite(t)) return '';
208 const m = Math.max(0, Math.round((Date.now() - t) / 60000));
209 return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
210 }
211
212 // ── Drawing ──────────────────────────────────────────────
213 function styles() {
214 if (document.getElementById('openvibe-search-styles')) return;
215 const st = document.createElement('style');
216 st.id = 'openvibe-search-styles';
217 st.textContent = `
218.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
219.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
220/* The button becomes its tab (4.200.0): the flight layer above everything, its parts drawn from the two buttons. */
221.ovs-fly { position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; }
222.ovs-fly-part { position: absolute; display: grid; place-items: center; color: inherit; will-change: transform, opacity, filter; }
223.ovs-fly-ring { position: fixed; z-index: 2147483600; border: 2px solid; border-radius: 50%; pointer-events: none; }
224.ovs-fly-part > svg, .ovs-fly-part > .ov-icon { width: 100%; height: 100%; }
225.ovs-fly-part > img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
226.ovs-fly-part--count { width: auto !important; height: auto !important; }
227.ovs-fly-count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: #ef4444; color: #fff; font: 800 11px/1 system-ui, sans-serif; }
228.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
229.ovs-bar { display: flex; align-items: center; gap: 10px; margin: 10px 10px 6px; padding: 0 6px 0 14px; border-radius: 999px; background: var(--bg-primary, rgba(0,0,0,.25)); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.12)); transition: box-shadow .2s, background .2s; }
230.ovs-bar:focus-within { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
231/* A site's own focus ring (the orange box on openvibe.download, 10-29) never draws on the field: the pill shows focus. */
232.ovs .ovs-input, .ovs .ovs-input:focus, .ovs .ovs-input:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; background: transparent !important; }
233.ovs-bar > svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }
234.ovs-field { position: relative; flex: 1; min-width: 0; display: flex; }
235.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }
236.ovs-input { position: relative; z-index: 1; flex: 1; min-width: 0; border: 0; outline: 0; padding: 0; background: transparent; color: inherit; }
237.ovs-input::-webkit-search-cancel-button { -webkit-appearance: none; }
238.ovs-ghost { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; overflow: hidden; pointer-events: none; color: var(--text-muted, #93a0b4); opacity: .75; }
239.ovs-ghost b { color: transparent; font-weight: inherit; }
240.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
241.ovs-x svg { display: none; width: 20px; height: 20px; }
242.ovs-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
243/* 4.223.0 (the owner, 11-12: "Close … ICON"; "these buttons and borders … get extra wide … make that more sleek"): Close
244 is a round × on every screen; the phone's orb keeps a hairline edge and a soft glass, never a thick ring. */
245.ovs-x { display: inline-grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 50%; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s, transform .12s; }
246.ovs-x svg { display: block; width: 18px; height: 18px; }
247.ovs-x span { display: none; } /* the button's own aria-label (Close the menu) names it */
248.ovs-x:active { transform: scale(.9); }
249@media (hover: hover) { .ovs-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); } }
250@media (max-width: 640px) {
251 .ovs.ovs-anchored .ovs-orb { background: color-mix(in srgb, var(--bg-secondary, #121826) 72%, transparent); -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4);
252 box-shadow: 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 14%, transparent), 0 8px 22px -10px rgba(0, 0, 0, .6); }
253 .ovs.ovs-anchored .ovs-orb.is-x::before { border-width: 1px; }
254}
255/* Phones and small windows: the menu covers the screen (the bar under it too), so Close is always there, a round x at
256 the end of the search pill (the owner, 11-07: "This needs a close button"). The hit area is 48 px. */
257@media (max-width: 860px) {
258 .ovs-x { position: relative; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 50%; background: var(--bg-hover, rgba(255,255,255,.07)); color: var(--text-secondary, #a8b3c4); }
259 .ovs-x::after { content: ""; position: absolute; inset: -4px; }
260 .ovs-x svg { display: block; }
261 .ovs-x span { display: none; }
262 .ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
263}
264.ovs-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
265/* Five places in one row (4.194.0): on a phone each is its icon over its word; the doors to the Dock look like the tabs. */
266.ovs-starttabs.has-doors .ovs-tab { padding: 0 6px; }
267.ovs-starttabs .ovs-tab[data-dock] .ovs-tab-ic > svg { width: 20px; height: 20px; }
268@media (max-width: 640px) { .ovs-starttabs.has-doors { gap: 4px; padding: 2px 8px 8px; } .ovs-starttabs.has-doors .ovs-tab { flex-direction: column; gap: 3px; height: 54px; padding: 4px 2px; font-size: 11.5px; border-radius: 14px; } }
269.ovs-bridge { flex: 1 0 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; margin-top: 4px; }
270.ovs-bridge-b { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; min-height: 44px; padding: 0 6px; border-radius: 12px; cursor: pointer; font-size: 12.5px; font-weight: 650;
271 color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); transition: background-color .15s, color .15s, transform .12s; }
272.ovs-bridge-b span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
273.ovs-bridge-i { display: grid; place-items: center; flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
274.ovs-bridge-b:hover, .ovs-bridge-b:focus-visible { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
275.ovs-bridge-b:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
276.ovs-bridge-b:active { transform: scale(.96); }
277@media (max-width: 640px) { .ovs-bridge-b { flex-direction: column; gap: 2px; min-height: 50px; padding: 4px 2px; font-size: 11.5px; } .ovs-bridge-i svg { width: 19px; height: 19px; } }
278/* The menu's three tabs (4.149.0): one segmented row under the field; the chosen one in the accent. */
279.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }
280.ovs-starttabs .ovs-tab { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 38px; padding: 0 10px; border-radius: 12px; font-size: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
281.ovs-starttabs .ovs-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
282.ovs-starttabs .ovs-tab { height: 42px; gap: 8px; transition: background-color .18s, color .18s; }
283.ovs-starttabs .ovs-tab .ovs-tab-ic { position: relative; flex: none; display: grid; place-items: center; width: 22px; height: 22px; overflow: visible; }
284.ovs-tab-ic > svg { width: 20px; height: 20px; }
285.ovs-tab-ic .ov-icon { display: grid; place-items: center; }
286/* 4.236.0, the tab's nudge: this site's tab rolls its glyph and name through the places in its section, and back. */
287.ovs-tab .ovs-roll { animation: ovsRoll .38s cubic-bezier(.2, .9, .25, 1); }
288@keyframes ovsRoll { from { opacity: 0; transform: translateY(65%); } 60% { opacity: 1; } }
289.ovs-tab.is-rolling > span:not(.ovs-tab-ic) { color: var(--accent-light, var(--accent, #60a5fa)); transition: color .3s; }
290html[data-ov-motion=off] .ovs-tab .ovs-roll { animation: none; }
291/* The site's tab wears its glyph like Feed and You (owner, 11-09): no disc and no ring, the glyph at their weight and in
292 the label's colour. The icon's box is drawn larger than the slot so the solid glyph itself is about 18 px. */
293.ovs-tab-ic .ov-icon .ovi-bg, .ovs-tab-ic .ov-icon .ovi-ring, .ovs-tab-ic .ov-icon .ovi-comet, .ovs-tab-ic .ov-icon .ovi-prog { display: none; }
294.ovs-tab-ic .ov-icon { --ovi-glyph: currentColor; color: currentColor; width: 22px; height: 22px; overflow: visible; }
295.ovs-tab-ic .ov-icon svg { width: 40px; height: 40px; margin: -9px; flex: none; }
296@media (max-width: 420px) { .ovs-starttabs .ovs-tab { padding: 0 7px; gap: 6px; font-size: 13.5px; } }
297.ovs-tab-av { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; display: block; box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--text-primary, #e6edf7) 18%, transparent); }
298.ovs-tab[aria-selected="true"] .ovs-tab-av { box-shadow: 0 0 0 2px var(--accent, #3b82f6); }
299.ovs-tab-on { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border-radius: 50%; background: var(--success, #22c55e); box-shadow: 0 0 0 2px var(--bg-secondary, #111826); }
300.ovs-tabnew { flex: none; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box; border-radius: 999px; font: 750 11px/18px Inter, system-ui, sans-serif; letter-spacing: -.01em; text-align: center; font-variant-numeric: tabular-nums; color: #fff; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24), 0 3px 8px -2px rgba(0, 0, 0, .55); animation: ovsBadgeIn .3s cubic-bezier(.34, 1.56, .64, 1); background: var(--accent, #3b82f6); }
301.ovs-tabnew-red { background: var(--danger, #ef4444); }
302.openvibe-navbar .ovnav-orb .ovs-feed-dot { position: absolute; top: 2px; right: 2px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent-light, var(--accent, #60a5fa)); }
303.openvibe-navbar .ovnav-orb .ovs-feed-dot[hidden] { display: none; }
304.ovs-tabdot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--live-red, #ef4444); box-shadow: 0 0 0 3px color-mix(in srgb, var(--live-red, #ef4444) 22%, transparent); }
305/* 4.237.0: one badge for every count in the menu (the You tab's, Feed's, Notifications'), on the icon's corner; the live
306 dot after the word, breathing. */
307.ovs-tab-ic > .ovs-tabnew { position: absolute; top: -7px; left: calc(100% - 8px); z-index: 1; }
308.ovs-tab span > .ovs-tabdot { display: inline-block; margin: 0 0 1px 5px; vertical-align: middle; animation: ovsLiveDot 2s ease-in-out infinite; }
309.ovs-tab > span:not(.ovs-tab-ic) { display: inline-flex; align-items: center; }
310@keyframes ovsLiveDot { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--live-red, #ef4444) 10%, transparent); } }
311@keyframes ovsBadgeIn { from { transform: scale(.5); opacity: 0; } }
312html[data-ov-motion=off] .ovs-tab span > .ovs-tabdot, html[data-ov-motion=off] .ovs-tabnew { animation: none; }
313.ovs-grid.is-mini { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; padding: 0 2px 4px; }
314.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }
315.ovs-grid.is-mini .ovs-ic { width: 26px; height: 26px; border-radius: 8px; }
316.ovs-grid.is-mini .ovs-tile b { font-size: 13.5px; font-weight: 650; }
317/* 4.189.0: the sites in their groups are full touch targets with a larger mark, and each group's name runs into a
318 hairline, so the list reads as sections and not as one long column of small rings. */
319.ovs-grid.is-mini { gap: 4px; }
320.ovs-grid.is-mini .ovs-tile { min-height: 48px; padding: 6px 8px; border-radius: 12px; transition: background-color .15s, transform .12s; }
321/* 4.222.0: a site's whole name, never "Downlo…" or "Commu…": a long name takes a second line instead of an ellipsis. */
322.ovs-grid.is-mini .ovs-tile b { white-space: normal; text-overflow: clip; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.15; overflow-wrap: anywhere; }
323.ovs-grid.is-mini .ovs-tile:active { transform: scale(.97); }
324.ovs-grid.is-mini .ovs-ic { width: 32px; height: 32px; border-radius: 10px; }
325/* 4.202.0: on a phone a site's name sits under its mark, so the whole name shows (Download, Community: not "Downlo…"). */
326@media (max-width: 480px) { .ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: center; gap: 5px; min-height: 64px; padding: 6px 2px 5px; text-align: center; } .ovs-grid.is-mini .ovs-tile b { max-width: 100%; font-size: 12.5px; } }
327.ovs-group[data-sec="all"] { display: flex; align-items: center; gap: 10px; }
328/* 4.223.0 (the owner, 11-12: "the icons are so small, they're hard to see … make the icons bigger so they actually take up
329 more of the square … fix the alignment"): a site's icon is an app icon in every tab — its own hue on a rounded square,
330 the glyph at 58% of it, placed absolutely so no hover transform can move it off centre. */
331.ovs .ovs-ic.ovs-site { position: relative; overflow: hidden; border-radius: 30%; background: linear-gradient(150deg, hsl(var(--ovs-h, 215) 72% 58% / .36), hsl(var(--ovs-h, 215) 70% 38% / .2)); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 70% 64% / .3); }
332.ovs .ovs-ic.ovs-site .ovi-ring, .ovs-ic.ovs-site .ovi-bg, .ovs-ic.ovs-site .ovi-comet, .ovs-ic.ovs-site .ovi-prog { display: none; }
333.ovs .ovs-ic.ovs-site > .ov-icon { position: absolute; inset: 0; width: 100%; height: 100%; display: block; --ovi-glyph: hsl(var(--ovs-h, 215) 92% 84%); color: hsl(var(--ovs-h, 215) 92% 84%); }
334:root[data-theme="light"] .ovs .ovs-ic.ovs-site > .ov-icon { --ovi-glyph: hsl(var(--ovs-h, 215) 70% 30%); color: hsl(var(--ovs-h, 215) 70% 30%); }
335.ovs .ovs-ic.ovs-site > .ov-icon svg { position: absolute; left: -13%; top: -13%; width: 126%; height: 126%; margin: 0; transform: none !important; }
336/* The site list: a grid of app icons, four across on a phone and as many as fit beyond, names on two balanced lines. */
337.ovs-grid.is-mini { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 6px; }
338@media (min-width: 560px) { .ovs-grid.is-mini { grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); } }
339.ovs-grid.is-mini .ovs-tile { flex-direction: column; justify-content: flex-start; align-items: center; gap: 7px; min-height: 0; padding: 6px 0 8px; border-radius: 16px; text-align: center; background: transparent; }
340.ovs-grid.is-mini .ovs-tile > span:last-child { max-width: 100%; }
341.ovs-grid.is-mini .ovs-ic { width: min(100% - 14px, 64px); height: auto; aspect-ratio: 1; border-radius: 29%; transition: transform .22s cubic-bezier(.2, 1.4, .4, 1), box-shadow .22s; }
342.ovs-grid.is-mini .ovs-tile b { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; white-space: normal; text-overflow: clip; font-size: 12px; font-weight: 650; line-height: 1.2; text-wrap: balance; }
343.ovs-grid.is-mini .ovs-tile.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
344.ovs-grid.is-mini .ovs-tile:active .ovs-ic { transform: scale(.93); transition-duration: .08s; }
345@media (hover: hover) { .ovs-grid.is-mini .ovs-tile:hover { background: transparent; } .ovs-grid.is-mini .ovs-tile:hover .ovs-ic { transform: translateY(-3px) scale(1.04); box-shadow: inset 0 0 0 1px hsl(var(--ovs-h, 215) 75% 70% / .5), 0 12px 26px -8px hsl(var(--ovs-h, 215) 75% 45% / .6); } }
346html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }
347/* Each section of sites wears its glyph. */
348.ovs-group-ic { order: -1; flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
349.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
350/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UI
351 face): the site list, the tabs, Vibe's switch. */
352.ovs-grid.is-mini .ovs-tile b, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }
353.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }
354.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }
355/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */
356/* 4.223.1: the panel clips its light (overflow: clip is never a scroll container; hidden was, and a focus inside it
357 scrolled the whole panel up, taking the search field and the tabs off the screen). */
358.ovs-panel { isolation: isolate; overflow: clip; }
359.ovs-panel::before { content: ""; position: absolute; inset: -20%; z-index: -1; pointer-events: none; opacity: .5;
360 background: radial-gradient(38% 30% at 20% 10%, color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent), transparent 70%), radial-gradient(34% 28% at 85% 30%, color-mix(in srgb, #a855f7 16%, transparent), transparent 70%), radial-gradient(40% 32% at 50% 95%, color-mix(in srgb, #14b8a6 12%, transparent), transparent 70%);
361 animation: ovs-drift 26s ease-in-out infinite alternate; }
362@keyframes ovs-drift { from { transform: translate3d(-3%, -2%, 0) rotate(0deg); } to { transform: translate3d(3%, 2%, 0) rotate(8deg); } }
363html[data-ov-motion=reduced] .ovs-panel::before { animation-duration: 60s; }
364html[data-ov-motion=off] .ovs-panel::before { animation: none; }
365:root[data-theme="light"] .ovs-panel::before { opacity: .35; }
366.ovs-group[data-sec="all"]::after { content: ""; flex: 1; height: 1px; background: var(--border, rgba(255,255,255,.1)); }
367.ovs-tabs[hidden] { display: none; }
368.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
369.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
370.ovs-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
371.ovs-typing .ovs-bar { border-bottom: 0; }
372/* Opening, the groups rise in one after another (not while typing: every key redraws the list). */
373.ovs:not(.ovs-typing) .ovs-list > li { animation: ovs-rise .32s cubic-bezier(.2, 1.2, .3, 1) both; }
374.ovs-list > li:nth-child(2) { animation-delay: 25ms; } .ovs-list > li:nth-child(3) { animation-delay: 50ms; } .ovs-list > li:nth-child(4) { animation-delay: 75ms; }
375.ovs-list > li:nth-child(5) { animation-delay: 100ms; } .ovs-list > li:nth-child(6) { animation-delay: 125ms; } .ovs-list > li:nth-child(n+7) { animation-delay: 150ms; }
376@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
377.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
378/* A place of the Dock inside the menu (312 step 2, a phone): it fills the sheet under the head and scrolls itself. */
379.ovs-door { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
380.ovs-door[hidden] { display: none; }
381.ovs-door-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
382/* A place's actions in the search pill (4.200.0); the panel's own row of them is hidden inside the menu. */
383.ovs-acts { display: flex; flex: none; gap: 2px; }
384.ovs-acts:empty, .ovs-door-host .ovdk-bar { display: none; }
385/* One UI (4.220.0; the owner, 11-12: "part of the one UI instead of having two different areas"): inside the menu a place has
386 no head of its own. A conversation keeps a slim way back and its name; its actions are the search pill's; its mark is the
387 greeting's; no band, no border. */
388.ovs-door-host .ovdk-thread-h { min-height: 40px; padding: 2px 12px 2px 6px; gap: 4px; border-bottom: 0; }
389.ovs-door-host .ovdk-thread-h > .ovdk-ib, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-mark, .ovs-door-host .ovdk-vibe-name small { display: none; }
390.ovs-door-host .ovdk-thread-h .ovdk-back, .ovs-door-host .ovdk-vibe-h .ovdk-vibe-back { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
391.ovs-door-host .ovdk-thread-h b, .ovs-door-host .ovdk-vibe-name b { font-size: 14px; font-weight: 700; color: var(--text-secondary, #b4c0d4); }
392.ovs-door-host .ovdk-vibe-start { padding-top: 2px; }
393
394.ovs-act { width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary, #b6b6c0); cursor: pointer; }
395.ovs-act svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; }
396.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }
397.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
398.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
399/* 4.225.0: Vibe's runs fill the place under the menu's head (ov-run.js keeps its own filters, thread and box). */
400.ovs-door-host > .ovr { flex: 1 1 auto; min-height: 0; padding: 8px 10px 10px; box-sizing: border-box; }
401.ovs-door-wait { margin: 18px 16px; font-size: 14px; color: var(--text-muted, #93a0b4); }
402@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { display: none; } }
403.ovs-in-door .ovs-foot { display: none; }
404.ovs-group { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
405.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
406.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
407.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }
408/* A tap leaves :hover on what it touched: rows light on hover only where a pointer really hovers. */
409@media (hover: hover) { .ovs-item a:hover, .ovs-chip:hover, .ovs-tile:hover, .ovs-x:hover, .ovs-tab:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } }
410.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }
411.ovs-ic > svg { width: 16px; height: 16px; }
412.ovs-ic.ovs-site { background: none; }
413.ovs-thumb { flex: none; width: 72px; height: 40px; border-radius: 9px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); }
414.ovs-thumb--ico { display: grid; place-items: center; }
415.ovs .ov-icon:empty { border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
416.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
417.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }
418.ovs-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
419.ovs-key { flex: none; font-size: 11px; color: var(--text-muted, #93a0b4); }
420.ovs-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 8px 6px; }
421.ovs-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; height: 34px; padding: 0 13px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 600; box-sizing: border-box; }
422.ovs-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
423.ovs-chip svg { width: 14px; height: 14px; flex: none; color: var(--text-muted, #93a0b4); }
424.ovs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; padding: 2px 4px 6px; }
425.ovs-grid.is-sites { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
426.ovs-tile { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 8px 10px; border-radius: 12px; border: 1px solid transparent; color: inherit; text-decoration: none; }
427.ovs-tile > span:last-child { min-width: 0; }
428.ovs-tile b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 650; }
429.ovs-tile small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11.5px; line-height: 1.3; color: var(--text-muted, #93a0b4); }
430.ovs-tile.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
431.ovs-note { padding: 14px 12px; font-size: 13px; color: var(--text-muted, #93a0b4); }
432/* The menu before typing (4.128.0): the places dock, this site's pages, the live bar, counted folds. */
433.ovs-apps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; padding: 2px 2px 6px; }
434.ovs-app { display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; padding: 10px 2px 8px; border-radius: 16px; color: inherit; text-decoration: none; }
435.ovs .ovs-appic { width: 54px; height: 54px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1), box-shadow .18s; }
436.ovs-appname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
437/* This site's pages: named as such, in the case they are written in, behind a dot that says "you are here"; each a tile. */
438.ovs-group[data-sec="here"] { gap: 9px; padding-top: 12px; font-size: 13.5px; font-weight: 750; letter-spacing: 0; text-transform: none; color: var(--text-secondary, #a8b3c4); }
439.ovs-group[data-sec="here"]::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light, var(--accent, #60a5fa)); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); animation: ovs-here 2.4s ease-in-out infinite; }
440@keyframes ovs-here { 50% { box-shadow: 0 0 0 calc(4px + 3px * var(--ov-move, 1)) color-mix(in srgb, var(--accent, #3b82f6) 8%, transparent); } }
441.ovs-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 2px 4px 6px; }
442.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 46px; padding: 0 12px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); transition: background-color .15s, transform .12s; }
443.ovs-link:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
444.ovs-link:active { transform: scale(.97); }
445.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }
446.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
447.ovs-link.is-here { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 42%, transparent); color: var(--text-primary, #e6edf3); }
448.ovs-link.is-here svg { color: var(--accent, #3b82f6); }
449.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }
450.ovs-livebar::-webkit-scrollbar { display: none; }
451.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }
452.ovs-live.is-count { border-color: color-mix(in srgb, var(--danger, #ef4444) 45%, transparent); background: color-mix(in srgb, var(--danger, #ef4444) 10%, transparent); }
453.ovs-live small { color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 600; }
454.ovs-livedot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--danger, #ef4444); animation: ovs-livedot 1.6s ease-in-out infinite; }
455@keyframes ovs-livedot { 50% { opacity: .4; transform: scale(.75); } }
456.ovs-foldrow { padding: 6px 4px 2px; }
457.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }
458/* 4.201.0's look: Notifications' head in You; the Timer under Vibe; the Dock's actions in You. */
459.ovs-nhead { display: flex; align-items: center; gap: 8px; padding: 10px 14px 6px; }
460.ovs-nhead b { font-size: 13px; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #a8b3c4); }
461.ovs-nhead-n { padding: 2px 8px; border-radius: 999px; background: var(--live-red, #ef4444); color: #fff; font-size: 11.5px; font-weight: 750; }
462.ovs-nhead-n[hidden], .ovs-nhead-more[hidden] { display: none; }
463.ovs-nhead-more { margin-left: auto; min-height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none; color: var(--accent-light, var(--accent, #60a5fa)); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
464.ovs-nhead-more:hover, .ovs-nhead-more:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
465.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
466.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
467.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
468.ovs-feed label { display: flex; align-items: center; gap: 12px; min-height: 44px; min-width: 0; cursor: pointer; }
469.ovs-feed input { width: 18px; height: 18px; flex: none; accent-color: var(--accent, #3b82f6); }
470.ovs-feed label span { display: grid; min-width: 0; }
471.ovs-feed b { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
472.ovs-feed small { font-size: 12px; color: var(--text-muted, #8b95a7); text-transform: capitalize; }
473.ovs-feed button { width: 40px; height: 40px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 16px; cursor: pointer; }
474.ovs-feed button:hover:not(:disabled) { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
475.ovs-feed button:disabled { opacity: .3; cursor: default; }
476.ovs-feeds-note { margin: 2px 6px 0; font-size: 12px; color: var(--text-muted, #8b95a7); }
477.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }
478.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
479.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
480.ovs-card { padding: 2px 6px 6px; }
481.ovs-cardbody { border-radius: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); padding: 12px 14px; }
482.ovs-displayrow { padding: 4px 8px 10px; }
483.ovs-displayrow .ovnav-display { margin-top: 4px; }
484.ovs-host { display: block; margin-top: 2px; font-size: 11px; font-weight: 650; letter-spacing: .01em; color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--text-muted, #93a0b4)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
485.ovs-group .ovs-more { margin-left: auto; color: var(--accent-light, #8ab4ff); font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; text-decoration: none; }
486@media (hover: hover) { .ovs-app:hover, .ovs-link:hover, .ovs-live:hover, .ovs-fold:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } .ovs-app:hover .ovs-appic { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); } }
487@media (max-width: 640px) { .ovs-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
488@media (pointer: coarse) { .ovs-chip { height: 44px; } .ovs-group button, .ovs-group .ovs-more { min-height: 44px; display: inline-flex; align-items: center; } }
489.ovs-foot { display: flex; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: 11.5px; color: var(--text-muted, #93a0b4); }
490.ovs-foot kbd { font: inherit; font-weight: 700; padding: 1px 5px; border-radius: 5px; border: 1px solid var(--border, rgba(255,255,255,.14)); }
491.ovs-busy .ovs-bar { background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent), transparent); background-size: 40% 2px; background-repeat: no-repeat; background-position: -40% 100%; animation: ovs-scan 1s linear infinite; }
492.ovs-snip { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; font-size: 13px; line-height: 1.4; color: var(--text-secondary, #a8b3c4); white-space: normal; }
493.ovs-results-on .ovs-item a { align-items: flex-start; padding: 9px 10px; }
494.ovs-results-on .ovs-ic { margin-top: 1px; text-transform: lowercase; }
495.ovs-rtabs { overflow-x: auto; scrollbar-width: none; padding-top: 8px; }
496.ovs-rtabs::-webkit-scrollbar { display: none; }
497.ovs-rtabs .ovs-tab[hidden] { display: none; }
498.ovs-skels { display: grid; gap: 6px; padding: 4px 6px 8px; }
499.ovs-skel { display: block; height: 52px; border-radius: 12px; background: linear-gradient(90deg, var(--bg-hover, rgba(255,255,255,.05)) 0%, color-mix(in srgb, var(--bg-hover, rgba(255,255,255,.05)) 40%, transparent) 50%, var(--bg-hover, rgba(255,255,255,.05)) 100%); background-size: 200% 100%; animation: ovs-shimmer 1.3s linear infinite; }
500.ovs-route { padding: 10px 14px 6px; font-size: 12px; color: var(--text-muted, #93a0b4); }
501@keyframes ovs-shimmer { to { background-position: -200% 0; } }
502@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }
503@keyframes ovs-scan { to { background-position: 140% 100%; } }
504@media (hover: none), (max-width: 640px) { .ovs-foot, .ovs-key { display: none; } }
505@media (max-width: 640px) {
506 .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; }
507 .ovs-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
508 .ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }
509 .ovs-grid:not(.is-sites) .ovs-tile small { -webkit-line-clamp: 1; }
510}
511/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */
512.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }
513/* It grows out of the button that was pressed (4.195.0; the owner: "go … to the proper element … what you clicked on"): its
514 origin is that button's centre, wherever the panel was clamped to (it unfolded from its left corner, though the button is at
515 the right since 4.193.0), on the dropdown's own curve. */
516.ovs.ovs-anchored .ovs-panel { --ovs-pl: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); top: 8px; left: var(--ovs-pl); transform: none; transform-origin: calc(var(--ovs-cx, 40px) - var(--ovs-pl)) 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .26s cubic-bezier(.2, 1.25, .3, 1); }
517@keyframes ovs-drop { from { opacity: 0; transform: translateY(-6px) scale(.9); } to { opacity: 1; transform: none; } }
518@media (max-width: 640px) {
519 /* The whole screen on a phone (the owner, 11-07: "make full use of the height, not compensate for the navbar"),
520 opening as a circle that grows from the button that opened it. */
521 .ovs.ovs-anchored { top: 0; }
522 .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
523}
524@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
525@keyframes ovs-fold { from { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(20px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
526@keyframes ovs-fade { to { opacity: 0; } }
527/* The orb, on top of the phone's menu, in the button's place (4.150.0): it glides into the search pill as the menu grows
528 from it, its nine dots turning into an x (the corners and the centre stay, the edges fold away); closing, it glides
529 back and the menu folds into it. */
530.ovs-orb { display: none; }
531@media (max-width: 640px) {
532 .ovs.ovs-anchored .ovs-x { display: none; }
533 .ovs.ovs-anchored .ovs-bar { padding-right: 52px; }
534 .ovs.ovs-anchored .ovs-orb { position: fixed; z-index: 3; left: var(--orb-x, calc(100vw - 52px)); top: var(--orb-y, 12px); width: 40px; height: 40px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
535 background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-primary, #0b0f19)); color: var(--text-primary, #e6edf7); -webkit-tap-highlight-color: transparent;
536 box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-light, #60a5fa) 35%, transparent), 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
537 --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9);
538 transform: none; transition: transform .42s cubic-bezier(.2, 1.1, .3, 1), background-color .3s, box-shadow .3s; }
539 .ovs.ovs-anchored .ovs-orb.is-from { transform: translate(var(--orb-dx, 0px), var(--orb-dy, 0px)) scale(.9); background: transparent; box-shadow: none; }
540 .ovs.ovs-anchored .ovs-orb::after { content: ""; position: absolute; inset: -4px; }
541 /* A ring leaves the orb as its sheet opens (4.186.0; the Dock's sheet does the same). */
542 /* 4.229.0: the ring is invisible at rest, so where animations are off (a phone's "Remove animations", reduced motion)
543 it never stays as a white circle around the x (the owner, 11-12: "why is it white"). */
544 .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; }
545 @keyframes ovs-ring { from { opacity: .8; transform: scale(.9); } to { opacity: 0; transform: scale(calc(1 + 2.2 * var(--ov-move, 1))); } }
546 .ovs.ovs-anchored .ovs-orb:active { transform: scale(.9); }
547 /* 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,
548 a little past its size); closing with the button flying back, it turns away where it is. */
549 .ovs.ovs-anchored .ovs-orb.is-pop { animation: ovs-orb-in .38s cubic-bezier(.2, 1.3, .3, 1) .1s both; }
550 .ovs.ovs-anchored .ovs-orb.is-gone { animation: ovs-orb-out .22s cubic-bezier(.5, 0, .75, .2) both; }
551 @keyframes ovs-orb-in { from { opacity: 0; transform: scale(.45) rotate(-90deg); } }
552 @keyframes ovs-orb-out { to { opacity: 0; transform: scale(.5) rotate(90deg); } }
553 .ovs.ovs-anchored .ovs-orb svg { display: block; overflow: visible; }
554 .ovs.is-closing .ovs-panel { animation: ovs-fold .28s cubic-bezier(.5, 0, .75, .2) both; }
555 .ovs.is-closing { animation: ovs-fade .28s ease-in both; }
556}
557`;
558 document.head.appendChild(st);
559 }
560
561 const ICON = {
562 search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>',
563 close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></svg>',
564 clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
565 go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',
566 spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',
567 back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>',
568 grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
569 };
570
571 // The orb (navbar.js's drawing, root.OpenVibeOrb): on a phone the menu covers the bar, so the menu draws the orb in the
572 // button's own place, on top, and it turns into the menu's close (4.150.0, the owner: "it renders on top … turns into
573 // the close button when it's open … a cool effect as it goes in and out").
574 const orbSvg = () => (root.OpenVibeOrb ? root.OpenVibeOrb.svg(24) : '');
575 let orbEl = null, closing = 0;
576 const phoneSheet = () => { try { return !!(el && el.classList.contains('ovs-anchored') && root.matchMedia('(max-width: 640px)').matches); } catch { return false; } };
577 // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
578 const calm = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
579
580 function build() {
581 styles();
582 el = document.createElement('div');
583 el.className = 'ovs';
584 el.setAttribute('role', 'dialog');
585 el.setAttribute('aria-modal', 'true');
586 el.setAttribute('aria-label', 'Search and every OpenVibe site');
587 el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><div class="ovs-acts"></div><button type="button" class="ovs-x" aria-label="Close the menu" title="Close (Esc)">${ICON.close}<span>Close</span></button></div>`
588 + '<div class="ovs-tabs ovs-starttabs" hidden></div>'
589 + `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
590 + '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
591 input = el.querySelector('.ovs-input');
592 ghost = el.querySelector('.ovs-ghost');
593 listEl = el.querySelector('.ovs-list');
594 doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;
595 // 4.225.0: Vibe is one place (the owner, 11-12, crossing out the Chat · Code · Agents switch: "one new unified
596 // agent … a shared UI"): every chat, task and coding run in one list with its own filters, drawn by ov-run.js.
597 doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
598 doorEl.append(doorHost);
599 listEl.after(doorEl);
600 doorHost.addEventListener('openvibe:open-vibe', (e) => {
601 if (door !== 'chats' || !canDoor()) return;
602 e.preventDefault();
603 openDoor('vibe');
604 });
605 tabsEl = el.querySelector('.ovs-tabs');
606 actsEl = el.querySelector('.ovs-acts');
607 rtabsEl = el.querySelector('.ovs-rtabs');
608 rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
609 el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
610 el.querySelector('.ovs-x').addEventListener('click', close);
611 // 4.223.1: the panel never scrolls as a whole (only its list does); an engine that still scrolls it on a focus
612 // is put back at once, so the search field and the tabs stay in place.
613 { const panel = el.querySelector('.ovs-panel'); if (panel) panel.addEventListener('scroll', () => { if (panel.scrollTop || panel.scrollLeft) { panel.scrollTop = 0; panel.scrollLeft = 0; } }, { passive: true }); }
614 orbEl = document.createElement('button');
615 orbEl.type = 'button'; orbEl.className = 'ovs-orb'; orbEl.setAttribute('aria-label', 'Close the menu'); orbEl.title = 'Close (Esc)';
616 orbEl.innerHTML = orbSvg();
617 orbEl.addEventListener('click', close);
618 el.appendChild(orbEl);
619 tabsEl.addEventListener('click', (e) => {
620 const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
621 leaveDoor();
622 tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); input.value = ''; render(''); listEl.scrollTop = 0;
623 let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
624 if (touch) b.focus(); else input.focus();
625 });
626 document.addEventListener('openvibe-notifications-count', () => { if (el && el.classList.contains('is-open')) paintTabs(); });
627 input.addEventListener('input', () => { moved = false; if (view === 'results') leaveResults(); paintGhost(); schedule(input.value); });
628 input.addEventListener('keydown', onKey);
629 listEl.addEventListener('click', (e) => {
630 const clear = e.target.closest && e.target.closest('[data-ovs-clear]');
631 if (clear) { e.preventDefault(); recent.clear(); render(input.value); input.focus(); return; }
632 const fold = e.target.closest && e.target.closest('[data-ovs-fold]');
633 if (fold) {
634 e.preventDefault();
635 const key = fold.getAttribute('data-ovs-fold');
636 if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen; else if (key === 'feeds') feedsOpen = !feedsOpen;
637 render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;
638 }
639 const tick = e.target.closest && e.target.closest('[data-ovs-feed]');
640 const move = e.target.closest && e.target.closest('[data-ovs-feed-move]');
641 if (tick || move) {
642 const shown = [...listEl.querySelectorAll('[data-ovs-feed]')];
643 let on = shown.filter((x) => x.checked).map((x) => x.getAttribute('data-ovs-feed'));
644 const cur = feedPref();
645 if (cur) on = cur.filter((id) => on.includes(id)).concat(on.filter((id) => !cur.includes(id)));
646 let focusSel = tick ? `[data-ovs-feed="${tick.getAttribute('data-ovs-feed')}"]` : null;
647 if (move) {
648 e.preventDefault();
649 const [dir, id] = move.getAttribute('data-ovs-feed-move').split('|');
650 const i = on.indexOf(id), j = dir === 'up' ? i - 1 : i + 1;
651 if (i >= 0 && j >= 0 && j < on.length) { [on[i], on[j]] = [on[j], on[i]]; }
652 focusSel = `[data-ovs-feed-move="${dir}|${id}"]`;
653 }
654 saveFeeds(on);
655 render('');
656 const back = focusSel && listEl.querySelector(focusSel); if (back) back.focus();
657 return;
658 }
659 const a = e.target.closest && e.target.closest('a[data-i]');
660 const it = a && items[Number(a.getAttribute('data-i'))];
661 if (it && !input.value.trim()) beacon(it, a);
662 if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {
663 e.preventDefault(); if (!showDoor(it.dock)) { try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } } return;
664 }
665 if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
666 if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
667 if (it) remember(it);
668 // A page chosen in the menu (owner, 11-09: "it doesn't update the navigation that's highlighted… the menu
669 // should animate closed when you change pages"): the menu closes as the page changes, whether Boost swaps it in
670 // place or the browser loads the next one. Its Back entry stays, so Back is not spent on it during the move.
671 const link = e.target.closest && e.target.closest('a[href]');
672 if (link && !e.defaultPrevented && !e.button && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && (!link.target || link.target === '_self') && !/^#/.test(link.getAttribute('href'))) close({ navigating: true });
673 });
674 document.body.appendChild(el);
675 }
676
677 function onKey(e) {
678 if (e.key === 'Escape') { e.preventDefault(); if (input.value) { input.value = ''; paintGhost(); render(''); return; } close(); return; }
679 if ((e.key === 'Tab' && !e.shiftKey || e.key === 'ArrowRight' && input.selectionStart === input.value.length) && completion) {
680 e.preventDefault(); input.value = completion; paintGhost(); schedule(input.value); return;
681 }
682 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
683 e.preventDefault();
684 if (!items.length) return;
685 moved = true;
686 active = (active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
687 paintActive();
688 return;
689 }
690 if (e.key === 'Enter') {
691 e.preventDefault();
692 const q = input.value.trim();
693 if (tab === 'feed' && homeLib() && !door) { render(q); return; }
694 // Vibe: on Chats (the Dock's Vibe) the words go to its box as before; anywhere else they start a run.
695 if (door === 'vibe' && q) {
696 if (runFilter === 'chat') { try { if (ctx.ask) ctx.ask(q); } catch { /* the Dock is optional */ } }
697 else if (runView) runView.start(q).then(() => { input.value = ''; paintGhost(); }, () => {});
698 return;
699 }
700 if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
701 const it = items[active >= 0 ? active : 0];
702 // Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
703 if (q && !moved && view !== 'results' && !exact(it, q)) { showResults(q); return; }
704 if (it && it.kind === 'results') { showResults(q); return; }
705 if (it && it.kind === 'recent') { input.value = it.title; paintGhost(); showResults(it.title); return; }
706 if (it) { remember(it); location.href = it.url; }
707 }
708 }
709 const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
710 /** 4.229.0 (the owner, 11-12: "fix the history back button so it properly brings you back to this GUI"): the menu's
711 * entry remembers where it was when a link took the person away, and Back reopens it there (navbar.js restoreMenu). */
712 function markBack() {
713 try {
714 const back = { tab, door: door || null, scroll: listEl ? Math.round(listEl.scrollTop) : 0, at: Date.now() };
715 history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false, ovsBack: back }), '');
716 } catch { /* a page that cannot keep state: Back shows the page */ }
717 }
718 function remember(it) {
719 if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }
720 const q = input && input.value.trim();
721 if (q && it && it.kind !== 'recent') recent.add(q);
722 }
723 function paintActive() {
724 Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-opt'), (a, i) => {
725 const on = i === active;
726 a.classList.toggle('is-active', on);
727 a.setAttribute('aria-selected', on ? 'true' : 'false');
728 if (on) { input.setAttribute('aria-activedescendant', a.id); a.scrollIntoView({ block: 'nearest' }); }
729 });
730 }
731
732 /** What the box would complete to: the person's searches, then their history, then sites and this site's pages. */
733 function paintGhost() {
734 const q = input.value;
735 completion = '';
736 if (q.trim().length >= 2 && !/\s$/.test(q)) {
737 const n = q.toLowerCase();
738 const pool = recent.get().concat(mine ? mine.items.map((h) => h.title) : [], sites().map((s) => s.name), (ctx.links || []).map((l) => l && l.label).filter(Boolean));
739 completion = pool.find((x) => x && x.length > q.length && x.toLowerCase().startsWith(n)) || '';
740 if (completion) completion = q + completion.slice(q.length);
741 }
742 ghost.textContent = '';
743 if (!completion) return;
744 const typed = document.createElement('b'); typed.textContent = q;
745 ghost.append(typed, document.createTextNode(completion.slice(q.length)));
746 }
747
748 /** A steady hue per site (ov-home.js has the same), so an app wears one colour in every tab. */
749 function hueOf(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; }
750 // The glyphs a section of sites can wear (openvibe-shared/glyphs.js), and which one a section's name asks for.
751 const GROUP_GLYPH = {
752 'video': '<rect x="3.4" y="6.4" width="12.3" height="11.3" rx="1.9"/><path d="M15.6 10.2l4.7-2.8v9.4l-4.7-2.8"/>',
753 'chat': '<path d="M3.8 4.3h16.8v11.7H8l-4.2 4.2Z"/>',
754 'game': '<rect x="3.7" y="7.2" width="16.7" height="9.7" rx="4.4"/><path d="M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0"/>',
755 'book': '<path d="M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z"/><path d="M12 6.3v13.2"/>',
756 'store': '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>',
757 'wrench': '<path d="M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z"/>',
758 'spark': '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',
759 'code': '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',
760 'compass': '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',
761 };
762 function groupGlyph(name) {
763 const n = String(name || '').toLowerCase();
764 return /watch|stream|video|live/.test(n) ? 'video' : /talk|belong|chat|social|community/.test(n) ? 'chat' : /play|game|fun/.test(n) ? 'game'
765 : /read|ask|news|learn/.test(n) ? 'book' : /shop|money|buy|market/.test(n) ? 'store' : /tool/.test(n) ? 'wrench'
766 : /\bai\b|make/.test(n) ? 'spark' : /develop|build|code/.test(n) ? 'code' : 'compass';
767 }
768 function icon(it) {
769 const ic = document.createElement('span');
770 ic.className = 'ovs-ic';
771 if (it.site) { ic.classList.add('ovs-site'); ic.style.setProperty('--ovs-h', String(hueOf(it.site))); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }
772 else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];
773 else ic.textContent = (it.icon || '•').slice(0, 2);
774 return ic;
775 }
776 function option(tag, cls, it) {
777 const i = items.length;
778 items.push(it);
779 const a = document.createElement(tag);
780 a.className = `ovs-opt ${cls}`;
781 a.id = `ovs-o${i}`;
782 a.setAttribute('role', 'option');
783 a.href = it.url;
784 a.setAttribute('data-i', String(i));
785 a.tabIndex = -1;
786 if (it.here) { a.classList.add('is-here'); a.setAttribute('aria-current', 'page'); }
787 return a;
788 }
789 /** groups: [{ title, items, as: 'rows' | 'chips' | 'tiles' | 'sites' | 'apps' | 'links' | 'livebar' | 'fold', section,
790 * clear, more: { label, tab | href }, fold: { open, label, key } }]; text only through textContent. */
791 function foldButton(f, sec) {
792 const li = document.createElement('li'); li.setAttribute('role', 'presentation'); li.className = 'ovs-foldrow';
793 if (sec) li.setAttribute('data-sec', sec);
794 const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-fold';
795 b.setAttribute('data-ovs-fold', f.key); b.setAttribute('aria-expanded', f.open ? 'true' : 'false');
796 b.insertAdjacentHTML('beforeend', ICON.go); const t = document.createElement('span'); t.textContent = f.label; b.appendChild(t);
797 li.appendChild(b); return li;
798 }
799 function paint(groups, note) {
800 items = [];
801 listEl.textContent = '';
802 if (doorMount && !door) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
803 groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall', 'dock-activity', 'notifications'].includes(g.as)).forEach((g) => {
804 if (g.as === 'notifications') {
805 const li = document.createElement('li'); li.className = 'ovs-notifications'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'notifications');
806 const host = document.createElement('div'); host.className = 'ovs-notifications-host'; li.appendChild(host);
807 const N = root.OpenVibeNotifications;
808 // 4.201.0's look: the section's head says what it is and how many wait, and Show all sits in it.
809 const head = document.createElement('div'); head.className = 'ovs-nhead';
810 const title = document.createElement('b'); title.textContent = 'Notifications';
811 const unread = document.createElement('span'); unread.className = 'ovs-nhead-n'; unread.hidden = true;
812 try { const n = N.unread ? Number(N.unread()) : 0; if (n > 0) { unread.textContent = `${n > 99 ? '99+' : n} new`; unread.hidden = false; } } catch { /* the count is optional */ }
813 const more = document.createElement('button'); more.type = 'button'; more.className = 'ovs-nhead-more'; more.hidden = true;
814 head.append(title, unread, more);
815 li.insertBefore(head, host);
816 let compact = null;
817 try { compact = N.renderMenuNotifications(host, (total) => { if (total > 3) { more.textContent = `Show all ${total}`; more.hidden = false; } }); } catch { host.textContent = 'Notifications are unavailable right now.'; }
818 more.addEventListener('click', () => { if (compact) compact.showAll(); more.hidden = true; });
819 listEl.appendChild(li); return;
820 }
821 if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {
822 const H = root.OpenVibeHome; if (!H) return;
823 const kept = homeViews[g.as];
824 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; }
825 const li = document.createElement('li'); li.setAttribute('role', 'presentation');
826 let api = null;
827 try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : (H.feed || H.timeline))(li, homeOpts()); } catch { return; }
828 homeViews[g.as] = { li, api };
829 listEl.appendChild(li);
830 if (g.as === 'home') drawerIfAsked(api);
831 return;
832 }
833 if (g.as === 'route') {
834 const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
835 return;
836 }
837 if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }
838 if (g.as === 'feeds') {
839 const li = document.createElement('li'); li.className = 'ovs-feeds'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'feeds');
840 const onCount = g.items.filter((x) => x.on).length;
841 g.items.forEach((f) => {
842 const row = document.createElement('div'); row.className = 'ovs-feed';
843 const lab = document.createElement('label');
844 const box = document.createElement('input'); box.type = 'checkbox'; box.checked = f.on; box.setAttribute('data-ovs-feed', f.id);
845 if (!f.on && onCount >= FEEDS_MAX) box.disabled = true;
846 const t = document.createElement('span'); const b = document.createElement('b'); b.textContent = f.title; const m = document.createElement('small'); m.textContent = f.meta;
847 t.append(b, m); lab.append(box, t); row.appendChild(lab);
848 [['up', '↑', 'Show it earlier'], ['down', '↓', 'Show it later']].forEach(([dir, glyph, name]) => {
849 const mv = document.createElement('button'); mv.type = 'button'; mv.textContent = glyph; mv.setAttribute('aria-label', `${name}: ${f.title}`); mv.setAttribute('data-ovs-feed-move', `${dir}|${f.id}`); mv.disabled = !f.on; row.appendChild(mv);
850 });
851 li.appendChild(row);
852 });
853 const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = 'Every feed shows unless you turn it off; the arrows set the order.'; li.appendChild(note);
854 listEl.appendChild(li);
855 return;
856 }
857 if (g.as === 'display') {
858 const li = document.createElement('li'); li.className = 'ovs-displayrow'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', 'display');
859 try { ctx.display(li); } catch { return; }
860 listEl.appendChild(li);
861 return;
862 }
863 if (g.as === 'card') {
864 const body = document.createElement('div'); body.className = 'ovs-cardbody';
865 try {
866 g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.token && ctx.user && !ctx.user.is_anon) });
867 } catch { return; }
868 if (!body.childNodes.length) return;
869 if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }
870 const li = document.createElement('li'); li.className = 'ovs-card'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', g.section);
871 li.appendChild(body); listEl.appendChild(li);
872 return;
873 }
874 const h = document.createElement('li');
875 h.className = 'ovs-group';
876 h.setAttribute('role', 'presentation');
877 if (g.section) h.setAttribute('data-sec', g.section);
878 // A section of sites wears its glyph (4.223.0), drawn from the glyph registry's set.
879 h.appendChild(document.createTextNode(g.title));
880 // The name stays the first node (readers and the launcher read it there); the glyph is drawn before it (order -1).
881 if (g.section === 'all' && g.title) { const gi = document.createElement('span'); gi.className = 'ovs-group-ic'; gi.setAttribute('aria-hidden', 'true'); gi.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${GROUP_GLYPH[groupGlyph(g.title)]}</svg>`; h.appendChild(gi); }
882 if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
883 if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }
884 else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }
885 if (g.title) listEl.appendChild(h);
886 const as = g.as || 'rows';
887 if (as === 'note' || as === 'loading') {
888 const n = document.createElement('li'); n.setAttribute('role', 'presentation');
889 if (as === 'loading') { n.className = 'ovs-skels'; n.setAttribute('aria-label', g.text || 'Looking'); for (let k = 0; k < (g.rows || 3); k++) { const sk = document.createElement('span'); sk.className = 'ovs-skel'; n.appendChild(sk); } }
890 else { n.className = 'ovs-note'; n.textContent = g.text; }
891 listEl.appendChild(n);
892 return;
893 }
894 if (as === 'rows') {
895 // A row with a picture needs no site icon beside it (4.139.0); in a group where some rows have one, the
896 // others carry their icon in a picture-sized tile so the titles line up.
897 const pictured = g.items.some((x) => x.image);
898 g.items.forEach((it) => {
899 const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');
900 if (g.section) li.setAttribute('data-sec', g.section);
901 const a = option('a', '', it);
902 const main = document.createElement('span'); main.className = 'ovs-main';
903 const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);
904 if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; t.appendChild(tg); }
905 if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; if (it.at) { m.dataset.at = it.at; m.dataset.pre = it.metaPre || ''; } main.appendChild(m); }
906 if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
907 if (it.image) {
908 const im = document.createElement('img');
909 im.className = 'ovs-thumb'; im.src = it.image; im.alt = '';
910 im.width = 72; im.height = 40; im.loading = 'lazy'; im.decoding = 'async';
911 a.appendChild(im);
912 } else if (pictured) {
913 const tile = document.createElement('span'); tile.className = 'ovs-thumb ovs-thumb--ico';
914 tile.append(icon(it)); a.appendChild(tile);
915 } else a.append(icon(it));
916 a.appendChild(main);
917 const q = input.value.trim();
918 if (q && view !== 'results' && (it.kind === 'results' || (items.length === 1 && exact(it, q)))) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
919 li.appendChild(a); listEl.appendChild(li);
920 });
921 return;
922 }
923 const box = document.createElement('li');
924 box.setAttribute('role', 'presentation');
925 if (g.section) box.setAttribute('data-sec', g.section);
926 box.className = as === 'chips' ? 'ovs-chips' : as === 'apps' ? 'ovs-apps' : as === 'links' ? 'ovs-links' : as === 'livebar' ? 'ovs-livebar' : `ovs-grid${as === 'sites' ? ' is-sites' : as === 'mini' ? ' is-mini' : ''}`;
927 g.items.forEach((it, k) => {
928 if (as === 'chips') {
929 const a = option('a', 'ovs-chip', it);
930 // The place's own icon (the navbar's drawn set, never page text), else the menu's.
931 if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);
932 const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);
933 box.appendChild(a);
934 return;
935 }
936 if (as === 'apps') {
937 // A dock of big icons: the name under each, one tap to the place.
938 const a = option('a', 'ovs-app', it);
939 const ic = icon(it); ic.classList.add('ovs-appic');
940 const span = ic.querySelector('.ov-icon'); if (span) span.setAttribute('data-size', '40');
941 const t = document.createElement('span'); t.className = 'ovs-appname'; t.textContent = it.title;
942 a.append(ic, t); box.appendChild(a); return;
943 }
944 if (as === 'links') {
945 const a = option('a', 'ovs-link', it);
946 if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else a.insertAdjacentHTML('afterbegin', ICON.go);
947 const t = document.createElement('span'); t.textContent = it.title; a.appendChild(t);
948 box.appendChild(a); return;
949 }
950 if (as === 'livebar') {
951 // The first is the count ("3 streams live now"), then the live channels when Network names them.
952 const a = option('a', k === 0 ? 'ovs-live is-count' : 'ovs-live', it);
953 if (k === 0) { const dot = document.createElement('span'); dot.className = 'ovs-livedot'; a.appendChild(dot); }
954 const t = document.createElement('span'); t.className = 'ovs-livename'; t.textContent = it.title; a.appendChild(t);
955 if (it.meta) { const m = document.createElement('small'); m.textContent = it.meta; a.appendChild(m); }
956 box.appendChild(a); return;
957 }
958 const a = option('a', 'ovs-tile', it);
959 const txt = document.createElement('span');
960 const b = document.createElement('b'); b.textContent = it.title; txt.appendChild(b);
961 if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }
962 if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
963 if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
964 const ic = icon(it);
965 if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '44'); }
966 a.append(ic, txt);
967 box.appendChild(a);
968 });
969 listEl.appendChild(box);
970 if (g.fold) listEl.appendChild(foldButton(g.fold, g.section));
971 });
972 if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }
973 active = items.length && input.value.trim() && view !== 'results' ? 0 : -1;
974 paintActive();
975 if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }
976 }
977
978 // This site's short name for the field, the first tab and the headings: a product name ("Download", "Space"), never a
979 // page title or a tagline ("OpenVibe.Download: a free 25 GB drive with share links" ran over two lines; on 11-08 the
980 // tab read "Websites that l…", openvibe.website's tagline). In order: the site's own short label, the network's name
981 // for this address (path-aware: openvibe.my/inventory is Inventory, openvibe.my itself is not), the "OpenVibe.X" in
982 // the title, the address (openvibe.website → Website, connect.openvibe.link → Connect), then a short title segment.
983 const SHORT_NAME = 18;
984 const shortName = (t) => String(t || '').split(/\s[·|–—-]\s|:\s/)[0].replace(/^OpenVibe\./, '').trim();
985 // A page's word for itself ("Home", "Welcome") never names the site: the first tab is the site's name (owner, 11-09:
986 // "the first tab should be named after the site you're on… I meant the home options", which are Home in its list).
987 const GENERIC = /^(?:home|homepage|index|welcome|start|main|untitled|dashboard)$/i;
988 const brief = (t) => { const v = shortName(t); return v && v.length <= SHORT_NAME && !GENERIC.test(v) ? v : ''; };
989 const capital = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : '');
990 function addressName(host) {
991 const p = String(host || '').toLowerCase().replace(/^www\./, '').split('.');
992 if (p.length === 2 && p[0] === 'openvibe') return p[1] === 'ai' ? 'AI' : capital(p[1]);
993 if (p.length >= 3 && p[p.length - 2] === 'openvibe' && /^[a-z][a-z-]{1,17}$/.test(p[0])) return capital(p[0]);
994 return '';
995 }
996 const here = () => {
997 const f = ctx.frame, list = f && Array.isArray(f.nav) ? f.nav.map((n) => ({ name: n.name, url: safeUrl(n.url) })) : [];
998 const s = list.length ? hereSite(list) : null;
999 const titled = /OpenVibe\.([A-Za-z][A-Za-z0-9-]{0,17})\b/.exec(document.title || '');
1000 return brief(ctx.scope && ctx.scope.label) || (s && brief(s.name)) || (titled && titled[1]) || addressName(location.hostname)
1001 || brief(String(document.title || '').split(/\s[·|–—-]\s/).pop()) || location.hostname;
1002 };
1003 const siteItem = (s, all) => ({ title: s.name, meta: s.desc, url: s.url, site: s.icon || s.id, kind: 'site', here: all && s === hereSite(all) });
1004 function pagesOf(q) {
1005 const n = q.toLowerCase();
1006 return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
1007 .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
1008 }
1009 function actions(q) {
1010 const out = [];
1011 if (ctx.scope && ctx.scope.page) {
1012 const u = safeUrl(`${ctx.scope.page}${ctx.scope.page.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`);
1013 if (u) out.push({ title: `Search “${q}” on ${here()}`, meta: 'This site\'s own search', url: u, icon: 'search', kind: 'action' });
1014 }
1015 out.push({ title: `All results for “${q}”`, meta: 'Every OpenVibe site and the web, in one list', url: `${API}/?q=${encodeURIComponent(q)}`, icon: 'search', kind: 'results' });
1016 out.push({ title: `Ask Vibe: “${q}”`, meta: 'OpenVibe\'s assistant searches the web and OpenVibe', url: `https://openvibe.chat/vibe?ask=${encodeURIComponent(q)}`, icon: 'spark', kind: 'action' });
1017 return out;
1018 }
1019 // How well a name answers what was typed: its start (3), inside it (2), or only the words behind it (1).
1020 const scoreOf = (n, name, words) => { const nm = name.toLowerCase(); return nm.startsWith(n) ? 3 : nm.includes(n) ? 2 : words && words.includes(n) ? 1 : 0; };
1021 // Quick tools on Start: the everyday ones first (the owner, 10-29: "I'm trying to just get to basic tools"), then the
1022 // network's most used.
1023 const EVERYDAY = ['QR Code Generator', 'Image Compressor', 'Merge PDF', 'Image Converter', 'Word Counter', 'YouTube Downloader'];
1024 function quickTools() {
1025 const picked = catalog ? EVERYDAY.map((n) => catalog.tools.find((t) => t.name === n)).filter(Boolean).map(toolRow) : [];
1026 const seen = new Set(picked.map((r) => r.url));
1027 return [...picked, ...popularTools().filter((r) => !seen.has(r.url))].slice(0, 6);
1028 }
1029 const toolRow = (t) => ({ title: t.name, meta: t.desc ? `Tool · ${t.desc}` : 'Tool', url: t.go || t.url, site: t.icon, kind: 'tool' });
1030 /** How often this person opened a tool (their history's visits to any of its addresses). */
1031 function toolUse(t) {
1032 if (!mine || !t.hosts || !t.hosts.length) return 0;
1033 return mine.items.reduce((n, h) => n + (t.hosts.indexOf(hostOf(h.url)) >= 0 ? 1 : 0), 0);
1034 }
1035 const toolByHost = (url) => { const h = hostOf(url); return catalog ? catalog.tools.find((t) => t.hosts.indexOf(h) >= 0 || hostOf(t.url) === h) : null; };
1036 /** The tools this person opened lately, most first (their history), and the most used across OpenVibe (the Frame's analytics). */
1037 function yourTools() {
1038 if (!mine || !catalog) return [];
1039 return catalog.tools.map((t) => [toolUse(t), t]).filter(([n]) => n > 0).sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, t]) => toolRow(t));
1040 }
1041 function popularTools() {
1042 const list = ctx.frame && ctx.frame.footer && Array.isArray(ctx.frame.footer.popular) ? ctx.frame.footer.popular : [];
1043 const rows = list.map((p) => { const t = toolByHost(p.url); return t ? toolRow(t) : { title: String(p.name || ''), meta: 'Tool', url: safeUrl(p.url), site: 'tools', kind: 'tool' }; }).filter((x) => x.url && x.title);
1044 return rows.length ? rows : catalog ? catalog.tools.slice(0, 8).map(toolRow) : [];
1045 }
1046 /** Sites, tool families and tools whose NAME matches (the start first, then what this person uses most): the places to go. */
1047 function goTo(q) {
1048 const n = q.toLowerCase();
1049 const all = sites();
1050 const s = all.map((x) => [scoreOf(n, x.name) * 100 + Math.min(99, useOf(x.id)), x]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).map(([, x]) => siteItem(x, all));
1051 const fams = ((catalog && catalog.families.length) ? catalog.families : FALLBACK_FAMILIES).filter((f) => scoreOf(n, f.name) >= 2).map((f) => ({ title: f.name, meta: 'Tools', url: f.url, site: f.icon, kind: 'family' }));
1052 const tools = catalog ? catalog.tools.map((t) => [scoreOf(n, t.name) * 100 + Math.min(99, toolUse(t)), t]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).slice(0, 4).map(([, t]) => toolRow(t)) : [];
1053 // What this person already uses goes first: a tool they opened before outranks a site that merely matches.
1054 return tools.filter((t) => toolUse(toolByHost(t.url) || {}) > 0).concat(s, fams, tools.filter((t) => !(toolUse(toolByHost(t.url) || {}) > 0))).slice(0, 6);
1055 }
1056 /** Tools whose description or keywords mention it (but not their name): useful, never the first answer. */
1057 function toolsAbout(q) {
1058 const n = q.toLowerCase();
1059 if (!catalog || n.length < 3) return [];
1060 return catalog.tools.filter((t) => scoreOf(n, t.name, t.k) === 1).slice(0, 3).map(toolRow);
1061 }
1062 /** A result's site icon: the Frame's site for its address (openvibe.video is Video), else its first letters. */
1063 function siteIcon(url) {
1064 const h = hostOf(url); let best = null, len = 0;
1065 for (const s of sites()) { const sh = hostOf(s.url); if (sh && (h === sh || h.endsWith(`.${sh}`)) && sh.length > len) { best = s; len = sh.length; } }
1066 return best ? best.icon || best.id : null;
1067 }
1068
1069 // ── The menu before typing (the owner, 2026-11-07: "rethink the whole idea of this whole menu … not spreading
1070 // traffic good enough … not smartly done based on what's popular"). One calm scroll, no tabs, in the order people
1071 // need it: who is live, the places they use (one tap to Live from anywhere), this site's own pages, where people
1072 // really go next from here (Network's cross-site moves, 11-07), two places new to them (fair rotation through the
1073 // long tail), their own corner (agents, Vibe, account), tools, every site behind a counted fold, and the pages they
1074 // were on last. Network's Frame may send `next`, `discover`, `you` and `live`; until it does the menu works them out.
1075 const NEXT_OF = {
1076 live: ['media-hub', 'community', 'chat', 'tools', 'bot', 'vip'],
1077 'media-hub': ['live', 'pics', 'community', 'download'],
1078 community: ['live', 'chat', 'tools', 'space'],
1079 chat: ['live', 'community', 'media-hub', 'games'],
1080 tools: ['live', 'community', 'download', 'chat'],
1081 network: ['live', 'media-hub', 'community', 'design'],
1082 games: ['live', 'casino', 'quest', 'chat'],
1083 bot: ['live', 'actor', 'codes', 'run'],
1084 pics: ['media-hub', 'download', 'live', 'design'],
1085 download: ['pics', 'media-hub', 'tools', 'zone'],
1086 design: ['space', 'website', 'pics', 'network'],
1087 space: ['website', 'design', 'community', 'domains'],
1088 };
1089 const NEXT_WHY = {
1090 live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',
1091 tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',
1092 };
1093 let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;
1094 // Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'
1095 // cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.
1096 // Every feed shows until the person turns some off (11-09); this device keeps up to twelve in their order. Network's
1097 // menu-feeds@1 keeps the first four until @2 (order and off) is live.
1098 const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 12, FEEDS_SYNC = 4;
1099 const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
1100 const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };
1101 const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
1102 // The choice follows the person (4.135.0): Network keeps it (GET/PUT /api/v1/me/menu-feeds, network.menu-feeds@1);
1103 // this device's copy paints at once and stands whenever Network does not answer.
1104 let feedsSynced = false, feedsPut = 0;
1105 const feedsUrl = () => `${ctx.apiBase || 'https://openvibe.network'}/api/v1/me/menu-feeds`;
1106 const signedInCtx = () => !!(ctx.token && ctx.user && !ctx.user.is_anon);
1107 function pushFeeds(on) {
1108 if (!signedInCtx() || typeof fetch !== 'function') return;
1109 clearTimeout(feedsPut);
1110 feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_SYNC) }) }).catch(() => {}); }, 800);
1111 }
1112 const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };
1113 async function syncFeeds() {
1114 if (feedsSynced || !signedInCtx() || typeof fetch !== 'function') return;
1115 feedsSynced = true;
1116 try {
1117 const r = await fetch(feedsUrl(), { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
1118 if (!r.ok) return;
1119 const j = await r.json();
1120 const local = feedPref();
1121 if (Array.isArray(j.on)) {
1122 const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);
1123 // Network's v1 copy holds the first four: a device whose longer list starts with them keeps its own.
1124 if (local && remote.length === FEEDS_SYNC && local.length > remote.length && remote.every((id, i) => local[i] === id)) return;
1125 if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }
1126 } else if (local) pushFeeds(local);
1127 } catch { /* the device copy stands */ }
1128 }
1129 /** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
1130 function loadPulse() {
1131 try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
1132 return fetch('https://openvibe.network/api/frame/pulse', { credentials: 'omit' }).then((r) => (r.ok ? r.json() : null)).then((d) => {
1133 if (d) { pulseData = d; try { sessionStorage.setItem('ov_pulse', JSON.stringify({ at: Date.now(), data: d })); } catch { /* */ } }
1134 return d;
1135 }).catch(() => null);
1136 }
1137 /** What shipped lately, every site (Network's GET /api/v1/changelog, the feed OpenVibe.Blog keeps; kept 5 minutes):
1138 * the newest real change per site, library bumps left out (the same rule as shipped.js). */
1139 let changesData = null;
1140 const HOUSEKEEPING = /^(?:openvibe-(?:shared|sdk|contracts|publishing)|openvibe-codes-cli)\s+v?\d|^(?:bump|pin)\b|\(was v\d/i;
1141 function loadChanges() {
1142 try { const c = JSON.parse(sessionStorage.getItem('ov_changes') || 'null'); if (c && Date.now() - c.at < 300000) { changesData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
1143 return fetch('https://openvibe.network/api/v1/changelog?limit=100', { credentials: 'omit', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : null)).then((d) => {
1144 if (d && Array.isArray(d.entries)) { changesData = d.entries; try { sessionStorage.setItem('ov_changes', JSON.stringify({ at: Date.now(), data: d.entries })); } catch { /* */ } }
1145 return changesData;
1146 }).catch(() => null);
1147 }
1148 /** A commit subject in plain words: no PR number, no stage tag ("A6: "), no trailing period. */
1149 const plainChange = (t) => String(t || '').replace(/\s*\(#\d+\)\s*$/, '').replace(/^[A-Z]{1,3}\d{0,3}(?:-\d+)?:\s+/, '').replace(/\.$/, '').slice(0, 110);
1150 /** When this person was last on a site (their history), for "new since your last visit". */
1151 function lastVisit(id) {
1152 if (!mine) return 0;
1153 let t = 0;
1154 for (const h of mine.items) if (h.service === id || (id === 'media-hub' && h.service === 'media')) { const v = Date.parse(h.at || ''); if (v > t) t = v; }
1155 return t;
1156 }
1157
1158 /** Two places this person has never used, picked fairly from the network's ranked list: better-used places come up
1159 * more often, but every place gets its turn (the pick changes each time the menu opens). */
1160 function pickDiscover(pool) {
1161 // The same two for the whole opening (history arriving later must not reshuffle them); one that has since moved
1162 // up into the person's places is replaced, so there are always two.
1163 const kept = discoverPick ? discoverPick.map((id) => pool.find((s) => s.id === id)).filter(Boolean) : [];
1164 if (discoverPick && kept.length >= 2) return kept;
1165 const fresh = pool.filter((s) => !useOf(s.id) && !kept.includes(s));
1166 const picked = kept.slice();
1167 const bag = fresh.map((s, i) => [s, 1 / Math.sqrt(1 + i)]);
1168 while (picked.length < 2 && bag.length) {
1169 const total = bag.reduce((n, [, w]) => n + w, 0);
1170 let r = Math.random() * total, k = 0;
1171 for (; k < bag.length - 1 && r > bag[k][1]; k++) r -= bag[k][1];
1172 picked.push(bag[k][0]); bag.splice(k, 1);
1173 }
1174 discoverPick = picked.map((s) => s.id);
1175 return picked;
1176 }
1177 /** The person's workspace (signed in): their agents and Vibe (the Dock opens in place), Codes, their files, their
1178 * machines, wallet, account and themes, one tap from every site (the owner, 11-07: "access codes through this menu
1179 * easily, and talk to your agent … less having to hop between sites"). */
1180 function youItems() {
1181 const f = ctx.frame;
1182 if (f && Array.isArray(f.you) && f.you.length) return f.you.map((y) => ({ title: String(y.name || ''), url: safeUrl(y.url), site: y.icon || 'ov', kind: y.dock ? 'dock' : 'you', dock: y.dock || null })).filter((x) => x.url && x.title);
1183 return [
1184 { title: 'Agents', url: 'https://openvibe.actor/', site: 'actor', kind: 'dock', dock: 'agent' },
1185 { title: 'Vibe', url: 'https://openvibe.chat/vibe', site: 'ov', kind: 'dock', dock: 'vibe' },
1186 { title: 'Codes', url: 'https://openvibe.codes/', site: 'codes', kind: 'you' },
1187 { title: 'Files', url: 'https://openvibe.download/files', site: 'drive', kind: 'you' },
1188 { title: 'Machines', url: 'https://openvibe.link/machines', site: 'plug', kind: 'you' },
1189 { title: 'Wallet', url: 'https://openvibe.network/wallet', site: 'billing', kind: 'you' },
1190 { title: 'Account', url: 'https://openvibe.network/my', site: 'account', kind: 'you' },
1191 { title: 'Themes', url: 'https://openvibe.network/themes', site: 'theme', kind: 'you' },
1192 ];
1193 }
1194 // ── Site cards: a site's own live piece of the menu (the owner, 11-07: "content aware … on Download, a little
1195 // download thing … or create custom elements"). A page pushes { id, title, render(el, api) } onto
1196 // window.OpenVibeMenuCards (a queue, so it works before search.js loads) or calls OpenVibeSearch.registerCard().
1197 // render() gets an empty element and { close, here, user, signedIn } and draws with DOM nodes; it runs on the
1198 // site's own page, so it is the site's code and its own responsibility. A card that throws is left out.
1199 const cards = [];
1200 function registerCard(c) {
1201 if (!c || typeof c.render !== 'function' || !c.id || cards.some((x) => x.id === c.id)) return () => {};
1202 cards.push(c);
1203 return () => { const i = cards.indexOf(c); if (i >= 0) cards.splice(i, 1); };
1204 }
1205 function drainCards() {
1206 const q = root.OpenVibeMenuCards;
1207 if (Array.isArray(q)) { while (q.length) registerCard(q.shift()); }
1208 // Later pushes go straight in.
1209 root.OpenVibeMenuCards = { push: (c) => { registerCard(c); return cards.length; } };
1210 }
1211
1212 // The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
1213 const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];
1214
1215 /** What is new in the Feed since it was last looked at: the network's real changes after that moment (0 on a first look). */
1216 function feedNew() {
1217 const seen = feedSeen();
1218 if (!seen || !Array.isArray(changesData)) return 0;
1219 let n = 0;
1220 for (const e of changesData) { const t = Date.parse(e.deployed_at || e.committed_at || ''); if (t > seen && !HOUSEKEEPING.test(String(e.subject || ''))) n++; }
1221 return n;
1222 }
1223 const TAB_SVG = {
1224 feed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="7" rx="2"/><path d="M4 15.5h16M4 19.5h10"/></svg>',
1225 you: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8.5" r="3.6"/><path d="M5 19.5c1.2-3.3 3.9-5 7-5s5.8 1.7 7 5"/></svg>',
1226 };
1227 /**
1228 * The tab strip under the search field (11-09, the owner: "these icons should be visually improved… your avatar…
1229 * the feed content aware… each site its own icon"): this site's own mark and name, Feed with a red dot while anyone is
1230 * live or a count of what is new since you last looked, and You with your picture and an online dot.
1231 */
1232 /** A phone's menu with the Dock to hand: its places open inside the menu, under the menu's own head. */
1233 /** Chat and Vibe open inside the menu, on every screen (4.220.0: the floating Dock is gone). */
1234 function canDoor() { return !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function'); }
1235 // A place's own actions (+ for a new chat, ↗ to open it in Chat) sit in the search pill, left of the close orb, so
1236 // they cost no row (asks 335, 336: "an icon plus button that looks nice and integrated", "open in Chat … a button
1237 // icon"). The Dock's panel draws them in its slim row; inside the menu that row is hidden and mirrored here, and
1238 // each mirror presses the panel's own button (an address opens from the person's own tap).
1239 function mirrorActs() {
1240 actsTick = 0;
1241 if (!actsEl) return;
1242 actsEl.textContent = '';
1243 if (!door || !doorHost) return;
1244 // A list's actions and an open conversation's (+ for a new chat, ↗ to open it in Chat): one head, the menu's (4.220.0).
1245 doorHost.querySelectorAll('.ovdk-bar > .ovdk-ib, .ovdk-thread-h > .ovdk-ib').forEach((orig) => {
1246 const b = document.createElement('button'); b.type = 'button';
1247 b.className = 'ovs-act' + (orig.classList.contains('is-new') ? ' is-new' : '');
1248 const label = orig.getAttribute('aria-label') || ''; b.setAttribute('aria-label', label); b.title = label;
1249 b.innerHTML = orig.innerHTML;
1250 b.addEventListener('click', (e) => { e.stopPropagation(); orig.click(); });
1251 actsEl.appendChild(b);
1252 });
1253 }
1254 function watchActs(on) {
1255 if (actsObs) { actsObs.disconnect(); actsObs = null; }
1256 if (on && doorHost && typeof MutationObserver === 'function') {
1257 actsObs = new MutationObserver(() => { if (!actsTick) actsTick = requestAnimationFrame(mirrorActs); });
1258 actsObs.observe(doorHost, { childList: true, subtree: true });
1259 }
1260 mirrorActs();
1261 }
1262 /** A place by the Dock's name ('chats', or Vibe's lists 'vibe' | 'code' | 'agent' | 'tasks'), in the open menu. */
1263 function showDoor(id) {
1264 if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;
1265 // The Dock's places by name: its Vibe is Chats, its Agents are Tasks.
1266 const want = { all: 'all', vibe: 'chat', chat: 'chat', code: 'code', agent: 'task', agents: 'task', tasks: 'task' }[id];
1267 if (want) runFilter = want;
1268 openDoor(id === 'chats' ? 'chats' : 'vibe');
1269 return door != null;
1270 }
1271 /** Vibe's runs (ov-run.js, loaded beside the navbar by its hash the first time Vibe opens). */
1272 let runLoading = null;
1273 function runLib() {
1274 if (root.OpenVibeRun) return Promise.resolve(root.OpenVibeRun);
1275 if (runLoading || typeof ctx.sibling !== 'function') return runLoading || Promise.reject(new Error('no sibling'));
1276 runLoading = new Promise((resolve, reject) => {
1277 const sc = document.createElement('script'); sc.src = ctx.sibling('ov-run.js'); sc.async = true;
1278 sc.onload = () => (root.OpenVibeRun ? resolve(root.OpenVibeRun) : reject(new Error('ov-run.js')));
1279 sc.onerror = () => { runLoading = null; sc.remove(); reject(new Error('ov-run.js')); };
1280 document.head.appendChild(sc);
1281 });
1282 return runLoading;
1283 }
1284 // What Vibe can do, to tap (they were the Dock's Vibe starters).
1285 const VIBE_STARTERS = [
1286 ['bell', 'Remind me to stretch every 2 hours'],
1287 ['calendar', 'What is on today?'],
1288 ['live', 'Who is live right now?'],
1289 ['clock', 'Start a 25-minute focus timer'],
1290 ['phone', 'Remind me tomorrow at 9 to call Sam'],
1291 ['search', 'What is new on OpenVibe this week?'],
1292 ];
1293 let pendingAsk = null;
1294 /** One of the Dock's panels in a slot of the run view (the Dock lends one at a time). */
1295 function lend(slot, how) {
1296 try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }
1297 }
1298 function mountRuns() {
1299 const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
1300 // Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).
1301 const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
1302 const D = () => root.OpenVibeDock;
1303 const opts = {
1304 base: `${net}/api/frame`, token: () => ctx.token, me, sibling: ctx.sibling, filter: runFilter,
1305 // All: every run, with the Action Center's work from every product, the Dock's Now, your Actor and the live
1306 // agent view above it, and Vibe's starters. Chats, Tasks and Code are still the Dock's own panels (its Vibe,
1307 // Agents and Code: their history, skills, voice, undo, repos), lent whole until the one view does all they do.
1308 actions: { base: net }, starters: VIBE_STARTERS,
1309 top: (slot) => lend(slot, { tab: 'agent', part: 'top' }),
1310 panes: { chat: (slot) => lend(slot, { tab: 'vibe' }), task: (slot) => lend(slot, { tab: 'agent' }), code: (slot) => lend(slot, { tab: 'code' }) },
1311 onPlace: (p) => { if (p.filter) runFilter = p.filter; },
1312 };
1313 const go = (R) => {
1314 if (door !== 'vibe' || runView) return;
1315 doorHost.textContent = ''; runView = R.mount(doorHost, opts);
1316 if (pendingAsk != null) { runView.prefill(pendingAsk); pendingAsk = null; }
1317 };
1318 if (root.OpenVibeRun) { go(root.OpenVibeRun); return; }
1319 doorHost.textContent = '';
1320 const wait = document.createElement('p'); wait.className = 'ovs-door-wait'; wait.textContent = 'Opening Vibe…'; doorHost.append(wait);
1321 runLib().then(go, () => { if (door === 'vibe') wait.textContent = 'Vibe did not load. Check your connection and open it again.'; });
1322 }
1323 /** Show a place in the menu: Messages (the Dock's), or Vibe (your runs). */
1324 function openDoor(id) {
1325 if (!doorEl || !canDoor()) return;
1326 if (door && door !== id) closeDoorBody();
1327 door = id;
1328 listEl.hidden = true; doorEl.hidden = false; el.classList.add('ovs-in-door');
1329 el.classList.toggle('ovs-in-vibe', id === 'vibe');
1330 if (id === 'vibe') {
1331 if (runView) runView.list(runFilter); else mountRuns();
1332 } else {
1333 try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: 'chats' }); else doorMount.show('chats'); }
1334 catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }
1335 }
1336 watchActs(true);
1337 input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;
1338 paintTabs();
1339 }
1340 function closeDoorBody() {
1341 if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
1342 if (runView) { try { runView.destroy(); } catch { /* */ } runView = null; }
1343 if (doorHost) doorHost.textContent = '';
1344 }
1345 /** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
1346 function leaveDoor() {
1347 if (!door) return;
1348 door = null;
1349 closeDoorBody();
1350 watchActs(false);
1351 if (doorEl) doorEl.hidden = true;
1352 if (listEl) listEl.hidden = false;
1353 if (el) el.classList.remove('ovs-in-door', 'ovs-in-vibe');
1354 if (input) input.placeholder = `Search ${here()}, or ask Vibe`;
1355 }
1356
1357 function paintTabs() {
1358 tabsEl.hidden = false;
1359 tabsEl.setAttribute('role', 'tablist');
1360 tabsEl.setAttribute('aria-label', 'Menu');
1361 tabsEl.textContent = '';
1362 const live = pulseData && Number(pulseData.live_now) > 0;
1363 const fresh = tab === 'feed' ? 0 : feedNew();
1364 try { localStorage.setItem(FEED_COUNT, String(fresh)); } catch { /* */ }
1365 paintFeedDot(fresh);
1366 try { feedChannel?.postMessage({ type: 'count', count: fresh }); } catch { /* */ }
1367 // Your picture on You whenever the bar knows you, with or without a token for the network's reads (4.236.0; the
1368 // owner, 11-13: "what happened to the user avatar": on a site with its own session it fell back to a glyph).
1369 const me = ctx.user && !ctx.user.is_anon ? ctx.user : null;
1370 let siteId = ctx.service || '';
1371 try { const b0 = basis(); if (b0.mineSite) siteId = b0.mineSite.icon || b0.mineSite.id; } catch { /* the sites load later */ }
1372 // One row of places (4.194.0): this site, the feed, your messages, AI (Vibe and your agents), you. Messages and
1373 // AI are the Dock's; they are in this row, between Feed and You, where the bar can open the Dock for this person.
1374 const doors = typeof ctx.dock === 'function' ? [['chats', ctx.service === 'chat' ? 'Messages' : 'Chat'], ['vibe', 'Vibe']] : [];
1375 const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
1376 tabsEl.classList.toggle('has-doors', doors.length > 0);
1377 for (const [id, label, isDoor] of row) {
1378 const b = document.createElement('button');
1379 b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab');
1380 if (isDoor) {
1381 // A place of the Dock. On a phone it opens here, inside the menu, and is then the chosen tab; on a computer
1382 // it opens the floating Dock beside the page (it is kept beside the work there).
1383 b.setAttribute('data-tab', id); b.setAttribute('data-dock', id); b.setAttribute('aria-selected', door === id ? 'true' : 'false');
1384 const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
1385 const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
1386 b.addEventListener('click', (e) => { e.stopPropagation(); if (canDoor()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* not loaded yet: it opens the menu here when it is */ } });
1387 tabsEl.appendChild(b);
1388 continue;
1389 }
1390 b.setAttribute('data-tab', id);
1391 b.setAttribute('aria-selected', !door && tab === id ? 'true' : 'false');
1392 const ic = document.createElement('span'); ic.className = 'ovs-tab-ic';
1393 if (id === 'here' && siteId) { const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', siteId); g.setAttribute('data-size', '20'); g.setAttribute('data-fx', 'none'); ic.appendChild(g); }
1394 else if (id === 'you' && me && me.avatar_url && /^https:\/\//.test(me.avatar_url)) {
1395 const img = document.createElement('img'); img.src = me.avatar_url; img.alt = ''; img.width = 22; img.height = 22; img.decoding = 'async'; img.className = 'ovs-tab-av';
1396 img.onerror = () => { ic.innerHTML = TAB_SVG.you; };
1397 ic.appendChild(img);
1398 const on = document.createElement('i'); on.className = 'ovs-tab-on'; on.setAttribute('aria-hidden', 'true'); ic.appendChild(on);
1399 } else if (TAB_SVG[id]) ic.innerHTML = TAB_SVG[id];
1400 if (ic.firstChild) b.appendChild(ic);
1401 const t = document.createElement('span'); t.textContent = id === 'here' ? here() : label; b.appendChild(t);
1402 // 4.237.0 (the owner, 11-13): the live dot after the word, a count on the icon's corner, never a third line.
1403 if (id === 'feed' && live) { const d = document.createElement('i'); d.className = 'ovs-tabdot'; d.setAttribute('aria-label', 'live now'); t.appendChild(d); }
1404 else if (id === 'feed' && fresh) { const n = document.createElement('b'); n.className = 'ovs-tabnew'; n.textContent = fresh > 9 ? '9+' : String(fresh); n.setAttribute('aria-label', `${fresh} new since you last looked`); (b.querySelector('.ovs-tab-ic') || b).appendChild(n); }
1405 if (id === 'you' && ctx.token && root.OpenVibeNotifications) {
1406 const count = Number(root.OpenVibeNotifications.unread ? root.OpenVibeNotifications.unread() : 0) || 0;
1407 if (count) { const n = document.createElement('b'); n.className = 'ovs-tabnew ovs-tabnew-red'; n.textContent = count > 99 ? '99+' : String(count); n.setAttribute('aria-label', `${count} unread notifications`); (b.querySelector('.ovs-tab-ic') || b).appendChild(n); }
1408 }
1409 tabsEl.appendChild(b);
1410 }
1411 if (typeof ctx.icons === 'function') { try { ctx.icons(tabsEl); } catch { /* the glyphs come with ov-icons.js */ } }
1412 if (!tabsEl.__ovsRollWired) { tabsEl.__ovsRollWired = true; tabsEl.addEventListener('pointerdown', rollStop, true); tabsEl.addEventListener('keydown', rollStop, true); }
1413 rollSchedule();
1414 }
1415 /**
1416 * The tab's nudge (4.236.0; the owner, 11-13: "every once in a while it swaps between all the options that exist in
1417 * that section … scroll through all these icons and labels … nudges the user"): this site's tab rolls its glyph and
1418 * name through the other places in its section, a beat each, and back to its own; once a little after the menu
1419 * opens, then every 40 s while it is open and nobody is typing or pointing at the tabs. Calm rolls once an opening;
1420 * Off never. Its accessible name stays its own, and a press stops it at once.
1421 */
1422 let rollTimer = 0, rollRun = 0, rolledOpen = false;
1423 function rollSteps() {
1424 try {
1425 const b0 = basis();
1426 const mineSite = b0.mineSite;
1427 if (!mineSite) return [];
1428 return b0.ranked.filter((x) => x.group === mineSite.group && x.id !== mineSite.id && x.name).slice(0, 6).map((x) => ({ label: x.name, icon: x.icon || x.id }));
1429 } catch { return []; }
1430 }
1431 function rollStop() {
1432 rollRun += 1;
1433 const b = tabsEl && tabsEl.querySelector('.ovs-tab[data-tab="here"].is-rolling');
1434 if (!b || !b.__ovsOwn) return;
1435 const t = b.querySelector(':scope > span:not(.ovs-tab-ic)'), g = b.querySelector('.ovs-tab-ic .ov-icon');
1436 if (t) t.textContent = b.__ovsOwn.label;
1437 if (g && b.__ovsOwn.icon) { g.setAttribute('data-icon', b.__ovsOwn.icon); if (typeof ctx.icons === 'function') { try { ctx.icons(b); } catch { /* */ } } }
1438 b.classList.remove('is-rolling');
1439 }
1440 function rollHere() {
1441 if (document.documentElement.getAttribute('data-ov-motion') === 'off') return;
1442 const b = tabsEl && tabsEl.querySelector('.ovs-tab[data-tab="here"]');
1443 const steps = rollSteps();
1444 if (!b || steps.length < 2 || b.classList.contains('is-rolling')) return;
1445 const t = b.querySelector(':scope > span:not(.ovs-tab-ic)'), g = b.querySelector('.ovs-tab-ic .ov-icon');
1446 if (!t) return;
1447 b.__ovsOwn = { label: t.textContent, icon: g ? g.getAttribute('data-icon') : null };
1448 b.setAttribute('aria-label', b.__ovsOwn.label);
1449 const seq = steps.concat([b.__ovsOwn]);
1450 const run = ++rollRun;
1451 b.classList.add('is-rolling');
1452 let i = 0;
1453 const beat = () => {
1454 if (run !== rollRun || !b.isConnected) return;
1455 const st = seq[i++];
1456 for (const x of [t, g]) if (x) x.classList.remove('ovs-roll');
1457 void t.offsetWidth;
1458 t.textContent = st.label;
1459 if (g && st.icon) { g.setAttribute('data-icon', st.icon); if (typeof ctx.icons === 'function') { try { ctx.icons(b); } catch { /* */ } } }
1460 for (const x of [t, g]) if (x) x.classList.add('ovs-roll');
1461 if (i < seq.length) setTimeout(beat, 820); else setTimeout(() => { if (run === rollRun) b.classList.remove('is-rolling'); }, 420);
1462 };
1463 beat();
1464 }
1465 function rollSchedule() {
1466 if (rolledOpen) return;
1467 rolledOpen = true;
1468 setTimeout(() => { if (el && el.classList.contains('is-open') && !input.value) rollHere(); }, 1400);
1469 clearInterval(rollTimer);
1470 rollTimer = setInterval(() => {
1471 if (!el || !el.classList.contains('is-open')) { clearInterval(rollTimer); rolledOpen = false; return; }
1472 if (document.hidden || input.value || tabsEl.matches(':hover, :focus-within')) return;
1473 if (document.documentElement.getAttribute('data-ov-motion') === 'reduced') return;
1474 rollHere();
1475 }, 40000);
1476 }
1477 const bsvg = (d) => `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
1478 const BRIDGE_SVG = {
1479 vibe: bsvg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
1480 code: bsvg('<path d="M8 7l-5 5 5 5M16 7l5 5-5 5M13.5 4.5l-3 15"/>'),
1481 agent: bsvg('<rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4.5M9.5 13v.01M14.5 13v.01M3 12.5v2M21 12.5v2"/>'),
1482 talk: bsvg('<path d="M4.6 11.6a7.4 6.4 0 1 1 3.3 5.4l-3.4 1.3 1-3.1a6 6 0 0 1-.9-3.6z"/><path d="M9 11.6h.01M12 11.6h.01M15 11.6h.01"/>'),
1483 chats: bsvg('<path d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7L8 20v-3.5H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5z"/>'),
1484 activity: bsvg('<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>'),
1485 };
1486
1487 /** What every tab needs: the sites, this one, and the person's ranking of them. */
1488 function basis() {
1489 // This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).
1490 const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;
1491 const byId = new Map(all.map((x) => [x.id, x]));
1492 const shown = new Set(mineSite ? [mineSite.id] : []);
1493 const take = (list, n) => { const out = []; for (const x of list) { if (out.length >= n) break; if (x && !shown.has(x.id)) { out.push(x); shown.add(x.id); } } return out; };
1494 const ranked = all.map((x, i) => [useOf(x.id) * 1000 - i, x]).sort((a, b) => b[0] - a[0]).map(([, x]) => x);
1495 return { all, mineSite, byId, shown, take, ranked, f: ctx.frame };
1496 }
1497
1498 /** Who is live (Network's pulse), as the live bar: the count, then the live channels when Network names them. */
1499 function liveGroup() {
1500 const p = pulseData;
1501 const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;
1502 if (!liveNow) return null;
1503 const chans = Array.isArray(p.live) ? p.live.slice(0, 8).map((c) => ({ title: String(c.name || ''), meta: c.viewers ? `${c.viewers} watching` : 'Live', url: safeUrl(c.url), site: 'live', kind: 'live' })).filter((x) => x.url && x.title) : [];
1504 return { title: '', as: 'livebar', section: 'live', items: [{ title: liveNow === 1 ? '1 stream live now' : `${liveNow} streams live now`, meta: p.watching ? `${p.watching} watching` : 'Watch on Live', url: 'https://openvibe.live/', site: 'live', kind: 'live' }, ...chans] };
1505 }
1506
1507 /**
1508 * This site's tab: its own pages, then every OpenVibe site, compact and grouped (this site's group first). People
1509 * live show here only on Live itself; everywhere else they are the Feed's.
1510 */
1511 function hereGroups() {
1512 const { all, mineSite, byId, f } = basis();
1513 const groups = [];
1514 if (mineSite && mineSite.id === 'live') { const g = liveGroup(); if (g) groups.push(g); }
1515 // This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
1516 for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
1517 const pages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
1518 // A site's links that never name its home get one first, so the home page has a place of its own in the menu
1519 // and wears the highlight there too (owner 11-09, on openvibe.exchange: "shouldn't there be a link to home that's
1520 // highlighted").
1521 const rootHere = (x) => { try { const u = new URL(x.url, root.location.href); return u.origin === root.location.origin && (u.pathname.replace(/\/+$/, '') || '/') === '/'; } catch { return false; } };
1522 if (pages.length && !pages.some(rootHere)) pages.unshift({ title: 'Home', url: '/', icon: 'go', drawn: typeof ctx.navIcon === 'function' ? ctx.navIcon('fa-house') : '', kind: 'page' });
1523 // The page the person is on wears the highlight (owner 11-08): the link whose path is this page's, or the longest one
1524 // this page sits under (/messages/abc under Messages); "/" only on the home itself.
1525 let best = -1, bestLen = -1;
1526 pages.forEach((x, i) => {
1527 let u; try { u = new URL(x.url, root.location.href); } catch { return; }
1528 if (u.origin !== root.location.origin) return;
1529 const p = u.pathname.replace(/\/+$/, '') || '/', at = root.location.pathname.replace(/\/+$/, '') || '/';
1530 const len = p === at ? 10000 + p.length : (p !== '/' && at.startsWith(`${p}/`) ? p.length : -1);
1531 if (len > bestLen) { best = i; bestLen = len; }
1532 });
1533 if (best >= 0) pages[best].here = true;
1534 if (pages.length) groups.push({ title: `Pages on ${here()}`, as: 'links', items: pages, section: 'here' });
1535 const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
1536 if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
1537 const inGroup = (x, g) => !g.id || x.group === g.id || (!fgroups.some((y) => y.id === x.group) && g === fgroups[fgroups.length - 1]);
1538 const mini = (x) => siteItem(x, all);
1539 fgroups.forEach((g, i) => {
1540 // The site you are on is first in its group (its group is already first): the menu opens where you are.
1541 const list = all.filter((x) => inGroup(x, g)).sort((a, b) => (mineSite ? (b.id === mineSite.id) - (a.id === mineSite.id) : 0)).map(mini);
1542 if (list.length) groups.push({ title: i === 0 && fgroups.length > 1 ? g.name : g.name, as: 'mini', items: list, section: 'all' });
1543 });
1544 const soon = f && Array.isArray(f.soon) ? f.soon : [];
1545 if (soon.length) groups.push({ title: 'Opening soon', as: 'chips', items: soon.map((x) => ({ title: x.name, url: safeUrl(x.url), kind: 'site' })).filter((x) => x.url), section: 'all' });
1546 void byId;
1547 return groups;
1548 }
1549
1550 /**
1551 * The Feed: who is live, the feeds the person chose (the sites' cards: Video's trending, Games' new worlds, the
1552 * forum…), what changed lately, places new to them and where people go next from here; the chooser at the end.
1553 */
1554 function feedGroups() {
1555 const { all, mineSite, byId, shown, take, ranked, f } = basis();
1556 const groups = [];
1557 const wall = homeLib();
1558 if (wall && input && input.value.trim()) return [{ as: 'wall', items: [], section: 'wall' }];
1559 if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
1560 const live = liveGroup(); if (live) groups.push(live);
1561 if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
1562 if (wall && feedV2) return groups; // Feed v2: the composer, today, the live bar and the stream
1563 // The network's cards: text and links only (never another site's code), checked here again, with an entry's https
1564 // picture when it sent one; the person's choice and order, else Network's first two.
1565 const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
1566 const chosen = feedPref();
1567 // Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").
1568 let netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;
1569 // Nothing old dressed as new (11-09, the owner: "why is New in the forum first when it's 17 days ago"): a card's age
1570 // is its newest item's (a timestamp when the card sends one, else its "3d ago" words); one older than 3 days drops
1571 // the word "New" from its title, and until the person orders them, fresher cards come first and month-old ones last.
1572 const AGE = { m: 60e3, min: 60e3, h: 3600e3, d: 86400e3, w: 604800e3, mo: 2592e6, y: 31536e6 };
1573 const ageOf = (c) => {
1574 let best = Infinity;
1575 for (const x of c.items || []) {
1576 const t = Date.parse(x && (x.at || x.updated_at || x.published_at) || '');
1577 if (t) { best = Math.min(best, Date.now() - t); continue; }
1578 const m = /(\d+)\s*(min|mo|m|h|d|w|y)\b[^·]*ago/.exec(String(x && x.meta || ''));
1579 if (m) best = Math.min(best, Number(m[1]) * AGE[m[2]]);
1580 }
1581 return best;
1582 };
1583 const ages = new Map(netCards.map((c) => [c, ageOf(c)]));
1584 const later = []; // cards older than two weeks: after What's new
1585 if (!chosen) netCards = netCards.slice().sort((a, b) => (ages.get(a) === Infinity ? 1e15 : ages.get(a)) - (ages.get(b) === Infinity ? 1e15 : ages.get(b)));
1586 netCards.forEach((c) => {
1587 const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
1588 const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
1589 image: x.image && /^https:\/\//.test(String(x.image.src || '')) ? String(x.image.src) : null,
1590 site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
1591 const stale = isFinite(ages.get(c)) && ages.get(c) > 3 * 86400e3; // an unknown age is never called old
1592 const title = stale ? String(c.title).replace(/^New\s+(in|on|from)\s+/i, (m0, w) => `${w[0].toUpperCase()}${w.slice(1)} `).replace(/^New\s+/i, '') : String(c.title);
1593 if (rows.length) (isFinite(ages.get(c)) && ages.get(c) > 14 * 86400e3 && !chosen ? later : groups).push({ title: title.slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });
1594 });
1595 // What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.
1596 if (Array.isArray(changesData) && changesData.length) {
1597 const newest = new Map();
1598 for (const e of changesData) {
1599 if (!e || !e.service || HOUSEKEEPING.test(String(e.subject || '')) || newest.has(e.service)) continue;
1600 const site = byId.get(e.service) || (e.service === 'media' ? byId.get('media-hub') : null);
1601 if (!site || (site.group === 'build' && !useOf(site.id))) continue;
1602 newest.set(e.service, { e, site });
1603 }
1604 // Each row (owner, 11-09: "keep the recently updated… see some info about the last update"): the site, what
1605 // changed in plain words, when, "New" since this person's last visit and "Big update" for a major one; the
1606 // heading counts today's real changes across the network.
1607 const at = (e) => e.deployed_at || e.committed_at;
1608 const rows = [...newest.values()].sort((a, b) => Date.parse(at(b.e) || 0) - Date.parse(at(a.e) || 0)).slice(0, 6).map(({ e, site }) => {
1609 const seen = lastVisit(site.id);
1610 const fresh = seen && Date.parse(at(e)) > seen;
1611 return { title: site.name, meta: [`Updated ${ago(at(e))}`, e.major ? 'Big update' : ''].filter(Boolean).join(' · '), snippet: plainChange(e.subject),
1612 url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };
1613 }).filter((x) => x.url && x.snippet);
1614 const dayAgo = Date.now() - 86400000;
1615 const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;
1616 if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
1617 }
1618 groups.push(...later);
1619 // Two places new to this person, rotating fairly through the network (developer services only for builders).
1620 const builder = all.some((x) => x.group === 'build' && useOf(x.id));
1621 const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
1622 .filter((x) => !shown.has(x.id) && (builder || x.group !== 'build'));
1623 const disc = pickDiscover(discoverPool);
1624 disc.forEach((x) => shown.add(x.id));
1625 if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((x) => Object.assign(siteItem(x), { tag: 'New' })), section: 'discover' });
1626 // Where people go next from here: the Frame's list, else the moves Network saw and this site's group.
1627 const nextIds = mineSite ? ((f && f.next && Array.isArray(f.next[mineSite.id]) && f.next[mineSite.id]) || NEXT_OF[mineSite.id] || all.filter((x) => x.group === mineSite.group).map((x) => x.id)) : [];
1628 const next = take(nextIds.map((id) => byId.get(id)), 4);
1629 if (next.length) groups.push({ title: `Next from ${here()}`, as: 'sites', items: next.map((x) => Object.assign(siteItem(x), { meta: NEXT_WHY[x.id] || x.desc })), section: 'next' });
1630 // Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).
1631 if (feeds.length > 1 || chosen) {
1632 groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${netCards.length} of ${feeds.length})` }, section: 'feeds' });
1633 if (feedsOpen) groups.push({ as: 'feeds', section: 'feeds', items: feeds.map((c) => ({ id: feedId(c), title: String(c.title).slice(0, 60), meta: String(c.site || '').replace('media-hub', 'Video'), on: netCards.includes(c) })) });
1634 }
1635 if (!groups.length) groups.push({ as: 'note', text: 'Nothing new right now. Choose your feeds, or look around the sites on the first tab.' });
1636 return groups;
1637 }
1638
1639 /**
1640 * A page they were on (11-09, the owner: "more like live data… smart and aware… better info, looks nicer"): the site's
1641 * own icon, the title without the site's name glued on, when, and "Live now" for a channel that is live this minute.
1642 */
1643 function backRow(x) {
1644 const title = String(x.title || '').replace(/\s*[·|–—-]\s*(?:OpenVibe\.\w+|open\.\w+)\s*$/i, '').replace(/^OpenVibe\.\w+:\s*/i, '').trim() || x.title;
1645 const live = pulseData && Array.isArray(pulseData.live) && pulseData.live.some((c) => c && c.url && safeUrl(c.url) && hostOf(c.url) === hostOf(x.url) && String(c.url).replace(/\/$/, '').toLowerCase() === String(x.url).replace(/[?#].*$/, '').replace(/\/$/, '').toLowerCase());
1646 const svc = x.service === 'media' ? 'media-hub' : x.service;
1647 const pre = [live ? 'Live now' : '', x.site].filter(Boolean).join(' · ');
1648 return { title: title.slice(0, 90), meta: [pre, ago(x.at)].filter(Boolean).join(' · '), metaPre: pre, at: x.at, url: x.url, site: svc && /^[a-z0-9-]{1,30}$/.test(svc) ? svc : null, icon: 'back', kind: 'history', tag: live ? 'Live' : '' };
1649 }
1650
1651 /** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */
1652 function youGroups() {
1653 const { take, ranked } = basis();
1654 const groups = [];
1655 const H = homeLib();
1656 // With the home screen, Notifications is one of You's tabs (4.223.0); without it, the section stays at the top.
1657 if (root.OpenVibeNotifications && ctx.token && !H) groups.push({ title: 'Notifications', as: 'notifications', items: [] });
1658 if (H) {
1659 groups.push({ as: 'home', items: [], section: 'home' });
1660 // Signed out, Display stays at the foot (a signed-in person has it beside their name, in ov-home).
1661 if (!(ctx.token && ctx.user && !ctx.user.is_anon) && typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
1662 return groups;
1663 }
1664 if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
1665 const yours = mine && mine.services.some((x) => x.count > 0);
1666 // Without their own history, developer services are not places to visit: they come up here once used.
1667 const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
1668 groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((x) => siteItem(x)), section: 'places' });
1669 const seen = new Set();
1670 const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5).map(backRow) : [];
1671 groups.push({ title: 'Jump back in', items: back, section: 'back' });
1672 const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);
1673 groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
1674 const r = recent.get();
1675 groups.push({ title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })), section: 'recent' });
1676 // Display: theme, text size and motion.
1677 if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
1678 return groups;
1679 }
1680
1681 // ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed
1682 // paints; until it arrives (or if it cannot), the tabs paint as before.
1683 // One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.
1684 let homeLoading = false, pulseReady = null, homeViews = {}, feedV2 = false;
1685 function homeLib() {
1686 if (root.OpenVibeHome) return root.OpenVibeHome;
1687 if (homeLoading || typeof ctx.sibling !== 'function') return null;
1688 homeLoading = true;
1689 try {
1690 const sc = document.createElement('script'); sc.src = ctx.sibling('ov-home.js'); sc.async = true;
1691 sc.onload = () => { if (el && el.classList.contains('is-open') && !input.value && (tab === 'you' || tab === 'feed')) render(''); };
1692 document.head.appendChild(sc);
1693 } catch { /* the old tabs stand */ }
1694 return null;
1695 }
1696 const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
1697 /** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */
1698 /** 4.229.0: You's home tells the menu which view shows: All apps makes the pill its finder ("Find among 64 apps"),
1699 * and leaving it gives the pill back to searching OpenVibe. */
1700 function homeView(view, info) {
1701 if (!input || tab !== 'you') return;
1702 if (view === 'drawer') { input.placeholder = `Find among ${(info && info.apps) || ''} apps`.replace(' ', ' '); input.value = ''; return; }
1703 if (/^Find among /.test(input.placeholder)) { input.placeholder = `Search ${here()}, or ask Vibe`; input.value = ''; }
1704 }
1705 function homeOpts() {
1706 const { all, ranked, f } = basis();
1707 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);
1708 const yours = ctx.token && ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];
1709 const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];
1710 const seen = new Set(), recentIds = [];
1711 for (const h of (mine ? mine.items : [])) {
1712 const id = h.service === 'media' ? 'media-hub' : h.service;
1713 if (!id || seen.has(id) || !all.some((x) => x.id === id)) continue;
1714 seen.add(id); recentIds.push(id);
1715 if (recentIds.length >= 5) break;
1716 }
1717 // Jump back in (the last places, newest first) and what this person uses most (pages opened per site).
1718 const seenB = new Set();
1719 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) : [];
1720 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 })) : [];
1721 return { onView: homeView, apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
1722 token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling,
1723 // You's Notifications tab: the whole list with Mark all read, mounted once; the count rides on the tab.
1724 notifications: (host) => mountNotes(host), unread: () => { try { return root.OpenVibeNotifications ? Number(root.OpenVibeNotifications.unread()) || 0 : 0; } catch { return 0; } },
1725 startTab: ['notifications', 'recent', 'apps'].includes(ctx.section) ? ctx.section : null, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
1726 onSearch: (q) => { input.value = q; render(q); input.focus(); },
1727 pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
1728 // The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
1729 feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
1730 }
1731
1732 /** The whole notifications list for You's tab: a head with how many are new and Mark all read, then every notice. */
1733 function mountNotes(host) {
1734 const N = root.OpenVibeNotifications;
1735 if (!N || typeof N.renderMenuNotifications !== 'function') {
1736 // The navbar fetches notification-ui.js once you are signed in; the tab can open first. Wait for it (or fetch it
1737 // here), start it with your token, then draw. It said "unavailable" for good before (11-12).
1738 host.textContent = '';
1739 const wait = document.createElement('p'); wait.className = 'ovs-note'; wait.textContent = 'Loading your notifications…'; host.appendChild(wait);
1740 const ready = () => {
1741 const M = root.OpenVibeNotifications;
1742 if (!M || typeof M.renderMenuNotifications !== 'function') { wait.textContent = 'Notifications did not load. Close the menu and open it again.'; return; }
1743 try { if (!M.__ovNavInit && ctx.token) { M.init({ token: ctx.token, apiBase: ctx.apiBase || 'https://openvibe.network' }); M.__ovNavInit = true; } } catch { /* the navbar's own init may follow */ }
1744 host.textContent = ''; mountNotes(host);
1745 };
1746 let sc = document.getElementById('ov-notify-loader');
1747 if (!sc && typeof ctx.sibling === 'function') { sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = ctx.sibling('notification-ui.js'); sc.async = true; document.head.appendChild(sc); }
1748 if (!sc) { wait.textContent = 'Notifications are on openvibe.network/notifications.'; return; }
1749 sc.addEventListener('load', () => setTimeout(ready, 0), { once: true });
1750 sc.addEventListener('error', () => { wait.textContent = 'Notifications did not load. Check your connection and open the menu again.'; }, { once: true });
1751 setTimeout(() => { if (root.OpenVibeNotifications && host.contains(wait)) ready(); }, 1200);
1752 return;
1753 }
1754 // The list draws its own search, tabs and Mark all read: the tab needs no second heading.
1755 const list = document.createElement('div'); list.className = 'ovs-notifications-host';
1756 host.append(list);
1757 try { const view = N.renderMenuNotifications(list, () => {}); if (view && view.showAll) view.showAll(); } catch { list.textContent = 'Notifications did not load.'; }
1758 }
1759
1760 function renderTab() {
1761 paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
1762 }
1763
1764 function render(q) {
1765 q = String(q || '').trim();
1766 el.classList.toggle('ovs-typing', !!q);
1767 if (door === 'chats' && canDoor()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
1768 if (door === 'vibe') return;
1769 // All apps open under You: the pill finds an app there, in place (4.229.0; one search, not a second box).
1770 const homeApi = homeViews.home && homeViews.home.api;
1771 if (tab === 'you' && homeApi && homeApi.view === 'drawer') {
1772 tabsEl.hidden = false;
1773 input.placeholder = `Find among ${homeApi.apps()} apps`;
1774 homeApi.filter(q);
1775 return;
1776 }
1777 if (tab === 'feed' && homeLib()) {
1778 tabsEl.hidden = false;
1779 input.placeholder = 'Search the feed';
1780 paintTabs(); renderTab();
1781 const feed = homeViews.wall && homeViews.wall.api;
1782 if (feed && feed.search && (q || feed.query)) feed.search(q);
1783 return;
1784 }
1785 input.placeholder = `Search ${here()}, or ask Vibe`;
1786 if (q) leaveDoor();
1787 tabsEl.hidden = !!q;
1788 if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
1789 const n = q.toLowerCase();
1790 const recentHits = recent.get().filter((x) => x.toLowerCase().startsWith(n) && x.toLowerCase() !== n).slice(0, 3).map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' }));
1791 const history = mine ? mine.items.filter((h) => h.title.toLowerCase().includes(n)).slice(0, 4).map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
1792 const first = (found) => {
1793 const owner = ctx.scope && ctx.scope.owner;
1794 const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, site: siteIcon(s.url), icon: (s.site || '?').slice(0, 2), kind: 'result' });
1795 const minehere = owner ? found.filter((s) => s.owner === owner) : [];
1796 // The rest of OpenVibe: the sites this person uses most come first, Search's own order otherwise.
1797 const rest = found.filter((s) => !minehere.includes(s)).map((s, i) => [useOf(s.owner) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
1798 return [
1799 { title: 'Go to', items: goTo(q) },
1800 { title: `On ${here()}`, items: [...minehere.map(row), ...pagesOf(q)] },
1801 { title: 'Your history', items: history },
1802 { title: 'Across OpenVibe', items: rest.map(row) },
1803 { title: 'Tools for it', items: toolsAbout(q) },
1804 { title: 'Your searches', items: recentHits },
1805 { title: 'Search', items: actions(q) },
1806 ];
1807 };
1808 paint(first([]), 'Looking…');
1809 if (inflight) inflight.abort();
1810 const ac = new AbortController();
1811 inflight = ac;
1812 el.classList.add('ovs-busy');
1813 suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {
1814 if (ac.signal.aborted) return;
1815 paint(first(found), found.length || goTo(q).length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');
1816 }).catch(() => { if (!ac.signal.aborted) paint(first([]), ''); })
1817 .finally(() => { if (inflight === ac) { inflight = null; if (el) el.classList.remove('ovs-busy'); } });
1818 }
1819 function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }
1820
1821 // ── Results: OpenVibe.Search's fabric through Network's /api/frame/query (the same connect-src as suggestions) ──
1822 /** One fabric question; the person's token goes along (their own results, the paid web providers). null on failure. */
1823 function query(q, scopes, { depth = 'standard', limit = 10, signal } = {}) {
1824 const base = ctx.suggestUrl && /\/suggest$/.test(ctx.suggestUrl) ? ctx.suggestUrl.replace(/\/suggest$/, '/query') : SUGGEST.replace(/\/suggest$/, '/query');
1825 const u = new URL(base);
1826 u.searchParams.set('q', q);
1827 u.searchParams.set('scopes', scopes.join(','));
1828 u.searchParams.set('depth', depth);
1829 u.searchParams.set('limit', String(limit));
1830 return fetch(u.href, { headers: ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}, credentials: 'omit', signal })
1831 .then((r) => (r.ok ? r.json() : null)).catch(() => null);
1832 }
1833 const SCOPES_OF = { all: ['network'], openvibe: ['network'], video: ['video'], news: ['news'], products: ['products'], mine: ['mine'], web: [] };
1834 function showResults(q, which) {
1835 q = String(q || '').trim();
1836 if (!q) return;
1837 rq = q; rtab = RTABS.some(([id]) => id === which) ? which : (which ? 'all' : rtab);
1838 if (rtab === 'mine' && !ctx.token) rtab = 'all';
1839 view = 'results';
1840 recent.add(q);
1841 clearTimeout(timer);
1842 if (inflight) { inflight.abort(); inflight = null; }
1843 if (rflight) rflight.abort();
1844 const ac = new AbortController();
1845 rflight = ac;
1846 el.classList.add('ovs-typing', 'ovs-results-on');
1847 tabsEl.hidden = true;
1848 rtabsEl.hidden = false;
1849 rtabsEl.querySelectorAll('[data-rtab]').forEach((b) => {
1850 const id = b.getAttribute('data-rtab');
1851 b.hidden = id === 'mine' && !ctx.token;
1852 b.setAttribute('aria-selected', String(id === rtab));
1853 });
1854 const fast = SCOPES_OF[rtab].concat(rtab === 'all' && ctx.token ? ['mine'] : []);
1855 const web = rtab === 'all' || rtab === 'web';
1856 const st = { q, fast: null, web: null, fastDone: !fast.length, webDone: !web, t0: Date.now(), fastMs: 0, webMs: 0 };
1857 const done = () => { if (st.fastDone && st.webDone && rflight === ac) { rflight = null; el.classList.remove('ovs-busy'); } };
1858 el.classList.add('ovs-busy');
1859 paintResults(st);
1860 if (fast.length) query(q, fast, { depth: 'quick', limit: rtab === 'all' ? 8 : 20, signal: ac.signal }).then((d) => {
1861 if (ac.signal.aborted) return;
1862 st.fast = d; st.fastDone = true; st.fastMs = Date.now() - st.t0; paintResults(st); done();
1863 });
1864 if (web) query(q, ['web'], { depth: rtab === 'web' ? 'deep' : 'standard', limit: rtab === 'web' ? 15 : 6, signal: ac.signal }).then((d) => {
1865 if (ac.signal.aborted) return;
1866 st.web = d; st.webDone = true; st.webMs = Date.now() - st.t0; paintResults(st); done();
1867 });
1868 }
1869 function leaveResults() {
1870 view = 'suggest';
1871 if (rflight) { rflight.abort(); rflight = null; }
1872 el.classList.remove('ovs-results-on');
1873 rtabsEl.hidden = true;
1874 }
1875 /** A fabric result as a row: OpenVibe's with the site's own icon, the web's with its host's letters and who found it. */
1876 function resultRow(r) {
1877 const page = r.kind === 'page';
1878 const host = r.site || hostOf(r.url);
1879 const meta = page
1880 ? [host, PROVIDER[r.provider] ? `via ${PROVIDER[r.provider]}` : '', ago(r.published_at)].filter(Boolean)
1881 : [host, TYPE[r.type] || r.type || '', ago(r.published_at)].filter(Boolean);
1882 const url = safeUrl(r.url);
1883 return url ? { title: r.title || host, meta: meta.join(' · '), snippet: r.snippet || '', url, site: page ? null : siteIcon(url), icon: page ? host.replace(/^www\./, '').slice(0, 2) : (host || '?').slice(0, 2), kind: 'result' } : null;
1884 }
1885 function paintResults(st) {
1886 const owner = ctx.scope && ctx.scope.owner;
1887 const all = (d) => (d && Array.isArray(d.results) ? d.results : []);
1888 const docs = all(st.fast).filter((r) => r.scope !== 'mine');
1889 const yours = all(st.fast).filter((r) => r.scope === 'mine').map(resultRow).filter(Boolean);
1890 const hereRows = owner ? docs.filter((r) => r.owner === owner).map(resultRow).filter(Boolean) : [];
1891 const rest = docs.filter((r) => !owner || r.owner !== owner).map(resultRow).filter(Boolean);
1892 const webRows = all(st.web).map(resultRow).filter(Boolean);
1893 const label = (RTABS.find(([id]) => id === rtab) || [])[1];
1894 const groups = [];
1895 if (rtab === 'all' || rtab === 'mine') groups.push({ title: 'Yours', items: yours });
1896 if (!st.fastDone) groups.push({ title: rtab === 'all' ? 'On OpenVibe' : label, as: 'loading', rows: 3, text: 'Searching OpenVibe' });
1897 else {
1898 if (hereRows.length) groups.push({ title: `On ${here()}`, items: hereRows });
1899 groups.push({ title: rtab === 'all' || rtab === 'openvibe' ? (hereRows.length ? 'Across OpenVibe' : 'On OpenVibe') : label, items: rest });
1900 }
1901 if (rtab === 'all' || rtab === 'web') {
1902 if (!st.webDone) groups.push({ title: 'From the web', as: 'loading', rows: rtab === 'web' ? 5 : 2, text: 'Searching the web' });
1903 else groups.push({ title: 'From the web', items: webRows });
1904 }
1905 const any = yours.length + hereRows.length + rest.length + webRows.length;
1906 const settled = st.fastDone && st.webDone;
1907 if (settled && !any) groups.push({ title: '', as: 'note', text: rtab === 'all' ? 'Nothing on OpenVibe or the web for that yet. Try other words, or ask Vibe.' : `Nothing in ${label} for that yet.` });
1908 groups.push({ title: 'More', items: actions(st.q).filter((a) => a.kind !== 'results') });
1909 const route = routeLine(st);
1910 if (route) groups.push({ as: 'route', text: route, items: [] });
1911 paint(groups);
1912 }
1913 /** What answered, in words: "OpenVibe in 40 ms · the web through DeepSeek and Wikipedia in 1.4 s (kept from earlier)". */
1914 function routeLine(st) {
1915 const parts = [];
1916 const secs = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
1917 if (st.fastDone && st.fast) parts.push(`OpenVibe in ${secs(st.fastMs)}`);
1918 if (st.webDone && st.web && st.web.route && Array.isArray(st.web.route.explain)) {
1919 const ran = st.web.route.explain.filter((x) => x.scope === 'web' && x.ok && x.count > 0);
1920 const who = ran.map((x) => PROVIDER[x.step] || x.step).filter((v, i, a) => a.indexOf(v) === i);
1921 const cached = ran.length && ran.every((x) => x.cached);
1922 if (who.length) parts.push(`the web through ${who.length > 1 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who[0]} in ${secs(st.webMs)}${cached ? ' (kept from earlier)' : ''}`);
1923 }
1924 return parts.length ? `Searched ${parts.join(' · ')}` : '';
1925 }
1926
1927 /** What people pick from the menu (section, position, where it leads), when Network asks for it: no person, no
1928 * page, summed per day there, so the menu can learn what helps and spread people across the network. */
1929 function beacon(it, a) {
1930 const b = ctx.frame && ctx.frame.menu && typeof ctx.frame.menu.beacon === 'string' ? safeUrl(ctx.frame.menu.beacon) : null;
1931 if (!b || !navigator.sendBeacon) return;
1932 const li = a.closest('[data-sec]');
1933 const sec = li ? li.getAttribute('data-sec') : '';
1934 const peers = li ? Array.prototype.indexOf.call(li.querySelectorAll('a[data-i]'), a) : -1;
1935 try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }
1936 }
1937
1938 // ── The button becomes its tab (ask 334; the owner, 11-10: "when you click this button, I want it to animate into the
1939 // tab it becomes. Same with the other buttons … if you click notifications, it should merge into the notifications
1940 // tab"). The bar button that was pressed (the orb, the bell, the picture, a balance: opts.from) flies to its tab's
1941 // icon and turns into it on the way; the bell's count flies with it to the tab's count. Closing flies it back.
1942 // 4.213.0 (the owner, 11-11: "the icon becomes whiter than it was when idle … needs general polish … effects when it
1943 // lands … improve both the open and close transitions"):
1944 // - the copy is the button exactly as it looked when pressed: its look is frozen before the press turns it bright
1945 // and into an x, with every computed paint copied (the bar's colour rules stop at its edge, so a bare copy fell
1946 // back to the brightest text colour);
1947 // - it drops first and glides in last (a landing, not a line across the search field), swelling a little and
1948 // tilting into the turn, with a short trail and a glow while it moves;
1949 // - it lands: the tab's icon gives with a squash and a ring leaves it; flying back, the bar button does the same;
1950 // - the menu's own circle grows from the button while it flies (it was cut short before), and the close orb turns
1951 // in once the button has left its place.
1952 // Motion Off skips it all; Calm plays the same path short, with no trail, tilt or glow (Calm reduces, only Off removes).
1953 let flyFrom = null, flyIdle = null;
1954 const motionLevel = () => (typeof document !== 'undefined' && document.documentElement.getAttribute('data-ov-motion')) || '';
1955 function tabFace(id) {
1956 const b = tabsEl && tabsEl.querySelector(`.ovs-tab[data-tab="${id}"], .ovs-tab[data-dock="${id}"]`);
1957 return b ? (b.querySelector('.ovs-tab-ic') || b) : null;
1958 }
1959 const PAINT = ['color', 'fill', 'stroke', 'stroke-width', 'opacity', 'fill-opacity', 'stroke-opacity', 'transform', 'transform-origin', 'transform-box', 'visibility'];
1960 /** What the eye sees of a button, as it looks now: its picture or its glyph, every computed paint copied onto the copy
1961 * (so it keeps its colour outside the bar's CSS), without ids, handlers, transitions or animations. */
1962 function faceOf(node) {
1963 const pic = (node.matches && node.matches('img, svg') && node) || node.querySelector('img, svg, .ov-icon') || node;
1964 const g = pic.cloneNode(true);
1965 try {
1966 const from = [pic, ...pic.querySelectorAll('*')], to = [g, ...g.querySelectorAll('*')];
1967 from.forEach((n, i) => {
1968 const t = to[i], cs = getComputedStyle(n);
1969 if (!t || !t.style) return;
1970 PAINT.forEach((k) => t.style.setProperty(k, cs.getPropertyValue(k)));
1971 t.style.transition = 'none'; t.style.animation = 'none';
1972 });
1973 } catch { /* a plain copy */ }
1974 if (g.removeAttribute) g.removeAttribute('id');
1975 if (g.querySelectorAll) g.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
1976 return g;
1977 }
1978 const countIn = (node) => node && node.querySelector && [...node.querySelectorAll('b, span, i')].find((n) => /^\d+\+?$/.test((n.textContent || '').trim()) && n.getBoundingClientRect().width);
1979 const inOut = (t) => (t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
1980 const fadeAt = (g, a, b) => Math.min(1, Math.max(0, (g - a) / (b - a)));
1981 /** A ring that leaves a point and fades (where the flight lands). */
1982 function ring(r, color, big) {
1983 const d = Math.max(r.width, r.height) + 10, o = document.createElement('div');
1984 o.className = 'ovs-fly-ring'; o.setAttribute('aria-hidden', 'true');
1985 o.style.cssText = `left:${r.left + r.width / 2 - d / 2}px;top:${r.top + r.height / 2 - d / 2}px;width:${d}px;height:${d}px;border-color:${color}`;
1986 document.body.appendChild(o);
1987 const a = o.animate([{ transform: 'scale(.55)', opacity: .75 }, { transform: `scale(${big ? 1.9 : 1.35})`, opacity: 0 }], { duration: big ? 520 : 320, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'forwards' });
1988 a.finished.then(() => o.remove(), () => o.remove());
1989 }
1990 function fly(fromEl, toEl, { back = false, fromFace = null, toFace = null } = {}) {
1991 const level = motionLevel();
1992 if (!fromEl || !toEl || level === 'off' || !toEl.animate || !fromEl.isConnected || !toEl.isConnected) return;
1993 const A = fromEl.getBoundingClientRect(), B = toEl.getBoundingClientRect();
1994 if (!A.width || !B.width || A.bottom < 0 || A.top > innerHeight) return;
1995 // Only a button flies, and only a button lands: a larger opener (a page passing document.body) would be copied
1996 // whole and squashed on landing, the whole page with it.
1997 if (Math.max(A.width, A.height, B.width, B.height) > 160) return;
1998 const calm = level === 'reduced';
1999 const D = calm ? 240 : back ? 340 : 460, N = calm ? 8 : 22;
2000 const accent = (getComputedStyle(document.documentElement).getPropertyValue('--accent-light') || '').trim() || '#60a5fa';
2001 const layer = document.createElement('div'); layer.className = 'ovs-fly'; layer.setAttribute('aria-hidden', 'true');
2002 const part = (face, r, cls = '') => { const box = document.createElement('div'); box.className = 'ovs-fly-part' + cls; box.style.cssText = `left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px`; box.appendChild(face); layer.appendChild(box); return box; };
2003 const srcFace = () => (fromFace ? fromFace.cloneNode(true) : faceOf(fromEl));
2004 const ghosts = calm ? [] : [part(srcFace(), A), part(srcFace(), A)];
2005 const src = part(srcFace(), A), dst = part(toFace ? toFace.cloneNode(true) : faceOf(toEl), B), riders = [];
2006 document.body.appendChild(layer);
2007 const dx = (B.left + B.width / 2) - (A.left + A.width / 2), dy = (B.top + B.height / 2) - (A.top + A.height / 2), s = B.width / A.width;
2008 // The path, from the bar to the tab: down fast, across slowly (both settle at the end). Flying back is the same
2009 // path walked the other way.
2010 const xs = (g) => g * g * (3 - 2 * g), ys = (g) => 1 - Math.pow(1 - g, 2.4);
2011 const X = (g) => (back ? 1 - xs(1 - g) : xs(g)), Y = (g) => (back ? 1 - ys(1 - g) : ys(g));
2012 const swell = calm ? 0 : .14, tilt = calm ? 0 : (dx < 0 ? -9 : 9);
2013 const frames = (fn) => Array.from({ length: N + 1 }, (_, i) => { const g = inOut(i / N); return Object.assign({ offset: i / N }, fn(g)); });
2014 const look = (g) => { const sc = 1 + (s - 1) * g + swell * Math.sin(Math.PI * g); return { sc, rot: tilt * Math.sin(Math.PI * g), glow: calm ? 0 : 7 * Math.sin(Math.PI * g) }; };
2015 const glow = (n) => (n > .2 ? `drop-shadow(0 0 ${n.toFixed(1)}px ${accent})` : 'none');
2016 const srcOp = (g) => 1 - fadeAt(g, .32, .74), dstOp = (g) => fadeAt(g, .26, .7);
2017 src.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });
2018 const end = dst.animate(frames((g) => { const l = look(g); return { transform: `translate(${-dx * (1 - X(g))}px, ${-dy * (1 - Y(g))}px) rotate(${l.rot}deg) scale(${l.sc / s})`, opacity: dstOp(g), filter: glow(l.glow) }; }), { duration: D, easing: 'linear', fill: 'forwards' });
2019 // The trail: two fainter copies a beat behind, gone before it lands.
2020 ghosts.forEach((gh, i) => gh.animate(frames((g) => { const l = look(g); return { transform: `translate(${dx * X(g)}px, ${dy * Y(g)}px) rotate(${l.rot}deg) scale(${l.sc})`, opacity: srcOp(g) * (i ? .14 : .28) }; }), { duration: D, delay: 34 * (i + 1), easing: 'linear', fill: 'forwards' }));
2021 // The count rides along (the bell's red count to the tab's, and back), when both are showing.
2022 const c1 = back ? countIn(fromEl.closest && fromEl.closest('.ovs-tab')) : countIn(fromEl);
2023 const c2 = back ? countIn(toEl) : countIn(toEl.closest && toEl.closest('.ovs-tab'));
2024 if (c1 && c2) {
2025 const R1 = c1.getBoundingClientRect(), R2 = c2.getBoundingClientRect();
2026 const pill = document.createElement('b'); pill.className = 'ovs-fly-count'; pill.textContent = (c2.textContent || '').trim();
2027 const box = part(pill, R1, ' ovs-fly-part--count');
2028 const mx = R2.left - R1.left, my = R2.top - R1.top;
2029 box.animate(frames((g) => ({ transform: `translate(${mx * X(g)}px, ${my * Y(g)}px) scale(${1 + (calm ? 0 : .12) * Math.sin(Math.PI * g)})` })), { duration: D, easing: 'linear', fill: 'forwards' });
2030 c1.style.opacity = c2.style.opacity = '0';
2031 riders.push(c1, c2);
2032 }
2033 const hidden = [fromEl, toEl, ...riders];
2034 hidden.forEach((n) => { n.style.opacity = '0'; }); // opacity, not visibility: a button still takes a press while it flies
2035 const done = () => {
2036 hidden.forEach((n) => { n.style.opacity = ''; });
2037 layer.remove();
2038 // The landing: the icon gives under it (a squash, then up past its size and back) and a ring leaves it.
2039 const give = calm ? [{ transform: 'scale(.94)' }, { transform: 'none' }]
2040 : [{ transform: 'scale(1.16, .84)' }, { transform: 'scale(.94, 1.07)', offset: .42 }, { transform: 'scale(1.02, .99)', offset: .72 }, { transform: 'none' }];
2041 try { toEl.animate(give, { duration: calm ? 160 : 380, easing: 'cubic-bezier(.2,.9,.3,1)' }); } catch { /* */ }
2042 ring(B, accent, !calm && !back);
2043 };
2044 end.finished.then(done, done);
2045 }
2046 /** After the menu has painted its tabs: they are finished where they rest (so the flight lands on them), while the
2047 * menu's own circle keeps growing from the button around the flight. */
2048 function flyIn() {
2049 const from = flyFrom;
2050 if (!from || !el) return;
2051 const to = tabFace(door || tab);
2052 if (!to) return;
2053 // Anything that moves the tabs is finished (a computer's dropdown scales in); the circle and the fade only show
2054 // more of the menu, so they keep playing.
2055 try { (el.getAnimations ? el.getAnimations({ subtree: true }) : []).forEach((a) => { if (a.animationName === 'ovs-reveal' || a.animationName === 'ovs-in') return; try { a.finish(); } catch { /* an endless one */ } }); } catch { /* */ }
2056 fly(from, to, { fromFace: flyIdle });
2057 }
2058
2059 function drawerIfAsked(api) {
2060 if (!wantDrawer || !api || typeof api.show !== 'function') return;
2061 wantDrawer = false;
2062 try { api.show('drawer'); } catch { /* the home screen stays */ }
2063 }
2064 function open(opts = {}) {
2065 homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
2066 if (typeof document === 'undefined') return;
2067 ctx = opts || {};
2068 ctx.from = opts.from || null;
2069 if (!el) build();
2070 flyFrom = opts.from && opts.from.getBoundingClientRect ? opts.from : null;
2071 // Its look now, before the press turns it bright and into an x: what flies (and what it flies back to). Only a
2072 // button-sized opener is copied.
2073 const fr = flyFrom ? flyFrom.getBoundingClientRect() : null;
2074 flyIdle = fr && fr.width && Math.max(fr.width, fr.height) <= 160 ? faceOf(flyFrom) : null;
2075 lastFocus = document.activeElement;
2076 // The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
2077 // tab, whose second half is every site; "tools" is You); else this site.
2078 // "sites" is a request for the list of every site (the legacy drawer's All sites): it opens this site's tab.
2079 const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];
2080 // The menu opens on this site's tab unless a tab was asked (the picture asks You): no tab is remembered (4.189.0).
2081 tab = asked || 'here';
2082 allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
2083 drainCards();
2084 syncFeeds();
2085 el.classList.add('is-open');
2086 tickAgo();
2087 // Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
2088 // history entry; Back pops it and only closes the menu.
2089 try {
2090 if (opts.restore) { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: true, ovsBack: null }), ''); pushed = true; }
2091 else if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; }
2092 } catch { pushed = false; }
2093 if (opts.restore && opts.restore.scroll > 0) {
2094 // Its parts arrive a moment later (the home screen, the wall): the scroll is put back as they land.
2095 const want = opts.restore.scroll;
2096 const put = () => { if (listEl && listEl.scrollTop < want) listEl.scrollTop = want; };
2097 requestAnimationFrame(put); setTimeout(put, 250); setTimeout(put, 700);
2098 }
2099 // Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
2100 const at = opts.anchor;
2101 el.classList.toggle('ovs-anchored', !!at);
2102 if (at) {
2103 el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`);
2104 const cx = Number.isFinite(at.cx) ? at.cx : (Number.isFinite(at.left) ? at.left + 20 : 40);
2105 const cy = Number.isFinite(at.cy) ? at.cy : (Number.isFinite(at.top) ? Math.max(20, at.top - 28) : 28);
2106 el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);
2107 }
2108 try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
2109 if (closing) { clearTimeout(closing); closing = 0; el.classList.remove('is-closing'); }
2110 if (orbEl) {
2111 orbEl.classList.remove('is-x', 'is-pop', 'is-gone');
2112 if (phoneSheet()) {
2113 // From the button's centre to the pill's right end: the same orb, moving into the menu. When the pressed
2114 // button flies into its tab instead, the orb is simply there as the close X.
2115 const cx = Number.isFinite(at && at.cx) ? at.cx : innerWidth - 24, cy = Number.isFinite(at && at.cy) ? at.cy : 28;
2116 const bar = el.querySelector('.ovs-bar'), br = bar ? bar.getBoundingClientRect() : null;
2117 const tx = br ? br.right - 26 : cx, ty = br ? br.top + br.height / 2 : cy;
2118 orbEl.style.setProperty('--orb-x', `${Math.round(tx - 20)}px`); orbEl.style.setProperty('--orb-y', `${Math.round(ty - 20)}px`);
2119 orbEl.style.setProperty('--orb-dx', `${Math.round(cx - tx)}px`); orbEl.style.setProperty('--orb-dy', `${Math.round(cy - ty)}px`);
2120 if (flyFrom) orbEl.classList.add('is-x', 'is-pop'); // the button has flown to its tab: the close turns in at the pill
2121 else {
2122 orbEl.classList.add('is-from');
2123 void orbEl.offsetWidth;
2124 requestAnimationFrame(() => { orbEl.classList.remove('is-from'); orbEl.classList.add('is-x'); });
2125 }
2126 }
2127 }
2128 if (root.OVSurfaces) {
2129 surface = root.OVSurfaces.sheet({ el: el.querySelector('.ovs-panel'), opener: lastFocus,
2130 header: el.querySelector('.ovs-bar'), body: listEl, close });
2131 surface.open();
2132 } else document.documentElement.style.overflow = 'hidden';
2133 input.placeholder = `Search ${here()}, or ask Vibe`;
2134 input.value = opts.q || '';
2135 paintGhost();
2136 render(input.value);
2137 if (flyFrom && !input.value) requestAnimationFrame(flyIn);
2138 // All apps is a view of You's home screen: it opens as the home screen arrives (4.229.0, you/apps).
2139 wantDrawer = opts.section === 'apps';
2140 const sec = opts.section === 'apps' ? 'home' : opts.section;
2141 if (sec) setTimeout(() => { const section = listEl.querySelector(`[data-sec="${sec}"]`); if (section && section.scrollIntoView) section.scrollIntoView({ block: 'start' }); }, 80);
2142 // On a phone the orb opens a menu, not a search: focusing the field would raise the keyboard over the menu (owner
2143 // 11-08), so focus stays on the close orb until the person taps the field. A query, or a computer, still types.
2144 setTimeout(() => { if (phoneSheet() && !opts.q && !opts.search && orbEl && orbEl.isConnected) orbEl.focus({ preventScroll: true }); else input.focus(); }, 0);
2145 // What makes it the person's own arrives after the first paint: their history, and the tools catalog.
2146 mine = null;
2147 loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });
2148 if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
2149 loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
2150 loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
2151 if (opts.door) showDoor(opts.door);
2152 const jump = opts.tab === 'tools' || opts.tab === 'sites' ? opts.tab : '';
2153 if (jump) setTimeout(() => { const t = listEl.querySelector(`[data-sec="${jump === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);
2154 }
2155 // ownBack: the history.back() a close made itself. Its popstate arrives later, and must not close a menu opened
2156 // again in between (a quick close-and-reopen on the picture lost the second open, 2026-11-10).
2157 let pushed = false, popping = false, surface = null, ownBack = false, wantDrawer = false;
2158 if (typeof window !== 'undefined') window.addEventListener('popstate', () => {
2159 if (ownBack) return;
2160 if (!(el && el.classList.contains('is-open'))) {
2161 try { if (history.state && history.state.ovsBack) document.dispatchEvent(new CustomEvent('openvibe-menu-restore')); } catch { /* */ }
2162 }
2163 });
2164 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; } });
2165 function close({ navigating = false } = {}) {
2166 // The tab flies back into the bar button that opened it (not when a link was followed: the page is going).
2167 const backTo = flyFrom; flyFrom = null;
2168 if (backTo && !navigating && el && el.classList.contains('is-open') && !closing) fly(tabFace(door || tab), backTo, { back: true, toFace: flyIdle });
2169 flyIdle = null;
2170 if (view === 'results' && rtabsEl) leaveResults();
2171 leaveDoor();
2172 if (!el || closing) return;
2173 if (navigating) { pushed = false; markBack(); }
2174 // Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
2175 if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) { ownBack = true; history.back(); } } catch { /* */ } }
2176 // A phone's menu folds back into the orb (its x turns back into dots) before it goes; elsewhere it closes at once.
2177 if (phoneSheet() && !calm() && el.classList.contains('is-open')) {
2178 el.classList.add('is-closing');
2179 // With a button flying back, the close turns away where it is (two things never land on the bar at once);
2180 // otherwise it glides back into the button's place.
2181 if (orbEl) { orbEl.classList.remove('is-x', 'is-pop'); orbEl.classList.add(backTo ? 'is-gone' : 'is-from'); }
2182 closing = setTimeout(() => { closing = 0; el.classList.remove('is-open', 'is-closing'); if (orbEl) orbEl.classList.remove('is-from', 'is-gone'); }, 280);
2183 } else {
2184 el.classList.remove('is-open');
2185 rollStop(); clearInterval(rollTimer); rolledOpen = false;
2186 if (orbEl) orbEl.classList.remove('is-x', 'is-from', 'is-pop', 'is-gone');
2187 }
2188 if (surface) { surface.destroy(); surface = null; }
2189 else document.documentElement.style.overflow = '';
2190 if (inflight) { inflight.abort(); inflight = null; }
2191 if (ctx && typeof ctx.onClose === 'function') { try { ctx.onClose(); } catch { /* */ } }
2192 try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: false } })); } catch { /* */ }
2193 if (lastFocus && lastFocus.focus) lastFocus.focus();
2194 }
2195
2196 // On a phone the empty menu keeps focus on its orb to avoid raising the keyboard. Escape must still close it.
2197 document.addEventListener('keydown', (e) => {
2198 if (e.key === 'Escape' && !e.defaultPrevented && el && el.classList.contains('is-open')) { e.preventDefault(); close(); }
2199 });
2200
2201 document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
2202
2203 /** Ask your agent: Vibe, with the words in its box, unsent (the Dock's ask() comes here when the bar wires it). */
2204 function prefill(text) {
2205 const t = String(text || '').slice(0, 4000);
2206 if (door === 'vibe' && runView) { runView.prefill(t); return true; }
2207 pendingAsk = t; runFilter = 'all';
2208 return showDoor('vibe');
2209 }
2210 const api = { open, close, door: (id) => showDoor(id), prefill, suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };
2211 root.OpenVibeSearch = api;
2212 if (typeof module === 'object' && module.exports) module.exports = api;
2213})(typeof globalThis !== 'undefined' ? globalThis : this);
2214