openvibers/openvibe.shared Commits
4.206.0: the menu keeps media playing, the Feed in the menu searchable with the one composer, a Code task in the Dock (#467)
Changes
@@@ -1,5 +1,23 @@# Changelog++## 4.206.0 — 2026-11-11++++**The menu keeps a stream playing, the Feed in the menu is searchable with the one composer at its top, and a Code task++opens in the Dock's step card.**++++- **The menu no longer pauses what is playing (#461, ask 360).** Opening the menu on a phone left live video and audio++ paused under it; the sheet now leaves media alone and the page can read the player's state.++- **The Feed in the menu (#463, ask 360).** Feed search uses Community's search and examples through Network, with++ loaded-card filtering when search is unavailable; the example searches are chips that fill the field. The Feed's++ post form is the one composer (OpenVibeComposer: files, paste, drop, commands, drafts, settings); each picture needs a++ description and other files upload to Media. Its + and the example chips carry their own styles in the menu (they++ drew as bare browser buttons in the PR's screenshots).++- **Chat and Vibe (#463).** The Chat summary's Vibe row is an accessible Open Vibe button that selects the menu's Vibe++ tab; on a desktop Chat and Vibe open the Dock after the menu closes, and the Dock opens from its pill only when that++ pill is visible.++- **A Code task in the Dock (#465, ask 354).** The Dock's Code card shows the task's live steps with the AG-UI step view++ (agent-session.js card mode) and opens the whole run.++## 4.205.0 — 2026-11-11**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.205.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.206.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,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.205.0",++ "version": "4.206.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)","contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.147.0","features": [++ "the menu Feed searches Community with examples and uses the one composer for pictures and files; the Chat summary opens the Vibe tab (4.206.0)","the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)","buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)","the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",@@@ -18,6 +18,7 @@if (typeof document === 'undefined' || root.OpenVibeDockCode) return;const CODES = 'https://openvibe.codes';++ const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';const POLL_MS = 8000;const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };@@@ -100,6 +101,72 @@const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;++ function loadScript(file, ready) {++ if (ready()) return Promise.resolve(true);++ return new Promise((resolve) => {++ const id = `ovdc-${file}`;++ let script = d.getElementById(id);++ if (!script) { script = d.createElement('script'); script.id = id; script.src = SCRIPT_BASE + file; script.async = true; d.head.appendChild(script); }++ script.addEventListener('load', () => resolve(!!ready()), { once: true });++ script.addEventListener('error', () => resolve(false), { once: true });++ });++ }++ function runUrl(id) {++ return (direct ? direct + '/api/v1/runs/' : relay + '/codes-runs/') + encodeURIComponent(id);++ }++ async function openRun(task, from) {++ const href = onCodes(task.url);++ if (!href || !tok()) return;++ const loaded = await Promise.all([++ loadScript('ov-window.js', () => !!root.OVWindow),++ loadScript('agent-session.js', () => !!root.OpenVibeAgentSession)++ ]);++ if (loaded.some((ok) => !ok)) { d.defaultView.location.assign(href); return; }++ const body = h('div');++ let stopped = false, controller = null, view = null;++ const win = root.OVWindow.open({ id: `codes-run-${task.id}`, title: task.title || 'Coding task', body, kind: 'dialog', size: 'md', from,++ onClose: () => { stopped = true; if (controller) controller.abort(); if (view) view.destroy(); } });++ body.appendChild(h('p', 'ovdc-none', 'Loading the run…'));++ const headers = () => ({ Authorization: `Bearer ${tok()}`, Accept: 'application/json' });++ try {++ const res = await fetch(runUrl(task.id), { headers: headers(), credentials: 'omit' });++ if (!res.ok) throw new Error('The run could not be read.');++ const run = await res.json();++ if (stopped) return;++ body.replaceChildren();++ view = root.OpenVibeAgentSession.mount(body, { title: run.title || task.title || 'Coding task',++ status: run.status || task.status || 'running', mode: 'card', href });++ let last = 0;++ while (!stopped) {++ controller = new AbortController();++ const events = await fetch(runUrl(task.id) + '/events', { credentials: 'omit',++ headers: { ...headers(), Accept: 'text/event-stream', 'Last-Event-ID': String(last) }, signal: controller.signal });++ if (!events.ok || !events.body) break;++ const reader = events.body.getReader();++ const decoder = new TextDecoder();++ let buffer = '', terminal = false;++ while (!stopped) {++ const part = await reader.read();++ if (part.done) break;++ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');++ let cut;++ while ((cut = buffer.indexOf('\n\n')) !== -1) {++ const frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);++ const id = /^id: *(\d+)$/m.exec(frame), data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');++ if (id) last = Number(id[1]);++ if (!data) continue;++ try { const event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') terminal = true; } catch { /* skip one malformed event */ }++ }++ }++ if (terminal || !/running|queued|placed/.test(run.status || '')) break;++ await new Promise((resolve) => setTimeout(resolve, 1500));++ }++ } catch (err) {++ if (!stopped) { body.replaceChildren(); const a = h('a', 'ovk-btn ovk-btn--quiet', 'Open on Codes'); a.href = href; body.appendChild(a); }++ }++ return win;++ }++container.innerHTML = '';container.classList.add('ovdc');const box = h('form', 'ovdc-box');@@@ -183,7 +250,10 @@const li = h('li', 'ovdc-task');const there = onCodes(t.url);const a = h(there ? 'a' : 'div', 'ovdc-a');-- if (there) a.href = there;++ if (there) { a.href = there; a.addEventListener('click', (event) => {++ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;++ event.preventDefault(); openRun(t, a);++ }); }a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));const m = h('span', 'ovdc-m');const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';@@@ -58,7 +58,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': 'fbe0a5b0d179' };++ const VIBE_REV = { 'ov-composer.js': '94fe9cbede93' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -89,7 +89,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '7f3c25bc076d', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': '2c597ddf5604', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -694,8 +694,11 @@const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));-- b.append(av, body, meta); b.setAttribute('aria-label', `Vibe, ${c.count} chats${at ? `, ${answered(at)}` : ''}`);-- b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });++ b.append(av, body, meta); b.setAttribute('aria-label', 'Open Vibe');++ b.addEventListener('click', () => {++ if (!b.dispatchEvent(new CustomEvent('openvibe:open-vibe', { bubbles: true, cancelable: true }))) return;++ vibeOnList = true; save({ vibe: null }); setTab('vibe', true);++ });li.append(b); ul.append(li); return;}const li = h('li'), b = h('button', 'ovdk-chat');@@@ -1042,7 +1045,10 @@fromOrb();void el.offsetWidth; el.classList.add('is-in');// On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.-- if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) root.OpenVibeNavbar.pop(el, document.getElementById('ovdk-pill'));++ if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {++ const pill = document.getElementById('ovdk-pill');++ root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);++ }if (phone()) {el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }nnew file mode 100644BBinary files /dev/null and b/docs/screenshots/360-chat-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/360-chat-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/360-feed-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/360-feed-390.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/ask-354-1280.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/ask-354-390.png differ@@@ -607,7 +607,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '0748f4087385', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '1428fea2f91c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -30,7 +30,15 @@// 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's// Vibe and Code boxes included. Injected once, before the first box is joined.-- const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }++ const CSS = `.ovt-composer { position: relative; display: grid; gap: 6px; max-width: 780px; margin: 0 auto; padding: 10px 12px; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 18px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }++.ovt-composer textarea { width: 100%; min-height: 48px; box-sizing: border-box; padding: 6px 2px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; }++.ovt-c-row { display: flex; align-items: center; gap: 8px; }++.ovt-c-tools { flex: 1; display: flex; align-items: center; gap: 6px; }++.ovt-send { min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }++.ovt-send:disabled { opacity: .5; }++.ovt-composer .ovc-file.ovc-image { height: auto; min-height: 100px; flex-wrap: wrap; overflow: visible; }++.ovt-composer .ovc-image input { flex: 1 0 100%; min-width: 0; min-height: 36px; box-sizing: border-box; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 8px; background: var(--bg-primary, #0b1220); color: inherit; font: inherit; }++.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }.ovc-files[hidden] { display: none; }.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }@@@ -251,6 +259,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[datafiles.splice(i, 1);if (f.url) URL.revokeObjectURL(f.url);if (f.hidden) f.hidden.remove();++ form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));f.chip.remove();strip.hidden = !files.length;syncInputs();@@@ -266,6 +275,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[dataconst f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };files.push(f);paintFile(f);++ form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));if (up) {f.progress = 0; paintFile(f);Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))@@@ -505,7 +515,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[dataconst api = {form, ctx,refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },-- add, files: () => files.map((f) => f.file),++ add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },uploads: () => files.map((f) => f.id).filter(Boolean),clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },@@@ -152,28 +152,6 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }--.ovh-compose { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);-- box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }--.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));-- background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }--.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }--.ovh-post:disabled { opacity: .5; cursor: default; }--.ovh-compose-main { display: grid; gap: 8px; min-width: 0; }--.ovh-compose-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }--.ovh-compose-bar .ovh-post { margin-left: auto; }--.ovh-compose-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }--.ovh-pic-btn { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 12px; background: transparent; color: var(--accent-light, var(--accent, #60a5fa)); cursor: pointer; }--.ovh-pic-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }--.ovh-pic-btn:disabled { opacity: .4; cursor: default; background: transparent; }--.ovh-pic-btn svg, .ovh-pic-x svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }--.ovh-pics { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }--.ovh-pics[hidden] { display: none; }--.ovh-pic { display: grid; grid-template-columns: 56px minmax(0, 1fr) 40px; gap: 8px; align-items: center; }--.ovh-pic img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }--.ovh-pic input { min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }--.ovh-pic-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }--.ovh-pic-x svg { width: 18px; height: 18px; }--.ovh-pic-x:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); }.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }@@@ -359,6 +337,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durconst ICON = {pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],++ attach: [['path', { d: 'M12 5v14M5 12h14' }]],plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],@@@ -872,85 +851,83 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durreturn out;}function composer(box, o) {-- const form = h('form', 'ovh-compose');-- const face = h('span', 'ovh-face');-- const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';-- if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();++ const form = h('form', 'ovt-composer'); form.id = 'ovh-feed-composer'; form.setAttribute('data-ovc', ''); form.setAttribute('data-ovc-max-files', '10'); form.setAttribute('data-ovc-max-mb', '300');++ const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 2000; ta.rows = 2; ta.required = true; ta.setAttribute('data-ovt-text', '');const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];-- const main = h('div', 'ovh-compose-main');-- const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");-- // Pictures (owner, 11-09: "you can make posts and put images"): up to four, each with a line saying what it shows,-- // which Community requires (alt text, for people who cannot see it).-- const pics = []; const list = h('ul', 'ovh-pics'); list.hidden = true;-- const bar = h('div', 'ovh-compose-bar');-- const add = h('button', 'ovh-pic-btn'); add.type = 'button'; add.setAttribute('aria-label', 'Add pictures'); add.title = 'Add pictures (up to 4)'; add.append(svgIcon('image'));-- const file = h('input'); file.type = 'file'; file.accept = 'image/jpeg,image/png,image/webp,image/gif'; file.multiple = true; file.hidden = true;-- const hint = h('small', 'ovh-compose-hint');-- const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;-- const ready = () => {-- const missing = pics.some((p) => !p.alt.value.trim());-- post.disabled = !ta.value.trim() || missing;-- hint.textContent = missing ? 'Describe each picture' : '';-- add.disabled = pics.length >= 4;-- list.hidden = !pics.length;-- };-- ta.addEventListener('input', () => { ready(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });-- add.addEventListener('click', () => file.click());-- file.addEventListener('change', async () => {-- const chosen = [...(file.files || [])].slice(0, 4 - pics.length); file.value = '';-- for (const f of chosen) {-- if (!/^image\//.test(f.type)) continue;-- const pr = await preparePicture(f);-- if (pr.file.size > 8 * 1024 * 1024) { note.hidden = false; note.textContent = `${f.name} is over 8 MB.`; continue; }-- const li = h('li', 'ovh-pic');-- const im = h('img'); im.alt = ''; if (pr.thumb) im.src = pr.thumb;-- const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?'; alt.setAttribute('aria-label', `What ${f.name} shows`);-- const x = h('button', 'ovh-pic-x'); x.type = 'button'; x.setAttribute('aria-label', 'Remove this picture'); x.title = 'Remove'; x.append(svgIcon('x'));-- const entry = { file: pr.file, alt, li };-- x.addEventListener('click', () => { pics.splice(pics.indexOf(entry), 1); li.remove(); ready(); });-- alt.addEventListener('input', ready);-- li.append(im, alt, x); list.append(li); pics.push(entry);-- }-- ready();-- const empty = pics.find((p) => !p.alt.value.trim()); if (empty) empty.alt.focus();++ ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");++ const row = h('div', 'ovt-c-row'), tools = h('div', 'ovt-c-tools');++ // The composer's own + (its contract: label.ovt-btn.ovt-attach holding the file input), drawn by ov-composer.js's++ // styles wherever the menu opens; a kit button had no styles here and drew bare ("Attach files").++ const attach = h('label', 'ovt-btn ovt-attach'); attach.title = 'Attach files'; attach.setAttribute('aria-label', 'Attach files');++ attach.append(svgIcon('attach'));++ const file = h('input'); file.type = 'file'; file.multiple = true; file.hidden = true; file.setAttribute('data-ovt-files', ''); file.setAttribute('aria-label', 'Attach files');++ attach.append(file); tools.append(attach);++ const post = h('button', 'ovt-send', 'Post'); post.type = 'submit'; post.disabled = true; post.setAttribute('data-ovt-send', '');++ const note = h('p', 'ovh-note'); note.hidden = true; note.setAttribute('data-ovt-note', '');++ row.append(tools, post); form.append(ta, row); box.append(form, note);++ const pics = new Map();++ const ready = () => { post.disabled = !ta.value.trim() || [...pics.values()].some((alt) => !alt.value.trim()); };++ form.addEventListener('input', ready);++ form.addEventListener('ovc:file-added', (e) => {++ const { file: f, chip } = e.detail;++ if (!/^image\//.test(f.type)) return;++ chip.classList.add('ovc-image');++ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';++ alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;++ chip.append(alt); pics.set(f, alt); queueMicrotask(ready);});-- bar.append(add, file, hint, post);-- main.append(ta, list, bar);-- form.append(face, main);-- const note = h('p', 'ovh-note'); note.hidden = true;++ form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); queueMicrotask(ready); });const auth = { Authorization: `Bearer ${o.token}` };++ const upload = async (url, f) => {++ const fd = new FormData(); fd.append('file', f, f.name || 'file');++ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: auth, body: fd });++ const j = await r.json().catch(() => ({}));++ const id = (j.attachment && (j.attachment.media_id || j.attachment.id)) || j.id;++ if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);++ return id;++ };form.addEventListener('submit', async (e) => {e.preventDefault();-- const text = ta.value.trim(); if (!text || post.disabled) return;-- post.disabled = true; add.disabled = true; note.hidden = true;++ const text = ta.value.trim(), files = form.__ovc ? form.__ovc.files() : [...file.files];++ if (!text) { ta.focus(); return; }++ const images = files.filter((f) => /^image\//.test(f.type));++ if (images.length > 4) { note.hidden = false; note.textContent = 'Choose up to four pictures.'; return; }++ const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());++ if (missing) { note.hidden = false; note.textContent = 'Describe each picture.'; pics.get(missing)?.focus(); return; }++ post.disabled = true; note.hidden = true;const label = post.textContent;try {-- let r;-- if (pics.length) {-- const named = [];-- for (let k = 0; k < pics.length; k++) {-- post.textContent = pics.length > 1 ? `Picture ${k + 1} of ${pics.length}` : 'Sending the picture';-- const fd = new FormData(); fd.append('file', pics[k].file, pics[k].file.name || 'picture.jpg');-- const up = await fetch(`${o.frameBase || FRAME}/attachments`, { method: 'POST', credentials: 'omit', headers: auth, body: fd });-- const uj = await up.json().catch(() => ({}));-- const id = uj && uj.attachment && uj.attachment.media_id;-- if (!up.ok || !id) throw new Error(uj.detail || 'A picture did not upload. Try again in a moment.');-- named.push({ media_id: id, alt: pics[k].alt.value.trim() });++ const pictures = [], attachments = [];++ for (const f of files) {++ post.textContent = `Uploading ${f.name || 'file'}`;++ if (/^image\//.test(f.type)) {++ const prepared = await preparePicture(f);++ const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);++ pictures.push({ media_id, alt: pics.get(f).value.trim() });++ } else {++ const media_id = await upload('https://openvibe.media/api/v1/attachments', f);++ attachments.push(media_id);}-- post.textContent = 'Posting';-- r = await fetch(`${o.frameBase || FRAME}/posts`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text, pictures: named }) });-- } else {-- r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text }) });}++ post.textContent = 'Posting';++ const url = files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;++ const body = files.length ? { text, pictures, attachments } : { text };++ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });const j = await r.json().catch(() => ({}));if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');-- ta.value = ''; ta.style.height = ''; pics.splice(0).forEach((p) => p.li.remove());++ ta.value = ''; ta.dispatchEvent(new Event('input', { bubbles: true })); form.__ovc?.clearFiles(); pics.clear();note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';if (o.onPosted) o.onPosted(j);-- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); }++ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); ta.dispatchEvent(new Event('input', { bubbles: true })); }post.textContent = label; ready();});-- box.append(form, note);++ const join = () => { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); };++ if (root.OpenVibeComposer) join();++ else if (typeof o.sibling === 'function') {++ let script = document.getElementById('ov-composer-script');++ if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = join; document.head.append(script); }++ else script.addEventListener('load', join, { once: true });++ }}function spark(series) {const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);@@@ -1259,11 +1236,64 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durel.classList.add('ovh', 'ovfd');el.setAttribute('data-sec', 'wall');el.textContent = '';++ const examples = h('div', 'ovfd-examples'); examples.setAttribute('aria-label', 'Example feed searches');++ const resultHead = h('div', 'ovfd-results'); resultHead.hidden = true;const chips = h('div', 'ovfd-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');const list = h('div', 'ovfd-list');const sentinel = h('div', 'ovfd-more');-- el.append(chips, list, sentinel);++ el.append(examples, resultHead, chips, list, sentinel);let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';++ let query = '', searchSeq = 0;++ const loaded = [];++ const feedBase = `${o.frameBase || FRAME}/feed`;++ const headers = () => Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {});++ const fallbackExamples = ["Who's live", 'New photos', 'Clips from today', 'Videos this week'];++ const showExamples = (values) => {++ examples.textContent = '';++ [...new Set([...values, ...fallbackExamples])].slice(0, 6).forEach((value) => {++ const b = h('button', 'ovfd-ex', value); b.type = 'button';++ b.addEventListener('click', () => { if (o.onSearch) o.onSearch(value); });++ examples.append(b);++ });++ };++ showExamples(fallbackExamples);++ fetch(`${feedBase}/examples`, { credentials: 'omit', headers: headers() }).then((r) => r.ok ? r.json() : null).then((j) => {++ const values = j && Array.isArray(j.examples) ? j.examples.map((x) => x && x.text).filter((x) => typeof x === 'string' && x.trim()) : [];++ if (values.length) showExamples(values);++ }).catch(() => {});++ const matches = (x, q) => {++ const words = q.toLowerCase().replace(/^(new |who's |from |this )/g, '').split(/\s+/).filter((w) => !['the', 'today', 'week'].includes(w));++ const hay = [x.title, x.text, x.kind, x.site, x.site_name, x.actor && x.actor.name].join(' ').toLowerCase();++ if (q.toLowerCase().includes('today') && String(x.at || '').slice(0, 10) !== new Date().toISOString().slice(0, 10)) return false;++ if (q.toLowerCase().includes('this week') && Date.parse(x.at || '') < Date.now() - 7 * 864e5) return false;++ return words.filter((w) => !['from', 'this', 'week'].includes(w)).every((w) => {++ const word = w.replace(/s$/, '');++ return hay.includes(word) || word === 'photo' && x.kind === 'pic';++ });++ };++ async function search(q) {++ query = String(q || '').trim(); const current = ++searchSeq;++ resultHead.textContent = '';++ resultHead.hidden = !query; chips.hidden = !!query; sentinel.hidden = !!query;++ examples.hidden = !!query;++ if (!query) { cursor = null; done = false; busy = false; more(true); return; }++ list.textContent = '';++ resultHead.append(h('span', '', 'Searching…'));++ let found = null;++ try {++ const r = await fetch(`${feedBase}/search?q=${encodeURIComponent(query)}`, { credentials: 'omit', headers: headers() });++ if (r.ok) { const j = await r.json(); if (Array.isArray(j.items)) found = j.items; }++ } catch { /* the loaded cards remain searchable */ }++ if (current !== searchSeq) return;++ if (!found) found = loaded.filter((x) => matches(x, query));++ resultHead.textContent = '';++ resultHead.append(h('span', '', `${found.length} result${found.length === 1 ? '' : 's'}`));++ const clear = h('button', 'ovk-btn ovk-btn--quiet', '×'); clear.type = 'button'; clear.setAttribute('aria-label', 'Clear feed search');++ clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);++ list.textContent = '';++ found.forEach((x) => { const card = feedCard(x); if (card) list.append(card); });++ if (typeof o.mountIcons === 'function') o.mountIcons(list);++ }const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };const clearSkels = () => list.querySelectorAll('.ovfd-skel').forEach((x) => x.remove());async function more(first) {@@@ -1272,12 +1302,15 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durif (first) { list.textContent = ''; skeletons(); } else skeletons();const j = await loadFeed(o, kind, cursor);if (my !== seq) return;-- clearSkels(); busy = false;++ clearSkels();if (!j) {++ busy = false;if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall standsdone = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;}if (first && o.feedLive) o.feedLive();++ if (!kind) { if (first) loaded.length = 0; loaded.push(...j.items); }++ if (query) { busy = false; search(query); return; }// Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);let run = [];@@@ -1291,6 +1324,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durif (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }cursor = j.next_cursor || null;done = !cursor;++ busy = false;sentinel.textContent = '';if (done) sentinel.append(h('p', 'ovh-note ovfd-caught', list.querySelector('.ovfd-card,.ovfd-act,.ovfd-update') ? "You're all caught up.": (kind === 'following' ? 'Nothing yet from the people you follow. Follow someone on their profile and what they share shows here.' : 'Nothing here yet.')));@@@ -1311,7 +1345,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durio.observe(sentinel);}more(true);-- return { update() {} };++ return { update() {}, search, get query() { return query; } };}function feedStyles() {if (document.getElementById('ovfd-styles')) return;@@@ -1319,6 +1353,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-durst.textContent = `.ovfd-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}.ovfd-chips::-webkit-scrollbar{display:none}++.ovfd-examples{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:2px 4px 6px;margin:0 -4px}.ovfd-examples::-webkit-scrollbar{display:none}.ovfd-examples[hidden]{display:none}++.ovfd-ex{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border-radius:999px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:13px;font-weight:650;cursor:pointer;white-space:nowrap;transition:background .15s}++.ovfd-ex::before{content:"";width:12px;height:12px;flex:none;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;opacity:.8}++.ovfd-ex:hover,.ovfd-ex:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent)}.ovfd-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}.ovfd-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}.ovfd-list{display:grid;gap:10px;padding:4px 0}@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': 'fbe0a5b0d179', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': '94fe9cbede93', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -37,6 +37,11 @@const cards = new Map(); // id → cardconst declared = new Map(); // id → descriptor (drawn by the page itself)let styleEl = null, clicksWired = false;++ const changed = (reason, card) => { try { root.dispatchEvent(new CustomEvent('openvibe:player', { detail: { reason, card: card || current() } })); } catch { /* */ } };++ function current() {++ const live = [...declared.values(), ...cards.values()].map((c) => c.d || c).find((c) => c.live);++ return live ? { ...live } : null;++ }// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';@@@ -59,7 +64,7 @@function portableOf(d) {const src = safeSrc(d && d.src);if (!src) return null;-- const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live };++ const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live, sound: !!d.sound };const href = safeSrc(d.href); if (href) out.href = href;const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);@@@ -353,7 +358,11 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xif (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));ui.append(top, row);const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');-- el.append(frame(d.src, d.title), ui, grip);++ const mediaFrame = frame(d.src, d.title);++ if (d.sound && embed) mediaFrame.addEventListener('load', () => {++ try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(d.src).origin); } catch { /* browser may require a new tap */ }++ });++ el.append(mediaFrame, ui, grip);document.body.appendChild(el);cards.set(c.id, c);let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }@@@ -368,6 +377,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xwire(c);if (!fine()) showUi(c);remember();++ changed('open', d);return handle(c);}function handle(c) {@@@ -380,12 +390,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xclearTimeout(c.idle);try { c.el.remove(); } catch { /* */ }remember();++ changed('close');}function list() { return [...cards.values()].map((c) => ({ ...c.d })); }function declare(id, desc) {const key = clean(id, 40);if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }if (declared.size) wireClicks();++ changed('declare');}// ── Within a site: the cards come back after a reload ──@@@ -412,7 +424,9 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xif (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;const data = b64(JSON.stringify(list));if (data.length > 3000) return href;-- u.hash = `ovo=${data}`;++ const existing = u.hash.slice(1);++ u.hash = existing ? `${existing}&ovo=${data}` : `ovo=${data}`;++ changed('handoff');return u.href;} catch { return href; }}@@@ -433,7 +447,12 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xlet list = null;if (m) {try { list = JSON.parse(unb64(m[1])); } catch { list = null; }-- try { const u = new URL(root.location.href); u.hash = ''; root.history.replaceState(root.history.state, '', u.href.replace(/#$/, '')); } catch { /* */ }++ try {++ const u = new URL(root.location.href);++ const keep = u.hash.slice(1).split('&').filter((part) => !/^ovo=/.test(part)).join('&');++ u.hash = keep;++ root.history.replaceState(root.history.state, '', u.href.replace(/#$/, ''));++ } catch { /* */ }} else {try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }}@@@ -456,14 +475,18 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-xconst f = c.el.querySelector('.ovo-frame');if (!f || e.source !== f.contentWindow || e.origin !== new URL(c.d.src).origin) continue;c.muted = !!m.muted;++ c.d.sound = !c.muted;++ c.d.playing = !!m.playing;const b = c.el.querySelector('[data-ovo="sound"]');if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;++ remember();++ changed('state', { ...c.d });}});root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });-- root.OVOverlay = { open, close, list, declare, handoffUrl, restore, safeSrc, _travelling: travelling };++ root.OVOverlay = { open, close, list, current, declare, handoffUrl, restore, safeSrc, _travelling: travelling };// After the page's own start-up (its scripts may set OVOverlayHere on DOMContentLoaded).const later = () => setTimeout(restore, 0);if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', later, { once: true }); else later();@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.205.0",++ "version": "4.206.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.205.0",++ "version": "4.206.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.205.0",++ "version": "4.206.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -529,6 +529,11 @@timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });doorEl.append(doorSw, doorHost, timerButton);listEl.after(doorEl);++ doorHost.addEventListener('openvibe:open-vibe', (e) => {++ if (door !== 'chats' || !inSheet()) return;++ e.preventDefault();++ openDoor('vibe');++ });tabsEl = el.querySelector('.ovs-tabs');actsEl = el.querySelector('.ovs-acts');rtabsEl = el.querySelector('.ovs-rtabs');@@@ -543,7 +548,7 @@tabsEl.addEventListener('click', (e) => {const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;leaveDoor();-- tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;++ tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); input.value = ''; render(''); listEl.scrollTop = 0;let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }if (touch) b.focus(); else input.focus();});@@@ -614,6 +619,7 @@if (e.key === 'Enter') {e.preventDefault();const q = input.value.trim();++ if (tab === 'feed' && homeLib() && !door) { render(q); return; }if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }const it = items[active >= 0 ? active : 0];@@@ -1213,7 +1219,7 @@if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);const t = document.createElement('span'); t.textContent = label; b.appendChild(t);-- b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* the Dock is optional */ } });++ b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } close(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });tabsEl.appendChild(b);continue;}@@@ -1320,6 +1326,7 @@const { all, mineSite, byId, shown, take, ranked, f } = basis();const groups = [];const wall = homeLib();++ if (wall && input && input.value.trim()) return [{ as: 'wall', items: [], section: 'wall' }];if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });const live = liveGroup(); if (live) groups.push(live);if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });@@@ -1475,7 +1482,8 @@const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],-- token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,++ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,++ onSearch: (q) => { input.value = q; render(q); input.focus(); },pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };@@@ -1490,6 +1498,15 @@el.classList.toggle('ovs-typing', !!q);if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }if (door === 'vibe' && inSheet()) return;++ if (tab === 'feed' && homeLib()) {++ tabsEl.hidden = false;++ input.placeholder = 'Search the feed';++ paintTabs(); renderTab();++ const feed = homeViews.wall && homeViews.wall.api;++ if (feed && feed.search && (q || feed.query)) feed.search(q);++ return;++ }++ input.placeholder = `Search ${here()}, or ask Vibe`;if (q) leaveDoor();tabsEl.hidden = !!q;if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }@@@ -8,6 +8,11 @@let saved = null;let pop = null;let opener = null;++ let mediaLock = false;++ const blockBackdropScroll = (e) => {++ const top = [...sheets].at(-1);++ if (top && top.el && !top.el.contains(e.target)) e.preventDefault();++ };function lock() {if (saved) return;@@@ -19,13 +24,21 @@const gap = Math.max(0, root.innerWidth - html.clientWidth);if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;html.style.overflow = 'hidden';-- body.style.position = 'fixed'; body.style.top = `-${y}px`;-- body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';++ // Moving a playing video into a fixed-position body interrupts playback on some++ // mobile WebKit builds. Keep the media layer in its existing layout instead.++ mediaLock = [...document.querySelectorAll('video, audio')].some((media) => !media.paused && !media.ended);++ if (mediaLock) document.addEventListener('touchmove', blockBackdropScroll, { passive: false, capture: true });++ else {++ body.style.position = 'fixed'; body.style.top = `-${y}px`;++ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';++ }}function unlock() {if (!saved || sheets.size) return;const s = saved, html = document.documentElement, body = document.body;saved = null;++ if (mediaLock) document.removeEventListener('touchmove', blockBackdropScroll, true);++ mediaLock = false;html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;body.style.position = s.bodyPosition; body.style.top = s.bodyTop;body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;@@@ -62,7 +75,7 @@};if (head) { head.addEventListener('pointerdown', onDown); head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); head.addEventListener('pointercancel', onUp); }if (el instanceof HTMLDialogElement) el.addEventListener('close', release);-- const api = { open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,++ const api = { el, open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,destroy() { release(); scroll.style.overscrollBehavior = oldOverscroll; if (head) { head.style.touchAction = oldTouch; head.removeEventListener('pointerdown', onDown); head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); head.removeEventListener('pointercancel', onUp); } if (el instanceof HTMLDialogElement) el.removeEventListener('close', release); } };return api;}@@@ -24,6 +24,10 @@ let up = true;const server = http.createServer((req, res) => {const u = new URL(req.url, 'http://x');if (u.pathname === '/dock-code.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'dock-code.js'))); }++ if (u.pathname === '/ov-window.js' || u.pathname === '/agent-session.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.pathname.slice(1)))); }++ if (u.pathname === '/agent-session.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(root, 'agent-session.css'))); }++ if (u.pathname === '/api/frame/codes-runs/t1') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ id: 't1', title: 'A goal bar for my sub goal', status: 'running', plan: [] })); }++ if (u.pathname === '/api/frame/codes-runs/t1/events') { res.writeHead(200, { 'content-type': 'text/event-stream' }); return res.end('id: 1\nevent: RUN_STARTED\ndata: {"type":"RUN_STARTED","runId":"t1","threadId":"t1"}\n\nid: 2\nevent: STEP_STARTED\ndata: {"type":"STEP_STARTED","stepName":"Read files"}\n\n'); }if (u.pathname.startsWith('/api/frame/codes-tasks')) {let body = '';req.on('data', (c) => { body += c; });@@@ -65,6 +69,12 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (assert.equal(seen[0].auth, 'Bearer tok_abc', 'the read carries the person\'s token');assert.equal(seen[0].path, '/api/frame/codes-tasks?limit=8', 'through Network\'s relay');++ // A task opens the shared card in the one window; the full Codes link stays available.++ await page.evaluate(`document.querySelector('.ovdc-task a.ovdc-a').click()`);++ assert.ok(await until(`!!document.querySelector('.ovw .ovas')`), 'the agent session mounted in OVWindow');++ assert.equal(await page.evaluate(`document.querySelector('.ovw a[href="https://openvibe.codes/tasks/t1"]') !== null`), true);++ await page.evaluate(`OVWindow.closeAll()`);++// My code wants a repository; Ctrl+Enter starts the task.await page.evaluate(`document.querySelector('[data-mode="repo"]').click()`);const box = () => page.evaluate(`({ repo: !document.querySelector('.ovdc-repo').hidden, go: document.querySelector('.ovdc-go').disabled, ph: document.querySelector('.ovdc-text').placeholder, checked: document.querySelector('.ovdc-mode[aria-checked="true"]').textContent })`);nnew file mode 100644BBinary files /dev/null and b/test/fixtures/menu-playing.mp4 differnnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-feed-search-chrome: skipped'); process.exit(0); }++const item = (id, title, kind, person) => ({ id, title, text: title, kind, actor: { name: person }, site: 'community', site_name: 'Community', url: `https://openvibe.community/p/${id}`, at: new Date().toISOString(), counts: {} });++const loaded = [item('1', 'New photo of a dog', 'pic', 'Ada'), item('2', 'Clip from today', 'clip', 'Bo')];++const server = http.createServer((req, res) => {++ if (req.url === '/ov-home.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'))); }++ res.writeHead(200, { 'Content-Type': 'text/html' });++ res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div id="feed"></div><script>++ window.__mode = 'api'; window.__queries = []; window.__search = '';++ const loaded = ${JSON.stringify(loaded)};++ const J = (data, ok = true) => Promise.resolve({ ok, json: async () => data });++ window.fetch = (url) => { const u = String(url);++ if (u.includes('/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Ada' }] });++ if (u.includes('/feed/search')) { window.__queries.push(u); return window.__mode === 'api' ? J({ items: [${JSON.stringify(item('3', 'A matching video', 'video', 'Ada'))}], next_cursor: null }) : J({}, false); }++ if (u.includes('/feed?')) return J({ items: loaded, next_cursor: null });++ return J({}, false);++ };++ </script><script src="/ov-home.js"></script><script>++ const target = document.getElementById('feed');++ window.api = OpenVibeHome.feed(target, { onSearch: (q) => { window.__search = q; api.search(q); } });++ </script>`);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feed-search-')) });++ const context = await browser.newContext();++ const page = await h.openPage(browser, context, { width: 390, height: 844 });++ const wait = async (expr) => { for (let n = 0; n < 100; n++) { if (await page.evaluate(expr)) return true; await new Promise((ok) => setTimeout(ok, 50)); } return false; };++ try {++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });++ assert.ok(await wait(`document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'loaded cards show');++ assert.ok(await wait(`document.querySelectorAll('.ovfd-examples button').length >= 4`), 'API and fallback examples show');++ await page.evaluate(`api.search('Ada')`);++ assert.ok(await wait(`document.querySelector('.ovfd-results').textContent.includes('1 result')`), 'API results show a count');++ assert.ok(await page.evaluate(`window.__queries[0].includes('q=Ada') && document.querySelector('.ovfd-list').textContent.includes('A matching video')`), 'search calls the API and replaces the list');++ await page.evaluate(`window.__mode = 'fallback'; api.search('Bo')`);++ assert.ok(await wait(`document.querySelector('.ovfd-list').textContent.includes('Clip from today') && !document.querySelector('.ovfd-list').textContent.includes('dog')`), 'unavailable API filters loaded cards by person');++ await page.evaluate(`document.querySelector('[aria-label="Clear feed search"]').click()`);++ assert.ok(await wait(`window.__search === '' && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'clear restores the list');++ await page.evaluate(`document.querySelector('.ovfd-examples button').click()`);++ assert.strictEqual(await page.evaluate(`window.__search`), 'New photos', 'example chip fills the query');++ console.log('menu-feed-search-chrome: API results, examples, clear, and loaded-card fallback ok');++ } finally { await page.close(); await browser.close(); server.close(); }++})().catch((e) => { console.error(e); server.close(); process.exitCode = 1; });@@@ -33,6 +33,10 @@ const PULSE = { items: [{ title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },{ title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },], next_cursor: 'c2' };++const FEED_V2 = { items: [++ { id: 'p:1', kind: 'pic', title: 'Ada shared a new photo', text: 'A sunny sky', actor: { name: 'Ada' }, site: 'community', site_name: 'Community', url: 'https://openvibe.community/p/1', at: new Date().toISOString(), counts: {} },++ { id: 'p:2', kind: 'clip', title: 'Bo shared a clip', text: 'A new trick', actor: { name: 'Bo' }, site: 'video', site_name: 'Video', url: 'https://openvibe.video/c/1', at: new Date().toISOString(), counts: {} },++], next_cursor: null };const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">@@@ -52,7 +56,13 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utif (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);++ if (s === 'https://openvibe.media/api/v1/attachments') return J({ id: 'att_01ABCDEFGHJKMNPQRSTVWXYZ01' }, true, 201);if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);++ if (s === 'https://openvibe.chat/api/dm/conversations') return J({ conversations: [] });++ if (s === 'https://openvibe.chat/api/vibe/conversations') return J({ count: 4, conversations: [{ name: 'Read https://…', last_message_at: new Date().toISOString() }] });++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Clips from today' }, { text: 'Ada' }] });++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/search')) return J({ items: ${JSON.stringify(FEED_V2.items.slice(0, 1))}, next_cursor: null });++ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed?')) return J(${JSON.stringify(FEED_V2)});if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });if (s.startsWith('https://')) return J({}, false, 404);@@@ -209,27 +219,33 @@ const server = http.createServer((req, res) => {await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);-- assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');-- await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));-- // Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each-- // uploads through Network and the post names them with that text.++ assert.ok(await until(s, `!!document.querySelector('#ovh-feed-composer.__none, #ovh-feed-composer.ovc')`), 'the one composer joins the Feed form');++ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('#ovh-feed-composer textarea').placeholder`), "What's new, Ada?"));await s.evaluate(`(async () => {-- const ta = document.querySelector('.ovh-compose textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input'));++ const ta = document.querySelector('#ovh-feed-composer textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input', { bubbles: true }));const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));-- const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File([blob], 'b.png', { type: 'image/png' }));-- const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));++ const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File(['PDF'], 'notes.pdf', { type: 'application/pdf' }));++ const input = document.querySelector('#ovh-feed-composer input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));})(), true`);-- assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');-- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));-- await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);-- await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));-- await s.evaluate(`document.querySelector('.ovh-post').click(), true`);-- assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');-- const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);-- assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');-- assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');-- assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);++ assert.ok(await until(s, `document.querySelectorAll('#ovh-feed-composer .ovc-file').length === 2`), 'the picture and PDF become chips');++ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('#ovh-feed-composer').checkValidity()`), 'the picture needs a description'));++ await s.evaluate(`(() => { const a = document.querySelector('#ovh-feed-composer .ovc-image input'); a.value = 'A dark sky'; a.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ await s.evaluate(`document.querySelector('#ovh-feed-composer .ovt-send').click(), true`);++ const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer + .ovh-note').textContent)`);++ const postState = await s.evaluate(`({ note: document.querySelector('#ovh-feed-composer + .ovh-note').textContent, valid: document.querySelector('#ovh-feed-composer').checkValidity(), disabled: document.querySelector('#ovh-feed-composer .ovt-send').disabled, calls: window.__calls.filter((c) => /attachments|posts/.test(c.u)).map((c) => c.u) })`);++ assert.ok(posted, `it posts: ${JSON.stringify(postState)}`);++ const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/v1/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u, auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);++ assert.deepStrictEqual(sent.map((c) => c.u.replace(/^https:\/\/(?:openvibe.network\/api\/frame|openvibe.media\/api\/v1)\//, '')), ['attachments', 'attachments', 'posts'], 'picture and PDF upload before the post');++ assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'uploads and post use the person\'s token');++ const body = JSON.parse(sent[2].body);++ assert.strictEqual(body.pictures[0].alt, 'A dark sky');++ assert.deepStrictEqual(body.attachments, ['att_01ABCDEFGHJKMNPQRSTVWXYZ01']);++ assert.deepStrictEqual(await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/attachments') && c.body instanceof FormData).map((c) => c.body.get('file').name)`), ['a.png', 'notes.pdf'], 'the picture and PDF bytes use the two upload routes');++ await s.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);++ assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');++ await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);++ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');// ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,// 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button// that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes@@@ -257,6 +273,22 @@ const server = http.createServer((req, res) => {const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');await s.close();++ if (process.env.OV_CAPTURE_MENU) {++ for (const width of [390, 1280]) {++ const shotPage = await h.openPage(browser, context, { width, height: width === 390 ? 844 : 900, block: blocked });++ await shotPage.goto(`${base}/?signed=1&feedv2=1`, { maxMs: 6000 });++ await openTab(shotPage, 'feed');++ assert.ok(await until(shotPage, `document.querySelectorAll('.ovfd-examples button').length >= 4 && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`));++ const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });++ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));++ await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);++ if (width === 1280) assert.ok(await until(shotPage, `!document.querySelector('.ovs').classList.contains('is-open')`), 'desktop Chat closes the menu so the Dock is visible');++ assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');++ const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });++ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));++ await shotPage.close();++ }++ }console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');} finally {await browser.close();nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++// Real mobile engines: a person starts a stream with sound, opens the network menu,++// and the same media element continues advancing. The fixture is a short loop.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const http = require('node:http');++const path = require('node:path');++let playwright;++try { playwright = require(process.env.OV_PLAYWRIGHT || 'playwright'); } catch {++ console.log('menu-media-playwright: skipped (set OV_PLAYWRIGHT to a Playwright package)');++ process.exit(0);++}++const root = path.join(__dirname, '..');++const fixture = path.join(__dirname, 'fixtures/menu-playing.mp4');++const html = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">++<button id="start">Play with sound</button><button id="menu">Open menu</button>++<video id="stream" playsinline loop src="/media.mp4"></video><div style="height:1200px"></div>++<script src="/surfaces.js"></script><script src="/search.js"></script>++<script>document.querySelector('#start').onclick=()=>{const v=document.querySelector('#stream');v.muted=false;v.play()};++document.querySelector('#menu').onclick=()=>OpenVibeSearch.open({links:[],service:'Live'});</script>`;++const server = http.createServer((req, res) => {++ if (req.url === '/media.mp4') { res.writeHead(200, { 'content-type': 'video/mp4', 'content-length': fs.statSync(fixture).size, 'accept-ranges': 'bytes' }); return fs.createReadStream(fixture).pipe(res); }++ if (req.url === '/surfaces.js' || req.url === '/search.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return fs.createReadStream(path.join(root, req.url.slice(1))).pipe(res); }++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const url = `http://127.0.0.1:${server.address().port}/`;++ try {++ for (const name of ['chromium', 'webkit']) {++ const browser = await playwright[name].launch({ headless: true,++ ...(name === 'webkit' && process.env.OV_WEBKIT_BIN ? { executablePath: process.env.OV_WEBKIT_BIN } : {}) });++ try {++ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,++ userAgent: name === 'webkit' ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1' : 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/130.0 Mobile Safari/537.36' });++ const page = await context.newPage();++ await page.goto(url);++ await page.locator('#start').tap();++ await page.waitForFunction(() => document.querySelector('#stream').currentTime > .2);++ const before = await page.locator('#stream').evaluate((v) => v.currentTime);++ await page.locator('#menu').tap();++ await page.waitForTimeout(650);++ const state = await page.locator('#stream').evaluate((v) => ({ time: v.currentTime, paused: v.paused, muted: v.muted, body: document.body.style.position }));++ assert.ok(!state.paused && !state.muted && state.time > before + .25, `${name}: unmuted stream advances with menu open (${JSON.stringify(state)})`);++ assert.equal(state.body, '', `${name}: menu leaves the playing video's body layout intact`);++ await context.close();++ } finally { await browser.close(); }++ }++ console.log('menu-media-playwright: Chromium and WebKit keep an unmuted stream playing through the menu');++ } finally { server.close(); }++})().catch((error) => { console.error(error); process.exitCode = 1; server.close(); });@@@ -73,6 +73,8 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re// A link to another OpenVibe site carries it; one on this site does not.const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);++ assert.strictEqual(await p.evaluate(`OVOverlay.current().href`), `${base}/a`, 'the active live card exposes its way back');++ assert.match(await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements#scores')`), /#scores&ovo=/, 'handoff keeps the destination section');const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));
@@ -1,5 +1,23 @@
@@ -1,5 +1,23 @@
# Changelog
# Changelog
+## 4.206.0 — 2026-11-11
+
+**The menu keeps a stream playing, the Feed in the menu is searchable with the one composer at its top, and a Code task
+opens in the Dock's step card.**
+
+- **The menu no longer pauses what is playing (#461, ask 360).** Opening the menu on a phone left live video and audio
+ paused under it; the sheet now leaves media alone and the page can read the player's state.
+- **The Feed in the menu (#463, ask 360).** Feed search uses Community's search and examples through Network, with
+ loaded-card filtering when search is unavailable; the example searches are chips that fill the field. The Feed's
+ post form is the one composer (OpenVibeComposer: files, paste, drop, commands, drafts, settings); each picture needs a
+ description and other files upload to Media. Its + and the example chips carry their own styles in the menu (they
+ drew as bare browser buttons in the PR's screenshots).
+- **Chat and Vibe (#463).** The Chat summary's Vibe row is an accessible Open Vibe button that selects the menu's Vibe
+ tab; on a desktop Chat and Vibe open the Dock after the menu closes, and the Dock opens from its pill only when that
+ pill is visible.
+- **A Code task in the Dock (#465, ask 354).** The Dock's Code card shows the task's live steps with the AG-UI step view
+ (agent-session.js card mode) and opens the whole run.
+
## 4.205.0 — 2026-11-11
## 4.205.0 — 2026-11-11
**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to
**Agents show their work everywhere: the one step view reads AG-UI, compact like the portal, with the one composer to
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.205.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.206.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,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.205.0",
+ "version": "4.206.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)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.399.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.147.0",
"sdk": "openvibe-sdk v0.147.0",
"features": [
"features": [
+ "the menu Feed searches Community with examples and uses the one composer for pictures and files; the Chat summary opens the Vibe tab (4.206.0)",
"the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
"the one step view reads AG-UI (phases, titled steps, sources, thinking, compact groups, card mode) with the one composer to talk to a run, and crawlable first HTML for every site (4.205.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"buttons that show their state (kinds, badge, dot, attention, busy/done/error, hold), resizable panel windows, the one composer everywhere, players that start sooner, and openvibe-shared/csp (4.204.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
"the Dock updates edited DM text and link cards live and lets authors edit their own messages (4.203.0)",
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
if (typeof document === 'undefined' || root.OpenVibeDockCode) return;
if (typeof document === 'undefined' || root.OpenVibeDockCode) return;
const CODES = 'https://openvibe.codes';
const CODES = 'https://openvibe.codes';
+ const SCRIPT_BASE = document.currentScript && document.currentScript.src ? new URL('.', document.currentScript.src).href : '/shared/';
const POLL_MS = 8000;
const POLL_MS = 8000;
const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];
const MODES = [['create', 'Create', 'A new website, game or app'], ['openvibe', 'OpenVibe', 'A change to OpenVibe itself'], ['repo', 'My code', 'A repository of yours']];
const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };
const STATE = { queued: 'Waiting', running: 'Running', 'needs-you': 'Needs you', done: 'Done', failed: 'Failed', stopped: 'Stopped' };
@@ -100,6 +101,72 @@
@@ -100,6 +101,72 @@
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;
let gone = false, timer = 0, mode = 'create', tasks = null, reachable = true;
+ function loadScript(file, ready) {
+ if (ready()) return Promise.resolve(true);
+ return new Promise((resolve) => {
+ const id = `ovdc-${file}`;
+ let script = d.getElementById(id);
+ if (!script) { script = d.createElement('script'); script.id = id; script.src = SCRIPT_BASE + file; script.async = true; d.head.appendChild(script); }
+ script.addEventListener('load', () => resolve(!!ready()), { once: true });
+ script.addEventListener('error', () => resolve(false), { once: true });
+ });
+ }
+ function runUrl(id) {
+ return (direct ? direct + '/api/v1/runs/' : relay + '/codes-runs/') + encodeURIComponent(id);
+ }
+ async function openRun(task, from) {
+ const href = onCodes(task.url);
+ if (!href || !tok()) return;
+ const loaded = await Promise.all([
+ loadScript('ov-window.js', () => !!root.OVWindow),
+ loadScript('agent-session.js', () => !!root.OpenVibeAgentSession)
+ ]);
+ if (loaded.some((ok) => !ok)) { d.defaultView.location.assign(href); return; }
+ const body = h('div');
+ let stopped = false, controller = null, view = null;
+ const win = root.OVWindow.open({ id: `codes-run-${task.id}`, title: task.title || 'Coding task', body, kind: 'dialog', size: 'md', from,
+ onClose: () => { stopped = true; if (controller) controller.abort(); if (view) view.destroy(); } });
+ body.appendChild(h('p', 'ovdc-none', 'Loading the run…'));
+ const headers = () => ({ Authorization: `Bearer ${tok()}`, Accept: 'application/json' });
+ try {
+ const res = await fetch(runUrl(task.id), { headers: headers(), credentials: 'omit' });
+ if (!res.ok) throw new Error('The run could not be read.');
+ const run = await res.json();
+ if (stopped) return;
+ body.replaceChildren();
+ view = root.OpenVibeAgentSession.mount(body, { title: run.title || task.title || 'Coding task',
+ status: run.status || task.status || 'running', mode: 'card', href });
+ let last = 0;
+ while (!stopped) {
+ controller = new AbortController();
+ const events = await fetch(runUrl(task.id) + '/events', { credentials: 'omit',
+ headers: { ...headers(), Accept: 'text/event-stream', 'Last-Event-ID': String(last) }, signal: controller.signal });
+ if (!events.ok || !events.body) break;
+ const reader = events.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '', terminal = false;
+ while (!stopped) {
+ const part = await reader.read();
+ if (part.done) break;
+ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
+ let cut;
+ while ((cut = buffer.indexOf('\n\n')) !== -1) {
+ const frame = buffer.slice(0, cut); buffer = buffer.slice(cut + 2);
+ const id = /^id: *(\d+)$/m.exec(frame), data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
+ if (id) last = Number(id[1]);
+ if (!data) continue;
+ try { const event = JSON.parse(data); view.push(event); if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR') terminal = true; } catch { /* skip one malformed event */ }
+ }
+ }
+ if (terminal || !/running|queued|placed/.test(run.status || '')) break;
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ }
+ } catch (err) {
+ if (!stopped) { body.replaceChildren(); const a = h('a', 'ovk-btn ovk-btn--quiet', 'Open on Codes'); a.href = href; body.appendChild(a); }
+ }
+ return win;
+ }
+
container.innerHTML = '';
container.innerHTML = '';
container.classList.add('ovdc');
container.classList.add('ovdc');
const box = h('form', 'ovdc-box');
const box = h('form', 'ovdc-box');
@@ -183,7 +250,10 @@
@@ -183,7 +250,10 @@
const li = h('li', 'ovdc-task');
const li = h('li', 'ovdc-task');
const there = onCodes(t.url);
const there = onCodes(t.url);
const a = h(there ? 'a' : 'div', 'ovdc-a');
const a = h(there ? 'a' : 'div', 'ovdc-a');
- if (there) a.href = there;
+ if (there) { a.href = there; a.addEventListener('click', (event) => {
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ event.preventDefault(); openRun(t, a);
+ }); }
a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));
a.appendChild(h('span', 'ovdc-t', t.title || 'A coding task'));
const m = h('span', 'ovdc-m');
const m = h('span', 'ovdc-m');
const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';
const s = h('span', 'ovdc-s', STATE[t.status] || t.status || ''); s.dataset.s = t.status || '';
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': 'fbe0a5b0d179' };
+ const VIBE_REV = { 'ov-composer.js': '94fe9cbede93' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '7f3c25bc076d', 'dock-code.js': '3dcbaca10bfb', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': '2c597ddf5604', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -694,8 +694,11 @@
@@ -694,8 +694,11 @@
const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
const meta = h('span', 'ovdk-chat-m'); const at = c.latest && c.latest.last_answer_at;
const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
const t = h('time', '', when(at || c.last_message_at)); if (at) t.title = answered(at); meta.append(t);
const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
const av = h('span', 'ovdk-av ovdk-av-vibe'); av.append(icon(G.vibe));
- b.append(av, body, meta); b.setAttribute('aria-label', `Vibe, ${c.count} chats${at ? `, ${answered(at)}` : ''}`);
- b.addEventListener('click', () => { vibeOnList = true; save({ vibe: null }); setTab('vibe', true); });
+ b.append(av, body, meta); b.setAttribute('aria-label', 'Open Vibe');
+ b.addEventListener('click', () => {
+ if (!b.dispatchEvent(new CustomEvent('openvibe:open-vibe', { bubbles: true, cancelable: true }))) return;
+ vibeOnList = true; save({ vibe: null }); setTab('vibe', true);
+ });
li.append(b); ul.append(li); return;
li.append(b); ul.append(li); return;
}
}
const li = h('li'), b = h('button', 'ovdk-chat');
const li = h('li'), b = h('button', 'ovdk-chat');
@@ -1042,7 +1045,10 @@
@@ -1042,7 +1045,10 @@
fromOrb();
fromOrb();
void el.offsetWidth; el.classList.add('is-in');
void el.offsetWidth; el.classList.add('is-in');
// On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
// On a computer it grows out of its pill; a phone's sheet opens out of the navbar's orb (fromOrb), or rises.
- if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) root.OpenVibeNavbar.pop(el, document.getElementById('ovdk-pill'));
+ if (!phone() && root.OpenVibeNavbar && root.OpenVibeNavbar.pop) {
+ const pill = document.getElementById('ovdk-pill');
+ root.OpenVibeNavbar.pop(el, pill?.offsetWidth ? pill : null);
+ }
if (phone()) {
if (phone()) {
el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
el.setAttribute('aria-modal', 'true'); document.documentElement.classList.add('ovdk-sheet-open');
if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }
if (root.OVSurfaces && !dockSurface) { dockSurface = root.OVSurfaces.sheet({ el, body: el.querySelector('.ovdk-body'), close }); dockSurface.open(); }
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/360-chat-1280.png differ
Binary files /dev/null and b/docs/screenshots/360-chat-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/360-chat-390.png differ
Binary files /dev/null and b/docs/screenshots/360-chat-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/360-feed-1280.png differ
Binary files /dev/null and b/docs/screenshots/360-feed-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/360-feed-390.png differ
Binary files /dev/null and b/docs/screenshots/360-feed-390.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
Binary files /dev/null and b/docs/screenshots/ask-354-1280.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/ask-354-390.png differ
Binary files /dev/null and b/docs/screenshots/ask-354-390.png differ
@@ -607,7 +607,7 @@
@@ -607,7 +607,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '8eec18660ff4', 'dock.js': '0748f4087385', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': 'ea69b8a08868', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '771efd0e3963', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '0dbfc679c2a2', 'dock.js': '1428fea2f91c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4b39796c5145', 'history.js': 'f9fb9e096c65', 'bottom.js': 'b9f708d04350', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': '52439265b980', 'overlay.js': 'c72c0e31505d', 'ov-home.js': 'cdacf9b1154b', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'e84894600223', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '749120bfd24b', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -30,7 +30,15 @@
@@ -30,7 +30,15 @@
// 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
// 4.203.0: the composer carries its own look (it was in thread.css), so any box on any page gets it: the Dock's
// Vibe and Code boxes included. Injected once, before the first box is joined.
// Vibe and Code boxes included. Injected once, before the first box is joined.
- const CSS = `.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
+ const CSS = `.ovt-composer { position: relative; display: grid; gap: 6px; max-width: 780px; margin: 0 auto; padding: 10px 12px; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 18px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); }
+.ovt-composer textarea { width: 100%; min-height: 48px; box-sizing: border-box; padding: 6px 2px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; }
+.ovt-c-row { display: flex; align-items: center; gap: 8px; }
+.ovt-c-tools { flex: 1; display: flex; align-items: center; gap: 6px; }
+.ovt-send { min-height: 40px; padding: 0 14px; border: 0; border-radius: 12px; background: var(--accent, #3b82f6); color: var(--on-accent, #fff); font: inherit; font-weight: 650; cursor: pointer; }
+.ovt-send:disabled { opacity: .5; }
+.ovt-composer .ovc-file.ovc-image { height: auto; min-height: 100px; flex-wrap: wrap; overflow: visible; }
+.ovt-composer .ovc-image input { flex: 1 0 100%; min-width: 0; min-height: 36px; box-sizing: border-box; border: 1px solid var(--border, rgba(148,163,184,.18)); border-radius: 8px; background: var(--bg-primary, #0b1220); color: inherit; font: inherit; }
+.ovc-files { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; margin: 0 0 2px -4px; scrollbar-width: thin; }
.ovc-files[hidden] { display: none; }
.ovc-files[hidden] { display: none; }
.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
.ovc-file { position: relative; flex: none; display: flex; align-items: center; gap: 9px; max-width: 230px; height: 52px; padding: 5px 30px 5px 5px; box-sizing: border-box; border-radius: 14px;
border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
border: 1px solid var(--ovt-line, var(--border, rgba(148, 163, 184, .18))); background: color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); overflow: hidden; animation: ovc-in .2s cubic-bezier(.2, .9, .3, 1) both; }
@@ -251,6 +259,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -251,6 +259,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
files.splice(i, 1);
files.splice(i, 1);
if (f.url) URL.revokeObjectURL(f.url);
if (f.url) URL.revokeObjectURL(f.url);
if (f.hidden) f.hidden.remove();
if (f.hidden) f.hidden.remove();
+ form.dispatchEvent(new CustomEvent('ovc:file-removed', { detail: { file: f.file, chip: f.chip } }));
f.chip.remove();
f.chip.remove();
strip.hidden = !files.length;
strip.hidden = !files.length;
syncInputs();
syncInputs();
@@ -266,6 +275,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -266,6 +275,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
const f = { file, url: kind === 'image' || kind === 'video' ? URL.createObjectURL(file) : null, pasted: !!pasted, lines: pasted ? pasted.lines : 0, text: pasted ? pasted.text : null };
files.push(f);
files.push(f);
paintFile(f);
paintFile(f);
+ form.dispatchEvent(new CustomEvent('ovc:file-added', { detail: { file, chip: f.chip } }));
if (up) {
if (up) {
f.progress = 0; paintFile(f);
f.progress = 0; paintFile(f);
Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
Promise.resolve(up(file, (p) => { f.progress = Math.max(0, Math.min(0.99, Number(p) || 0)); paintFile(f); syncSend(); }))
@@ -505,7 +515,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
@@ -505,7 +515,7 @@ html[data-ov-motion=off] .ovc-file, html[data-ov-motion=off] .ovc-pop, html[data
const api = {
const api = {
form, ctx,
form, ctx,
refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
refresh() { form.classList.toggle('ovc-compact', prefs.density === 'compact'); micButton(); paintTools(); syncSend(); },
- add, files: () => files.map((f) => f.file),
+ add, files: () => files.map((f) => f.file), clearFiles: () => { [...files].forEach(remove); },
uploads: () => files.map((f) => f.id).filter(Boolean),
uploads: () => files.map((f) => f.id).filter(Boolean),
clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
clear() { files.slice().forEach(remove); text.value = ''; if (draftKey) store.set(draftKey, null); fire(); },
destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
destroy() { live.delete(api); pop.remove(); strip.remove(); if (mic) mic.remove(); modTools.forEach((b) => b.remove()); form.__ovc = null; },
@@ -152,28 +152,6 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
@@ -152,28 +152,6 @@ html[data-ov-motion=reduced] .ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { a
.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
.ovh-actor.is-invite .ovh-x { min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); color: var(--text-secondary, #a8b3c4); font-weight: 600; }
@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
@media (min-width: 560px) { .ovh-actor.is-invite { grid-template-columns: 48px minmax(0, 1fr) auto; } .ovh-actor.is-invite .ovh-acts { grid-column: auto; } }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
.ovh-skel { display: block; height: 14px; margin: 10px 8px; border-radius: 7px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
-.ovh-compose { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px; align-items: start; margin: 6px 4px; padding: 10px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent);
- box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); }
-.ovh-compose textarea { flex: 1; min-width: 0; min-height: 44px; max-height: 160px; padding: 11px 12px; border-radius: 12px; border: 1px solid var(--border, rgba(255,255,255,.14));
- background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 15px; line-height: 1.4; resize: none; overflow-y: auto; }
-.ovh-post { flex: none; min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); font: inherit; font-weight: 700; cursor: pointer; }
-.ovh-post:disabled { opacity: .5; cursor: default; }
-.ovh-compose-main { display: grid; gap: 8px; min-width: 0; }
-.ovh-compose-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
-.ovh-compose-bar .ovh-post { margin-left: auto; }
-.ovh-compose-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ovh-pic-btn { flex: none; display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 12px; background: transparent; color: var(--accent-light, var(--accent, #60a5fa)); cursor: pointer; }
-.ovh-pic-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
-.ovh-pic-btn:disabled { opacity: .4; cursor: default; background: transparent; }
-.ovh-pic-btn svg, .ovh-pic-x svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
-.ovh-pics { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
-.ovh-pics[hidden] { display: none; }
-.ovh-pic { display: grid; grid-template-columns: 56px minmax(0, 1fr) 40px; gap: 8px; align-items: center; }
-.ovh-pic img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); }
-.ovh-pic input { min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); background: var(--bg-primary, rgba(0,0,0,.2)); color: inherit; font: inherit; font-size: 14px; }
-.ovh-pic-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
-.ovh-pic-x svg { width: 18px; height: 18px; }
-.ovh-pic-x:hover { background: color-mix(in srgb, var(--text-primary, #e8ecf3) 8%, transparent); color: var(--text-primary, #e8ecf3); }
.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-note { margin: 2px 8px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
.ovh-today { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 4px 4px 6px; }
.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
.ovh-stat { display: grid; gap: 2px; align-content: start; min-width: 0; padding: 10px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
@@ -359,6 +337,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -359,6 +337,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
const ICON = {
const ICON = {
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
pencil: [['path', { d: 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
check: [['path', { d: 'M5 12.5l4.5 4.5L19 7.5' }]],
+ attach: [['path', { d: 'M12 5v14M5 12h14' }]],
plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
plus: [['circle', { cx: 9.5, cy: 8.5, r: 3.2 }], ['path', { d: 'M3.5 18.5c1-2.900 3.300-4.400 6-4.400 1.300 0 2.500.300 3.500 1' }], ['path', { d: 'M18 13.500v6M15 16.500h6' }]],
out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
out: [['path', { d: 'M13.500 4.500h4a1.500 1.500 0 0 1 1.500 1.500v12a1.500 1.500 0 0 1-1.500 1.500h-4' }], ['path', { d: 'M9.500 8l-4 4 4 4M5.500 12h9.500' }]],
grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],
grid: [['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.6 }]],
@@ -872,85 +851,83 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -872,85 +851,83 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
return out;
return out;
}
}
function composer(box, o) {
function composer(box, o) {
- const form = h('form', 'ovh-compose');
- const face = h('span', 'ovh-face');
- const pic = o.user && /^https:\/\//.test(String(o.user.avatar_url || o.user.avatar || '')) ? (o.user.avatar_url || o.user.avatar) : '';
- if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String((o.user && (o.user.display_name || o.user.username)) || 'Y').charAt(0).toUpperCase();
+ const form = h('form', 'ovt-composer'); form.id = 'ovh-feed-composer'; form.setAttribute('data-ovc', ''); form.setAttribute('data-ovc-max-files', '10'); form.setAttribute('data-ovc-max-mb', '300');
+ const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 2000; ta.rows = 2; ta.required = true; ta.setAttribute('data-ovt-text', '');
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
const first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
- const main = h('div', 'ovh-compose-main');
- const ta = h('textarea'); ta.name = 'text'; ta.maxLength = 500; ta.rows = 1; ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
- // Pictures (owner, 11-09: "you can make posts and put images"): up to four, each with a line saying what it shows,
- // which Community requires (alt text, for people who cannot see it).
- const pics = []; const list = h('ul', 'ovh-pics'); list.hidden = true;
- const bar = h('div', 'ovh-compose-bar');
- const add = h('button', 'ovh-pic-btn'); add.type = 'button'; add.setAttribute('aria-label', 'Add pictures'); add.title = 'Add pictures (up to 4)'; add.append(svgIcon('image'));
- const file = h('input'); file.type = 'file'; file.accept = 'image/jpeg,image/png,image/webp,image/gif'; file.multiple = true; file.hidden = true;
- const hint = h('small', 'ovh-compose-hint');
- const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;
- const ready = () => {
- const missing = pics.some((p) => !p.alt.value.trim());
- post.disabled = !ta.value.trim() || missing;
- hint.textContent = missing ? 'Describe each picture' : '';
- add.disabled = pics.length >= 4;
- list.hidden = !pics.length;
- };
- ta.addEventListener('input', () => { ready(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });
- add.addEventListener('click', () => file.click());
- file.addEventListener('change', async () => {
- const chosen = [...(file.files || [])].slice(0, 4 - pics.length); file.value = '';
- for (const f of chosen) {
- if (!/^image\//.test(f.type)) continue;
- const pr = await preparePicture(f);
- if (pr.file.size > 8 * 1024 * 1024) { note.hidden = false; note.textContent = `${f.name} is over 8 MB.`; continue; }
- const li = h('li', 'ovh-pic');
- const im = h('img'); im.alt = ''; if (pr.thumb) im.src = pr.thumb;
- const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?'; alt.setAttribute('aria-label', `What ${f.name} shows`);
- const x = h('button', 'ovh-pic-x'); x.type = 'button'; x.setAttribute('aria-label', 'Remove this picture'); x.title = 'Remove'; x.append(svgIcon('x'));
- const entry = { file: pr.file, alt, li };
- x.addEventListener('click', () => { pics.splice(pics.indexOf(entry), 1); li.remove(); ready(); });
- alt.addEventListener('input', ready);
- li.append(im, alt, x); list.append(li); pics.push(entry);
- }
- ready();
- const empty = pics.find((p) => !p.alt.value.trim()); if (empty) empty.alt.focus();
+ ta.placeholder = first ? `What's new, ${first}?` : "What's new?"; ta.setAttribute('aria-label', "What's new");
+ const row = h('div', 'ovt-c-row'), tools = h('div', 'ovt-c-tools');
+ // The composer's own + (its contract: label.ovt-btn.ovt-attach holding the file input), drawn by ov-composer.js's
+ // styles wherever the menu opens; a kit button had no styles here and drew bare ("Attach files").
+ const attach = h('label', 'ovt-btn ovt-attach'); attach.title = 'Attach files'; attach.setAttribute('aria-label', 'Attach files');
+ attach.append(svgIcon('attach'));
+ const file = h('input'); file.type = 'file'; file.multiple = true; file.hidden = true; file.setAttribute('data-ovt-files', ''); file.setAttribute('aria-label', 'Attach files');
+ attach.append(file); tools.append(attach);
+ const post = h('button', 'ovt-send', 'Post'); post.type = 'submit'; post.disabled = true; post.setAttribute('data-ovt-send', '');
+ const note = h('p', 'ovh-note'); note.hidden = true; note.setAttribute('data-ovt-note', '');
+ row.append(tools, post); form.append(ta, row); box.append(form, note);
+ const pics = new Map();
+ const ready = () => { post.disabled = !ta.value.trim() || [...pics.values()].some((alt) => !alt.value.trim()); };
+ form.addEventListener('input', ready);
+ form.addEventListener('ovc:file-added', (e) => {
+ const { file: f, chip } = e.detail;
+ if (!/^image\//.test(f.type)) return;
+ chip.classList.add('ovc-image');
+ const alt = h('input'); alt.type = 'text'; alt.maxLength = 300; alt.placeholder = 'What does it show?';
+ alt.setAttribute('aria-label', `What ${f.name || 'the picture'} shows`); alt.required = true;
+ chip.append(alt); pics.set(f, alt); queueMicrotask(ready);
});
});
- bar.append(add, file, hint, post);
- main.append(ta, list, bar);
- form.append(face, main);
- const note = h('p', 'ovh-note'); note.hidden = true;
+ form.addEventListener('ovc:file-removed', (e) => { pics.delete(e.detail.file); queueMicrotask(ready); });
const auth = { Authorization: `Bearer ${o.token}` };
const auth = { Authorization: `Bearer ${o.token}` };
+ const upload = async (url, f) => {
+ const fd = new FormData(); fd.append('file', f, f.name || 'file');
+ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
+ const j = await r.json().catch(() => ({}));
+ const id = (j.attachment && (j.attachment.media_id || j.attachment.id)) || j.id;
+ if (!r.ok || !id) throw new Error(j.detail || `${f.name || 'File'} did not upload.`);
+ return id;
+ };
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
- const text = ta.value.trim(); if (!text || post.disabled) return;
- post.disabled = true; add.disabled = true; note.hidden = true;
+ const text = ta.value.trim(), files = form.__ovc ? form.__ovc.files() : [...file.files];
+ if (!text) { ta.focus(); return; }
+ const images = files.filter((f) => /^image\//.test(f.type));
+ if (images.length > 4) { note.hidden = false; note.textContent = 'Choose up to four pictures.'; return; }
+ const missing = images.find((f) => !pics.get(f) || !pics.get(f).value.trim());
+ if (missing) { note.hidden = false; note.textContent = 'Describe each picture.'; pics.get(missing)?.focus(); return; }
+ post.disabled = true; note.hidden = true;
const label = post.textContent;
const label = post.textContent;
try {
try {
- let r;
- if (pics.length) {
- const named = [];
- for (let k = 0; k < pics.length; k++) {
- post.textContent = pics.length > 1 ? `Picture ${k + 1} of ${pics.length}` : 'Sending the picture';
- const fd = new FormData(); fd.append('file', pics[k].file, pics[k].file.name || 'picture.jpg');
- const up = await fetch(`${o.frameBase || FRAME}/attachments`, { method: 'POST', credentials: 'omit', headers: auth, body: fd });
- const uj = await up.json().catch(() => ({}));
- const id = uj && uj.attachment && uj.attachment.media_id;
- if (!up.ok || !id) throw new Error(uj.detail || 'A picture did not upload. Try again in a moment.');
- named.push({ media_id: id, alt: pics[k].alt.value.trim() });
+ const pictures = [], attachments = [];
+ for (const f of files) {
+ post.textContent = `Uploading ${f.name || 'file'}`;
+ if (/^image\//.test(f.type)) {
+ const prepared = await preparePicture(f);
+ const media_id = await upload(`${o.frameBase || FRAME}/attachments`, prepared.file);
+ pictures.push({ media_id, alt: pics.get(f).value.trim() });
+ } else {
+ const media_id = await upload('https://openvibe.media/api/v1/attachments', f);
+ attachments.push(media_id);
}
}
- post.textContent = 'Posting';
- r = await fetch(`${o.frameBase || FRAME}/posts`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text, pictures: named }) });
- } else {
- r = await fetch(`${o.frameBase || FRAME}/status`, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify({ text }) });
}
}
+ post.textContent = 'Posting';
+ const url = files.length ? `${o.frameBase || FRAME}/posts` : `${o.frameBase || FRAME}/status`;
+ const body = files.length ? { text, pictures, attachments } : { text };
+ const r = await fetch(url, { method: 'POST', credentials: 'omit', headers: Object.assign({ 'Content-Type': 'application/json' }, auth), body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
if (!r.ok) throw new Error(j.detail || 'It did not post. Try again in a moment.');
- ta.value = ''; ta.style.height = ''; pics.splice(0).forEach((p) => p.li.remove());
+ ta.value = ''; ta.dispatchEvent(new Event('input', { bubbles: true })); form.__ovc?.clearFiles(); pics.clear();
note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';
note.hidden = false; note.textContent = 'Posted. It is on your profile and in the Feed.';
if (o.onPosted) o.onPosted(j);
if (o.onPosted) o.onPosted(j);
- } catch (err) { note.hidden = false; note.textContent = String(err.message || err); }
+ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); ta.dispatchEvent(new Event('input', { bubbles: true })); }
post.textContent = label; ready();
post.textContent = label; ready();
});
});
- box.append(form, note);
+ const join = () => { if (root.OpenVibeComposer && form.isConnected) root.OpenVibeComposer.enhance(form); };
+ if (root.OpenVibeComposer) join();
+ else if (typeof o.sibling === 'function') {
+ let script = document.getElementById('ov-composer-script');
+ if (!script) { script = document.createElement('script'); script.id = 'ov-composer-script'; script.src = o.sibling('ov-composer.js'); script.onload = join; document.head.append(script); }
+ else script.addEventListener('load', join, { once: true });
+ }
}
}
function spark(series) {
function spark(series) {
const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);
const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);
@@ -1259,11 +1236,64 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1259,11 +1236,64 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
el.classList.add('ovh', 'ovfd');
el.classList.add('ovh', 'ovfd');
el.setAttribute('data-sec', 'wall');
el.setAttribute('data-sec', 'wall');
el.textContent = '';
el.textContent = '';
+ const examples = h('div', 'ovfd-examples'); examples.setAttribute('aria-label', 'Example feed searches');
+ const resultHead = h('div', 'ovfd-results'); resultHead.hidden = true;
const chips = h('div', 'ovfd-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');
const chips = h('div', 'ovfd-chips'); chips.setAttribute('role', 'tablist'); chips.setAttribute('aria-label', 'Feed');
const list = h('div', 'ovfd-list');
const list = h('div', 'ovfd-list');
const sentinel = h('div', 'ovfd-more');
const sentinel = h('div', 'ovfd-more');
- el.append(chips, list, sentinel);
+ el.append(examples, resultHead, chips, list, sentinel);
let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
let kind = '', cursor = null, busy = false, done = false, seq = 0, lastDay = '';
+ let query = '', searchSeq = 0;
+ const loaded = [];
+ const feedBase = `${o.frameBase || FRAME}/feed`;
+ const headers = () => Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {});
+ const fallbackExamples = ["Who's live", 'New photos', 'Clips from today', 'Videos this week'];
+ const showExamples = (values) => {
+ examples.textContent = '';
+ [...new Set([...values, ...fallbackExamples])].slice(0, 6).forEach((value) => {
+ const b = h('button', 'ovfd-ex', value); b.type = 'button';
+ b.addEventListener('click', () => { if (o.onSearch) o.onSearch(value); });
+ examples.append(b);
+ });
+ };
+ showExamples(fallbackExamples);
+ fetch(`${feedBase}/examples`, { credentials: 'omit', headers: headers() }).then((r) => r.ok ? r.json() : null).then((j) => {
+ const values = j && Array.isArray(j.examples) ? j.examples.map((x) => x && x.text).filter((x) => typeof x === 'string' && x.trim()) : [];
+ if (values.length) showExamples(values);
+ }).catch(() => {});
+ const matches = (x, q) => {
+ const words = q.toLowerCase().replace(/^(new |who's |from |this )/g, '').split(/\s+/).filter((w) => !['the', 'today', 'week'].includes(w));
+ const hay = [x.title, x.text, x.kind, x.site, x.site_name, x.actor && x.actor.name].join(' ').toLowerCase();
+ if (q.toLowerCase().includes('today') && String(x.at || '').slice(0, 10) !== new Date().toISOString().slice(0, 10)) return false;
+ if (q.toLowerCase().includes('this week') && Date.parse(x.at || '') < Date.now() - 7 * 864e5) return false;
+ return words.filter((w) => !['from', 'this', 'week'].includes(w)).every((w) => {
+ const word = w.replace(/s$/, '');
+ return hay.includes(word) || word === 'photo' && x.kind === 'pic';
+ });
+ };
+ async function search(q) {
+ query = String(q || '').trim(); const current = ++searchSeq;
+ resultHead.textContent = '';
+ resultHead.hidden = !query; chips.hidden = !!query; sentinel.hidden = !!query;
+ examples.hidden = !!query;
+ if (!query) { cursor = null; done = false; busy = false; more(true); return; }
+ list.textContent = '';
+ resultHead.append(h('span', '', 'Searching…'));
+ let found = null;
+ try {
+ const r = await fetch(`${feedBase}/search?q=${encodeURIComponent(query)}`, { credentials: 'omit', headers: headers() });
+ if (r.ok) { const j = await r.json(); if (Array.isArray(j.items)) found = j.items; }
+ } catch { /* the loaded cards remain searchable */ }
+ if (current !== searchSeq) return;
+ if (!found) found = loaded.filter((x) => matches(x, query));
+ resultHead.textContent = '';
+ resultHead.append(h('span', '', `${found.length} result${found.length === 1 ? '' : 's'}`));
+ const clear = h('button', 'ovk-btn ovk-btn--quiet', '×'); clear.type = 'button'; clear.setAttribute('aria-label', 'Clear feed search');
+ clear.addEventListener('click', () => { if (o.onSearch) o.onSearch(''); }); resultHead.append(clear);
+ list.textContent = '';
+ found.forEach((x) => { const card = feedCard(x); if (card) list.append(card); });
+ if (typeof o.mountIcons === 'function') o.mountIcons(list);
+ }
const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
const skeletons = () => { for (let k = 0; k < 3; k++) list.append(h('span', 'ovfd-skel')); };
const clearSkels = () => list.querySelectorAll('.ovfd-skel').forEach((x) => x.remove());
const clearSkels = () => list.querySelectorAll('.ovfd-skel').forEach((x) => x.remove());
async function more(first) {
async function more(first) {
@@ -1272,12 +1302,15 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1272,12 +1302,15 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (first) { list.textContent = ''; skeletons(); } else skeletons();
if (first) { list.textContent = ''; skeletons(); } else skeletons();
const j = await loadFeed(o, kind, cursor);
const j = await loadFeed(o, kind, cursor);
if (my !== seq) return;
if (my !== seq) return;
- clearSkels(); busy = false;
+ clearSkels();
if (!j) {
if (!j) {
+ busy = false;
if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands
if (first && !kind) { el.textContent = ''; if (o.feedMissing) o.feedMissing(); timeline(el, o); return; } // the API is not there: the wall stands
done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
done = true; sentinel.textContent = ''; sentinel.append(h('p', 'ovh-note', 'That did not load. Pull the menu again to retry.')); return;
}
}
if (first && o.feedLive) o.feedLive();
if (first && o.feedLive) o.feedLive();
+ if (!kind) { if (first) loaded.length = 0; loaded.push(...j.items); }
+ if (query) { busy = false; search(query); return; }
// Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).
// Back-to-back go-lives that are over fold into one card of faces (a wall of "X went live" was the whole feed).
const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);
const past = (x) => x && (x.kind === 'stream' || (x.kind === 'live' && !x.live_now)) && safe(x.url);
let run = [];
let run = [];
@@ -1291,6 +1324,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1291,6 +1324,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
if (typeof o.mountIcons === 'function') { try { o.mountIcons(list); } catch { /* */ } }
cursor = j.next_cursor || null;
cursor = j.next_cursor || null;
done = !cursor;
done = !cursor;
+ busy = false;
sentinel.textContent = '';
sentinel.textContent = '';
if (done) sentinel.append(h('p', 'ovh-note ovfd-caught', list.querySelector('.ovfd-card,.ovfd-act,.ovfd-update') ? "You're all caught up."
if (done) sentinel.append(h('p', 'ovh-note ovfd-caught', list.querySelector('.ovfd-card,.ovfd-act,.ovfd-update') ? "You're all caught up."
: (kind === 'following' ? 'Nothing yet from the people you follow. Follow someone on their profile and what they share shows here.' : 'Nothing here yet.')));
: (kind === 'following' ? 'Nothing yet from the people you follow. Follow someone on their profile and what they share shows here.' : 'Nothing here yet.')));
@@ -1311,7 +1345,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1311,7 +1345,7 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
io.observe(sentinel);
io.observe(sentinel);
}
}
more(true);
more(true);
- return { update() {} };
+ return { update() {}, search, get query() { return query; } };
}
}
function feedStyles() {
function feedStyles() {
if (document.getElementById('ovfd-styles')) return;
if (document.getElementById('ovfd-styles')) return;
@@ -1319,6 +1353,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
@@ -1319,6 +1353,10 @@ html[data-ov-motion=reduced] .ovh-card { animation-name: ovh-fade; animation-dur
st.textContent = `
st.textContent = `
.ovfd-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}
.ovfd-chips{position:sticky;top:0;z-index:2;display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:8px 4px;margin:0 -4px;background:var(--bg-secondary,#141a24)}
.ovfd-chips::-webkit-scrollbar{display:none}
.ovfd-chips::-webkit-scrollbar{display:none}
+.ovfd-examples{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding:2px 4px 6px;margin:0 -4px}.ovfd-examples::-webkit-scrollbar{display:none}.ovfd-examples[hidden]{display:none}
+.ovfd-ex{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;border-radius:999px;border:0;background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);color:var(--accent-light,var(--accent,#60a5fa));font:inherit;font-size:13px;font-weight:650;cursor:pointer;white-space:nowrap;transition:background .15s}
+.ovfd-ex::before{content:"";width:12px;height:12px;flex:none;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E") center/contain no-repeat;opacity:.8}
+.ovfd-ex:hover,.ovfd-ex:focus-visible{background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent)}
.ovfd-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}
.ovfd-chip{flex:none;min-height:36px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer}
.ovfd-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}
.ovfd-chip[aria-selected=true]{background:var(--text-primary,#e8ecf3);color:var(--bg-primary,#0b1220);border-color:transparent}
.ovfd-list{display:grid;gap:10px;padding:4px 0}
.ovfd-list{display:grid;gap:10px;padding:4px 0}
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': 'fbe0a5b0d179', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '94fe9cbede93', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -37,6 +37,11 @@
@@ -37,6 +37,11 @@
const cards = new Map(); // id → card
const cards = new Map(); // id → card
const declared = new Map(); // id → descriptor (drawn by the page itself)
const declared = new Map(); // id → descriptor (drawn by the page itself)
let styleEl = null, clicksWired = false;
let styleEl = null, clicksWired = false;
+ const changed = (reason, card) => { try { root.dispatchEvent(new CustomEvent('openvibe:player', { detail: { reason, card: card || current() } })); } catch { /* */ } };
+ function current() {
+ const live = [...declared.values(), ...cards.values()].map((c) => c.d || c).find((c) => c.live);
+ return live ? { ...live } : null;
+ }
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
@@ -59,7 +64,7 @@
@@ -59,7 +64,7 @@
function portableOf(d) {
function portableOf(d) {
const src = safeSrc(d && d.src);
const src = safeSrc(d && d.src);
if (!src) return null;
if (!src) return null;
- const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live };
+ const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live, sound: !!d.sound };
const href = safeSrc(d.href); if (href) out.href = href;
const href = safeSrc(d.href); if (href) out.href = href;
const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };
const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };
if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);
if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);
@@ -353,7 +358,11 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -353,7 +358,11 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
ui.append(top, row);
ui.append(top, row);
const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');
const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');
- el.append(frame(d.src, d.title), ui, grip);
+ const mediaFrame = frame(d.src, d.title);
+ if (d.sound && embed) mediaFrame.addEventListener('load', () => {
+ try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(d.src).origin); } catch { /* browser may require a new tap */ }
+ });
+ el.append(mediaFrame, ui, grip);
document.body.appendChild(el);
document.body.appendChild(el);
cards.set(c.id, c);
cards.set(c.id, c);
let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
@@ -368,6 +377,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -368,6 +377,7 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
wire(c);
wire(c);
if (!fine()) showUi(c);
if (!fine()) showUi(c);
remember();
remember();
+ changed('open', d);
return handle(c);
return handle(c);
}
}
function handle(c) {
function handle(c) {
@@ -380,12 +390,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -380,12 +390,14 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
clearTimeout(c.idle);
clearTimeout(c.idle);
try { c.el.remove(); } catch { /* */ }
try { c.el.remove(); } catch { /* */ }
remember();
remember();
+ changed('close');
}
}
function list() { return [...cards.values()].map((c) => ({ ...c.d })); }
function list() { return [...cards.values()].map((c) => ({ ...c.d })); }
function declare(id, desc) {
function declare(id, desc) {
const key = clean(id, 40);
const key = clean(id, 40);
if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }
if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }
if (declared.size) wireClicks();
if (declared.size) wireClicks();
+ changed('declare');
}
}
// ── Within a site: the cards come back after a reload ──
// ── Within a site: the cards come back after a reload ──
@@ -412,7 +424,9 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -412,7 +424,9 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
if (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;
if (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;
const data = b64(JSON.stringify(list));
const data = b64(JSON.stringify(list));
if (data.length > 3000) return href;
if (data.length > 3000) return href;
- u.hash = `ovo=${data}`;
+ const existing = u.hash.slice(1);
+ u.hash = existing ? `${existing}&ovo=${data}` : `ovo=${data}`;
+ changed('handoff');
return u.href;
return u.href;
} catch { return href; }
} catch { return href; }
}
}
@@ -433,7 +447,12 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -433,7 +447,12 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
let list = null;
let list = null;
if (m) {
if (m) {
try { list = JSON.parse(unb64(m[1])); } catch { list = null; }
try { list = JSON.parse(unb64(m[1])); } catch { list = null; }
- try { const u = new URL(root.location.href); u.hash = ''; root.history.replaceState(root.history.state, '', u.href.replace(/#$/, '')); } catch { /* */ }
+ try {
+ const u = new URL(root.location.href);
+ const keep = u.hash.slice(1).split('&').filter((part) => !/^ovo=/.test(part)).join('&');
+ u.hash = keep;
+ root.history.replaceState(root.history.state, '', u.href.replace(/#$/, ''));
+ } catch { /* */ }
} else {
} else {
try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }
try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }
}
}
@@ -456,14 +475,18 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
@@ -456,14 +475,18 @@ html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x
const f = c.el.querySelector('.ovo-frame');
const f = c.el.querySelector('.ovo-frame');
if (!f || e.source !== f.contentWindow || e.origin !== new URL(c.d.src).origin) continue;
if (!f || e.source !== f.contentWindow || e.origin !== new URL(c.d.src).origin) continue;
c.muted = !!m.muted;
c.muted = !!m.muted;
+ c.d.sound = !c.muted;
+ c.d.playing = !!m.playing;
const b = c.el.querySelector('[data-ovo="sound"]');
const b = c.el.querySelector('[data-ovo="sound"]');
if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }
if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }
if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
+ remember();
+ changed('state', { ...c.d });
}
}
});
});
root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });
root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });
- root.OVOverlay = { open, close, list, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
+ root.OVOverlay = { open, close, list, current, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
// After the page's own start-up (its scripts may set OVOverlayHere on DOMContentLoaded).
// After the page's own start-up (its scripts may set OVOverlayHere on DOMContentLoaded).
const later = () => setTimeout(restore, 0);
const later = () => setTimeout(restore, 0);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', later, { once: true }); else later();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', later, { once: true }); else later();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.205.0",
+ "version": "4.206.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.205.0",
+ "version": "4.206.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.205.0",
+ "version": "4.206.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": {
@@ -529,6 +529,11 @@
@@ -529,6 +529,11 @@
timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });
timerButton.addEventListener('click', () => { try { if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* Dock optional */ } });
doorEl.append(doorSw, doorHost, timerButton);
doorEl.append(doorSw, doorHost, timerButton);
listEl.after(doorEl);
listEl.after(doorEl);
+ doorHost.addEventListener('openvibe:open-vibe', (e) => {
+ if (door !== 'chats' || !inSheet()) return;
+ e.preventDefault();
+ openDoor('vibe');
+ });
tabsEl = el.querySelector('.ovs-tabs');
tabsEl = el.querySelector('.ovs-tabs');
actsEl = el.querySelector('.ovs-acts');
actsEl = el.querySelector('.ovs-acts');
rtabsEl = el.querySelector('.ovs-rtabs');
rtabsEl = el.querySelector('.ovs-rtabs');
@@ -543,7 +548,7 @@
@@ -543,7 +548,7 @@
tabsEl.addEventListener('click', (e) => {
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
leaveDoor();
leaveDoor();
- tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
+ tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); input.value = ''; render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
if (touch) b.focus(); else input.focus();
});
});
@@ -614,6 +619,7 @@
@@ -614,6 +619,7 @@
if (e.key === 'Enter') {
if (e.key === 'Enter') {
e.preventDefault();
e.preventDefault();
const q = input.value.trim();
const q = input.value.trim();
+ if (tab === 'feed' && homeLib() && !door) { render(q); return; }
if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
if (inSheet() && door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
if (inSheet() && door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
const it = items[active >= 0 ? active : 0];
const it = items[active >= 0 ? active : 0];
@@ -1213,7 +1219,7 @@
@@ -1213,7 +1219,7 @@
if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');
if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
- b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* the Dock is optional */ } });
+ b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } close(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });
tabsEl.appendChild(b);
tabsEl.appendChild(b);
continue;
continue;
}
}
@@ -1320,6 +1326,7 @@
@@ -1320,6 +1326,7 @@
const { all, mineSite, byId, shown, take, ranked, f } = basis();
const { all, mineSite, byId, shown, take, ranked, f } = basis();
const groups = [];
const groups = [];
const wall = homeLib();
const wall = homeLib();
+ if (wall && input && input.value.trim()) return [{ as: 'wall', items: [], section: 'wall' }];
if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
if (wall) groups.push({ as: 'walltop', items: [], section: 'wall-top' });
const live = liveGroup(); if (live) groups.push(live);
const live = liveGroup(); if (live) groups.push(live);
if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
if (wall) groups.push({ as: 'wall', items: [], section: 'wall' });
@@ -1475,7 +1482,8 @@
@@ -1475,7 +1482,8 @@
const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];
const back = mine ? mine.items.filter((x) => { if (seenB.has(x.url)) return false; seenB.add(x.url); return true; }).slice(0, 8).map(backRow) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
const usage = mine ? mine.services.filter((x) => x && x.count > 0).map((x) => ({ id: x.service === 'media' ? 'media-hub' : x.service, count: Number(x.count) || 0 })) : [];
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, back, usage, groups: f && Array.isArray(f.groups) ? f.groups : [],
- token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
+ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, look: ctx.look, me: ctx.me, sibling: ctx.sibling, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
+ onSearch: (q) => { input.value = q; render(q); input.focus(); },
pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })),
// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
// The feed answered: the Feed tab is the stream (its sections would sit after an endless list); it did not: as before.
feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
feedLive: () => { if (!feedV2) { feedV2 = true; if (el && el.classList.contains('is-open') && !input.value && tab === 'feed') render(''); } } };
@@ -1490,6 +1498,15 @@
@@ -1490,6 +1498,15 @@
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
if (door === 'chats' && inSheet()) { try { root.OpenVibeDock.filterChats(q); } catch { /* */ } return; }
if (door === 'vibe' && inSheet()) return;
if (door === 'vibe' && inSheet()) return;
+ if (tab === 'feed' && homeLib()) {
+ tabsEl.hidden = false;
+ input.placeholder = 'Search the feed';
+ paintTabs(); renderTab();
+ const feed = homeViews.wall && homeViews.wall.api;
+ if (feed && feed.search && (q || feed.query)) feed.search(q);
+ return;
+ }
+ input.placeholder = `Search ${here()}, or ask Vibe`;
if (q) leaveDoor();
if (q) leaveDoor();
tabsEl.hidden = !!q;
tabsEl.hidden = !!q;
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
@@ -8,6 +8,11 @@
@@ -8,6 +8,11 @@
let saved = null;
let saved = null;
let pop = null;
let pop = null;
let opener = null;
let opener = null;
+ let mediaLock = false;
+ const blockBackdropScroll = (e) => {
+ const top = [...sheets].at(-1);
+ if (top && top.el && !top.el.contains(e.target)) e.preventDefault();
+ };
function lock() {
function lock() {
if (saved) return;
if (saved) return;
@@ -19,13 +24,21 @@
@@ -19,13 +24,21 @@
const gap = Math.max(0, root.innerWidth - html.clientWidth);
const gap = Math.max(0, root.innerWidth - html.clientWidth);
if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
html.style.overflow = 'hidden';
html.style.overflow = 'hidden';
- body.style.position = 'fixed'; body.style.top = `-${y}px`;
- body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
+ // Moving a playing video into a fixed-position body interrupts playback on some
+ // mobile WebKit builds. Keep the media layer in its existing layout instead.
+ mediaLock = [...document.querySelectorAll('video, audio')].some((media) => !media.paused && !media.ended);
+ if (mediaLock) document.addEventListener('touchmove', blockBackdropScroll, { passive: false, capture: true });
+ else {
+ body.style.position = 'fixed'; body.style.top = `-${y}px`;
+ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
+ }
}
}
function unlock() {
function unlock() {
if (!saved || sheets.size) return;
if (!saved || sheets.size) return;
const s = saved, html = document.documentElement, body = document.body;
const s = saved, html = document.documentElement, body = document.body;
saved = null;
saved = null;
+ if (mediaLock) document.removeEventListener('touchmove', blockBackdropScroll, true);
+ mediaLock = false;
html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;
html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;
body.style.position = s.bodyPosition; body.style.top = s.bodyTop;
body.style.position = s.bodyPosition; body.style.top = s.bodyTop;
body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;
body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;
@@ -62,7 +75,7 @@
@@ -62,7 +75,7 @@
};
};
if (head) { head.addEventListener('pointerdown', onDown); head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); head.addEventListener('pointercancel', onUp); }
if (head) { head.addEventListener('pointerdown', onDown); head.addEventListener('pointermove', onMove); head.addEventListener('pointerup', onUp); head.addEventListener('pointercancel', onUp); }
if (el instanceof HTMLDialogElement) el.addEventListener('close', release);
if (el instanceof HTMLDialogElement) el.addEventListener('close', release);
- const api = { open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,
+ const api = { el, open() { if (active) return; active = true; sheets.add(api); lock(); }, close: release, dismiss,
destroy() { release(); scroll.style.overscrollBehavior = oldOverscroll; if (head) { head.style.touchAction = oldTouch; head.removeEventListener('pointerdown', onDown); head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); head.removeEventListener('pointercancel', onUp); } if (el instanceof HTMLDialogElement) el.removeEventListener('close', release); } };
destroy() { release(); scroll.style.overscrollBehavior = oldOverscroll; if (head) { head.style.touchAction = oldTouch; head.removeEventListener('pointerdown', onDown); head.removeEventListener('pointermove', onMove); head.removeEventListener('pointerup', onUp); head.removeEventListener('pointercancel', onUp); } if (el instanceof HTMLDialogElement) el.removeEventListener('close', release); } };
return api;
return api;
}
}
@@ -24,6 +24,10 @@ let up = true;
@@ -24,6 +24,10 @@ let up = true;
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
if (u.pathname === '/dock-code.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'dock-code.js'))); }
if (u.pathname === '/dock-code.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'dock-code.js'))); }
+ if (u.pathname === '/ov-window.js' || u.pathname === '/agent-session.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.pathname.slice(1)))); }
+ if (u.pathname === '/agent-session.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(root, 'agent-session.css'))); }
+ if (u.pathname === '/api/frame/codes-runs/t1') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ id: 't1', title: 'A goal bar for my sub goal', status: 'running', plan: [] })); }
+ if (u.pathname === '/api/frame/codes-runs/t1/events') { res.writeHead(200, { 'content-type': 'text/event-stream' }); return res.end('id: 1\nevent: RUN_STARTED\ndata: {"type":"RUN_STARTED","runId":"t1","threadId":"t1"}\n\nid: 2\nevent: STEP_STARTED\ndata: {"type":"STEP_STARTED","stepName":"Read files"}\n\n'); }
if (u.pathname.startsWith('/api/frame/codes-tasks')) {
if (u.pathname.startsWith('/api/frame/codes-tasks')) {
let body = '';
let body = '';
req.on('data', (c) => { body += c; });
req.on('data', (c) => { body += c; });
@@ -65,6 +69,12 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
@@ -65,6 +69,12 @@ window.part = OpenVibeDockCode.mount(document.getElementById('part'), { token: (
assert.equal(seen[0].auth, 'Bearer tok_abc', 'the read carries the person\'s token');
assert.equal(seen[0].auth, 'Bearer tok_abc', 'the read carries the person\'s token');
assert.equal(seen[0].path, '/api/frame/codes-tasks?limit=8', 'through Network\'s relay');
assert.equal(seen[0].path, '/api/frame/codes-tasks?limit=8', 'through Network\'s relay');
+ // A task opens the shared card in the one window; the full Codes link stays available.
+ await page.evaluate(`document.querySelector('.ovdc-task a.ovdc-a').click()`);
+ assert.ok(await until(`!!document.querySelector('.ovw .ovas')`), 'the agent session mounted in OVWindow');
+ assert.equal(await page.evaluate(`document.querySelector('.ovw a[href="https://openvibe.codes/tasks/t1"]') !== null`), true);
+ await page.evaluate(`OVWindow.closeAll()`);
+
// My code wants a repository; Ctrl+Enter starts the task.
// My code wants a repository; Ctrl+Enter starts the task.
await page.evaluate(`document.querySelector('[data-mode="repo"]').click()`);
await page.evaluate(`document.querySelector('[data-mode="repo"]').click()`);
const box = () => page.evaluate(`({ repo: !document.querySelector('.ovdc-repo').hidden, go: document.querySelector('.ovdc-go').disabled, ph: document.querySelector('.ovdc-text').placeholder, checked: document.querySelector('.ovdc-mode[aria-checked="true"]').textContent })`);
const box = () => page.evaluate(`({ repo: !document.querySelector('.ovdc-repo').hidden, go: document.querySelector('.ovdc-go').disabled, ph: document.querySelector('.ovdc-text').placeholder, checked: document.querySelector('.ovdc-mode[aria-checked="true"]').textContent })`);
new file mode 100644
new file mode 100644
Binary files /dev/null and b/test/fixtures/menu-playing.mp4 differ
Binary files /dev/null and b/test/fixtures/menu-playing.mp4 differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('menu-feed-search-chrome: skipped'); process.exit(0); }
+const item = (id, title, kind, person) => ({ id, title, text: title, kind, actor: { name: person }, site: 'community', site_name: 'Community', url: `https://openvibe.community/p/${id}`, at: new Date().toISOString(), counts: {} });
+const loaded = [item('1', 'New photo of a dog', 'pic', 'Ada'), item('2', 'Clip from today', 'clip', 'Bo')];
+const server = http.createServer((req, res) => {
+ if (req.url === '/ov-home.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'))); }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div id="feed"></div><script>
+ window.__mode = 'api'; window.__queries = []; window.__search = '';
+ const loaded = ${JSON.stringify(loaded)};
+ const J = (data, ok = true) => Promise.resolve({ ok, json: async () => data });
+ window.fetch = (url) => { const u = String(url);
+ if (u.includes('/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Ada' }] });
+ if (u.includes('/feed/search')) { window.__queries.push(u); return window.__mode === 'api' ? J({ items: [${JSON.stringify(item('3', 'A matching video', 'video', 'Ada'))}], next_cursor: null }) : J({}, false); }
+ if (u.includes('/feed?')) return J({ items: loaded, next_cursor: null });
+ return J({}, false);
+ };
+ </script><script src="/ov-home.js"></script><script>
+ const target = document.getElementById('feed');
+ window.api = OpenVibeHome.feed(target, { onSearch: (q) => { window.__search = q; api.search(q); } });
+ </script>`);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const browser = await h.launch({ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'ov-feed-search-')) });
+ const context = await browser.newContext();
+ const page = await h.openPage(browser, context, { width: 390, height: 844 });
+ const wait = async (expr) => { for (let n = 0; n < 100; n++) { if (await page.evaluate(expr)) return true; await new Promise((ok) => setTimeout(ok, 50)); } return false; };
+ try {
+ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });
+ assert.ok(await wait(`document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'loaded cards show');
+ assert.ok(await wait(`document.querySelectorAll('.ovfd-examples button').length >= 4`), 'API and fallback examples show');
+ await page.evaluate(`api.search('Ada')`);
+ assert.ok(await wait(`document.querySelector('.ovfd-results').textContent.includes('1 result')`), 'API results show a count');
+ assert.ok(await page.evaluate(`window.__queries[0].includes('q=Ada') && document.querySelector('.ovfd-list').textContent.includes('A matching video')`), 'search calls the API and replaces the list');
+ await page.evaluate(`window.__mode = 'fallback'; api.search('Bo')`);
+ assert.ok(await wait(`document.querySelector('.ovfd-list').textContent.includes('Clip from today') && !document.querySelector('.ovfd-list').textContent.includes('dog')`), 'unavailable API filters loaded cards by person');
+ await page.evaluate(`document.querySelector('[aria-label="Clear feed search"]').click()`);
+ assert.ok(await wait(`window.__search === '' && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`), 'clear restores the list');
+ await page.evaluate(`document.querySelector('.ovfd-examples button').click()`);
+ assert.strictEqual(await page.evaluate(`window.__search`), 'New photos', 'example chip fills the query');
+ console.log('menu-feed-search-chrome: API results, examples, clear, and loaded-card fallback ok');
+ } finally { await page.close(); await browser.close(); server.close(); }
+})().catch((e) => { console.error(e); server.close(); process.exitCode = 1; });
@@ -33,6 +33,10 @@ const PULSE = { items: [
@@ -33,6 +33,10 @@ const PULSE = { items: [
{ title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },
{ title: 'A new clip: the jump', url: 'https://openvibe.video/c/1', occurred_at: new Date(Date.now() - 3600e3).toISOString(), ref: { service: 'video' } },
{ title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },
{ title: '<img src=x onerror="window.pwned=1"> a post', url: 'https://openvibe.community/p/1', occurred_at: new Date(Date.now() - 7200e3).toISOString(), ref: { service: 'community' } },
], next_cursor: 'c2' };
], next_cursor: 'c2' };
+const FEED_V2 = { items: [
+ { id: 'p:1', kind: 'pic', title: 'Ada shared a new photo', text: 'A sunny sky', actor: { name: 'Ada' }, site: 'community', site_name: 'Community', url: 'https://openvibe.community/p/1', at: new Date().toISOString(), counts: {} },
+ { id: 'p:2', kind: 'clip', title: 'Bo shared a clip', text: 'A new trick', actor: { name: 'Bo' }, site: 'video', site_name: 'Video', url: 'https://openvibe.video/c/1', at: new Date().toISOString(), counts: {} },
+], next_cursor: null };
const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };
const TODAY = { people: { today: 1204, series: [800, 900, 1000, 1204] }, streams: { today: 37, series: [20, 30, 37] }, posts: { today: 210 }, videos: { today: 12 } };
const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">
const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home</title><link rel="icon" href="data:,">
@@ -52,7 +56,13 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
@@ -52,7 +56,13 @@ const page = (signed) => `<!doctype html><html lang="en"><head><meta charset="ut
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/history')) return J({ items: [{ title: 'A stream', url: 'https://openvibe.live/ada', service: 'live', updated_at: new Date().toISOString() }, { title: 'A pic', url: 'https://openvibe.pics/1', service: 'pics', updated_at: new Date(Date.now() - 1000).toISOString() }], services: [{ service: 'live', count: 5 }, { service: 'pics', count: 2 }] });
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s.startsWith('https://openvibe.network/api/v1/me/home-screen')) return (o && o.method === 'PUT') ? J({ ok: true }) : J({}, false, 404);
if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);
if (s === 'https://openvibe.network/api/frame/attachments') return J({ attachment: { media_id: 'med_01ABCDEFGHJKMNPQRSTVWXYZ0' + (window.__calls.filter((c) => c.u === s).length) } }, true, 201);
+ if (s === 'https://openvibe.media/api/v1/attachments') return J({ id: 'att_01ABCDEFGHJKMNPQRSTVWXYZ01' }, true, 201);
if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);
if (s === 'https://openvibe.network/api/frame/posts') return J({ id: 7 }, true, 201);
+ if (s === 'https://openvibe.chat/api/dm/conversations') return J({ conversations: [] });
+ if (s === 'https://openvibe.chat/api/vibe/conversations') return J({ count: 4, conversations: [{ name: 'Read https://…', last_message_at: new Date().toISOString() }] });
+ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/examples')) return J({ examples: [{ text: 'New photos' }, { text: 'Clips from today' }, { text: 'Ada' }] });
+ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed/search')) return J({ items: ${JSON.stringify(FEED_V2.items.slice(0, 1))}, next_cursor: null });
+ if (location.search.includes('feedv2=1') && s.startsWith('https://openvibe.network/api/frame/feed?')) return J(${JSON.stringify(FEED_V2)});
if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.network/api/frame/wall')) return J(Object.assign({}, ${JSON.stringify(PULSE)}, s.includes('after=c2') ? { items: [{ title: 'An older post', url: 'https://openvibe.community/p/0', occurred_at: new Date(Date.now() - 86400e3).toISOString(), ref: { service: 'community' } }], next_cursor: null } : {}, ${signed ? "{ viewer: { can_post: true } }" : '{}'}));
if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s.startsWith('https://openvibe.actor/api/v1/me/actor')) return J({ id: 'act_1', name: 'Juniper', enabled: true, status: 'working', now: { current_step: 'Reading job posts' }, asks: [] });
if (s.startsWith('https://')) return J({}, false, 404);
if (s.startsWith('https://')) return J({}, false, 404);
@@ -209,27 +219,33 @@ const server = http.createServer((req, res) => {
@@ -209,27 +219,33 @@ const server = http.createServer((req, res) => {
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
await s.evaluate(`(() => { const t = document.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); t.focus(); t.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); })()`);
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
assert.ok(await until(s, `window.__calls.some((c) => c.m === 'PUT' && c.u.endsWith('/api/v1/me/home-screen') && c.auth === 'Bearer tok-1')`, 3000), 'the arrangement goes to Network with the bearer');
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
await s.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
- assert.ok(await until(s, `!!document.querySelector('.ovh-compose textarea')`), 'the composer shows when Community says you can post');
- await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?"));
- // Pictures (11-09: "you can make posts and put images"): two picked, each must say what it shows, then each
- // uploads through Network and the post names them with that text.
+ assert.ok(await until(s, `!!document.querySelector('#ovh-feed-composer.__none, #ovh-feed-composer.ovc')`), 'the one composer joins the Feed form');
+ await eventually(async () => assert.strictEqual(await s.evaluate(`document.querySelector('#ovh-feed-composer textarea').placeholder`), "What's new, Ada?"));
await s.evaluate(`(async () => {
await s.evaluate(`(async () => {
- const ta = document.querySelector('.ovh-compose textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input'));
+ const ta = document.querySelector('#ovh-feed-composer textarea'); ta.value = 'Golden hour'; ta.dispatchEvent(new Event('input', { bubbles: true }));
const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);
const c = document.createElement('canvas'); c.width = 40; c.height = 30; c.getContext('2d').fillRect(0, 0, 40, 30);
const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));
const blob = await new Promise((ok) => c.toBlob(ok, 'image/png'));
- const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File([blob], 'b.png', { type: 'image/png' }));
- const input = document.querySelector('.ovh-compose input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change'));
+ const dt = new DataTransfer(); dt.items.add(new File([blob], 'a.png', { type: 'image/png' })); dt.items.add(new File(['PDF'], 'notes.pdf', { type: 'application/pdf' }));
+ const input = document.querySelector('#ovh-feed-composer input[type=file]'); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true }));
})(), true`);
})(), true`);
- assert.ok(await until(s, `document.querySelectorAll('.ovh-pic img[src^="data:"]').length === 2`), 'two pictures, each with a preview');
- await eventually(async () => assert.ok(await s.evaluate(`document.querySelector('.ovh-post').disabled && document.querySelector('.ovh-compose-hint').textContent === 'Describe each picture'`), 'no post until each picture says what it shows'));
- await s.evaluate(`[...document.querySelectorAll('.ovh-pic input')].forEach((x, i) => { x.value = ['A dark sky', 'The same sky'][i]; x.dispatchEvent(new Event('input')); }), true`);
- await eventually(async () => assert.ok(!(await s.evaluate(`document.querySelector('.ovh-post').disabled`)), 'described: Post is on'));
- await s.evaluate(`document.querySelector('.ovh-post').click(), true`);
- assert.ok(await until(s, `/Posted/.test(document.querySelector('.ovh-compose ~ .ovh-note, .ovh-note').textContent)`), 'it posts');
- const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u.replace('https://openvibe.network/api/frame/', ''), auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
- assert.deepStrictEqual(sent.map((c) => c.u), ['attachments', 'attachments', 'posts'], 'each picture, then the post');
- assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'with the person\'s token');
- assert.deepStrictEqual(JSON.parse(sent[2].body).pictures.map((x) => x.alt), ['A dark sky', 'The same sky']);
+ assert.ok(await until(s, `document.querySelectorAll('#ovh-feed-composer .ovc-file').length === 2`), 'the picture and PDF become chips');
+ await eventually(async () => assert.ok(await s.evaluate(`!document.querySelector('#ovh-feed-composer').checkValidity()`), 'the picture needs a description'));
+ await s.evaluate(`(() => { const a = document.querySelector('#ovh-feed-composer .ovc-image input'); a.value = 'A dark sky'; a.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ await s.evaluate(`document.querySelector('#ovh-feed-composer .ovt-send').click(), true`);
+ const posted = await until(s, `/Posted/.test(document.querySelector('#ovh-feed-composer + .ovh-note').textContent)`);
+ const postState = await s.evaluate(`({ note: document.querySelector('#ovh-feed-composer + .ovh-note').textContent, valid: document.querySelector('#ovh-feed-composer').checkValidity(), disabled: document.querySelector('#ovh-feed-composer .ovt-send').disabled, calls: window.__calls.filter((c) => /attachments|posts/.test(c.u)).map((c) => c.u) })`);
+ assert.ok(posted, `it posts: ${JSON.stringify(postState)}`);
+ const sent = await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/api/frame/attachments') || c.u.endsWith('/api/v1/attachments') || c.u.endsWith('/api/frame/posts')).map((c) => ({ u: c.u, auth: c.auth, body: typeof c.body === 'string' ? c.body : '' }))`);
+ assert.deepStrictEqual(sent.map((c) => c.u.replace(/^https:\/\/(?:openvibe.network\/api\/frame|openvibe.media\/api\/v1)\//, '')), ['attachments', 'attachments', 'posts'], 'picture and PDF upload before the post');
+ assert.ok(sent.every((c) => c.auth === 'Bearer tok-1'), 'uploads and post use the person\'s token');
+ const body = JSON.parse(sent[2].body);
+ assert.strictEqual(body.pictures[0].alt, 'A dark sky');
+ assert.deepStrictEqual(body.attachments, ['att_01ABCDEFGHJKMNPQRSTVWXYZ01']);
+ assert.deepStrictEqual(await s.evaluate(`window.__calls.filter((c) => c.u.endsWith('/attachments') && c.body instanceof FormData).map((c) => c.body.get('file').name)`), ['a.png', 'notes.pdf'], 'the picture and PDF bytes use the two upload routes');
+ await s.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
+ assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]')`), 'the Chat summary is an Open Vibe button');
+ await s.evaluate(`document.querySelector('.ovs-door-host .ovdk-chat[aria-label="Open Vibe"]').click()`);
+ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true'`), 'the Vibe row selects the menu Vibe tab');
// ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,
// ── A phone's Dock opens out of the navbar's menu button, and that button is its close button (the owner,
// 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button
// 2026-11-10: "that close button should… fade and have an animation effect from the top right, the menu button
// that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes
// that already exists"). The close orb starts on the navbar's orb and settles into its x; the navbar's orb makes
@@ -257,6 +273,22 @@ const server = http.createServer((req, res) => {
@@ -257,6 +273,22 @@ const server = http.createServer((req, res) => {
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
const still = await s.evaluate(`(() => { document.documentElement.setAttribute('data-ov-motion', 'off'); window.OpenVibeDock.open({ tab: 'activity' }); const dock = document.querySelector('.ovdk'); const a = dock.classList.contains('from-orb'); window.OpenVibeDock.close(); const out = { fromOrb: a, hidden: dock.hidden }; document.documentElement.removeAttribute('data-ov-motion'); return out; })()`);
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
assert.ok(!still.fromOrb && still.hidden, 'motion off: no move, and it closes at once');
await s.close();
await s.close();
+ if (process.env.OV_CAPTURE_MENU) {
+ for (const width of [390, 1280]) {
+ const shotPage = await h.openPage(browser, context, { width, height: width === 390 ? 844 : 900, block: blocked });
+ await shotPage.goto(`${base}/?signed=1&feedv2=1`, { maxMs: 6000 });
+ await openTab(shotPage, 'feed');
+ assert.ok(await until(shotPage, `document.querySelectorAll('.ovfd-examples button').length >= 4 && document.querySelectorAll('.ovfd-list .ovfd-card').length === 2`));
+ const feedShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
+ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-feed-${width}.png`), Buffer.from(feedShot.data, 'base64'));
+ await shotPage.evaluate(`document.querySelector('.ovs [data-dock="chats"]').click()`);
+ if (width === 1280) assert.ok(await until(shotPage, `!document.querySelector('.ovs').classList.contains('is-open')`), 'desktop Chat closes the menu so the Dock is visible');
+ assert.ok(await until(shotPage, `(() => { const b = document.querySelector('.ovdk-chat[aria-label="Open Vibe"]'), d = b && b.closest('.ovdk'), r = b && b.getBoundingClientRect(); return !!d && !d.hidden && d.getAnimations({ subtree: false }).every((a) => a.playState === 'finished') && r.width > 0 && r.left < innerWidth && r.top < innerHeight && b.contains(document.elementFromPoint((r.left+r.right)/2, (r.top+r.bottom)/2)); })()`), 'Chat is painted in the capture');
+ const chatShot = await shotPage.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
+ fs.writeFileSync(path.join(ROOT, `docs/screenshots/360-chat-${width}.png`), Buffer.from(chatShot.data, 'base64'));
+ await shotPage.close();
+ }
+ }
console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');
console.log('menu-home-screen-chrome: home grid, long-press and drag, keys, remove and put back, folders with tool icons, the wall, today, the composer with pictures, Network sync ok');
} finally {
} finally {
await browser.close();
await browser.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+// Real mobile engines: a person starts a stream with sound, opens the network menu,
+// and the same media element continues advancing. The fixture is a short loop.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+let playwright;
+try { playwright = require(process.env.OV_PLAYWRIGHT || 'playwright'); } catch {
+ console.log('menu-media-playwright: skipped (set OV_PLAYWRIGHT to a Playwright package)');
+ process.exit(0);
+}
+const root = path.join(__dirname, '..');
+const fixture = path.join(__dirname, 'fixtures/menu-playing.mp4');
+const html = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
+<button id="start">Play with sound</button><button id="menu">Open menu</button>
+<video id="stream" playsinline loop src="/media.mp4"></video><div style="height:1200px"></div>
+<script src="/surfaces.js"></script><script src="/search.js"></script>
+<script>document.querySelector('#start').onclick=()=>{const v=document.querySelector('#stream');v.muted=false;v.play()};
+document.querySelector('#menu').onclick=()=>OpenVibeSearch.open({links:[],service:'Live'});</script>`;
+const server = http.createServer((req, res) => {
+ if (req.url === '/media.mp4') { res.writeHead(200, { 'content-type': 'video/mp4', 'content-length': fs.statSync(fixture).size, 'accept-ranges': 'bytes' }); return fs.createReadStream(fixture).pipe(res); }
+ if (req.url === '/surfaces.js' || req.url === '/search.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return fs.createReadStream(path.join(root, req.url.slice(1))).pipe(res); }
+ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const url = `http://127.0.0.1:${server.address().port}/`;
+ try {
+ for (const name of ['chromium', 'webkit']) {
+ const browser = await playwright[name].launch({ headless: true,
+ ...(name === 'webkit' && process.env.OV_WEBKIT_BIN ? { executablePath: process.env.OV_WEBKIT_BIN } : {}) });
+ try {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
+ userAgent: name === 'webkit' ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1' : 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/130.0 Mobile Safari/537.36' });
+ const page = await context.newPage();
+ await page.goto(url);
+ await page.locator('#start').tap();
+ await page.waitForFunction(() => document.querySelector('#stream').currentTime > .2);
+ const before = await page.locator('#stream').evaluate((v) => v.currentTime);
+ await page.locator('#menu').tap();
+ await page.waitForTimeout(650);
+ const state = await page.locator('#stream').evaluate((v) => ({ time: v.currentTime, paused: v.paused, muted: v.muted, body: document.body.style.position }));
+ assert.ok(!state.paused && !state.muted && state.time > before + .25, `${name}: unmuted stream advances with menu open (${JSON.stringify(state)})`);
+ assert.equal(state.body, '', `${name}: menu leaves the playing video's body layout intact`);
+ await context.close();
+ } finally { await browser.close(); }
+ }
+ console.log('menu-media-playwright: Chromium and WebKit keep an unmuted stream playing through the menu');
+ } finally { server.close(); }
+})().catch((error) => { console.error(error); process.exitCode = 1; server.close(); });
@@ -73,6 +73,8 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
@@ -73,6 +73,8 @@ const RECT = `(() => { const c = document.querySelector('.ovo-card'); if (!c) re
// A link to another OpenVibe site carries it; one on this site does not.
// A link to another OpenVibe site carries it; one on this site does not.
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
const away = await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements')`);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
assert.match(away, /^https:\/\/openvibe\.games\/achievements#ovo=[A-Za-z0-9_-]+$/);
+ assert.strictEqual(await p.evaluate(`OVOverlay.current().href`), `${base}/a`, 'the active live card exposes its way back');
+ assert.match(await p.evaluate(`OVOverlay.handoffUrl('https://openvibe.games/achievements#scores')`), /#scores&ovo=/, 'handoff keeps the destination section');
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
const carried = JSON.parse(Buffer.from(away.split('#ovo=')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
assert.deepStrictEqual(carried.map((d) => [d.id, d.title, d.live, d.src, d.side && d.side.src]), [['live', 'maticus', true, `${base}/frame-a`, `${base}/frame-chat`]]);
await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));
await eventually(async () => assert.strictEqual(await p.evaluate(`OVOverlay.handoffUrl('${base}/b')`), `${base}/b`, 'the same site: no hash'));