openvibers/openvibe.shared search.js
search.js
| 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 | ; |
| 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; } |
| 290 | html[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; } } |
| 312 | html[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); } } |
| 346 | html[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); } } |
| 363 | html[data-ov-motion=reduced] .ovs-panel::before { animation-duration: 60s; } |
| 364 | html[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 |