openvibers/openvibe.shared Commits

4.135.0: Ask your agent from any page; every page preconnects to openvibe.network; your feeds follow you (#323)

OP OpenVibers committed 7 days ago · f25792a49c · 15 files, +148 −19

* 4.134.0: Ask your agent from any page (kit.askAgent / data-ov-ask open Vibe with the question waiting, unsent); every page preconnects to openvibe.network

* CI again

* Your feeds follow you: saved to Network and read back on a new device; the device copy stands when Network does not answer

* dock.js: Ask in one line, and the budget names 4.134.0 (15.55 KB of 15.70)

* Merge main (4.134.0 is the Dock's Agent panel fix): Ask your agent, the preconnect and feeds sync become 4.135.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6c34ca7..8d6073a 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,24 @@
# Changelog
++## 4.135.0 — 2026-11-07
++
++- **Ask your agent, from any page** (plan Z3; the owner, 11-07: "have your agent with you wherever you go"):
++ - `kit.askAgent({ text })` draws a chip ("✦ Ask your agent"). Any control with `data-ov-ask="…"` works the same way.
++ - A tap opens the person's Vibe in the Dock with the question waiting in its box, this page's context included. It is
++ never sent for them: they read it, change it and press Send.
++ - Signed out, the navbar sends them to sign in.
++ - New calls: `OpenVibeNavbar.askAgent(text)` and `OpenVibeDock.ask({ text })`.
++- **Your feeds follow you** (the menu's "Choose your feeds", 4.132.0):
++ - signed in, the choice is saved to Network (`PUT /api/v1/me/menu-feeds`, network.menu-feeds@1);
++ - it is read back on a new device the first time the menu opens there;
++ - a first choice made before signing in is sent up then;
++ - this device's copy paints at once and stands whenever Network does not answer, and the route is new, so until
++ Network ships it nothing changes.
++- **Every page opens its connection to openvibe.network early** (`seo.headTags()` adds
++ `<link rel="preconnect" href="https://openvibe.network" crossorigin>`, except on the Network itself;
++ `preconnect: false` turns it off). The phone-speed report of 11-07 found 102 of 108 pages asking openvibe.network for
++ the bar, sign-in and the menu without one.
++
## 4.134.0 — 2026-11-07
- **Fixed: the Dock's Agent panel could not read the timeline.** The navbar hands the Dock the person's token as a function (it may change when they sign in again); the panel used it as a string, so every call went out as `Bearer function…` and the panel said the agent was not reachable. The token is now resolved when a call is made.
ddiff --git a/README.md b/README.md
iindex e0ed543..ecca8f6 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.134.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.135.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f4f618b..ca8e726 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.134.0",
++ "version": "4.135.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)",
@@@ -22,6 +22,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
++ "Ask your agent: kit.askAgent and any [data-ov-ask] control open your Vibe in the Dock with the question waiting, unsent; and every page preconnects to openvibe.network (4.134.0)",
"a picture on the menu's network cards: each entry's 64×36 thumbnail before its title when the site sent one (frame.menu-card@1 images, Contracts 0.320.0), https only and lazy (4.133.0)",
"your feeds in the menu: choose which sites' cards show (Today, News, Trending, New worlds…) and their order; and the navbar keeps the Frame's cards, next and beacon, which it used to drop (4.132.0)",
"your online status on every site: a dot on your picture, a heartbeat once a minute across tabs, and a picker (Online, Do not disturb, Invisible, a status line) in the account menu (4.131.0)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 080eb23..d8ca1c4 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -140,6 +140,8 @@
form.append(box, go);
panel.append(head, scroll, err, form);
const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
++ vibe.fill = (text) => { box.value = String(text || '').slice(0, 8000); fit(); try { box.focus(); box.setSelectionRange(box.value.length, box.value.length); } catch { /* */ } };
++ if (vibe.pendingAsk) { vibe.fill(vibe.pendingAsk); vibe.pendingAsk = null; }
box.addEventListener('input', fit);
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
@@@ -282,6 +284,8 @@
refresh: () => { vibe.panel = panel; return openVibe(); },
pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
talk: () => { if (vibe.talk) vibe.talk(); },
++ // A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
++ ask: (text) => { if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
};
}
root.OpenVibeDockVibe = { mount };
ddiff --git a/dock.js b/dock.js
iindex 75c19d4..f700949 100644
---- a/dock.js
++++ b/dock.js
@@@ -827,6 +827,8 @@
}
// ── Open and close ───────────────────────────────────────
++ // Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
++ function ask({ text = '' } = {}, n = 0) { if (!n) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!(el && !el.hidden); }
function open(opts = {}) {
o = { ...o, ...opts };
@@@ -877,5 +879,5 @@
if (isOpen()) layout();
}
-- root.OpenVibeDock = { mount, open, close, isOpen, update, _state: () => ({ doc, tab, pending }) };
++ root.OpenVibeDock = { mount, open, close, isOpen, update, ask, _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/kit.css b/kit.css
iindex bcb4ba6..867c933 100644
---- a/kit.css
++++ b/kit.css
@@@ -429,3 +429,8 @@ html[data-ov-motion=reduced] .ovk-skel { animation: none; }
.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
++
++/* "Ask your agent" (4.134.0): the chip that hands this page's question to your Vibe in the Dock. */
++.ovk-ask { border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #2a3345)); }
++.ovk-ask .ovk-i { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovk-ask:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
ddiff --git a/kit.js b/kit.js
iindex 2a40286..e025296 100644
---- a/kit.js
++++ b/kit.js
@@@ -26,7 +26,7 @@
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
-- * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
++ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
@@@ -171,6 +171,14 @@
attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,
'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });
}
++ /**
++ * "Ask your agent" (4.135.0, plan Z3): one chip any page can carry. A tap opens the person's Vibe in the Dock with the
++ * question waiting in its box, this page's context included, for them to read and send; it never sends for them.
++ * Signed out, the navbar sends them to sign in. text: the prefilled question ("About this stream: …").
++ */
++ function askAgent({ text = '', label = 'Ask your agent', small = true } = {}) {
++ return button({ type: 'button', label, icon: 'spark', kind: 'quiet', small, attrs: { 'data-ov-ask': String(text || '').slice(0, 2000), class: null } }).replace('class="ovk-btn', 'class="ovk-ask ovk-btn');
++ }
/** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
@@@ -892,7 +900,7 @@
return {
skeleton,
-- esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, iconButton, action, badge, dot, card, rail, CARD_RATIO,
++ esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
ddiff --git a/navbar.js b/navbar.js
iindex 6e93030..0b9d55f 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1235,11 +1235,14 @@
if (document.getElementById('ov-presence-loader')) return;
const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = sibling('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc);
}
-- /**
-- * Open the Dock on a tab: 'agent' | 'activity'. opts may name the step to point at (opts.task): the
-- * Agent panel marks that entry and brings it into view, so a site can open the Dock at its own task.
-- */
++ // The Dock on a tab ('agent' | 'activity'); opts.task marks that step and brings it into view.
function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); }
++ // Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first.
++ function askAgent(text) {
++ if (!_config.token || _config.dock === false) { const u = resolveLoginHref(currentHost(), location.href); if (u) location.href = u; return; }
++ loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); });
++ }
++ if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('click', (e) => { const b = e.target && e.target.closest && e.target.closest('[data-ov-ask]'); if (!b) return; e.preventDefault(); askAgent(b.getAttribute('data-ov-ask') || ''); });
/** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
function sectionsHTML(sections) {
@@@ -2399,12 +2402,9 @@
* settles; a page that draws such a box later may call this). Returns how many it marked. */
focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
-- /**
-- * Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only.
-- * opts may name the step to point at (opts.task): the Agent panel marks that entry and brings it
-- * into view, so a site can open the Dock at its own task.
-- */
++ /** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
++ askAgent(text) { askAgent(text); },
/** Get the bell mount point for OpenVibeNotifications. */
getBellMount() {
ddiff --git a/package.json b/package.json
iindex f093418..c0a5b1f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.134.0",
++ "version": "4.135.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/search.js b/search.js
iindex 48ba6bc..f183c40 100644
---- a/search.js
++++ b/search.js
@@@ -699,7 +699,32 @@
const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;
const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };
-- const saveFeeds = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
++ const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
++ // The choice follows the person (4.135.0): Network keeps it (GET/PUT /api/v1/me/menu-feeds, network.menu-feeds@1);
++ // this device's copy paints at once and stands whenever Network does not answer.
++ let feedsSynced = false, feedsPut = 0;
++ const feedsUrl = () => `${ctx.apiBase || 'https://openvibe.network'}/api/v1/me/menu-feeds`;
++ const signedInCtx = () => !!(ctx.token && ctx.user && !ctx.user.is_anon);
++ function pushFeeds(on) {
++ if (!signedInCtx() || typeof fetch !== 'function') return;
++ clearTimeout(feedsPut);
++ feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_MAX) }) }).catch(() => {}); }, 800);
++ }
++ const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };
++ async function syncFeeds() {
++ if (feedsSynced || !signedInCtx() || typeof fetch !== 'function') return;
++ feedsSynced = true;
++ try {
++ const r = await fetch(feedsUrl(), { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
++ if (!r.ok) return;
++ const j = await r.json();
++ const local = feedPref();
++ if (Array.isArray(j.on)) {
++ const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);
++ if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }
++ } else if (local) pushFeeds(local);
++ } catch { /* the device copy stands */ }
++ }
/** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
function loadPulse() {
try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
@@@ -1063,6 +1088,7 @@
// 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;
drainCards();
++ syncFeeds();
el.classList.add('is-open');
// Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
// history entry; Back pops it and only closes the menu.
ddiff --git a/seo.js b/seo.js
iindex 7947a92..27f2a56 100644
---- a/seo.js
++++ b/seo.js
@@@ -62,6 +62,8 @@ function headTags(o = {}) {
if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
++ // Every page's bar asks openvibe.network (the Frame, sign-in, the menu): open that connection early (4.135.0).
++ if (o.preconnect !== false && !/^https:\/\/openvibe\.network(\/|$)/.test(canonical || '')) out.push('<link rel="preconnect" href="https://openvibe.network" crossorigin>');
for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 18b5021..43c211a 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -608,7 +608,23 @@ async function drag(p, from, to) {
assert.strictEqual(await p.evaluate("document.getElementById('ovdk-pill')"), null, 'signed out: no pill at all');
await p.close();
}
-- console.log('dock-chrome: the pill only when it matters, Vibe, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
++ // ── Ask your agent (4.135.0): a page's [data-ov-ask] chip opens Vibe with the question in the box, unsent. ──
++ {
++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: false } };
++ const before = asked.length;
++ const p = await fresh(1280, 860);
++ await p.goto(`${base}/page`, { maxMs: 5000 });
++ await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');
++ await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");
++ await until(p, CARD, 'the Dock opens');
++ const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
++ assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
++ assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [role=tab][aria-selected=true]').dataset.tab"), 'vibe', 'on the Vibe tab');
++ await wait(400);
++ assert.strictEqual(asked.length, before, 'nothing is sent for the person');
++ await p.close();
++ }
++ console.log('dock-chrome: the pill only when it matters, Vibe, Ask your agent, Agents, Chats and Activity, the timer, debounced state, drag and resize, restore on desktops only, 320 px clean, the Dock pops out and comes back');
} finally {
await browser.close();
server.close();
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex b0665d4..9c6bf36 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -19,7 +19,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
--const DOCK_BROTLI_BUDGET = 15872; // 15.50 KB (15435 after 4.120.0 + 437 bytes of headroom)
++const DOCK_BROTLI_BUDGET = 16077; // 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];
// ── the budget ──────────────────────────────────────────────
ddiff --git a/test/menu-feeds-chrome.test.js b/test/menu-feeds-chrome.test.js
iindex b6eade0..bc421e9 100644
---- a/test/menu-feeds-chrome.test.js
++++ b/test/menu-feeds-chrome.test.js
@@@ -20,6 +20,18 @@ if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
const card = (site, id, title, n) => ({ id, site, title, kind: 'rows', items: Array.from({ length: n }, (_, i) => ({ title: `${title} ${i + 1}`, url: `https://openvibe.${site === 'media-hub' ? 'video' : site}/x/${i}`, meta: `${i + 1} things` })) });
const FRAME = { nav: [{ id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live', group: 'watch' }], groups: [{ id: 'watch', name: 'Watch' }], soon: [], footer: {},
cards: [card('media-hub', 'trending', 'Trending', 3), card('games', 'new-worlds', 'New worlds', 2), card('news', 'top', 'Top stories', 4), card('today', 'today', 'Today', 2), card('bad', 'empty', 'Nothing', 0)] };
++const PAGE_IN = (saved) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Feeds</title><link rel="icon" href="data:,">
++<script>
++ const realFetch = window.fetch; window.__puts = [];
++ window.fetch = (u, o) => { u = String(u);
++ if (u.startsWith('https://openvibe.network/api/frame?')) return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) });
++ if (u === 'https://openvibe.network/api/v1/me/menu-feeds') { if (o && o.method === 'PUT') { window.__puts.push(JSON.parse(o.body)); return Promise.resolve({ ok: true, json: async () => ({}) }); } return Promise.resolve({ ok: true, json: async () => (${JSON.stringify(saved)}) }); }
++ if (u.startsWith('https://')) return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
++ return realFetch(u, o); };
++</script>
++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', auth: 'external', token: 'tok', user: { id: 7, username: 'maya' }, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false, dock: false, wallet: false, messages: false, presence: false }); });</script>
++</body></html>`;
const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Feeds</title><link rel="icon" href="data:,">
<script>
const realFetch = window.fetch;
@@@ -31,6 +43,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta n
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (u.pathname === '/in') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE_IN({ on: ['news:top', 'today:today'] })); }
const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
res.writeHead(404); return res.end();
@@@ -88,7 +101,21 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
assert.ok(await until(p, `!!document.querySelector('.ovs.is-open .ovs-group')`));
await wait(150);
assert.deepStrictEqual(await p.evaluate(SHOWN), ['Today', 'New worlds', 'Top stories'], 'your feeds, in your order');
-- console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device');
++ // 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 q = await h.openPage(browser, ctx2, { width: 390, block: ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'] });
++ await q.goto(`http://127.0.0.1:${server.address().port}/in`, { maxMs: 5000 });
++ await 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)');
++ 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')`);
++ await q.evaluate(`document.querySelector('[data-ovs-feed="games:new-worlds"]').click(), true`);
++ assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
++ assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
++ await q.close();
++ console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
} finally {
await browser.close();
server.close();
ddiff --git a/test/seo-preconnect.test.js b/test/seo-preconnect.test.js
nnew file mode 100644
iindex 0000000..0618cc6
---- /dev/null
++++ b/test/seo-preconnect.test.js
@@@ -0,0 +1,19 @@
++'use strict';
++// 4.135.0: every page opens its connection to openvibe.network early (the bar, sign-in and the menu all ask it), except
++// the Network's own pages and a page that says preconnect: false. And kit.askAgent draws the "Ask your agent" chip.
++const assert = require('assert');
++const seo = require('../seo');
++const kit = require('../kit');
++
++const tag = '<link rel="preconnect" href="https://openvibe.network" crossorigin>';
++assert.ok(seo.headTags({ title: 'Live', canonical: 'https://openvibe.live/' }).includes(tag), 'a product page preconnects');
++assert.ok(!seo.headTags({ title: 'Network', canonical: 'https://openvibe.network/my' }).includes(tag), 'not on the Network itself');
++assert.ok(!seo.headTags({ title: 'Kiosk', canonical: 'https://openvibe.live/tv', preconnect: false }).includes(tag), 'and not when a page says no');
++
++const chip = kit.askAgent({ text: 'About this stream: <b>night walk</b>' });
++assert.match(chip, /^<button class="ovk-ask ovk-btn/, 'a button, styled as the ask chip');
++assert.match(chip, /type="button"/);
++assert.match(chip, /data-ov-ask="About this stream: &lt;b&gt;night walk&lt;\/b&gt;"/, 'the question, escaped');
++assert.match(chip, />Ask your agent</);
++assert.strictEqual((kit.askAgent({ text: 'x'.repeat(3000) }).match(/data-ov-ask="(x+)"/) || [])[1].length, 2000, 'capped at 2,000 characters');
++console.log('seo-preconnect: preconnect on product pages only; kit.askAgent draws an escaped, capped ask chip');