openvibers/openvibe.shared Commits
4.149.0: the menu has tabs at the top (this site, Feed, You), remembered (#339)
Changes
@@@ -1,5 +1,24 @@# Changelog++## 4.149.0 — 2026-11-08++++- **The menu has tabs at the top** (the owner, 11-08: "we should have tabs at the top … a feed tab, which allows the++ user to set the feeds … remember whichever tab the user last had open … the main tab should just show links that++ exist on that site, and under that the full list of sites … make it nicer, more compact"). Under the search field,++ one segmented row: **this site** (labelled with its name), **Feed** and **You**. The menu reopens on the tab last++ chosen (this device); the legacy drawer's "All sites" still opens on this site's tab.++ - **This site:** its own pages, then every OpenVibe site in a compact grid (a small icon and the name, three to a++ row, two on a phone), grouped as the Frame groups them with this site's group first and this site marked. No fold.++ Who is live shows here only on openvibe.live; everywhere else it is the Feed's (it confused people elsewhere).++ - **Feed:** who is live (and a red dot on the tab while anyone is), the feeds the person chose (Video's trending,++ the forum, Games' new worlds…), what changed lately, places new to them and where people go next from here; the++ chooser ("Choose your feeds (2 of 4)") at its end.++ - **You:** their workspace (signed in), their places (else the popular ones), the pages they were on, tools, their++ searches, and the display controls (theme, text size, motion).++ - Tighter section headings; switching tabs on a phone does not raise the keyboard.++ Tests: menu-feeds-chrome (three tabs; the feeds are on Feed; it reopens on the last tab), navbar-launcher (the list++ of every site, compact, no fold; Display on You), search-loading-chrome.++## 4.148.0 — 2026-11-08- **On a phone the menu button is the orb, in the burger's place** (the owner, 11-08: "replace the hamburger with@@@ -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.148.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.149.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.148.0",++ "version": "4.149.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)",@@@ -537,7 +537,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'b563663a49d6', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '6da35435c118', 'dock.js': 'b563663a49d6', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.148.0",++ "version": "4.149.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -62,9 +62,15 @@{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },];-- const TABS = [['you', 'Start'], ['sites', 'Sites'], ['tools', 'Tools'], ['here', 'Here']];++ // The menu's three tabs (the owner, 11-08: "we should have tabs at the top … a feed tab … remember whichever tab the++ // user last had open … the main tab should just show links that exist on that site, and under that the full list++ // of sites"). The first is labelled with this site's own name.++ const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];++ const TAB_KEY = 'ovs-tab';++ const savedTab = () => { try { const v = localStorage.getItem(TAB_KEY); return TABS.some(([id]) => id === v) ? v : null; } catch { return null; } };++ const saveTab = (v) => { try { localStorage.setItem(TAB_KEY, v); } catch { /* private window */ } };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 = '';++ let tab = 'here', 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' };@@@ -188,6 +194,15 @@.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }}.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }++/* The menu's three tabs (4.149.0): one segmented row under the field; the chosen one in the accent. */++.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }++.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); }++.ovs-starttabs .ovs-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.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); }++.ovs-grid.is-mini { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; padding: 0 2px 4px; }++.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }++.ovs-grid.is-mini .ovs-ic { width: 26px; height: 26px; border-radius: 8px; }++.ovs-grid.is-mini .ovs-tile b { font-size: 13px; font-weight: 600; }.ovs-tabs[hidden] { display: none; }.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; }.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }@@@ -199,7 +214,7 @@.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; }@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }--.ovs-group { display: flex; align-items: center; gap: 8px; padding: 12px 10px 5px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }++.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); }.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; }.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }@@@ -336,7 +351,12 @@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(); } });++ tabsEl.addEventListener('click', (e) => {++ const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;++ tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;++ let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }++ if (touch) b.focus(); else input.focus();++ });input.addEventListener('input', () => { moved = false; if (view === 'results') leaveResults(); paintGhost(); schedule(input.value); });input.addEventListener('keydown', onKey);listEl.addEventListener('click', (e) => {@@@ -562,7 +582,7 @@const box = document.createElement('li');box.setAttribute('role', 'presentation');if (g.section) box.setAttribute('data-sec', g.section);-- box.className = as === 'chips' ? 'ovs-chips' : as === 'apps' ? 'ovs-apps' : as === 'links' ? 'ovs-links' : as === 'livebar' ? 'ovs-livebar' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;++ 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' : ''}`;g.items.forEach((it, k) => {if (as === 'chips') {const a = option('a', 'ovs-chip', it);@@@ -600,7 +620,9 @@if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }-- a.append(icon(it), txt);++ const ic = icon(it);++ if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '22'); }++ a.append(ic, txt);box.appendChild(a);});listEl.appendChild(box);@@@ -829,26 +851,80 @@// The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];-- function renderTab() {++ /** The tab strip under the search field: this site's name, Feed (a red dot while anyone is live), You. */++ function paintTabs() {++ tabsEl.hidden = false;++ tabsEl.setAttribute('role', 'tablist');++ tabsEl.setAttribute('aria-label', 'Menu');++ tabsEl.textContent = '';++ const live = pulseData && Number(pulseData.live_now) > 0;++ for (const [id, label] of TABS) {++ const b = document.createElement('button');++ b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);++ b.setAttribute('aria-selected', tab === id ? 'true' : 'false');++ const t = document.createElement('span'); t.textContent = id === 'here' ? here() : label; b.appendChild(t);++ if (id === 'feed' && live) { const d = document.createElement('i'); d.className = 'ovs-tabdot'; d.setAttribute('aria-label', 'live now'); b.appendChild(d); }++ tabsEl.appendChild(b);++ }++ }++++ /** What every tab needs: the sites, this one, and the person's ranking of them. */++ function basis() {// This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;-- const byId = new Map(all.map((s) => [s.id, s]));++ const byId = new Map(all.map((x) => [x.id, x]));const shown = new Set(mineSite ? [mineSite.id] : []);-- const take = (list, n) => { const out = []; for (const s of list) { if (out.length >= n) break; if (s && !shown.has(s.id)) { out.push(s); shown.add(s.id); } } return out; };-- const groups = [];-- const f0 = ctx.frame;-- // 1. Who is live (Network's pulse): the network's heart, one tap from every site.++ 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; };++ const ranked = all.map((x, i) => [useOf(x.id) * 1000 - i, x]).sort((a, b) => b[0] - a[0]).map(([, x]) => x);++ return { all, mineSite, byId, shown, take, ranked, f: ctx.frame };++ }++++ /** Who is live (Network's pulse), as the live bar: the count, then the live channels when Network names them. */++ function liveGroup() {const p = pulseData;const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;-- if (liveNow) {-- 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) : [];-- groups.push({ title: '', as: 'livebar', 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] });-- }-- // 1b. The network's cards: what sites publish for every menu (Network collects each site's-- // /.well-known/openvibe-menu.json into the Frame's `cards`): Live's channels, Games' new worlds, Video's trending.-- // Text and links only (never another site's code), checked here again, with an entry's https picture when it-- // sent one; at most two, Network's order.-- const feeds = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];++ if (!liveNow) return null;++ 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) : [];++ 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] };++ }++++ /**++ * This site's tab: its own pages, then every OpenVibe site, compact and grouped (this site's group first). People++ * live show here only on Live itself; everywhere else they are the Feed's.++ */++ function hereGroups() {++ const { all, mineSite, byId, f } = basis();++ const groups = [];++ if (mineSite && mineSite.id === 'live') { const g = liveGroup(); if (g) groups.push(g); }++ // This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).++ for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });++ 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);++ if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });++ const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];++ if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));++ const inGroup = (x, g) => !g.id || x.group === g.id || (!fgroups.some((y) => y.id === x.group) && g === fgroups[fgroups.length - 1]);++ const mini = (x) => siteItem(x, all);++ fgroups.forEach((g, i) => {++ const list = all.filter((x) => inGroup(x, g)).map(mini);++ if (list.length) groups.push({ title: i === 0 && fgroups.length > 1 ? g.name : g.name, as: 'mini', items: list, section: 'all' });++ });++ const soon = f && Array.isArray(f.soon) ? f.soon : [];++ 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' });++ void byId;++ return groups;++ }++++ /**++ * The Feed: who is live, the feeds the person chose (the sites' cards: Video's trending, Games' new worlds, the++ * forum…), what changed lately, places new to them and where people go next from here; the chooser at the end.++ */++ function feedGroups() {++ const { all, mineSite, byId, shown, take, ranked, f } = basis();++ const groups = [];++ const live = liveGroup(); if (live) groups.push(live);++ // The network's cards: text and links only (never another site's code), checked here again, with an entry's https++ // picture when it sent one; the person's choice and order, else Network's first two.++ 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) : [];const chosen = feedPref();const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);netCards.forEach((c) => {@@@ -858,45 +934,7 @@site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);if (rows.length) groups.push({ title: String(c.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 });});-- // 1c. Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).-- if (feeds.length > 1 || chosen) {-- groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${feeds.length})` }, section: 'feeds' });-- 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) })) });-- }-- // 2. The places this person uses most (else the network's most used): a dock of big icons.-- const yours = mine && mine.services.some((x) => x.count > 0);-- const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);-- // Without their own history, developer services (Media's API, Events, Zone…) are not places to visit: they stay in-- // the full list and come up here only once the person uses them.-- const dockPool = yours ? ranked : ranked.filter((s) => s.group !== 'build' || useOf(s.id));-- groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((s) => siteItem(s)), section: 'places' });-- // 2b. Their workspace, right under their places: agents and Vibe, Codes, files, machines, wallet, account, themes.-- if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });-- // 2c. This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).-- for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });-- // 3. This site's own pages: the bar's links, two to a row; more behind a count.-- const allPages = (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);-- if (allPages.length) {-- const head = hereOpen ? allPages : allPages.slice(0, 6);-- const hereGroup = { title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null };-- if (leadHere) groups.unshift(hereGroup); else groups.push(hereGroup);-- }-- // 4. Where people go next from here: the Frame's list, else the moves Network saw (11-07) and this site's group.-- const f = ctx.frame;-- const nextIds = mineSite ? ((f && f.next && Array.isArray(f.next[mineSite.id]) && f.next[mineSite.id]) || NEXT_OF[mineSite.id] || all.filter((s) => s.group === mineSite.group).map((s) => s.id)) : [];-- const next = take(nextIds.map((id) => byId.get(id)), 4);-- if (next.length) groups.push({ title: `Next from ${here()}`, as: 'sites', items: next.map((s) => Object.assign(siteItem(s), { meta: NEXT_WHY[s.id] || s.desc })), section: 'next' });-- // 5. Discover: two places new to this person, rotating fairly through the whole network.-- // Developer services are offered to people who already use one; everyone else discovers places to visit.-- const builder = all.some((s) => s.group === 'build' && useOf(s.id));-- const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)-- .filter((s) => !shown.has(s.id) && (builder || s.group !== 'build'));-- const disc = pickDiscover(discoverPool);-- disc.forEach((s) => shown.add(s.id));-- if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((s) => Object.assign(siteItem(s), { tag: 'New' })), section: 'discover' });-- // 5b. What's new (the owner, 11-07: "check which of the sites got updates recently, and if the user has been on-- // since … sort them by last update … bring you to the /updates page"): the newest real change per site,-- // newest first, "New" when it shipped after this person's last visit there.++ // What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.if (Array.isArray(changesData) && changesData.length) {const newest = new Map();for (const e of changesData) {@@@ -913,40 +951,57 @@}).filter((x) => x.url);if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });}-- // 7. Tools: the ones they use, then the everyday ones.-- const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);-- groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });-- // 8. Their searches.-- const r = recent.get();-- 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' });-- // 9. Every site, grouped, behind one count (this site's group first).-- const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];-- if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));-- groups.push({ title: '', as: 'fold', items: [], fold: { open: allOpen, label: allOpen ? 'Hide the list of sites' : `Every OpenVibe site (${all.length})`, key: 'all' }, section: 'all' });-- if (allOpen) {-- const inGroup = (s, g) => !g.id || s.group === g.id || (!fgroups.some((x) => x.id === s.group) && g === fgroups[fgroups.length - 1]);-- const full = (s) => Object.assign(siteItem(s, all), { host: hostOf(s.url).replace(/^www\./, '') });-- const flag = FLAGSHIPS.map((id) => byId.get(id)).filter(Boolean);-- if (flag.length) groups.push({ title: 'Flagships', as: 'sites', items: flag.map(full), section: 'all' });-- fgroups.forEach((g) => groups.push({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g)).map(full), section: 'all' }));-- const soon = f && Array.isArray(f.soon) ? f.soon : [];-- 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' });++ // Two places new to this person, rotating fairly through the network (developer services only for builders).++ const builder = all.some((x) => x.group === 'build' && useOf(x.id));++ const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)++ .filter((x) => !shown.has(x.id) && (builder || x.group !== 'build'));++ const disc = pickDiscover(discoverPool);++ disc.forEach((x) => shown.add(x.id));++ if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((x) => Object.assign(siteItem(x), { tag: 'New' })), section: 'discover' });++ // Where people go next from here: the Frame's list, else the moves Network saw and this site's group.++ 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)) : [];++ const next = take(nextIds.map((id) => byId.get(id)), 4);++ 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' });++ // Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).++ if (feeds.length > 1 || chosen) {++ groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${netCards.length} of ${feeds.length})` }, section: 'feeds' });++ 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) })) });}-- // 10. Last: the pages they were on.++ if (!groups.length) groups.push({ as: 'note', text: 'Nothing new right now. Choose your feeds, or look around the sites on the first tab.' });++ return groups;++ }++++ /** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */++ function youGroups() {++ const { take, ranked } = basis();++ const groups = [];++ if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });++ const yours = mine && mine.services.some((x) => x.count > 0);++ // Without their own history, developer services are not places to visit: they come up here once used.++ const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));++ groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((x) => siteItem(x)), section: 'places' });const seen = new Set();const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5).map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];groups.push({ title: 'Jump back in', items: back, section: 'back' });-- // Display: theme, text size and motion, at the foot (the drawer's row, now in the one menu).++ const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);++ groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });++ const r = recent.get();++ 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' });++ // Display: theme, text size and motion.if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });-- paint(groups);++ return groups;++ }++++ function renderTab() {++ paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());}function render(q) {q = String(q || '').trim();el.classList.toggle('ovs-typing', !!q);-- tabsEl.hidden = true;-- if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); renderTab(); return; }++ tabsEl.hidden = !!q;++ if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }const n = q.toLowerCase();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' }));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' })) : [];@@@ -1101,10 +1156,12 @@ctx = opts || {};if (!el) build();lastFocus = document.activeElement;-- tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';-- // The old tabs, kept as a way in: "sites" opens the list of every site, "tools" scrolls to the tools.-- // From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.-- allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;++ // The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's++ // tab, whose second half is every site; "tools" is You); else this site.++ // "sites" is a request for the list of every site (the legacy drawer's All sites): it opens this site's tab.++ const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];++ tab = opts.tab === 'sites' ? 'here' : (savedTab() || asked || 'here');++ allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;drainCards();syncFeeds();el.classList.add('is-open');@@@ -63,6 +63,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait until(p, `!!localStorage.getItem('ov_frame_v2')`);await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');++ // 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.++ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first');++ assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');++ await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);await wait(150);assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a@@@ -70,7 +74,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neconst looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);assert.deepStrictEqual(looks, [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);-- assert.strictEqual(fold, 'Choose your feeds (4)', 'every card with entries can be chosen; the empty one is not offered');++ assert.strictEqual(fold, 'Choose your feeds (2 of 4)', 'every card with entries can be chosen; the empty one is not offered');await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');@@@ -104,6 +108,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));await wait(150);++ assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'feed', 'it reopens on the tab last chosen');assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.const ctx2 = await browser.newContext();@@@ -112,6 +117,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neawait until(q, `!!localStorage.getItem('ov_frame_v2')`);await q.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);assert.ok(await until(q, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened (signed in)');++ await q.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);assert.ok(await until(q, `JSON.stringify(${SHOWN}) === JSON.stringify(['Top stories', 'Today'])`), 'the feeds saved on Network show on a new device');await q.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);await until(q, `!!document.querySelector('.ovs-feeds')`);@@@ -57,28 +57,32 @@ const server = http.createServer((req, res) => {await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-group')) { clearInterval(t); r(true); } }, 20); })`);const first = await p.evaluate(`({ head: document.querySelector('.ovs-list .ovs-group').firstChild.textContent, sec: document.querySelector('.ovs-list .ovs-group').getAttribute('data-sec'), display: !!document.querySelector('.ovs-displayrow .ovnav-display'), expanded: document.getElementById('openvibe-burger').getAttribute('aria-expanded') })`);assert.strictEqual(first.sec, 'here', 'from the burger, this site\'s pages lead: ' + first.head);-- assert.strictEqual(first.display, true, 'Display (theme, text size, motion) at the menu\'s foot');++ // 4.149.0: tabs at the top; Display (theme, text size, motion) is the You tab's.++ assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');++ await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);++ await wait(150);++ assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab');assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);-- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-mini[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {open: (l.querySelector('[data-ovs-fold="all"]') || { getAttribute: () => null }).getAttribute('aria-expanded'),heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),listHeads: [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()).filter((h) => h !== 'Flagships'),-- groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),-- here: [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),-- small: (l.querySelector('.ovs-grid.is-sites[data-sec="all"] .ovs-tile small') || {}).textContent,++ groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-mini[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),++ here: [...l.querySelectorAll('.ovs-grid.is-mini[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),++ small: (l.querySelector('.ovs-grid.is-mini[data-sec="all"] .ovs-tile small') || {}).textContent,rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);const r = await read();-- assert.strictEqual(r.open, 'true', 'the list of every site is open');++ assert.strictEqual(r.open, null, 'the list of every site is open, with no fold (4.149.0)');assert.deepStrictEqual(r.listHeads.slice(0, 3), ['Talk & learn', 'Watch & stream', 'Your OpenVibe'], 'this site\'s group first, then the Frame\'s groups in its order');assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');assert.deepStrictEqual(r.groups[1].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');assert.ok(r.groups[1].includes('Pics'), 'a product on its own domain sits in its group');assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');-- assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');++ assert.strictEqual(r.small, undefined, 'the list is compact: a name and an icon, no tagline (4.149.0)');// Typing goes to a site by its name, first.await p.evaluate(`(() => { const q = document.querySelector('.ovs-input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);// The list redraws after a short debounce: wait for it (up to 5 s; macOS runners are slower than 500 ms).@@@ -89,13 +89,14 @@ const server = http.createServer((req, res) => {let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,tabs: document.querySelectorAll('.ovs-tab[data-tab]').length,groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), links: [...document.querySelectorAll('.ovs-link span')].map((t) => t.textContent),-- sites: [...document.querySelectorAll('.ovs-appname')].map((t) => t.textContent), fold: (document.querySelector('[data-ovs-fold="all"]') || {}).textContent,++ sites: [...document.querySelectorAll('.ovs-grid.is-mini .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');-- assert.strictEqual(v.tabs, 0, 'one calm scroll, no tabs (4.128.0, the owner 11-07: "rethink the whole idea of this whole menu")');-- assert.deepStrictEqual([v.groups, v.links], [['Popular on OpenVibe', 'On Chat'], ['Rooms', 'Servers']], 'nothing typed: the places first (one tap to Live), then this site\'s own pages');-- assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');-- assert.strictEqual(v.fold, 'Every OpenVibe site (6)', 'every site behind one counted fold');++ // 4.149.0 (the owner, 11-08: "tabs at the top … the main tab should just show links that exist on that site, and++ // under that the full list of sites"): this site's tab, Feed and You; nothing typed opens on this site's tab.++ assert.strictEqual(v.tabs, 3, 'three tabs at the top');++ assert.deepStrictEqual([v.groups[0], v.links], ['On Chat', ['Rooms', 'Servers']], 'nothing typed: this site\'s own pages first');++ assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'then every site, even before the Frame answers');assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);@@@ -104,8 +105,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);await wait(100);-- await p.evaluate('document.querySelector("[data-ovs-fold=all]").click(); 1');-- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile") && document.querySelector("[data-ovs-fold=all]").getAttribute("aria-expanded") === "true"'), 'the fold opens the list of every site in place');++ assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold');// Typing: one request for the latest text (the earlier one cancelled), this site's results first.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);await wait(1200);@@@ -156,7 +156,8 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──-- await p.evaluate(`OpenVibeSearch.open({ service: 'chat', scope: { label: 'Chat', page: '/search' }, links: [], frame: { cards: [++ // The cards are the Feed tab's (4.149.0).++ await p.evaluate(`OpenVibeSearch.open({ service: 'chat', tab: 'feed', scope: { label: 'Chat', page: '/search' }, links: [], frame: { cards: [{ id: 'trending', site: 'media-hub', title: 'Trending', kind: 'rows', items: [{ title: 'A walk', meta: '9 views this week', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },{ title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },
@@ -1,5 +1,24 @@
@@ -1,5 +1,24 @@
# Changelog
# Changelog
+## 4.149.0 — 2026-11-08
+
+- **The menu has tabs at the top** (the owner, 11-08: "we should have tabs at the top … a feed tab, which allows the
+ user to set the feeds … remember whichever tab the user last had open … the main tab should just show links that
+ exist on that site, and under that the full list of sites … make it nicer, more compact"). Under the search field,
+ one segmented row: **this site** (labelled with its name), **Feed** and **You**. The menu reopens on the tab last
+ chosen (this device); the legacy drawer's "All sites" still opens on this site's tab.
+ - **This site:** its own pages, then every OpenVibe site in a compact grid (a small icon and the name, three to a
+ row, two on a phone), grouped as the Frame groups them with this site's group first and this site marked. No fold.
+ Who is live shows here only on openvibe.live; everywhere else it is the Feed's (it confused people elsewhere).
+ - **Feed:** who is live (and a red dot on the tab while anyone is), the feeds the person chose (Video's trending,
+ the forum, Games' new worlds…), what changed lately, places new to them and where people go next from here; the
+ chooser ("Choose your feeds (2 of 4)") at its end.
+ - **You:** their workspace (signed in), their places (else the popular ones), the pages they were on, tools, their
+ searches, and the display controls (theme, text size, motion).
+ - Tighter section headings; switching tabs on a phone does not raise the keyboard.
+ Tests: menu-feeds-chrome (three tabs; the feeds are on Feed; it reopens on the last tab), navbar-launcher (the list
+ of every site, compact, no fold; Display on You), search-loading-chrome.
+
## 4.148.0 — 2026-11-08
## 4.148.0 — 2026-11-08
- **On a phone the menu button is the orb, in the burger's place** (the owner, 11-08: "replace the hamburger with
- **On a phone the menu button is the orb, in the burger's place** (the owner, 11-08: "replace the hamburger with
@@ -2,7 +2,7 @@
@@ -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.
> 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.148.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.149.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.148.0",
+ "version": "4.149.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -537,7 +537,7 @@
@@ -537,7 +537,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '295b2fffb4e1', 'dock.js': 'b563663a49d6', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'fa4ded2928f4', 'ov-share.css': 'cbd18e6ce991', 'search.js': '6da35435c118', 'dock.js': 'b563663a49d6', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': '3ceb5266e7b8', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.148.0",
+ "version": "4.149.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -62,9 +62,15 @@
@@ -62,9 +62,15 @@
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
{ name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
];
];
- const TABS = [['you', 'Start'], ['sites', 'Sites'], ['tools', 'Tools'], ['here', 'Here']];
+ // The menu's three tabs (the owner, 11-08: "we should have tabs at the top … a feed tab … remember whichever tab the
+ // user last had open … the main tab should just show links that exist on that site, and under that the full list
+ // of sites"). The first is labelled with this site's own name.
+ const TABS = [['here', ''], ['feed', 'Feed'], ['you', 'You']];
+ const TAB_KEY = 'ovs-tab';
+ const savedTab = () => { try { const v = localStorage.getItem(TAB_KEY); return TABS.some(([id]) => id === v) ? v : null; } catch { return null; } };
+ const saveTab = (v) => { try { localStorage.setItem(TAB_KEY, v); } catch { /* private window */ } };
let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
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 = '';
+ let tab = 'here', 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.
// 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 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' };
const PROVIDER = { open_index: 'OpenVibe\'s index', deepseek: 'DeepSeek', wikipedia: 'Wikipedia', brave: 'Brave', exa: 'Exa', tavily: 'Tavily' };
@@ -188,6 +194,15 @@
@@ -188,6 +194,15 @@
.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
.ovs-x:active { background: var(--bg-active, rgba(255,255,255,.14)); color: var(--text-primary, #fff); }
}
}
.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
+/* The menu's three tabs (4.149.0): one segmented row under the field; the chosen one in the accent. */
+.ovs-starttabs { gap: 6px; padding: 2px 12px 10px; }
+.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); }
+.ovs-starttabs .ovs-tab span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.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); }
+.ovs-grid.is-mini { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; padding: 0 2px 4px; }
+.ovs-grid.is-mini .ovs-tile { gap: 8px; min-height: 40px; padding: 4px 8px; border-radius: 10px; }
+.ovs-grid.is-mini .ovs-ic { width: 26px; height: 26px; border-radius: 8px; }
+.ovs-grid.is-mini .ovs-tile b { font-size: 13px; font-weight: 600; }
.ovs-tabs[hidden] { display: none; }
.ovs-tabs[hidden] { display: none; }
.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; }
.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; }
.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
@@ -199,7 +214,7 @@
@@ -199,7 +214,7 @@
.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; }
.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; }
@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
-.ovs-group { display: flex; align-items: center; gap: 8px; padding: 12px 10px 5px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
+.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); }
.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; }
.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; }
.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }
.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }
@@ -336,7 +351,12 @@
@@ -336,7 +351,12 @@
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
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.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.querySelector('.ovs-x').addEventListener('click', 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(); } });
+ tabsEl.addEventListener('click', (e) => {
+ const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
+ tab = b.getAttribute('data-tab'); saveTab(tab); render(''); listEl.scrollTop = 0;
+ let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
+ if (touch) b.focus(); else input.focus();
+ });
input.addEventListener('input', () => { moved = false; if (view === 'results') leaveResults(); paintGhost(); schedule(input.value); });
input.addEventListener('input', () => { moved = false; if (view === 'results') leaveResults(); paintGhost(); schedule(input.value); });
input.addEventListener('keydown', onKey);
input.addEventListener('keydown', onKey);
listEl.addEventListener('click', (e) => {
listEl.addEventListener('click', (e) => {
@@ -562,7 +582,7 @@
@@ -562,7 +582,7 @@
const box = document.createElement('li');
const box = document.createElement('li');
box.setAttribute('role', 'presentation');
box.setAttribute('role', 'presentation');
if (g.section) box.setAttribute('data-sec', g.section);
if (g.section) box.setAttribute('data-sec', g.section);
- box.className = as === 'chips' ? 'ovs-chips' : as === 'apps' ? 'ovs-apps' : as === 'links' ? 'ovs-links' : as === 'livebar' ? 'ovs-livebar' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;
+ 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' : ''}`;
g.items.forEach((it, k) => {
g.items.forEach((it, k) => {
if (as === 'chips') {
if (as === 'chips') {
const a = option('a', 'ovs-chip', it);
const a = option('a', 'ovs-chip', it);
@@ -600,7 +620,9 @@
@@ -600,7 +620,9 @@
if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }
if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
- a.append(icon(it), txt);
+ const ic = icon(it);
+ if (as === 'mini') { const sp = ic.querySelector('.ov-icon'); if (sp) sp.setAttribute('data-size', '22'); }
+ a.append(ic, txt);
box.appendChild(a);
box.appendChild(a);
});
});
listEl.appendChild(box);
listEl.appendChild(box);
@@ -829,26 +851,80 @@
@@ -829,26 +851,80 @@
// The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
// The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];
const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];
- function renderTab() {
+ /** The tab strip under the search field: this site's name, Feed (a red dot while anyone is live), You. */
+ function paintTabs() {
+ tabsEl.hidden = false;
+ tabsEl.setAttribute('role', 'tablist');
+ tabsEl.setAttribute('aria-label', 'Menu');
+ tabsEl.textContent = '';
+ const live = pulseData && Number(pulseData.live_now) > 0;
+ for (const [id, label] of TABS) {
+ const b = document.createElement('button');
+ b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab'); b.setAttribute('data-tab', id);
+ b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
+ const t = document.createElement('span'); t.textContent = id === 'here' ? here() : label; b.appendChild(t);
+ if (id === 'feed' && live) { const d = document.createElement('i'); d.className = 'ovs-tabdot'; d.setAttribute('aria-label', 'live now'); b.appendChild(d); }
+ tabsEl.appendChild(b);
+ }
+ }
+
+ /** What every tab needs: the sites, this one, and the person's ranking of them. */
+ function basis() {
// This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).
// This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).
const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;
const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;
- const byId = new Map(all.map((s) => [s.id, s]));
+ const byId = new Map(all.map((x) => [x.id, x]));
const shown = new Set(mineSite ? [mineSite.id] : []);
const shown = new Set(mineSite ? [mineSite.id] : []);
- const take = (list, n) => { const out = []; for (const s of list) { if (out.length >= n) break; if (s && !shown.has(s.id)) { out.push(s); shown.add(s.id); } } return out; };
- const groups = [];
- const f0 = ctx.frame;
- // 1. Who is live (Network's pulse): the network's heart, one tap from every site.
+ 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; };
+ const ranked = all.map((x, i) => [useOf(x.id) * 1000 - i, x]).sort((a, b) => b[0] - a[0]).map(([, x]) => x);
+ return { all, mineSite, byId, shown, take, ranked, f: ctx.frame };
+ }
+
+ /** Who is live (Network's pulse), as the live bar: the count, then the live channels when Network names them. */
+ function liveGroup() {
const p = pulseData;
const p = pulseData;
const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;
const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;
- if (liveNow) {
- 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) : [];
- groups.push({ title: '', as: 'livebar', 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] });
- }
- // 1b. The network's cards: what sites publish for every menu (Network collects each site's
- // /.well-known/openvibe-menu.json into the Frame's `cards`): Live's channels, Games' new worlds, Video's trending.
- // Text and links only (never another site's code), checked here again, with an entry's https picture when it
- // sent one; at most two, Network's order.
- const feeds = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
+ if (!liveNow) return null;
+ 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) : [];
+ 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] };
+ }
+
+ /**
+ * This site's tab: its own pages, then every OpenVibe site, compact and grouped (this site's group first). People
+ * live show here only on Live itself; everywhere else they are the Feed's.
+ */
+ function hereGroups() {
+ const { all, mineSite, byId, f } = basis();
+ const groups = [];
+ if (mineSite && mineSite.id === 'live') { const g = liveGroup(); if (g) groups.push(g); }
+ // This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
+ for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
+ 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);
+ if (pages.length) groups.push({ title: `On ${here()}`, as: 'links', items: pages, section: 'here' });
+ const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
+ if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
+ const inGroup = (x, g) => !g.id || x.group === g.id || (!fgroups.some((y) => y.id === x.group) && g === fgroups[fgroups.length - 1]);
+ const mini = (x) => siteItem(x, all);
+ fgroups.forEach((g, i) => {
+ const list = all.filter((x) => inGroup(x, g)).map(mini);
+ if (list.length) groups.push({ title: i === 0 && fgroups.length > 1 ? g.name : g.name, as: 'mini', items: list, section: 'all' });
+ });
+ const soon = f && Array.isArray(f.soon) ? f.soon : [];
+ 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' });
+ void byId;
+ return groups;
+ }
+
+ /**
+ * The Feed: who is live, the feeds the person chose (the sites' cards: Video's trending, Games' new worlds, the
+ * forum…), what changed lately, places new to them and where people go next from here; the chooser at the end.
+ */
+ function feedGroups() {
+ const { all, mineSite, byId, shown, take, ranked, f } = basis();
+ const groups = [];
+ const live = liveGroup(); if (live) groups.push(live);
+ // The network's cards: text and links only (never another site's code), checked here again, with an entry's https
+ // picture when it sent one; the person's choice and order, else Network's first two.
+ 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) : [];
const chosen = feedPref();
const chosen = feedPref();
const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);
const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);
netCards.forEach((c) => {
netCards.forEach((c) => {
@@ -858,45 +934,7 @@
@@ -858,45 +934,7 @@
site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
if (rows.length) groups.push({ title: String(c.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 });
if (rows.length) groups.push({ title: String(c.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 });
});
});
- // 1c. Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).
- if (feeds.length > 1 || chosen) {
- groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${feeds.length})` }, section: 'feeds' });
- 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) })) });
- }
- // 2. The places this person uses most (else the network's most used): a dock of big icons.
- const yours = mine && mine.services.some((x) => x.count > 0);
- const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
- // Without their own history, developer services (Media's API, Events, Zone…) are not places to visit: they stay in
- // the full list and come up here only once the person uses them.
- const dockPool = yours ? ranked : ranked.filter((s) => s.group !== 'build' || useOf(s.id));
- groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((s) => siteItem(s)), section: 'places' });
- // 2b. Their workspace, right under their places: agents and Vibe, Codes, files, machines, wallet, account, themes.
- if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
- // 2c. This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
- for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
- // 3. This site's own pages: the bar's links, two to a row; more behind a count.
- const allPages = (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);
- if (allPages.length) {
- const head = hereOpen ? allPages : allPages.slice(0, 6);
- const hereGroup = { title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null };
- if (leadHere) groups.unshift(hereGroup); else groups.push(hereGroup);
- }
- // 4. Where people go next from here: the Frame's list, else the moves Network saw (11-07) and this site's group.
- const f = ctx.frame;
- const nextIds = mineSite ? ((f && f.next && Array.isArray(f.next[mineSite.id]) && f.next[mineSite.id]) || NEXT_OF[mineSite.id] || all.filter((s) => s.group === mineSite.group).map((s) => s.id)) : [];
- const next = take(nextIds.map((id) => byId.get(id)), 4);
- if (next.length) groups.push({ title: `Next from ${here()}`, as: 'sites', items: next.map((s) => Object.assign(siteItem(s), { meta: NEXT_WHY[s.id] || s.desc })), section: 'next' });
- // 5. Discover: two places new to this person, rotating fairly through the whole network.
- // Developer services are offered to people who already use one; everyone else discovers places to visit.
- const builder = all.some((s) => s.group === 'build' && useOf(s.id));
- const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
- .filter((s) => !shown.has(s.id) && (builder || s.group !== 'build'));
- const disc = pickDiscover(discoverPool);
- disc.forEach((s) => shown.add(s.id));
- if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((s) => Object.assign(siteItem(s), { tag: 'New' })), section: 'discover' });
- // 5b. What's new (the owner, 11-07: "check which of the sites got updates recently, and if the user has been on
- // since … sort them by last update … bring you to the /updates page"): the newest real change per site,
- // newest first, "New" when it shipped after this person's last visit there.
+ // What's new (the owner, 11-07): the newest real change per site, newest first, "New" after this person's last visit.
if (Array.isArray(changesData) && changesData.length) {
if (Array.isArray(changesData) && changesData.length) {
const newest = new Map();
const newest = new Map();
for (const e of changesData) {
for (const e of changesData) {
@@ -913,40 +951,57 @@
@@ -913,40 +951,57 @@
}).filter((x) => x.url);
}).filter((x) => x.url);
if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
}
}
- // 7. Tools: the ones they use, then the everyday ones.
- const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);
- groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
- // 8. Their searches.
- const r = recent.get();
- 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' });
- // 9. Every site, grouped, behind one count (this site's group first).
- const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
- if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
- groups.push({ title: '', as: 'fold', items: [], fold: { open: allOpen, label: allOpen ? 'Hide the list of sites' : `Every OpenVibe site (${all.length})`, key: 'all' }, section: 'all' });
- if (allOpen) {
- const inGroup = (s, g) => !g.id || s.group === g.id || (!fgroups.some((x) => x.id === s.group) && g === fgroups[fgroups.length - 1]);
- const full = (s) => Object.assign(siteItem(s, all), { host: hostOf(s.url).replace(/^www\./, '') });
- const flag = FLAGSHIPS.map((id) => byId.get(id)).filter(Boolean);
- if (flag.length) groups.push({ title: 'Flagships', as: 'sites', items: flag.map(full), section: 'all' });
- fgroups.forEach((g) => groups.push({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g)).map(full), section: 'all' }));
- const soon = f && Array.isArray(f.soon) ? f.soon : [];
- 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' });
+ // Two places new to this person, rotating fairly through the network (developer services only for builders).
+ const builder = all.some((x) => x.group === 'build' && useOf(x.id));
+ const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
+ .filter((x) => !shown.has(x.id) && (builder || x.group !== 'build'));
+ const disc = pickDiscover(discoverPool);
+ disc.forEach((x) => shown.add(x.id));
+ if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((x) => Object.assign(siteItem(x), { tag: 'New' })), section: 'discover' });
+ // Where people go next from here: the Frame's list, else the moves Network saw and this site's group.
+ 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)) : [];
+ const next = take(nextIds.map((id) => byId.get(id)), 4);
+ 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' });
+ // Choose your feeds: every card a site offers, a tick to show it, arrows to order it (four at most).
+ if (feeds.length > 1 || chosen) {
+ groups.push({ as: 'fold', fold: { key: 'feeds', open: feedsOpen, label: feedsOpen ? 'Done choosing' : `Choose your feeds (${netCards.length} of ${feeds.length})` }, section: 'feeds' });
+ 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) })) });
}
}
- // 10. Last: the pages they were on.
+ if (!groups.length) groups.push({ as: 'note', text: 'Nothing new right now. Choose your feeds, or look around the sites on the first tab.' });
+ return groups;
+ }
+
+ /** You: their workspace, their places, the pages they were on, tools, their searches, and the display controls. */
+ function youGroups() {
+ const { take, ranked } = basis();
+ const groups = [];
+ if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
+ const yours = mine && mine.services.some((x) => x.count > 0);
+ // Without their own history, developer services are not places to visit: they come up here once used.
+ const dockPool = yours ? ranked : ranked.filter((x) => x.group !== 'build' || useOf(x.id));
+ groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((x) => siteItem(x)), section: 'places' });
const seen = new Set();
const seen = new Set();
const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
.map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
.map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
groups.push({ title: 'Jump back in', items: back, section: 'back' });
groups.push({ title: 'Jump back in', items: back, section: 'back' });
- // Display: theme, text size and motion, at the foot (the drawer's row, now in the one menu).
+ const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);
+ groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
+ const r = recent.get();
+ 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' });
+ // Display: theme, text size and motion.
if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
- paint(groups);
+ return groups;
+ }
+
+ function renderTab() {
+ paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
}
}
function render(q) {
function render(q) {
q = String(q || '').trim();
q = String(q || '').trim();
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
- tabsEl.hidden = true;
- if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); renderTab(); return; }
+ tabsEl.hidden = !!q;
+ if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
const n = q.toLowerCase();
const n = q.toLowerCase();
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' }));
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' }));
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' })) : [];
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' })) : [];
@@ -1101,10 +1156,12 @@
@@ -1101,10 +1156,12 @@
ctx = opts || {};
ctx = opts || {};
if (!el) build();
if (!el) build();
lastFocus = document.activeElement;
lastFocus = document.activeElement;
- tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
- // The old tabs, kept as a way in: "sites" opens the list of every site, "tools" scrolls to the tools.
- // From the burger ('here'): this site's pages lead, all of them, the way the drawer had them.
- allOpen = tab === 'sites'; hereOpen = tab === 'here'; leadHere = tab === 'here'; discoverPick = null;
+ // The tab the person last chose wins; else the one the opener asked for (old names kept: "sites" is this site's
+ // tab, whose second half is every site; "tools" is You); else this site.
+ // "sites" is a request for the list of every site (the legacy drawer's All sites): it opens this site's tab.
+ const asked = { here: 'here', sites: 'here', feed: 'feed', you: 'you', tools: 'you' }[opts.tab];
+ tab = opts.tab === 'sites' ? 'here' : (savedTab() || asked || 'here');
+ allOpen = false; hereOpen = true; leadHere = false; discoverPick = null;
drainCards();
drainCards();
syncFeeds();
syncFeeds();
el.classList.add('is-open');
el.classList.add('is-open');
@@ -63,6 +63,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -63,6 +63,10 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await until(p, `!!localStorage.getItem('ov_frame_v2')`);
await until(p, `!!localStorage.getItem('ov_frame_v2')`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened');
+ // 4.149.0 (the owner: tabs at the top, a Feed tab, remember the last one): this site's tab first, then Feed and You.
+ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-starttabs .ovs-tab')].map((t) => t.getAttribute('data-tab') + ':' + t.getAttribute('aria-selected'))`), ['here:true', 'feed:false', 'you:false'], 'three tabs, this site first');
+ assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');
+ await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(150);
await wait(150);
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
@@ -70,7 +74,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -70,7 +74,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
assert.deepStrictEqual(looks, [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');
assert.deepStrictEqual(looks, [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');
const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);
const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);
- assert.strictEqual(fold, 'Choose your feeds (4)', 'every card with entries can be chosen; the empty one is not offered');
+ assert.strictEqual(fold, 'Choose your feeds (2 of 4)', 'every card with entries can be chosen; the empty one is not offered');
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
@@ -104,6 +108,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -104,6 +108,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await p.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
await wait(150);
+ assert.strictEqual(await p.evaluate(`document.querySelector('.ovs-starttabs [aria-selected="true"]').getAttribute('data-tab')`), 'feed', 'it reopens on the tab last chosen');
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
// Signed in on a new device: the person's saved feeds come from Network, and a change is saved back.
const ctx2 = await browser.newContext();
const ctx2 = await browser.newContext();
@@ -112,6 +117,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -112,6 +117,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
await until(q, `!!localStorage.getItem('ov_frame_v2')`);
await until(q, `!!localStorage.getItem('ov_frame_v2')`);
await q.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
await q.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true })), true`);
assert.ok(await until(q, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened (signed in)');
assert.ok(await until(q, `!!document.querySelector('.ovs.is-open .ovs-group')`), 'the menu opened (signed in)');
+ await q.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
assert.ok(await until(q, `JSON.stringify(${SHOWN}) === JSON.stringify(['Top stories', 'Today'])`), 'the feeds saved on Network show on a new device');
assert.ok(await until(q, `JSON.stringify(${SHOWN}) === JSON.stringify(['Top stories', 'Today'])`), 'the feeds saved on Network show on a new device');
await q.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
await q.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
await until(q, `!!document.querySelector('.ovs-feeds')`);
await until(q, `!!document.querySelector('.ovs-feeds')`);
@@ -57,28 +57,32 @@ const server = http.createServer((req, res) => {
@@ -57,28 +57,32 @@ const server = http.createServer((req, res) => {
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-group')) { clearInterval(t); r(true); } }, 20); })`);
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-group')) { clearInterval(t); r(true); } }, 20); })`);
const first = await p.evaluate(`({ head: document.querySelector('.ovs-list .ovs-group').firstChild.textContent, sec: document.querySelector('.ovs-list .ovs-group').getAttribute('data-sec'), display: !!document.querySelector('.ovs-displayrow .ovnav-display'), expanded: document.getElementById('openvibe-burger').getAttribute('aria-expanded') })`);
const first = await p.evaluate(`({ head: document.querySelector('.ovs-list .ovs-group').firstChild.textContent, sec: document.querySelector('.ovs-list .ovs-group').getAttribute('data-sec'), display: !!document.querySelector('.ovs-displayrow .ovnav-display'), expanded: document.getElementById('openvibe-burger').getAttribute('aria-expanded') })`);
assert.strictEqual(first.sec, 'here', 'from the burger, this site\'s pages lead: ' + first.head);
assert.strictEqual(first.sec, 'here', 'from the burger, this site\'s pages lead: ' + first.head);
- assert.strictEqual(first.display, true, 'Display (theme, text size, motion) at the menu\'s foot');
+ // 4.149.0: tabs at the top; Display (theme, text size, motion) is the You tab's.
+ assert.strictEqual(first.display, false, 'this site\'s tab is its pages and every site');
+ await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="you"]').click(), true`);
+ await wait(150);
+ assert.strictEqual(await p.evaluate(`!!document.querySelector('.ovs-displayrow .ovnav-display')`), true, 'Display is on the You tab');
assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
assert.strictEqual(first.expanded, 'true', 'the burger says the menu is open');
// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
// The drawer's "All sites" (kept for sites that ask for the legacy drawer) opens the list of every site.
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);
+ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-mini[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);
const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {
const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {
open: (l.querySelector('[data-ovs-fold="all"]') || { getAttribute: () => null }).getAttribute('aria-expanded'),
open: (l.querySelector('[data-ovs-fold="all"]') || { getAttribute: () => null }).getAttribute('aria-expanded'),
heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),
heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),
listHeads: [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()).filter((h) => h !== 'Flagships'),
listHeads: [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()).filter((h) => h !== 'Flagships'),
- groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),
- here: [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),
- small: (l.querySelector('.ovs-grid.is-sites[data-sec="all"] .ovs-tile small') || {}).textContent,
+ groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-mini[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),
+ here: [...l.querySelectorAll('.ovs-grid.is-mini[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),
+ small: (l.querySelector('.ovs-grid.is-mini[data-sec="all"] .ovs-tile small') || {}).textContent,
rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);
rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);
const r = await read();
const r = await read();
- assert.strictEqual(r.open, 'true', 'the list of every site is open');
+ assert.strictEqual(r.open, null, 'the list of every site is open, with no fold (4.149.0)');
assert.deepStrictEqual(r.listHeads.slice(0, 3), ['Talk & learn', 'Watch & stream', 'Your OpenVibe'], 'this site\'s group first, then the Frame\'s groups in its order');
assert.deepStrictEqual(r.listHeads.slice(0, 3), ['Talk & learn', 'Watch & stream', 'Your OpenVibe'], 'this site\'s group first, then the Frame\'s groups in its order');
assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');
assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');
assert.deepStrictEqual(r.groups[1].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
assert.deepStrictEqual(r.groups[1].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
assert.ok(r.groups[1].includes('Pics'), 'a product on its own domain sits in its group');
assert.ok(r.groups[1].includes('Pics'), 'a product on its own domain sits in its group');
assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');
assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');
assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');
assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');
- assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');
+ assert.strictEqual(r.small, undefined, 'the list is compact: a name and an icon, no tagline (4.149.0)');
// Typing goes to a site by its name, first.
// Typing goes to a site by its name, first.
await p.evaluate(`(() => { const q = document.querySelector('.ovs-input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
await p.evaluate(`(() => { const q = document.querySelector('.ovs-input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
// The list redraws after a short debounce: wait for it (up to 5 s; macOS runners are slower than 500 ms).
// The list redraws after a short debounce: wait for it (up to 5 s; macOS runners are slower than 500 ms).
@@ -89,13 +89,14 @@ const server = http.createServer((req, res) => {
@@ -89,13 +89,14 @@ const server = http.createServer((req, res) => {
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
tabs: document.querySelectorAll('.ovs-tab[data-tab]').length,
tabs: document.querySelectorAll('.ovs-tab[data-tab]').length,
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), links: [...document.querySelectorAll('.ovs-link span')].map((t) => t.textContent),
groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), links: [...document.querySelectorAll('.ovs-link span')].map((t) => t.textContent),
- sites: [...document.querySelectorAll('.ovs-appname')].map((t) => t.textContent), fold: (document.querySelector('[data-ovs-fold="all"]') || {}).textContent,
+ sites: [...document.querySelectorAll('.ovs-grid.is-mini .ovs-tile b')].map((t) => t.textContent),
placeholder: document.querySelector('.ovs-input').placeholder })`);
placeholder: document.querySelector('.ovs-input').placeholder })`);
assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
- assert.strictEqual(v.tabs, 0, 'one calm scroll, no tabs (4.128.0, the owner 11-07: "rethink the whole idea of this whole menu")');
- assert.deepStrictEqual([v.groups, v.links], [['Popular on OpenVibe', 'On Chat'], ['Rooms', 'Servers']], 'nothing typed: the places first (one tap to Live), then this site\'s own pages');
- assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');
- assert.strictEqual(v.fold, 'Every OpenVibe site (6)', 'every site behind one counted fold');
+ // 4.149.0 (the owner, 11-08: "tabs at the top … the main tab should just show links that exist on that site, and
+ // under that the full list of sites"): this site's tab, Feed and You; nothing typed opens on this site's tab.
+ assert.strictEqual(v.tabs, 3, 'three tabs at the top');
+ assert.deepStrictEqual([v.groups[0], v.links], ['On Chat', ['Rooms', 'Servers']], 'nothing typed: this site\'s own pages first');
+ assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'then every site, even before the Frame answers');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
@@ -104,8 +105,7 @@ const server = http.createServer((req, res) => {
@@ -104,8 +105,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(100);
await wait(100);
- await p.evaluate('document.querySelector("[data-ovs-fold=all]").click(); 1');
- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile") && document.querySelector("[data-ovs-fold=all]").getAttribute("aria-expanded") === "true"'), 'the fold opens the list of every site in place');
+ assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-mini .ovs-tile") && !document.querySelector("[data-ovs-fold=all]")'), 'every site is in the tab itself, compact, with no fold');
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await wait(1200);
await wait(1200);
@@ -156,7 +156,8 @@ const server = http.createServer((req, res) => {
@@ -156,7 +156,8 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
// ── The network's cards: a picture before the title, exactly as the entry sent it (Contracts 0.320.0) ──
- await p.evaluate(`OpenVibeSearch.open({ service: 'chat', scope: { label: 'Chat', page: '/search' }, links: [], frame: { cards: [
+ // The cards are the Feed tab's (4.149.0).
+ await p.evaluate(`OpenVibeSearch.open({ service: 'chat', tab: 'feed', scope: { label: 'Chat', page: '/search' }, links: [], frame: { cards: [
{ id: 'trending', site: 'media-hub', title: 'Trending', kind: 'rows', items: [
{ id: 'trending', site: 'media-hub', title: 'Trending', kind: 'rows', items: [
{ title: 'A walk', meta: '9 views this week', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },
{ title: 'A walk', meta: '9 views this week', url: 'https://openvibe.video/v/1', image: { src: 'https://openvibe.media/t/1.jpg?w=640', w: 640, h: 360 } },
{ title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },
{ title: 'No picture', url: 'https://openvibe.video/v/2', image: { src: 'javascript:alert(1)' } },