openvibers/openvibe.shared Commits

4.29.0: every result in the OpenVibe menu: Enter shows the Search Fabric's answer in the panel (All, OpenVibe, Web, Video, News, Products, Yours), OpenVibe's at once and the web's as it answers, each saying where it came from, and the route in words (#160)

OP OpenVibers committed 16 days ago · b1fdf12621 · 6 files, +222 −13

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e1e8d84..50a1e3c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,27 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.29.0 — 2026-10-28
++
++- **Every result, in the OpenVibe menu** (the Search Fabric, plan 2026-10-28 §4.5; Contracts 0.225.0
++ `search.fabric.query`). Pressing Enter in the menu's search box now shows every result in the panel itself.
++ - **Exceptions:** a row picked with the arrows opens that row, and a place's exact name (`live`, `tools`) still
++ goes to the place. "All results for …" and a recent search open the results too.
++ - **Tabs:** All · OpenVibe · Web · Video · News · Products, and Yours for a signed-in person.
++ - **OpenVibe's results** come at once: this site's first ("On OpenVibe.Live"), then across OpenVibe. They are
++ fetched with Network's `GET /api/frame/query` (scopes network and, signed in, mine; depth quick), through the same
++ connect-src as suggestions.
++ - **The web's results** arrive on their own request, with skeleton rows meanwhile. Each says its host and who
++ found it ("en.wikipedia.org · via Wikipedia").
++ - **Snippets** run two lines.
++ - **A closing line** says what was searched and how long it took ("Searched OpenVibe in 40 ms · the web through
++ DeepSeek and Wikipedia in 1.4 s").
++ - **Your own results and paid providers:** the person's token goes along, so their own results and the paid web
++ providers are theirs.
++ - **Safety:** titles are text and script links are dropped, as everywhere in the menu.
++ - Typing again returns to suggestions.
++- The menu's browser test waits for the lazily loaded box instead of a fixed 400 ms (a macOS runner was slower).
++
## 4.28.0 — 2026-10-28
- **The room kit** (plan 2026-10-28 §3.3–§3.5, §3.11): what every new room's pages are built from.
ddiff --git a/README.md b/README.md
iindex 78c234b..2822faf 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.28.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.29.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 09c88d4..2d6a64b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.28.0",
++ "version": "4.29.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/package.json b/package.json
iindex 8e26a4c..5b5017c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.28.0",
++ "version": "4.29.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex b61864d..b221507 100644
---- a/search.js
++++ b/search.js
@@@ -16,6 +16,13 @@
* OpenVibe.Search (sites they use most first), their recent searches, and "search everywhere" / "Ask Vibe". The box
* completes what they type from their own searches, history and the site names (Tab or → takes it).
*
++ * 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
++ * arrows or the text is a place's exact name (live, tools): tabs All · OpenVibe · Web · Video · News · Products (· Yours,
++ * signed in). OpenVibe's results come at once (Network's GET /api/frame/query, scopes network[,mine], depth quick); the
++ * web's arrive on their own (scope web) with skeleton rows meanwhile, each saying where it came from ("via Wikipedia").
++ * A line at the end says what was searched and how long it took. The person's token goes along, so their own results
++ * and the paid web providers are theirs; the query is never written into the page as markup.
++ *
* OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, tab, frame, token, apiBase, user,
* suggestUrl, icons }) navbar.js calls it
* OpenVibeSearch.close()
@@@ -58,6 +65,10 @@
const TABS = [['you', 'For you'], ['sites', 'Sites'], ['tools', 'Tools']];
let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
let tab = 'you', mine = null, catalog = null, completion = '';
++ // The results view (4.29.0): Enter shows the Search Fabric's answer here, by scope, instead of leaving the page.
++ const RTABS = [['all', 'All'], ['openvibe', 'OpenVibe'], ['web', 'Web'], ['video', 'Video'], ['news', 'News'], ['products', 'Products'], ['mine', 'Yours']];
++ const PROVIDER = { open_index: 'OpenVibe\'s index', deepseek: 'DeepSeek', wikipedia: 'Wikipedia', brave: 'Brave', exa: 'Exa', tavily: 'Tavily' };
++ let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;
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; } };
function siteOf(url) {
@@@ -198,6 +209,16 @@
.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); }
.ovs-foot kbd { font: inherit; font-weight: 700; padding: 1px 5px; border-radius: 5px; border: 1px solid var(--border, rgba(255,255,255,.14)); }
.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; }
++.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; }
++.ovs-results-on .ovs-item a { align-items: flex-start; padding: 9px 10px; }
++.ovs-results-on .ovs-ic { margin-top: 1px; text-transform: lowercase; }
++.ovs-rtabs { overflow-x: auto; scrollbar-width: none; padding-top: 8px; }
++.ovs-rtabs::-webkit-scrollbar { display: none; }
++.ovs-rtabs .ovs-tab[hidden] { display: none; }
++.ovs-skels { display: grid; gap: 6px; padding: 4px 6px 8px; }
++.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; }
++.ovs-route { padding: 10px 14px 6px; font-size: 12px; color: var(--text-muted, #93a0b4); }
++@keyframes ovs-shimmer { to { background-position: -200% 0; } }
@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ovs-scan { to { background-position: 140% 100%; } }
@media (hover: none), (max-width: 640px) { .ovs-foot, .ovs-key { display: none; } }
@@@ -212,7 +233,7 @@
.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }
@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }
--@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel { animation: none; } }`;
++@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel { animation: none; } }`;
document.head.appendChild(st);
}
@@@ -234,15 +255,18 @@
el.setAttribute('aria-label', 'Search and every OpenVibe site');
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><button type="button" class="ovs-x">Close</button></div>`
+ `<div class="ovs-tabs" role="tablist" aria-label="Show">${TABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-tab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
++ + `<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>`
+ '<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>';
input = el.querySelector('.ovs-input');
ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
tabsEl = el.querySelector('.ovs-tabs');
++ rtabsEl = el.querySelector('.ovs-rtabs');
++ rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.querySelector('.ovs-x').addEventListener('click', close);
tabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-tab]'); if (b) { tab = b.getAttribute('data-tab'); render(''); input.focus(); } });
-- input.addEventListener('input', () => { paintGhost(); schedule(input.value); });
++ input.addEventListener('input', () => { moved = false; if (view === 'results') leaveResults(); paintGhost(); schedule(input.value); });
input.addEventListener('keydown', onKey);
listEl.addEventListener('click', (e) => {
const clear = e.target.closest && e.target.closest('[data-ovs-clear]');
@@@ -250,7 +274,10 @@
const to = e.target.closest && e.target.closest('[data-ovs-tab]');
if (to) { e.preventDefault(); tab = to.getAttribute('data-ovs-tab'); render(''); input.focus(); return; }
const a = e.target.closest && e.target.closest('a[data-i]');
-- if (a) remember(items[Number(a.getAttribute('data-i'))]);
++ const it = a && items[Number(a.getAttribute('data-i'))];
++ if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
++ if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
++ if (it) remember(it);
});
document.body.appendChild(el);
}
@@@ -263,16 +290,23 @@
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!items.length) return;
++ moved = true;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
paintActive();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
++ const q = input.value.trim();
const it = items[active >= 0 ? active : 0];
++ // Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
++ if (q && !moved && view !== 'results' && !exact(it, q)) { showResults(q); return; }
++ if (it && it.kind === 'results') { showResults(q); return; }
++ if (it && it.kind === 'recent') { input.value = it.title; paintGhost(); showResults(it.title); return; }
if (it) { remember(it); location.href = it.url; }
}
}
++ const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
function remember(it) {
const q = input && input.value.trim();
if (q && it && it.kind !== 'recent') recent.add(q);
@@@ -327,15 +361,26 @@
function paint(groups, note) {
items = [];
listEl.textContent = '';
-- groups.filter((g) => g.items.length).forEach((g) => {
++ groups.filter((g) => (g.items && g.items.length) || g.as === 'note' || g.as === 'loading' || g.as === 'route').forEach((g) => {
++ if (g.as === 'route') {
++ const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
++ return;
++ }
const h = document.createElement('li');
h.className = 'ovs-group';
h.setAttribute('role', 'presentation');
h.appendChild(document.createTextNode(g.title));
if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
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); }
-- listEl.appendChild(h);
++ if (g.title) listEl.appendChild(h);
const as = g.as || 'rows';
++ if (as === 'note' || as === 'loading') {
++ const n = document.createElement('li'); n.setAttribute('role', 'presentation');
++ 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); } }
++ else { n.className = 'ovs-note'; n.textContent = g.text; }
++ listEl.appendChild(n);
++ return;
++ }
if (as === 'rows') {
g.items.forEach((it) => {
const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');
@@@ -343,8 +388,10 @@
const main = document.createElement('span'); main.className = 'ovs-main';
const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);
if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
++ if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
a.append(icon(it), main);
-- if (items.length === 1 && input.value.trim()) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
++ const q = input.value.trim();
++ 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); }
li.appendChild(a); listEl.appendChild(li);
});
return;
@@@ -371,7 +418,7 @@
listEl.appendChild(box);
});
if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }
-- active = items.length && input.value.trim() ? 0 : -1;
++ active = items.length && input.value.trim() && view !== 'results' ? 0 : -1;
paintActive();
if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }
}
@@@ -389,7 +436,7 @@
const u = safeUrl(`${ctx.scope.page}${ctx.scope.page.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`);
if (u) out.push({ title: `Search “${q}” on ${here()}`, meta: 'This site\'s own search', url: u, icon: 'search', kind: 'action' });
}
-- out.push({ title: `Search “${q}” on all of OpenVibe`, meta: 'Every site, in OpenVibe.Search', url: `${API}/?q=${encodeURIComponent(q)}`, icon: 'search', kind: 'action' });
++ 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' });
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' });
return out;
}
@@@ -509,6 +556,112 @@
}
function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }
++ // ── Results: OpenVibe.Search's fabric through Network's /api/frame/query (the same connect-src as suggestions) ──
++ /** One fabric question; the person's token goes along (their own results, the paid web providers). null on failure. */
++ function query(q, scopes, { depth = 'standard', limit = 10, signal } = {}) {
++ const base = ctx.suggestUrl && /\/suggest$/.test(ctx.suggestUrl) ? ctx.suggestUrl.replace(/\/suggest$/, '/query') : SUGGEST.replace(/\/suggest$/, '/query');
++ const u = new URL(base);
++ u.searchParams.set('q', q);
++ u.searchParams.set('scopes', scopes.join(','));
++ u.searchParams.set('depth', depth);
++ u.searchParams.set('limit', String(limit));
++ return fetch(u.href, { headers: ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}, credentials: 'omit', signal })
++ .then((r) => (r.ok ? r.json() : null)).catch(() => null);
++ }
++ const SCOPES_OF = { all: ['network'], openvibe: ['network'], video: ['video'], news: ['news'], products: ['products'], mine: ['mine'], web: [] };
++ function showResults(q, which) {
++ q = String(q || '').trim();
++ if (!q) return;
++ rq = q; rtab = RTABS.some(([id]) => id === which) ? which : (which ? 'all' : rtab);
++ if (rtab === 'mine' && !ctx.token) rtab = 'all';
++ view = 'results';
++ recent.add(q);
++ clearTimeout(timer);
++ if (inflight) { inflight.abort(); inflight = null; }
++ if (rflight) rflight.abort();
++ const ac = new AbortController();
++ rflight = ac;
++ el.classList.add('ovs-typing', 'ovs-results-on');
++ tabsEl.hidden = true;
++ rtabsEl.hidden = false;
++ rtabsEl.querySelectorAll('[data-rtab]').forEach((b) => {
++ const id = b.getAttribute('data-rtab');
++ b.hidden = id === 'mine' && !ctx.token;
++ b.setAttribute('aria-selected', String(id === rtab));
++ });
++ const fast = SCOPES_OF[rtab].concat(rtab === 'all' && ctx.token ? ['mine'] : []);
++ const web = rtab === 'all' || rtab === 'web';
++ const st = { q, fast: null, web: null, fastDone: !fast.length, webDone: !web, t0: Date.now(), fastMs: 0, webMs: 0 };
++ const done = () => { if (st.fastDone && st.webDone && rflight === ac) { rflight = null; el.classList.remove('ovs-busy'); } };
++ el.classList.add('ovs-busy');
++ paintResults(st);
++ if (fast.length) query(q, fast, { depth: 'quick', limit: rtab === 'all' ? 8 : 20, signal: ac.signal }).then((d) => {
++ if (ac.signal.aborted) return;
++ st.fast = d; st.fastDone = true; st.fastMs = Date.now() - st.t0; paintResults(st); done();
++ });
++ if (web) query(q, ['web'], { depth: rtab === 'web' ? 'deep' : 'standard', limit: rtab === 'web' ? 15 : 6, signal: ac.signal }).then((d) => {
++ if (ac.signal.aborted) return;
++ st.web = d; st.webDone = true; st.webMs = Date.now() - st.t0; paintResults(st); done();
++ });
++ }
++ function leaveResults() {
++ view = 'suggest';
++ if (rflight) { rflight.abort(); rflight = null; }
++ el.classList.remove('ovs-results-on');
++ rtabsEl.hidden = true;
++ }
++ /** 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. */
++ function resultRow(r) {
++ const page = r.kind === 'page';
++ const host = r.site || hostOf(r.url);
++ const meta = page
++ ? [host, PROVIDER[r.provider] ? `via ${PROVIDER[r.provider]}` : '', ago(r.published_at)].filter(Boolean)
++ : [host, TYPE[r.type] || r.type || '', ago(r.published_at)].filter(Boolean);
++ const url = safeUrl(r.url);
++ 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;
++ }
++ function paintResults(st) {
++ const owner = ctx.scope && ctx.scope.owner;
++ const all = (d) => (d && Array.isArray(d.results) ? d.results : []);
++ const docs = all(st.fast).filter((r) => r.scope !== 'mine');
++ const yours = all(st.fast).filter((r) => r.scope === 'mine').map(resultRow).filter(Boolean);
++ const hereRows = owner ? docs.filter((r) => r.owner === owner).map(resultRow).filter(Boolean) : [];
++ const rest = docs.filter((r) => !owner || r.owner !== owner).map(resultRow).filter(Boolean);
++ const webRows = all(st.web).map(resultRow).filter(Boolean);
++ const label = (RTABS.find(([id]) => id === rtab) || [])[1];
++ const groups = [];
++ if (rtab === 'all' || rtab === 'mine') groups.push({ title: 'Yours', items: yours });
++ if (!st.fastDone) groups.push({ title: rtab === 'all' ? 'On OpenVibe' : label, as: 'loading', rows: 3, text: 'Searching OpenVibe' });
++ else {
++ if (hereRows.length) groups.push({ title: `On ${here()}`, items: hereRows });
++ groups.push({ title: rtab === 'all' || rtab === 'openvibe' ? (hereRows.length ? 'Across OpenVibe' : 'On OpenVibe') : label, items: rest });
++ }
++ if (rtab === 'all' || rtab === 'web') {
++ if (!st.webDone) groups.push({ title: 'From the web', as: 'loading', rows: rtab === 'web' ? 5 : 2, text: 'Searching the web' });
++ else groups.push({ title: 'From the web', items: webRows });
++ }
++ const any = yours.length + hereRows.length + rest.length + webRows.length;
++ const settled = st.fastDone && st.webDone;
++ 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.` });
++ groups.push({ title: 'More', items: actions(st.q).filter((a) => a.kind !== 'results') });
++ const route = routeLine(st);
++ if (route) groups.push({ as: 'route', text: route, items: [] });
++ paint(groups);
++ }
++ /** What answered, in words: "OpenVibe in 40 ms · the web through DeepSeek and Wikipedia in 1.4 s (kept from earlier)". */
++ function routeLine(st) {
++ const parts = [];
++ const secs = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
++ if (st.fastDone && st.fast) parts.push(`OpenVibe in ${secs(st.fastMs)}`);
++ if (st.webDone && st.web && st.web.route && Array.isArray(st.web.route.explain)) {
++ const ran = st.web.route.explain.filter((x) => x.scope === 'web' && x.ok && x.count > 0);
++ const who = ran.map((x) => PROVIDER[x.step] || x.step).filter((v, i, a) => a.indexOf(v) === i);
++ const cached = ran.length && ran.every((x) => x.cached);
++ 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)' : ''}`);
++ }
++ return parts.length ? `Searched ${parts.join(' · ')}` : '';
++ }
++
function open(opts = {}) {
if (typeof document === 'undefined') return;
ctx = opts || {};
@@@ -533,6 +686,7 @@
if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
}
function close() {
++ if (view === 'results' && rtabsEl) leaveResults();
if (!el) return;
el.classList.remove('is-open');
document.documentElement.style.overflow = '';
ddiff --git a/test/search-loading-chrome.test.js b/test/search-loading-chrome.test.js
iindex 59b245e..71a3aec 100644
---- a/test/search-loading-chrome.test.js
++++ b/test/search-loading-chrome.test.js
@@@ -28,6 +28,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
window.fetch = async (u, init) => {
const url = new URL(String(u), location.href);
if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);
++ if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/query') return window.__realFetch('/query' + url.search, init);
if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');
if (url.hostname === 'openvibe.network') return new Response('{}', { status: 404 }); // the Frame and the catalog: offline here
return window.__realFetch(u, init);
@@@ -49,6 +50,19 @@ const server = http.createServer((req, res) => {
const send = () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(body); };
return q === 'slow' ? setTimeout(send, 600) : send();
}
++ if (u.pathname === '/query') {
++ // The fabric through Network (4.29.0): OpenVibe's documents at once, the web a moment later.
++ const scopes = u.searchParams.get('scopes');
++ asked.push(`query:${scopes}`);
++ const web = scopes === 'web';
++ const body = JSON.stringify(web
++ ? { results: [{ scope: 'web', kind: 'page', title: 'Night owls <i>forum</i>', url: 'https://example.org/owls', snippet: 'People who stay up.', site: 'example.org', provider: 'wikipedia' }, { scope: 'web', kind: 'page', title: 'bad', url: 'javascript:alert(1)', provider: 'deepseek' }],
++ groups: { web: 2 }, answer: null, citations: [], route: { explain: [{ scope: 'web', step: 'wikipedia', ok: true, count: 1, ms: 300 }], cost_usd: 0 }, next_cursor: null }
++ : { results: [{ scope: 'network', kind: 'document', title: 'night room', url: 'https://openvibe.chat/r/night-owls', owner: 'chat', type: 'room', snippet: 'Late chats.' }, { scope: 'network', kind: 'document', title: 'night stream', url: 'https://openvibe.video/v/1', owner: 'live', type: 'vod' }],
++ groups: { network: 2 }, answer: null, citations: [], route: { explain: [{ scope: 'network', step: 'index', ok: true, count: 2 }], cost_usd: 0 }, next_cursor: null });
++ const send = () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(body); };
++ return web ? setTimeout(send, 300) : send();
++ }
const name = u.pathname.slice(1);
if (/^[a-z-]+\.js$/.test(name) && fs.existsSync(path.join(ROOT, name))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, name))); }
res.writeHead(404); return res.end();
@@@ -73,7 +87,7 @@ const server = http.createServer((req, res) => {
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
await wait(150);
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
-- tabs: [...document.querySelectorAll('.ovs-tab')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),
++ tabs: [...document.querySelectorAll('.ovs-tab[data-tab]')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),
sites: [...document.querySelectorAll('.ovs-tile b')].map((t) => t.textContent), placeholder: document.querySelector('.ovs-input').placeholder })`);
assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
@@@ -101,10 +115,30 @@ const server = http.createServer((req, res) => {
assert.ok(!v.bold, 'no markup from a title reached the page');
assert.ok(!v.rows.some(([, u]) => /^javascript:/.test(u)), 'a script link is dropped');
assert.ok(v.rows.some(([t, u]) => t === 'Search “night” on OpenVibe.Chat' && u.endsWith('/search?q=night')), 'this site\'s own search page');
-- assert.ok(v.rows.some(([t, u]) => t === 'Search “night” on all of OpenVibe' && u === 'https://search.openvibe.network/?q=night'));
++ assert.ok(v.rows.some(([t, u]) => t === 'All results for “night”' && u === 'https://search.openvibe.network/?q=night'), 'every result, in this panel (the link for a new tab)');
assert.ok(v.rows.some(([t, u]) => t === 'Ask Vibe: “night”' && u === 'https://openvibe.chat/vibe?ask=night'));
assert.strictEqual(v.active, 'night room', 'the first row is ready for Enter');
assert.ok(asked.includes('night'), 'Search was asked');
++ // Enter (no row picked, not a place's exact name): every result here, OpenVibe's first, the web as it answers.
++ await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); 1');
++ await wait(150);
++ v = await p.evaluate(`({ rtabs: !document.querySelector('.ovs-rtabs').hidden, tabs: [...document.querySelectorAll('.ovs-rtabs .ovs-tab:not([hidden])')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),
++ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), loading: !!document.querySelector('.ovs-skel'), open: !!document.querySelector('.ovs.is-open') })`);
++ assert.ok(v.open && v.rtabs, 'Enter shows the results view in the panel');
++ assert.deepStrictEqual(v.tabs, ['All*', 'OpenVibe', 'Web', 'Video', 'News', 'Products'], 'scope tabs; Yours only for a signed-in person');
++ assert.deepStrictEqual(v.groups.slice(0, 2), ['On OpenVibe.Chat', 'Across OpenVibe'], 'this site first');
++ assert.ok(v.loading && v.groups.includes('From the web'), 'the web is still coming: skeleton rows, OpenVibe already shown');
++ await wait(500);
++ v = await p.evaluate(`({ rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href'), (a.querySelector('.ovs-meta') || {}).textContent || '']),
++ route: (document.querySelector('.ovs-route') || {}).textContent, markup: !!document.querySelector('.ovs-list i') })`);
++ assert.ok(v.rows.some(([t, u, m]) => t === 'Night owls <i>forum</i>' && u === 'https://example.org/owls' && m === 'example.org · via Wikipedia'), 'a web result says where it came from');
++ assert.ok(!v.markup && !v.rows.some(([, u]) => /^javascript:/.test(u)), 'titles are text and script links are dropped');
++ assert.ok(/^Searched OpenVibe in \d+ ms · the web through Wikipedia in /.test(v.route), `the route in words: ${v.route}`);
++ assert.ok(asked.includes('query:network') && asked.includes('query:web'), 'two questions: OpenVibe quickly, the web on its own');
++ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'nigh'; i.dispatchEvent(new Event('input')); return 1; })()`);
++ assert.strictEqual(await p.evaluate('document.querySelector(".ovs-rtabs").hidden'), true, 'typing goes back to suggestions');
++ await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'night'; i.dispatchEvent(new Event('input')); return 1; })()`);
++ await wait(400);
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');