openvibers/openvibe.shared Commits
The menu's You is a home screen, Feed is a wall (#354)
Changes
@@@ -1,5 +1,21 @@# Changelog++## Unreleased++++- **The menu's You tab is a home screen** (owner, 11-09: "make it more like a phone"). `ov-home.js` (a sibling, loaded++ the first time You or Feed opens): your Actor on top (its state and step, or one invitation), Recent (the last five++ places opened), then Home: your places in a grid, the most popular until you arrange it, with Tools (every tool with++ its own icon) and Build as folders. A long-press (or Edit) starts arranging: drag an app to move it, arrow keys move++ the focused one, − takes it off Home, All apps (N) lists every app and puts one back with +. The arrangement is kept++ on the device and on Network (`GET/PUT /api/v1/me/home-screen`, once Network serves it). Recent searches are gone++ from You (the field shows them).++- **The Feed tab is a wall.** What's new with you (the composer, when Community says you can post), today's numbers++ (people, streams, posts, new videos, with 14-day lines), the live bar, then the network's pulse: newest first,++ back-to-back "went live" in one row of names, Show more. Every feed card shows until you turn some off (was the++ first two); What's new names each site, what changed in plain words, when, "New" since your visit and "Big update",++ and counts today's changes.++- **The first tab is the site's name, never a page's word for itself** ("Home", "Welcome").++## 4.158.0 — 2026-11-09- **The menu's Home.** A site whose links never name its home (Exchange: Models, Chat, Arena…) now gets Home first in@@@ -46,6 +46,7 @@ const BROWSER = Object.freeze(['ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)++ 'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)'history.js', // cross-site history'sso-client.js', // link hand-off, FedCM'panels.js', // one-open-at-a-time panel coordinator@@@ -539,7 +539,7 @@const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'ad94f524fa45', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b113a2fd8e93', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -2208,7 +2208,7 @@if (!root.OpenVibeSearch) return;if (_navEl) _navEl.classList.remove('ovnav-hide');root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,-- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon,++ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });nnew file mode 100644@@@ -0,0 +1,616 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the menu's home screen and wall (plan 2026-11-09, ~/openvibe/OpenVibe_Menu_Home_Feed_Plan_2026-11-09.md)++//++// search.js loads this the first time the You or Feed tab paints (a sibling, by content hash) and hands it an++// element and what it knows:++// OpenVibeHome.home(el, o) You: your Actor, Recent, your Home grid (long-press or Edit, drag to arrange,++// folders), All apps (the drawer)++// OpenVibeHome.wallTop(el, o) Feed: what's new with you (the composer, when Community says you can post) and++// today's numbers++// OpenVibeHome.timeline(el, o) Feed: the network's wall, newest first, "Show more"++//++// The owner, 11-09: "make it more like a phone… hold down on an icon and move it around, just like on Android or++// iOS… the most popular sites until you set it up… recent apps at the top… tools as a folder of apps… the Actor++// built in… the feed more like a Facebook wall… who went live recently… how many people today".++//++// The arrangement is kept on this device (localStorage ovs-home) and, signed in, on Network++// (GET/PUT /api/v1/me/home-screen, network.home-screen@1); Network's copy wins when it answers. Everything a person or++// a site wrote is set as text; links are https or same-site only. No spinners: grey bars hold the place of what loads.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeHome) return;++++ const HOME_KEY = 'ovs-home';++ const INVITE_KEY = 'ovs-actor-invite';++ const LONG_MS = 450;++ const HOME_MAX = 40;++ const ACTOR = 'https://openvibe.actor';++ const COMMUNITY = 'https://openvibe.community';++++ const safe = (u) => { try { const x = new URL(String(u || ''), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };++ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ const ago = (iso) => {++ const t = Date.parse(iso || ''); if (!t) return '';++ const s = Math.max(1, Math.round((Date.now() - t) / 1000));++ if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;++ const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;++ };++ const num = (n) => { const v = Number(n) || 0; return v >= 10000 ? `${(v / 1000).toFixed(v >= 100000 ? 0 : 1)}k` : v.toLocaleString('en-US'); };++++ function styles() {++ if (document.getElementById('ovh-styles')) return;++ const st = document.createElement('style');++ st.id = 'ovh-styles';++ st.textContent = `++.ovh { list-style: none; padding: 2px 4px 6px; }++.ovh-sec { display: flex; align-items: center; gap: 8px; padding: 10px 6px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }++.ovh-sec > span { flex: 1; }++.ovh-sec button, .ovh-link { min-height: 36px; padding: 0 10px; border: 0; border-radius: 999px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 13px; font-weight: 700; letter-spacing: 0; text-transform: none; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }++.ovh-sec button.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }++.ovh-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px; }++@media (min-width: 400px) { .ovh-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }++@media (min-width: 861px) { .ovh-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }++.ovh-recent .ovh-app { min-height: 80px; }++.ovh-app { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; min-height: 88px; padding: 8px 2px 6px; border: 0; border-radius: 16px;++ background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }++.ovh-app:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++.ovh-ic { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent);++ box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1); }++.ovh-recent .ovh-ic { width: 48px; height: 48px; border-radius: 15px; }++.ovh-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++.ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, 18px); grid-auto-rows: 18px; gap: 4px; place-content: center; }++.ovh-folder .ovh-ic > span { display: grid; place-items: center; width: 18px; height: 18px; }++.ovh-glyph { display: grid; place-items: center; width: 100%; height: 100%; }++.ovh-glyph svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; color: var(--accent, #3b82f6); }++.ovh-badge { position: absolute; top: -6px; left: -6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;++ background: var(--bg-tertiary, #2a3140); color: var(--text-primary, #e8ecf3); font: 700 16px/1 system-ui, sans-serif; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }++.ovh-badge::after { content: ""; position: absolute; inset: -10px; }++.ovh-badge.is-add { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }++.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: none; }++.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation: ovh-wiggle .32s ease-in-out infinite alternate; }++.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app:nth-child(2n) .ovh-ic { animation-delay: -.16s; }++.ovh-app.is-lifted { opacity: .25; }++.ovh-ghost { position: fixed; z-index: 10040; pointer-events: none; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 19px; display: grid; place-items: center;++ background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }++@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }++.ovh-hint { margin: 2px 6px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }++.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));++ border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }++.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }++.ovh-head b { flex: 1; font-size: 16px; }++.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }++.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; 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; }++.ovh-actor { display: flex; align-items: center; gap: 12px; width: calc(100% - 8px); min-height: 64px; margin: 6px 4px 4px; padding: 10px 12px; border-radius: 16px; border: 0; text-align: left; cursor: pointer;++ background: color-mix(in srgb, var(--accent, #3b82f6) 11%, var(--bg-secondary, #141a24)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); color: inherit; font: inherit; }++.ovh-face { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; overflow: hidden; background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 17px; }++.ovh-face img { width: 100%; height: 100%; object-fit: cover; }++.ovh-actor-txt { flex: 1; min-width: 0; display: grid; gap: 2px; }++.ovh-actor-txt b { font-size: 14.5px; }++.ovh-actor-txt small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovh-chip { display: inline-flex; align-items: center; gap: 5px; margin-left: 6px; font-size: 11.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); }++.ovh-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #8b8b96); }++.ovh-chip.is-working .ovh-dot { background: var(--success, #22c55e); animation: ovh-pulse 1.8s ease-in-out infinite; }++.ovh-chip.is-waiting .ovh-dot { background: var(--warning, #f5a524); }++.ovh-go { flex: none; color: var(--accent, #3b82f6); font-weight: 700; font-size: 13px; }++.ovh-x { flex: none; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 12.5px; cursor: pointer; }++@keyframes ovh-pulse { 50% { opacity: .35; } }++.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: flex; gap: 10px; align-items: flex-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-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); }++.ovh-stat b { font-size: 18px; font-variant-numeric: tabular-nums; }++.ovh-stat small { font-size: 11.5px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovh-stat svg { width: 100%; height: 18px; color: var(--accent, #3b82f6); }++@media (max-width: 380px) { .ovh-today { grid-template-columns: repeat(2, minmax(0, 1fr)); } }++.ovh-wall { list-style: none; margin: 0; padding: 0; }++.ovh-entry { display: flex; gap: 12px; align-items: flex-start; min-height: 56px; padding: 10px 8px; border-radius: 14px; color: inherit; text-decoration: none; }++.ovh-entry + .ovh-entry { margin-top: 2px; }++@media (hover: hover) { .ovh-entry:hover, .ovh-app:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } }++.ovh-entry .ovh-ic { width: 40px; height: 40px; border-radius: 12px; flex: none; }++.ovh-entry-txt { flex: 1; min-width: 0; display: grid; gap: 3px; }++.ovh-entry-txt b { font-size: 14px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }++.ovh-entry-txt small { font-size: 12px; color: var(--text-muted, #93a0b4); }++.ovh-lives { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }++.ovh-lives a { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; background: color-mix(in srgb, var(--live, #ef4444) 12%, transparent); color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; }++.ovh-lives a::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--live, #ef4444); }++@media (prefers-reduced-motion: reduce) { .ovh.is-edit .ovh-grid .ovh-ic, .ovh-chip.is-working .ovh-dot { animation: none !important; } .ovh-ic { transition: none; } }++`;++ document.head.appendChild(st);++ }++++ // ── The arrangement ────────────────────────────────────────────────────────────────────────────────────────++ function readLocal() { try { const v = JSON.parse(localStorage.getItem(HOME_KEY) || 'null'); return v && Array.isArray(v.home) ? v : null; } catch { return null; } }++ function writeLocal(l) { try { localStorage.setItem(HOME_KEY, JSON.stringify(l)); } catch { /* a private window keeps it for this opening */ } }++ const homeUrl = (o) => `${o.apiBase || 'https://openvibe.network'}/api/v1/me/home-screen`;++ const signedIn = (o) => !!(o.token && o.user && !o.user.is_anon);++ let putTimer = 0;++ function pushRemote(o, l) {++ if (!signedIn(o) || typeof fetch !== 'function') return;++ clearTimeout(putTimer);++ putTimer = setTimeout(() => {++ fetch(homeUrl(o), { method: 'PUT', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' },++ body: JSON.stringify({ home: l.home.slice(0, HOME_MAX), folders: l.folders }) }).catch(() => {});++ }, 600);++ }++ let remoteAsked = false;++ async function pullRemote(o) {++ if (remoteAsked || !signedIn(o) || typeof fetch !== 'function') return null;++ remoteAsked = true;++ try {++ const r = await fetch(homeUrl(o), { credentials: 'omit', headers: { Authorization: `Bearer ${o.token}` } });++ if (!r.ok) return null;++ const j = await r.json();++ return j && Array.isArray(j.home) ? { home: j.home.filter((x) => typeof x === 'string').slice(0, HOME_MAX), folders: Array.isArray(j.folders) ? j.folders : [] } : null;++ } catch { return null; }++ }++++ /** Every app the menu knows, by id: sites, the person's own places (Files, Wallet…), the folders. */++ function universe(o) {++ const byId = new Map();++ (o.apps || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });++ (o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });++ return byId;++ }++ function folderDefs(o) {++ const apps = o.apps || [];++ return [++ { id: 'tools', name: 'Tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },++ { id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },++ ].filter((f) => f.apps.length);++ }++ /** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */++ function defaultHome(o) {++ const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);++ const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);++ const room = 12 - 2 - mineFirst.length;++ return mineFirst.concat(sites.slice(0, Math.max(6, room)), ['folder:tools', 'folder:build']);++ }++ function layoutOf(o) {++ const byId = universe(o);++ const folders = folderDefs(o);++ const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));++ const l = readLocal();++ const home = (l ? l.home : defaultHome(o)).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);++ return { home, folders, arranged: !!l, byId };++ }++++ // ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────++ function siteIcon(site, size) {++ const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');++ return s;++ }++ function appTile(o, a, { tag = 'a', size = 40, small = false } = {}) {++ const el = h(tag, 'ovh-app');++ if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';++ el.setAttribute('data-id', a.id);++ const ic = h('span', 'ovh-ic');++ ic.append(siteIcon(a.site, small ? 32 : size));++ el.append(ic, h('span', 'ovh-name', a.title));++ if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });++ return el;++ }++ function folderTile(o, f, byId, tag) {++ const el = h(tag, 'ovh-app ovh-folder');++ if (tag === 'button') el.type = 'button'; else { el.href = '#'; el.setAttribute('role', 'button'); }++ el.setAttribute('data-id', `folder:${f.id}`);++ el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);++ const ic = h('span', 'ovh-ic');++ f.apps.slice(0, 4).forEach((id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); const cell = h('span'); cell.append(siteIcon(a ? a.site : f.glyph, 16)); ic.append(cell); });++ el.append(ic, h('span', 'ovh-name', f.name));++ return el;++ }++++ // ── You: the home screen ───────────────────────────────────────────────────────────────────────────────────++ function home(el, o) { // o is replaced by update() when the menu learns more (history, the catalogue)++ styles();++ el.classList.add('ovh');++ el.setAttribute('data-sec', 'home');++ const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0 };++ let L = layoutOf(o);++ const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };++ const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };++ let actorBox = null;++++ function paintHome() {++ el.textContent = '';++ el.classList.toggle('is-edit', state.edit);++ if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }++ // Recent: the last places opened, newest first.++ const recent = (o.recent || []).map((id) => L.byId.get(id)).filter(Boolean).slice(0, 5);++ if (recent.length && !state.edit) {++ el.append(sec('Recent'));++ const row = h('div', 'ovh-grid ovh-recent'); row.setAttribute('aria-label', 'Recent');++ recent.forEach((a) => row.append(appTile(o, a, { small: true })));++ el.append(row);++ }++ const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');++ const edit = h('button', state.edit ? 'is-done' : '', state.edit ? 'Done' : 'Edit');++ edit.type = 'button';++ edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');++ edit.addEventListener('click', () => setEdit(!state.edit));++ head.append(edit);++ el.append(head);++ if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All apps puts it back.'));++ const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');++ L.home.forEach((id, i) => grid.append(homeTile(id, i)));++ el.append(grid);++ const total = L.byId.size;++ const all = h('button', 'ovh-all', `All apps (${total})`); all.type = 'button';++ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });++ el.append(all);++ icons();++ if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);++ }++++ function homeTile(id, i) {++ let t;++ if (id.startsWith('folder:')) {++ const f = L.folders.find((x) => `folder:${x.id}` === id);++ t = folderTile(o, f, L.byId, state.edit ? 'button' : 'a');++ if (!state.edit) t.addEventListener('click', (e) => { e.preventDefault(); if (Date.now() < state.suppress) return; state.view = 'folder'; state.folder = f.id; paint(); });++ } else {++ const a = L.byId.get(id);++ t = appTile(o, a, { tag: state.edit ? 'button' : 'a' });++ }++ const name = t.querySelector('.ovh-name').textContent;++ if (state.edit) {++ t.setAttribute('aria-label', `${name}, ${i + 1} of ${L.home.length}. Arrow keys move it; Delete takes it off Home.`);++ const rm = h('button', 'ovh-badge', '−'); rm.type = 'button'; rm.setAttribute('aria-label', `Take ${name} off Home`);++ rm.addEventListener('click', (e) => { e.stopPropagation(); L.home.splice(L.home.indexOf(id), 1); save(); paint(); });++ rm.addEventListener('pointerdown', (e) => e.stopPropagation());++ t.append(rm);++ t.addEventListener('keydown', (e) => keyMove(e, id));++ }++ press(t, id);++ return t;++ }++++ function keyMove(e, id) {++ const i = L.home.indexOf(id); let j = i;++ const cols = Math.max(1, getComputedStyle(el.querySelector('.ovh-grid:not(.ovh-recent)')).gridTemplateColumns.split(' ').length);++ if (e.key === 'ArrowLeft') j = i - 1; else if (e.key === 'ArrowRight') j = i + 1;++ else if (e.key === 'ArrowUp') j = i - cols; else if (e.key === 'ArrowDown') j = i + cols;++ else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); L.home.splice(i, 1); save(); paint(); const n = el.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); if (n) n.focus(); return; }++ else return;++ e.preventDefault(); e.stopPropagation();++ j = Math.max(0, Math.min(L.home.length - 1, j));++ if (j === i) return;++ L.home.splice(i, 1); L.home.splice(j, 0, id); save(); paint();++ const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.focus();++ }++++ // Long-press starts Edit (and keeps hold of the app, so a finger can drag at once); in Edit a press drags.++ function press(t, id) {++ t.addEventListener('contextmenu', (e) => { if (state.edit || state.drag) e.preventDefault(); });++ t.addEventListener('pointerdown', (e) => {++ if (e.button !== 0) return;++ const start = { x: e.clientX, y: e.clientY, id: e.pointerId };++ let timer = 0;++ const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };++ const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };++ window.addEventListener('pointermove', early);++ window.addEventListener('pointerup', cancel);++ window.addEventListener('pointercancel', cancel);++ if (!state.edit) {++ timer = setTimeout(() => {++ cancel();++ state.suppress = Date.now() + 700;++ try { if (navigator.vibrate) navigator.vibrate(12); } catch { /* */ }++ setEdit(true);++ // The finger is still down: the next move drags this app.++ const follow = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 6) { window.removeEventListener('pointermove', follow); beginDrag(id, m); } };++ window.addEventListener('pointermove', follow);++ window.addEventListener('pointerup', () => window.removeEventListener('pointermove', follow), { once: true });++ }, LONG_MS);++ }++ });++ // A long-press must not also open the app.++ t.addEventListener('click', (e) => { if (Date.now() < state.suppress && !(e.target.closest && e.target.closest('.ovh-badge'))) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);++ }++++ function beginDrag(id, e) {++ if (state.drag) return;++ const ghost = h('div', 'ovh-ghost');++ const src = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"] .ovh-ic`);++ if (src) ghost.append(src.cloneNode(true).firstChild || h('span'));++ document.body.append(ghost);++ state.drag = { id, ghost };++ const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };++ at(e);++ const lift = () => { const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-lifted'); };++ lift();++ const move = (m) => {++ at(m);++ const under = document.elementFromPoint(m.clientX, m.clientY);++ const cell = under && under.closest && under.closest('.ovh-grid:not(.ovh-recent) .ovh-app');++ if (!cell || !el.contains(cell)) return;++ const to = L.home.indexOf(cell.getAttribute('data-id')), from = L.home.indexOf(id);++ if (to < 0 || to === from) return;++ L.home.splice(from, 1); L.home.splice(to, 0, id);++ const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');++ grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));++ icons(); lift();++ };++ const end = () => {++ window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);++ ghost.remove(); state.drag = null; state.suppress = Date.now() + 300; save(); paint();++ };++ window.addEventListener('pointermove', move);++ window.addEventListener('pointerup', end);++ window.addEventListener('pointercancel', end);++ }++++ function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-sec button'); if (b && !on) b.focus(); }++++ function paintFolder() {++ const f = L.folders.find((x) => x.id === state.folder);++ if (!f) { state.view = 'home'; return paintHome(); }++ el.textContent = '';++ el.append(backHead(f.name));++ const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', f.name);++ const ranked = f.apps.map((id) => L.byId.get(id) || (o.tools || []).find((t) => t.id === id)).filter(Boolean);++ const shown = f.id === 'tools' ? ranked.slice(0, 24) : ranked;++ shown.forEach((a) => grid.append(appTile(o, a)));++ el.append(grid);++ if (f.more) { const m = h('a', 'ovh-all', `${f.more.label} →`); m.href = f.more.href; el.append(m); }++ icons();++ }++++ function paintDrawer() {++ el.textContent = '';++ el.append(backHead('All apps'));++ const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = 'Find an app'; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;++ el.append(field);++ const body = h('div');++ el.append(body);++ const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();++ const draw = () => {++ body.textContent = '';++ const q = state.filter.trim().toLowerCase();++ const all = [...L.byId.values()].filter((a) => !q || a.title.toLowerCase().includes(q)).sort((a, b) => a.title.localeCompare(b.title));++ const yours = all.filter((a) => a.id.startsWith('you:'));++ const sets = [];++ if (yours.length) sets.push(['Yours', yours]);++ groups.forEach((g, gi) => {++ const list = all.filter((a) => !a.id.startsWith('you:') && (g.id ? a.group === g.id : true) && (g.id || gi === groups.length - 1 || true));++ if (list.length) sets.push([g.name, list]);++ });++ const placed = new Set(sets.flatMap(([, list]) => list.map((a) => a.id)));++ const rest = all.filter((a) => !placed.has(a.id));++ if (rest.length) sets.push(['More', rest]);++ L.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });++ if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }++ sets.forEach(([name, list]) => {++ body.append(sec(`${name} (${list.length})`));++ const grid = h('div', 'ovh-grid');++ list.forEach((a) => {++ const id = a.folder ? `folder:${a.folder.id}` : a.id;++ const t = a.folder ? folderTile(o, a.folder, L.byId, 'a') : appTile(o, a);++ if (a.folder) t.addEventListener('click', (e) => { e.preventDefault(); state.view = 'folder'; state.folder = a.folder.id; paint(); });++ if (!L.home.includes(id) && L.home.length < HOME_MAX) {++ const add = h('button', 'ovh-badge is-add', '+'); add.type = 'button';++ add.setAttribute('aria-label', `Put ${a.folder ? a.folder.name : a.title} on Home`);++ add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); L.home.push(id); save(); draw(); icons(); });++ t.append(add);++ }++ grid.append(t);++ });++ body.append(grid);++ });++ icons();++ };++ field.addEventListener('input', () => { state.filter = field.value; draw(); });++ draw();++ }++++ function backHead(title) {++ const head = h('div', 'ovh-head');++ const back = h('button', 'ovh-back', '← Home'); back.type = 'button';++ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-all'); if (b) b.focus(); });++ head.append(back, h('b', '', title));++ return head;++ }++ function sec(text) { const s = h('div', 'ovh-sec'); s.append(h('span', '', text)); return s; }++++ function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }++ paint();++ // Network's copy wins when it answers and differs.++ pullRemote(o).then((r) => {++ if (!r) return;++ const now = JSON.stringify(L.home);++ const known = r.home.filter((id) => L.byId.has(id) || (id.startsWith('folder:') && L.folders.some((f) => `folder:${f.id}` === id)));++ if (!known.length || JSON.stringify(known) === now) return;++ writeLocal({ home: known, folders: r.folders });++ L = layoutOf(o);++ if (!state.drag && el.isConnected) paint();++ });++ return {++ repaint: paint,++ /** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */++ update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },++ };++ }++++ // ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────++ async function actor(box, o) {++ const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');++ let a = null;++ try {++ const r = await fetch(`${base}/api/v1/me/actor`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${o.token}` } });++ if (r.ok) a = await r.json();++ } catch { a = null; }++ if (!box.isConnected) return;++ box.textContent = '';++ if (a && a.id && a.enabled !== false) {++ const asks = Array.isArray(a.asks) ? a.asks : [];++ const st = asks.length ? 'waiting' : ['working', 'resting', 'paused'].includes(a.status) ? a.status : 'resting';++ const label = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' }[st];++ const b = h('button', 'ovh-actor'); b.type = 'button';++ const face = h('span', 'ovh-face');++ const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';++ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';++ const txt = h('span', 'ovh-actor-txt');++ const name = h('b', '', a.name || 'Your Actor');++ const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));++ name.append(chip);++ const now = a.now || {};++ txt.append(name, h('small', '', asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due'));++ b.append(face, txt, h('span', 'ovh-go', asks.length ? 'Answer' : 'Open'));++ b.addEventListener('click', () => {++ if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }++ location.href = `${base}/you`;++ });++ box.append(b);++ return;++ }++ // No Actor yet: one invitation, which the person can put away.++ try { if (localStorage.getItem(INVITE_KEY) === 'off') return; } catch { /* */ }++ const card = h('div', 'ovh-actor'); card.setAttribute('role', 'group'); card.setAttribute('aria-label', 'Your own AI');++ const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 28));++ const txt = h('span', 'ovh-actor-txt');++ txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch: finds work, posts, runs your channel.'));++ const go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;++ const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';++ no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });++ card.append(face, txt, go, no);++ box.append(card);++ if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }++ }++++ // ── Feed: what's new with you, and today ───────────────────────────────────────────────────────────────────++ function wallTop(el, o) {++ styles();++ el.classList.add('ovh');++ el.setAttribute('data-sec', 'wall-top');++ el.textContent = '';++ const compose = h('div'); const today = h('div');++ el.append(compose, today);++ // The composer shows when Community says this person can post (pulse viewer.can_post), so it never offers a++ // post that would be refused.++ o.pulseReady.then((p) => { if (p && p.viewer && p.viewer.can_post && signedIn(o) && compose.isConnected) composer(compose, o); });++ fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } })++ .then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});++ }++ 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 first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];++ 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");++ const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;++ ta.addEventListener('input', () => { post.disabled = !ta.value.trim(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });++ form.append(face, ta, post);++ const note = h('p', 'ovh-note'); note.hidden = true;++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const text = ta.value.trim(); if (!text) return;++ post.disabled = true;++ try {++ const r = await fetch(`${o.communityBase || COMMUNITY}/api/v1/me/status`, { method: 'POST', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });++ 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 = ''; note.hidden = false; note.textContent = 'Posted. It is on your profile and in your followers’ feeds.';++ if (o.onPosted) o.onPosted(j);++ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); post.disabled = false; }++ });++ box.append(form, note);++ }++ function spark(series) {++ const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);++ if (v.length < 2) return null;++ const max = Math.max(...v, 1), w = 100, hgt = 18;++ const pts = v.map((x, i) => `${(i / (v.length - 1)) * w},${hgt - (x / max) * (hgt - 2) - 1}`).join(' ');++ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');++ svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');++ const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');++ pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'currentColor'); pl.setAttribute('stroke-width', '1.6'); pl.setAttribute('vector-effect', 'non-scaling-stroke');++ svg.append(pl);++ return svg;++ }++ function paintToday(box, t) {++ const rows = [['people', 'People'], ['streams', 'Streams'], ['posts', 'Posts'], ['videos', 'New videos']]++ .map(([k, label]) => [label, t[k] && typeof t[k] === 'object' ? t[k] : { today: t[k] }]).filter(([, v]) => v && Number.isFinite(Number(v.today)));++ if (!rows.length) return;++ const s = h('div', 'ovh-sec'); s.append(h('span', '', 'Today on OpenVibe')); box.append(s);++ const grid = h('div', 'ovh-today');++ rows.forEach(([label, v]) => {++ const c = h('div', 'ovh-stat');++ c.append(h('b', '', num(v.today)), h('small', '', label));++ const sp = spark(v.series); if (sp) c.append(sp);++ grid.append(c);++ });++ box.append(grid);++ }++++ // ── Feed: the wall ─────────────────────────────────────────────────────────────────────────────────────────++ const LIVE_RE = /^(.{1,60}?) went live(?::\s*(.*))?$/;++ function pulseUrl(o, cursor) {++ const p = new URLSearchParams({ limit: '20' });++ if (signedIn(o)) p.set('for', 'me');++ if (cursor) p.set('after', cursor);++ return `${o.communityBase || COMMUNITY}/api/v1/pulse?${p}`;++ }++ function loadPulse(o, cursor) {++ return fetch(pulseUrl(o, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })++ .then((r) => (r.ok ? r.json() : null)).catch(() => null);++ }++ function timeline(el, o) {++ styles();++ el.classList.add('ovh');++ el.setAttribute('data-sec', 'wall');++ el.textContent = '';++ const s = h('div', 'ovh-sec'); s.append(h('span', '', 'On OpenVibe'));++ const list = h('div', 'ovh-wall');++ for (let k = 0; k < 3; k++) list.append(h('span', 'ovh-skel'));++ el.append(s, list);++ let next = null;++ const more = h('button', 'ovh-all', 'Show more'); more.type = 'button'; more.hidden = true;++ el.append(more);++ const draw = (j, append) => {++ if (!append) list.textContent = '';++ const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));++ if (!items.length && !append) { list.append(h('p', 'ovh-note', 'Nothing new on the network yet today.')); return; }++ // Went live, back to back: one row of names.++ let lives = null;++ items.forEach((x) => {++ const m = LIVE_RE.exec(x.title.trim());++ if (m) {++ if (!lives) {++ const row = h('div', 'ovh-entry');++ const ic = h('span', 'ovh-ic'); ic.append(siteIcon('live', 26));++ const txt = h('span', 'ovh-entry-txt'); txt.append(h('b', '', 'Went live recently'));++ lives = h('span', 'ovh-lives'); txt.append(lives, h('small', '', ago(x.occurred_at || x.at)));++ row.append(ic, txt); list.append(row);++ }++ if (lives.childNodes.length < 8) { const a = h('a', '', m[1]); a.href = safe(x.url); a.title = m[2] || ''; lives.append(a); }++ return;++ }++ lives = null;++ const a = h('a', 'ovh-entry'); a.href = safe(x.url);++ const svc = String((x.ref && x.ref.service) || x.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30);++ const ic = h('span', 'ovh-ic'); ic.append(siteIcon(svc || 'ov', 26));++ const txt = h('span', 'ovh-entry-txt');++ txt.append(h('b', '', x.title.slice(0, 220)));++ const meta = [ago(x.occurred_at || x.at), x.via ? `via ${String(x.via).slice(0, 40)}` : '', x.origin === 'ai' ? 'made by AI' : ''].filter(Boolean).join(' · ');++ if (meta) txt.append(h('small', '', meta));++ a.append(ic, txt); list.append(a);++ });++ next = j && j.next_cursor ? j.next_cursor : null;++ more.hidden = !next;++ if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* */ } }++ };++ o.pulseReady.then((j) => { if (el.isConnected) draw(j, false); });++ more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });++ }++++ root.OpenVibeHome = { home, wallTop, timeline, loadPulse, _defaultHome: defaultHome };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -60,6 +60,7 @@"./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js","./ov-icons": "./ov-icons.js",++ "./ov-home": "./ov-home.js","./ov-glyphs": "./ov-glyphs.js","./ov-forms": "./ov-forms.js","./ov-qr": "./ov-qr.js",@@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the// owner still saw the old menu hours after the new one shipped (11-07).const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',-- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js'];++ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js'];// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },@@@ -544,7 +544,18 @@function paint(groups, note) {items = [];listEl.textContent = '';-- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds'].includes(g.as)).forEach((g) => {++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall'].includes(g.as)).forEach((g) => {++ if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {++ const H = root.OpenVibeHome; if (!H) return;++ const kept = homeViews[g.as];++ if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }++ const li = document.createElement('li'); li.setAttribute('role', 'presentation');++ let api = null;++ try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : H.timeline)(li, homeOpts()); } catch { return; }++ homeViews[g.as] = { li, api };++ listEl.appendChild(li);++ return;++ }if (g.as === 'route') {const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);return;@@@ -565,7 +576,7 @@});li.appendChild(row);});-- const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = `Up to ${FEEDS_MAX}, in your order. Kept on this device.`; li.appendChild(note);++ const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = 'Every feed shows unless you turn it off; the arrows set the order.'; li.appendChild(note);listEl.appendChild(li);return;}@@@ -694,7 +705,10 @@// the title, the address (openvibe.website → Website, connect.openvibe.link → Connect), then a short title segment.const SHORT_NAME = 18;const shortName = (t) => String(t || '').split(/\s[·|–—-]\s|:\s/)[0].replace(/^OpenVibe\./, '').trim();-- const brief = (t) => { const v = shortName(t); return v && v.length <= SHORT_NAME ? v : ''; };++ // A page's word for itself ("Home", "Welcome") never names the site: the first tab is the site's name (owner, 11-09:++ // "the first tab should be named after the site you're on… I meant the home options", which are Home in its list).++ const GENERIC = /^(?:home|homepage|index|welcome|start|main|untitled|dashboard)$/i;++ const brief = (t) => { const v = shortName(t); return v && v.length <= SHORT_NAME && !GENERIC.test(v) ? v : ''; };const capital = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : '');function addressName(host) {const p = String(host || '').toLowerCase().replace(/^www\./, '').split('.');@@@ -802,7 +816,9 @@let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;// Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'// cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.-- const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;++ // Every feed shows until the person turns some off (11-09); this device keeps up to twelve in their order. Network's++ // menu-feeds@1 keeps the first four until @2 (order and off) is live.++ const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 12, FEEDS_SYNC = 4;const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };@@@ -814,7 +830,7 @@function pushFeeds(on) {if (!signedInCtx() || typeof fetch !== 'function') return;clearTimeout(feedsPut);-- feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_MAX) }) }).catch(() => {}); }, 800);++ feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_SYNC) }) }).catch(() => {}); }, 800);}const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };async function syncFeeds() {@@@ -827,6 +843,8 @@const local = feedPref();if (Array.isArray(j.on)) {const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);++ // Network's v1 copy holds the first four: a device whose longer list starts with them keeps its own.++ if (local && remote.length === FEEDS_SYNC && local.length > remote.length && remote.every((id, i) => local[i] === id)) return;if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }} else if (local) pushFeeds(local);} catch { /* the device copy stands */ }@@@ -1003,12 +1021,16 @@function feedGroups() {const { all, mineSite, byId, shown, take, ranked, f } = basis();const groups = [];++ const wall = homeLib();++ 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' });// The network's cards: text and links only (never another site's code), checked here again, with an entry's https// picture when it sent one; the person's choice and order, else Network's first two.const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];const chosen = feedPref();-- const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);++ // Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").++ const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;netCards.forEach((c) => {const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),@@@ -1025,13 +1047,19 @@if (!site || (site.group === 'build' && !useOf(site.id))) continue;newest.set(e.service, { e, site });}-- const rows = [...newest.values()].sort((a, b) => Date.parse(b.e.deployed_at || b.e.committed_at || 0) - Date.parse(a.e.deployed_at || a.e.committed_at || 0)).slice(0, 4).map(({ e, site }) => {-- const at = e.deployed_at || e.committed_at;++ // Each row (owner, 11-09: "keep the recently updated… see some info about the last update"): the site, what++ // changed in plain words, when, "New" since this person's last visit and "Big update" for a major one; the++ // heading counts today's real changes across the network.++ const at = (e) => e.deployed_at || e.committed_at;++ const rows = [...newest.values()].sort((a, b) => Date.parse(at(b.e) || 0) - Date.parse(at(a.e) || 0)).slice(0, 6).map(({ e, site }) => {const seen = lastVisit(site.id);-- const fresh = seen && Date.parse(at) > seen;-- return { title: site.name, meta: `${plainChange(e.subject)} · ${ago(at)}`, url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };-- }).filter((x) => x.url);-- if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });++ const fresh = seen && Date.parse(at(e)) > seen;++ return { title: site.name, meta: [`Updated ${ago(at(e))}`, e.major ? 'Big update' : ''].filter(Boolean).join(' · '), snippet: plainChange(e.subject),++ url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };++ }).filter((x) => x.url && x.snippet);++ const dayAgo = Date.now() - 86400000;++ const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;++ if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });}// Two places new to this person, rotating fairly through the network (developer services only for builders).const builder = all.some((x) => x.group === 'build' && useOf(x.id));@@@ -1057,6 +1085,15 @@function youGroups() {const { take, ranked } = basis();const groups = [];++ if (homeLib()) {++ groups.push({ as: 'home', items: [], section: 'home' });++ const seenH = new Set();++ const backH = mine ? mine.items.filter((x) => { if (seenH.has(x.url)) return false; seenH.add(x.url); return true; }).slice(0, 5)++ .map((x) => ({ title: x.title, meta: [x.site, ago(x.at)].filter(Boolean).join(' · '), url: x.url, icon: 'back', kind: 'history' })) : [];++ groups.push({ title: 'Jump back in', items: backH, section: 'back' });++ if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });++ return groups;++ }if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });const yours = mine && mine.services.some((x) => x.count > 0);// Without their own history, developer services are not places to visit: they come up here once used.@@@ -1075,6 +1112,40 @@return groups;}++ // ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed++ // paints; until it arrives (or if it cannot), the tabs paint as before.++ // One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.++ let homeLoading = false, pulseReady = null, homeViews = {};++ function homeLib() {++ if (root.OpenVibeHome) return root.OpenVibeHome;++ if (homeLoading || typeof ctx.sibling !== 'function') return null;++ homeLoading = true;++ try {++ const sc = document.createElement('script'); sc.src = ctx.sibling('ov-home.js'); sc.async = true;++ sc.onload = () => { if (el && el.classList.contains('is-open') && !input.value && (tab === 'you' || tab === 'feed')) render(''); };++ document.head.appendChild(sc);++ } catch { /* the old tabs stand */ }++ return null;++ }++ const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);++ /** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */++ function homeOpts() {++ const { all, ranked, f } = basis();++ const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);++ const yours = ctx.token && ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];++ const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];++ const seen = new Set(), recentIds = [];++ for (const h of (mine ? mine.items : [])) {++ const id = h.service === 'media' ? 'media-hub' : h.service;++ if (!id || seen.has(id) || !all.some((x) => x.id === id)) continue;++ seen.add(id); recentIds.push(id);++ if (recentIds.length >= 5) break;++ }++ return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, groups: f && Array.isArray(f.groups) ? f.groups : [],++ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,++ pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })) };++ }++function renderTab() {paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());}@@@ -1234,6 +1305,7 @@}function open(opts = {}) {++ homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opensif (typeof document === 'undefined') return;ctx = opts || {};if (!el) build();@@@ -68,18 +68,24 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neassert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);await wait(150);-- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)');// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);-- assert.deepStrictEqual(looks, [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');++ assert.deepStrictEqual(looks.slice(0, 2), [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);-- assert.strictEqual(fold, 'Choose your feeds (2 of 4)', 'every card with entries can be chosen; the empty one is not offered');++ assert.strictEqual(fold, 'Choose your feeds (4 of 4)', 'every card with entries can be chosen; the empty one is not offered');await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);-- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, false, false]);++ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]);++ // Turn two off: the rest show.++ await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);++ await wait(80);++ await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);++ await wait(100);++ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes');// Tick Today: it shows, after the two.await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);@@@ -125,7 +131,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="neassert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });await q.close();-- console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');++ console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');} finally {await browser.close();server.close();nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++// The menu's You and Feed tabs, redone (plan 2026-11-09, ov-home.js), in a real headless Chrome:++// You: the home grid of the most popular places until the person arranges it, the Tools and Build folders; a++// long-press starts Edit and the same press drags an app to a new place; in Edit the arrow keys move the focused++// app and − takes it off Home; All apps (N) puts it back with +; the Tools folder shows tools with their own icons++// and the count of all; the arrangement is kept on the device and sent to Network (PUT /api/v1/me/home-screen);++// signed in, the Actor strip is on top (or the invitation) and Recent shows the last places opened.++// Feed: today's numbers when Network answers, the wall from Community's pulse with back-to-back "went live" items in++// one row of names, Show more when there is more, and the composer only when Community says the person can post.++// Nothing runs off a 390 px screen. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const ROOT = path.join(__dirname, '..');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('menu-home-screen-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const SITES = ['live', 'chat', 'community', 'video', 'pics', 'download', 'design', 'games', 'space', 'codes', 'news', 'tools', 'bot', 'fun']++ .map((id, i) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), url: `https://openvibe.${id}/`, icon: id, group: i < 10 ? 'play' : 'more' }))++ .concat([{ id: 'ai', name: 'AI', url: 'https://openvibe.exchange/', icon: 'ai', group: 'build' }, { id: 'run', name: 'Run', url: 'https://openvibe.run/', icon: 'run', group: 'build' }]);++const FRAME = { nav: SITES, groups: [{ id: 'play', name: 'Play' }, { id: 'more', name: 'More' }, { id: 'build', name: 'Build' }], soon: [], footer: {} };++const CATALOG = { families: [], tools: Array.from({ length: 30 }, (_, i) => ({ name: `Tool ${i + 1}`, icon: ['pdf', 'image', 'qr', 'audio'][i % 4], url: `https://openvibe.tools/t${i + 1}`, tagline: 'a tool' })) };++const PULSE = { items: [++ { title: 'ada went live: Coding at night', url: 'https://openvibe.live/ada', occurred_at: new Date(Date.now() - 60e3).toISOString(), ref: { service: 'live' } },++ { title: 'bo went live: Morning stream', url: 'https://openvibe.live/bo', occurred_at: new Date(Date.now() - 120e3).toISOString(), ref: { service: 'live' } },++ { 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 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:,">++<script>++ window.__calls = [];++ const J = (b, ok = true, status = 200) => Promise.resolve({ ok, status, json: async () => b });++ const realFetch = window.fetch;++ window.fetch = (u, o) => {++ const s = String(u); window.__calls.push({ u: s, m: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && (o.headers.Authorization || o.headers.authorization) || '' });++ if (s.startsWith('https://openvibe.network/api/frame?')) return J(${JSON.stringify(FRAME)});++ if (s.startsWith('https://openvibe.network/api/catalog.json')) return J(${JSON.stringify(CATALOG)});++ if (s.startsWith('https://openvibe.network/api/v1/frame/today')) return J(${JSON.stringify(TODAY)});++ if (s.startsWith('https://openvibe.network/api/v1/changelog')) return J({ entries: [++ { service: 'live', subject: 'Go live from a phone: the Dock no longer covers the button (#711)', deployed_at: new Date(Date.now() - 3600e3).toISOString(), major: true },++ { service: 'live', subject: 'openvibe-shared v4.158.0 (was v4.157.0) (#712)', deployed_at: new Date(Date.now() - 600e3).toISOString() },++ { service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });++ 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.community/api/v1/pulse')) 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);++ return realFetch(u, o);++ };++</script>++<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>++<script>document.addEventListener('DOMContentLoaded', function () { try { localStorage.clear(); sessionStorage.clear(); } catch (e) {} OpenVibeNavbar.init({ service: 'site', user: ${signed ? "{ id: 'u1', username: 'ada', display_name: 'Ada' }" : 'null'}, token: ${signed ? "'tok-1'" : 'null'}, links: [{ label: 'Rooms', href: '/rooms' }], countViews: false, notifications: false }); });</script>++</body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css|woff2))$/.exec(u.pathname);++ if (m) {++ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }++ res.writeHead(200, { 'content-type': m[1].endsWith('.js') ? 'application/javascript' : 'application/octet-stream' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));++ }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(u.searchParams.has('signed')));++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-home-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };++ const openTab = async (p, which) => {++ await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`), 'the menu opened');++ await p.evaluate(`document.querySelector('.ovs [data-tab="${which}"]').click(), true`);++ };++ const order = (p) => p.evaluate(`[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id'))`);++ const center = (p, sel) => p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);++ try {++ const context = await browser.newContext();++ const blocked = ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'];++++ // ── Signed out: the most popular places, the folders, edit, drag, keys, remove and put back.++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });++ await p.goto(`${base}/`, { maxMs: 6000 });++ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');++ await openTab(p, 'you');++ assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');++ const first = await order(p);++ assert.strictEqual(first.length, 12, `twelve on Home by default (${first})`);++ assert.deepStrictEqual(first.slice(-2), ['folder:tools', 'folder:build'], 'Tools and Build are folders at the end');++ assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');++ assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');++ assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');++ assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');++ assert.ok(await p.evaluate(`/All apps \\(\\d+\\)/.test(document.querySelector('.ovh-all').textContent)`), 'All apps says how many');++ assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');++++ // A long-press starts Edit, and the same press drags the first app onto the third's place.++ await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring++ const a0 = await center(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);++ const a2 = await center(p, `.ovh-grid .ovh-app[data-id="${first[2]}"]`);++ await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });++ await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });++ await wait(650);++ assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit');++ for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }++ await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });++ await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)++ const dragged = await order(p);++ assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);++ assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app');++ const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);++ assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');++ assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours');++++ // Keys: the focused app moves; Delete takes it off Home.++ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);++ await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);++ const keyed = await order(p);++ assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');++ assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');++ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);++ const removed = await order(p);++ assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');++ // Done, then All apps puts it back.++ await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);++ await p.evaluate(`document.querySelector('.ovh-all').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');++ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);++ await p.evaluate(`document.querySelector('.ovh-back').click(), true`);++ assert.ok((await order(p)).includes(keyed[0]), 'All apps put it back on Home');++++ // The Tools folder: tools with their own icons, and the count of all.++ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovh-head b') && document.querySelector('.ovh-head b').textContent === 'Tools'`), 'the folder opened');++ const folder = await p.evaluate(`({ n: document.querySelectorAll('.ovh-grid .ovh-app').length, icons: [...document.querySelectorAll('.ovh-grid .ovh-app .ov-icon')].slice(0, 4).map((i) => i.getAttribute('data-icon')), more: (document.querySelector('a.ovh-all') || {}).textContent })`);++ assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');++ assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');++ assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');++ assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You');++++ // ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.++ await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);++ assert.ok(await until(p, `!!document.querySelector('.ovh-wall .ovh-entry')`), 'the wall painted');++ const wall = await p.evaluate(`({ today: [...document.querySelectorAll('.ovh-stat b')].map((b) => b.textContent), lives: [...document.querySelectorAll('.ovh-lives a')].map((a) => a.textContent),++ entries: [...document.querySelectorAll('.ovh-wall .ovh-entry-txt b')].map((b) => b.textContent), imgs: document.querySelectorAll('.ovh-wall img').length, pwned: !!window.pwned,++ compose: !!document.querySelector('.ovh-compose'), more: !document.querySelector('.ovh-all[hidden]') && !!document.querySelector('[data-sec="wall"] .ovh-all') })`);++ assert.deepStrictEqual(wall.today, ['1,204', '37', '210', '12'], 'today on OpenVibe');++ assert.deepStrictEqual(wall.lives, ['ada', 'bo'], 'back-to-back went live is one row of names');++ assert.strictEqual(wall.entries[0], 'Went live recently');++ assert.ok(wall.entries.includes('<img src=x onerror="window.pwned=1"> a post') && wall.imgs === 0 && !wall.pwned, 'what people wrote is text');++ assert.strictEqual(wall.compose, false, 'signed out: no composer');++ assert.ok(wall.more, 'Show more when there is more');++ await p.evaluate(`document.querySelector('[data-sec="wall"] .ovh-all').click(), true`);++ assert.ok(await until(p, `[...document.querySelectorAll('.ovh-wall .ovh-entry-txt b')].some((b) => b.textContent === 'An older post')`), 'Show more adds the next page');++ const news = await p.evaluate(`({ head: ([...document.querySelectorAll('.ovs-group')].find((g) => /What's new/.test(g.textContent)) || {}).textContent || '',++ rows: [...document.querySelectorAll('.ovs-item[data-sec="news"]')].map((r) => [r.querySelector('.ovs-title').firstChild.textContent, (r.querySelector('.ovs-meta') || {}).textContent, (r.querySelector('.ovs-snip') || {}).textContent]) })`);++ assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');++ assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');++ assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);++ assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed');++ await p.close();++++ // ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.++ const s = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });++ await s.goto(`${base}/?signed=1`, { maxMs: 6000 });++ assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');++ await openTab(s, 'you');++ assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');++ assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');++ assert.ok(await until(s, `document.querySelectorAll('.ovh-recent .ovh-app').length === 2`), 'Recent shows the places opened lately');++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-recent .ovh-app')].map((a) => a.getAttribute('data-id'))`), ['live', 'pics']);++ assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');++ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');++ await s.evaluate(`document.querySelector('.ovh-sec button').click(), 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');++ 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');++ assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");++ await s.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, Network sync ok');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++// ov-home.js, read as source (the Chrome test, menu-home-screen-chrome.test.js, drives it): what it must never do and++// the shapes it keeps. Text is set as text; links are https or same-site; the arrangement is kept on the device and on++// Network; the composer only posts to Community with the person's bearer and no cookies; the wall reads the pulse.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');++assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, loadPulse/.test(src), 'it exposes the home screen and the wall');++assert.ok(!/\.innerHTML\s*=/.test(src) && !/insertAdjacentHTML/.test(src), 'it never writes markup');++assert.ok(/const safe = \(u\) =>/.test(src) && /x\.protocol === 'https:' \|\| x\.origin === location\.origin/.test(src), 'links are https or this site only');++assert.ok(/localStorage\.setItem\(HOME_KEY/.test(src) && /\/api\/v1\/me\/home-screen/.test(src), 'the arrangement is kept here and on Network');++assert.ok(/credentials: 'omit'/.test(src) && !/credentials: 'include'/.test(src), 'no cookies go with any call');++assert.ok(/\/api\/v1\/me\/status/.test(src) && /viewer\.can_post/.test(src), 'the composer posts to Community only when it says the person can');++assert.ok(/\/api\/v1\/pulse\?/.test(src) && /for', 'me'/.test(src), 'the wall reads the pulse, personal when signed in');++assert.ok(/LONG_MS = 450/.test(src) && /ArrowLeft/.test(src) && /Delete/.test(src), 'long-press, and keys for people who cannot drag');++assert.ok(/prefers-reduced-motion: reduce/.test(src), 'the wiggle and the pulse stop for reduced motion');++assert.ok(!/@keyframes [a-z-]*spin|ovh-spin|rotate\(360deg\)/i.test(src), 'no spinners');++++// The default Home without the network's frame: the person's places first, then the popular places, then the folders.++global.window = undefined;++const sandbox = {};++new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});++const H = sandbox.OpenVibeHome;++const apps = ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot'].map((id) => ({ id, group: 'play' })).concat([{ id: 'ai', group: 'build' }]);++assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot', 'folder:tools', 'folder:build'], 'signed out: ten popular places and the two folders');++assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');++console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');
@@ -1,5 +1,21 @@
@@ -1,5 +1,21 @@
# Changelog
# Changelog
+## Unreleased
+
+- **The menu's You tab is a home screen** (owner, 11-09: "make it more like a phone"). `ov-home.js` (a sibling, loaded
+ the first time You or Feed opens): your Actor on top (its state and step, or one invitation), Recent (the last five
+ places opened), then Home: your places in a grid, the most popular until you arrange it, with Tools (every tool with
+ its own icon) and Build as folders. A long-press (or Edit) starts arranging: drag an app to move it, arrow keys move
+ the focused one, − takes it off Home, All apps (N) lists every app and puts one back with +. The arrangement is kept
+ on the device and on Network (`GET/PUT /api/v1/me/home-screen`, once Network serves it). Recent searches are gone
+ from You (the field shows them).
+- **The Feed tab is a wall.** What's new with you (the composer, when Community says you can post), today's numbers
+ (people, streams, posts, new videos, with 14-day lines), the live bar, then the network's pulse: newest first,
+ back-to-back "went live" in one row of names, Show more. Every feed card shows until you turn some off (was the
+ first two); What's new names each site, what changed in plain words, when, "New" since your visit and "Big update",
+ and counts today's changes.
+- **The first tab is the site's name, never a page's word for itself** ("Home", "Welcome").
+
## 4.158.0 — 2026-11-09
## 4.158.0 — 2026-11-09
- **The menu's Home.** A site whose links never name its home (Exchange: Models, Chat, Arena…) now gets Home first in
- **The menu's Home.** A site whose links never name its home (Exchange: Models, Chat, Arena…) now gets Home first in
@@ -46,6 +46,7 @@ const BROWSER = Object.freeze([
@@ -46,6 +46,7 @@ const BROWSER = Object.freeze([
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-share.js', // the one Share sheet (plan track Q): who can open a thing, people by @name, transfer; navbar.js loads it on the first press of a [data-ov-share-sheet] button
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
+ 'ov-home.js', // the menu's home screen and wall (search.js loads it for You and Feed)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
'panels.js', // one-open-at-a-time panel coordinator
'panels.js', // one-open-at-a-time panel coordinator
@@ -539,7 +539,7 @@
@@ -539,7 +539,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'ad94f524fa45', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075' };
+ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '1a0d8ce6909f', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '8e55bfe93238', 'ov-share.css': 'cbd18e6ce991', 'search.js': 'b113a2fd8e93', 'dock.js': '4dacf614797b', 'presence.js': '1b675c37d888', 'panels.js': '7d8267ca8035', 'notification-ui.js': 'a2d37237ba0b', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': '25960cf0c075', 'ov-home.js': '267bf753169d' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -2208,7 +2208,7 @@
@@ -2208,7 +2208,7 @@
if (!root.OpenVibeSearch) return;
if (!root.OpenVibeSearch) return;
if (_navEl) _navEl.classList.remove('ovnav-hide');
if (_navEl) _navEl.classList.remove('ovnav-hide');
root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon,
+ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
onClose: () => { const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,616 @@
@@ -0,0 +1,616 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the menu's home screen and wall (plan 2026-11-09, ~/openvibe/OpenVibe_Menu_Home_Feed_Plan_2026-11-09.md)
+//
+// search.js loads this the first time the You or Feed tab paints (a sibling, by content hash) and hands it an
+// element and what it knows:
+// OpenVibeHome.home(el, o) You: your Actor, Recent, your Home grid (long-press or Edit, drag to arrange,
+// folders), All apps (the drawer)
+// OpenVibeHome.wallTop(el, o) Feed: what's new with you (the composer, when Community says you can post) and
+// today's numbers
+// OpenVibeHome.timeline(el, o) Feed: the network's wall, newest first, "Show more"
+//
+// The owner, 11-09: "make it more like a phone… hold down on an icon and move it around, just like on Android or
+// iOS… the most popular sites until you set it up… recent apps at the top… tools as a folder of apps… the Actor
+// built in… the feed more like a Facebook wall… who went live recently… how many people today".
+//
+// The arrangement is kept on this device (localStorage ovs-home) and, signed in, on Network
+// (GET/PUT /api/v1/me/home-screen, network.home-screen@1); Network's copy wins when it answers. Everything a person or
+// a site wrote is set as text; links are https or same-site only. No spinners: grey bars hold the place of what loads.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeHome) return;
+
+ const HOME_KEY = 'ovs-home';
+ const INVITE_KEY = 'ovs-actor-invite';
+ const LONG_MS = 450;
+ const HOME_MAX = 40;
+ const ACTOR = 'https://openvibe.actor';
+ const COMMUNITY = 'https://openvibe.community';
+
+ const safe = (u) => { try { const x = new URL(String(u || ''), location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
+ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const ago = (iso) => {
+ const t = Date.parse(iso || ''); if (!t) return '';
+ const s = Math.max(1, Math.round((Date.now() - t) / 1000));
+ if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;
+ const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;
+ };
+ const num = (n) => { const v = Number(n) || 0; return v >= 10000 ? `${(v / 1000).toFixed(v >= 100000 ? 0 : 1)}k` : v.toLocaleString('en-US'); };
+
+ function styles() {
+ if (document.getElementById('ovh-styles')) return;
+ const st = document.createElement('style');
+ st.id = 'ovh-styles';
+ st.textContent = `
+.ovh { list-style: none; padding: 2px 4px 6px; }
+.ovh-sec { display: flex; align-items: center; gap: 8px; padding: 10px 6px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
+.ovh-sec > span { flex: 1; }
+.ovh-sec button, .ovh-link { min-height: 36px; padding: 0 10px; border: 0; border-radius: 999px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 13px; font-weight: 700; letter-spacing: 0; text-transform: none; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
+.ovh-sec button.is-done { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
+.ovh-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px; }
+@media (min-width: 400px) { .ovh-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
+@media (min-width: 861px) { .ovh-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
+.ovh-recent .ovh-app { min-height: 80px; }
+.ovh-app { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; min-height: 88px; padding: 8px 2px 6px; border: 0; border-radius: 16px;
+ background: transparent; color: inherit; font: inherit; text-decoration: none; cursor: pointer; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
+.ovh-app:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+.ovh-ic { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent);
+ box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1); }
+.ovh-recent .ovh-ic { width: 48px; height: 48px; border-radius: 15px; }
+.ovh-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+.ovh-folder .ovh-ic { display: grid; grid-template-columns: repeat(2, 18px); grid-auto-rows: 18px; gap: 4px; place-content: center; }
+.ovh-folder .ovh-ic > span { display: grid; place-items: center; width: 18px; height: 18px; }
+.ovh-glyph { display: grid; place-items: center; width: 100%; height: 100%; }
+.ovh-glyph svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; color: var(--accent, #3b82f6); }
+.ovh-badge { position: absolute; top: -6px; left: -6px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;
+ background: var(--bg-tertiary, #2a3140); color: var(--text-primary, #e8ecf3); font: 700 16px/1 system-ui, sans-serif; box-shadow: 0 0 0 2px var(--bg-secondary, #141a24); }
+.ovh-badge::after { content: ""; position: absolute; inset: -10px; }
+.ovh-badge.is-add { background: var(--accent-strong, var(--accent, #2563eb)); color: var(--on-accent-strong, #fff); }
+.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app { touch-action: none; }
+.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-ic { animation: ovh-wiggle .32s ease-in-out infinite alternate; }
+.ovh.is-edit .ovh-grid:not(.ovh-recent) .ovh-app:nth-child(2n) .ovh-ic { animation-delay: -.16s; }
+.ovh-app.is-lifted { opacity: .25; }
+.ovh-ghost { position: fixed; z-index: 10040; pointer-events: none; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 19px; display: grid; place-items: center;
+ background: var(--bg-secondary, #141a24); box-shadow: 0 12px 30px rgba(0,0,0,.35), inset 0 0 0 1px var(--border, rgba(255,255,255,.14)); transform: scale(1.08); }
+@keyframes ovh-wiggle { from { transform: rotate(-1.6deg); } to { transform: rotate(1.6deg); } }
+.ovh-hint { margin: 2px 6px 6px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
+.ovh-all { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 8px); min-height: 44px; margin: 6px 4px 2px; border: 1px solid var(--border, rgba(255,255,255,.12));
+ border-radius: 14px; background: transparent; color: var(--text-primary, #e8ecf3); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
+.ovh-head { display: flex; align-items: center; gap: 8px; padding: 6px 2px; }
+.ovh-head b { flex: 1; font-size: 16px; }
+.ovh-back { min-height: 44px; padding: 0 12px; border: 0; border-radius: 12px; background: transparent; color: var(--accent, #3b82f6); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
+.ovh-filter { width: calc(100% - 8px); min-height: 44px; margin: 2px 4px 4px; padding: 0 14px; 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; }
+.ovh-actor { display: flex; align-items: center; gap: 12px; width: calc(100% - 8px); min-height: 64px; margin: 6px 4px 4px; padding: 10px 12px; border-radius: 16px; border: 0; text-align: left; cursor: pointer;
+ background: color-mix(in srgb, var(--accent, #3b82f6) 11%, var(--bg-secondary, #141a24)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); color: inherit; font: inherit; }
+.ovh-face { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; overflow: hidden; background: var(--accent, #3b82f6); color: #fff; font-weight: 800; font-size: 17px; }
+.ovh-face img { width: 100%; height: 100%; object-fit: cover; }
+.ovh-actor-txt { flex: 1; min-width: 0; display: grid; gap: 2px; }
+.ovh-actor-txt b { font-size: 14.5px; }
+.ovh-actor-txt small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovh-chip { display: inline-flex; align-items: center; gap: 5px; margin-left: 6px; font-size: 11.5px; font-weight: 700; color: var(--text-secondary, #a8b3c4); }
+.ovh-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #8b8b96); }
+.ovh-chip.is-working .ovh-dot { background: var(--success, #22c55e); animation: ovh-pulse 1.8s ease-in-out infinite; }
+.ovh-chip.is-waiting .ovh-dot { background: var(--warning, #f5a524); }
+.ovh-go { flex: none; color: var(--accent, #3b82f6); font-weight: 700; font-size: 13px; }
+.ovh-x { flex: none; min-width: 44px; min-height: 44px; border: 0; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 12.5px; cursor: pointer; }
+@keyframes ovh-pulse { 50% { opacity: .35; } }
+.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: flex; gap: 10px; align-items: flex-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-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); }
+.ovh-stat b { font-size: 18px; font-variant-numeric: tabular-nums; }
+.ovh-stat small { font-size: 11.5px; color: var(--text-muted, #93a0b4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovh-stat svg { width: 100%; height: 18px; color: var(--accent, #3b82f6); }
+@media (max-width: 380px) { .ovh-today { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.ovh-wall { list-style: none; margin: 0; padding: 0; }
+.ovh-entry { display: flex; gap: 12px; align-items: flex-start; min-height: 56px; padding: 10px 8px; border-radius: 14px; color: inherit; text-decoration: none; }
+.ovh-entry + .ovh-entry { margin-top: 2px; }
+@media (hover: hover) { .ovh-entry:hover, .ovh-app:hover { background: var(--bg-hover, rgba(255,255,255,.07)); } }
+.ovh-entry .ovh-ic { width: 40px; height: 40px; border-radius: 12px; flex: none; }
+.ovh-entry-txt { flex: 1; min-width: 0; display: grid; gap: 3px; }
+.ovh-entry-txt b { font-size: 14px; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; }
+.ovh-entry-txt small { font-size: 12px; color: var(--text-muted, #93a0b4); }
+.ovh-lives { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
+.ovh-lives a { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; background: color-mix(in srgb, var(--live, #ef4444) 12%, transparent); color: inherit; text-decoration: none; font-size: 13px; font-weight: 650; }
+.ovh-lives a::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--live, #ef4444); }
+@media (prefers-reduced-motion: reduce) { .ovh.is-edit .ovh-grid .ovh-ic, .ovh-chip.is-working .ovh-dot { animation: none !important; } .ovh-ic { transition: none; } }
+`;
+ document.head.appendChild(st);
+ }
+
+ // ── The arrangement ────────────────────────────────────────────────────────────────────────────────────────
+ function readLocal() { try { const v = JSON.parse(localStorage.getItem(HOME_KEY) || 'null'); return v && Array.isArray(v.home) ? v : null; } catch { return null; } }
+ function writeLocal(l) { try { localStorage.setItem(HOME_KEY, JSON.stringify(l)); } catch { /* a private window keeps it for this opening */ } }
+ const homeUrl = (o) => `${o.apiBase || 'https://openvibe.network'}/api/v1/me/home-screen`;
+ const signedIn = (o) => !!(o.token && o.user && !o.user.is_anon);
+ let putTimer = 0;
+ function pushRemote(o, l) {
+ if (!signedIn(o) || typeof fetch !== 'function') return;
+ clearTimeout(putTimer);
+ putTimer = setTimeout(() => {
+ fetch(homeUrl(o), { method: 'PUT', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ home: l.home.slice(0, HOME_MAX), folders: l.folders }) }).catch(() => {});
+ }, 600);
+ }
+ let remoteAsked = false;
+ async function pullRemote(o) {
+ if (remoteAsked || !signedIn(o) || typeof fetch !== 'function') return null;
+ remoteAsked = true;
+ try {
+ const r = await fetch(homeUrl(o), { credentials: 'omit', headers: { Authorization: `Bearer ${o.token}` } });
+ if (!r.ok) return null;
+ const j = await r.json();
+ return j && Array.isArray(j.home) ? { home: j.home.filter((x) => typeof x === 'string').slice(0, HOME_MAX), folders: Array.isArray(j.folders) ? j.folders : [] } : null;
+ } catch { return null; }
+ }
+
+ /** Every app the menu knows, by id: sites, the person's own places (Files, Wallet…), the folders. */
+ function universe(o) {
+ const byId = new Map();
+ (o.apps || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
+ (o.yours || []).forEach((a) => { if (a && a.id && safe(a.url)) byId.set(a.id, a); });
+ return byId;
+ }
+ function folderDefs(o) {
+ const apps = o.apps || [];
+ return [
+ { id: 'tools', name: 'Tools', apps: (o.tools || []).map((t) => t.id), glyph: 'tools', more: { label: `All ${o.toolCount || (o.tools || []).length} tools`, href: 'https://openvibe.tools/' } },
+ { id: 'build', name: 'Build', apps: apps.filter((a) => a.group === 'build').map((a) => a.id), glyph: 'code' },
+ ].filter((f) => f.apps.length);
+ }
+ /** Until the person arranges it: their own places first (signed in), then the most used and popular sites. */
+ function defaultHome(o) {
+ const mineFirst = (o.yours || []).filter((y) => ['you:vibe', 'you:codes', 'you:files', 'you:wallet'].includes(y.id)).map((y) => y.id);
+ const sites = (o.apps || []).filter((a) => a.group !== 'build').map((a) => a.id);
+ const room = 12 - 2 - mineFirst.length;
+ return mineFirst.concat(sites.slice(0, Math.max(6, room)), ['folder:tools', 'folder:build']);
+ }
+ function layoutOf(o) {
+ const byId = universe(o);
+ const folders = folderDefs(o);
+ const known = (id) => byId.has(id) || (id.startsWith('folder:') && folders.some((f) => `folder:${f.id}` === id));
+ const l = readLocal();
+ const home = (l ? l.home : defaultHome(o)).filter((id, i, a) => known(id) && a.indexOf(id) === i).slice(0, HOME_MAX);
+ return { home, folders, arranged: !!l, byId };
+ }
+
+ // ── Drawing an icon ────────────────────────────────────────────────────────────────────────────────────────
+ function siteIcon(site, size) {
+ const s = h('span', 'ov-icon'); s.setAttribute('data-icon', site || 'ov'); s.setAttribute('data-size', String(size)); s.setAttribute('data-fx', 'none');
+ return s;
+ }
+ function appTile(o, a, { tag = 'a', size = 40, small = false } = {}) {
+ const el = h(tag, 'ovh-app');
+ if (tag === 'a') el.href = safe(a.url) || '#'; else el.type = 'button';
+ el.setAttribute('data-id', a.id);
+ const ic = h('span', 'ovh-ic');
+ ic.append(siteIcon(a.site, small ? 32 : size));
+ el.append(ic, h('span', 'ovh-name', a.title));
+ if (tag === 'a') el.addEventListener('click', () => { if (o.onOpen) o.onOpen(a); });
+ return el;
+ }
+ function folderTile(o, f, byId, tag) {
+ const el = h(tag, 'ovh-app ovh-folder');
+ if (tag === 'button') el.type = 'button'; else { el.href = '#'; el.setAttribute('role', 'button'); }
+ el.setAttribute('data-id', `folder:${f.id}`);
+ el.setAttribute('aria-label', `${f.name} folder, ${f.apps.length} apps`);
+ const ic = h('span', 'ovh-ic');
+ f.apps.slice(0, 4).forEach((id) => { const a = byId.get(id) || (o.tools || []).find((t) => t.id === id); const cell = h('span'); cell.append(siteIcon(a ? a.site : f.glyph, 16)); ic.append(cell); });
+ el.append(ic, h('span', 'ovh-name', f.name));
+ return el;
+ }
+
+ // ── You: the home screen ───────────────────────────────────────────────────────────────────────────────────
+ function home(el, o) { // o is replaced by update() when the menu learns more (history, the catalogue)
+ styles();
+ el.classList.add('ovh');
+ el.setAttribute('data-sec', 'home');
+ const state = { view: 'home', edit: false, folder: null, filter: '', drag: null, suppress: 0 };
+ let L = layoutOf(o);
+ const save = () => { const keep = { home: L.home.slice(0, HOME_MAX), folders: L.folders.map((f) => ({ id: f.id, name: f.name, apps: f.apps.slice(0, 400) })) }; writeLocal(keep); pushRemote(o, keep); L.arranged = true; };
+ const icons = () => { if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* optional */ } } };
+ let actorBox = null;
+
+ function paintHome() {
+ el.textContent = '';
+ el.classList.toggle('is-edit', state.edit);
+ if (signedIn(o)) { actorBox = actorBox || h('div'); el.append(actorBox); }
+ // Recent: the last places opened, newest first.
+ const recent = (o.recent || []).map((id) => L.byId.get(id)).filter(Boolean).slice(0, 5);
+ if (recent.length && !state.edit) {
+ el.append(sec('Recent'));
+ const row = h('div', 'ovh-grid ovh-recent'); row.setAttribute('aria-label', 'Recent');
+ recent.forEach((a) => row.append(appTile(o, a, { small: true })));
+ el.append(row);
+ }
+ const head = sec(L.arranged || signedIn(o) ? 'Your home' : 'Popular on OpenVibe');
+ const edit = h('button', state.edit ? 'is-done' : '', state.edit ? 'Done' : 'Edit');
+ edit.type = 'button';
+ edit.setAttribute('aria-pressed', state.edit ? 'true' : 'false');
+ edit.addEventListener('click', () => setEdit(!state.edit));
+ head.append(edit);
+ el.append(head);
+ if (state.edit) el.append(h('p', 'ovh-hint', 'Drag an app to move it, or use the arrow keys. − takes it off Home; All apps puts it back.'));
+ const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', 'Your home');
+ L.home.forEach((id, i) => grid.append(homeTile(id, i)));
+ el.append(grid);
+ const total = L.byId.size;
+ const all = h('button', 'ovh-all', `All apps (${total})`); all.type = 'button';
+ all.addEventListener('click', () => { state.view = 'drawer'; paint(); });
+ el.append(all);
+ icons();
+ if (signedIn(o) && actorBox && !actorBox.childNodes.length) actor(actorBox, o);
+ }
+
+ function homeTile(id, i) {
+ let t;
+ if (id.startsWith('folder:')) {
+ const f = L.folders.find((x) => `folder:${x.id}` === id);
+ t = folderTile(o, f, L.byId, state.edit ? 'button' : 'a');
+ if (!state.edit) t.addEventListener('click', (e) => { e.preventDefault(); if (Date.now() < state.suppress) return; state.view = 'folder'; state.folder = f.id; paint(); });
+ } else {
+ const a = L.byId.get(id);
+ t = appTile(o, a, { tag: state.edit ? 'button' : 'a' });
+ }
+ const name = t.querySelector('.ovh-name').textContent;
+ if (state.edit) {
+ t.setAttribute('aria-label', `${name}, ${i + 1} of ${L.home.length}. Arrow keys move it; Delete takes it off Home.`);
+ const rm = h('button', 'ovh-badge', '−'); rm.type = 'button'; rm.setAttribute('aria-label', `Take ${name} off Home`);
+ rm.addEventListener('click', (e) => { e.stopPropagation(); L.home.splice(L.home.indexOf(id), 1); save(); paint(); });
+ rm.addEventListener('pointerdown', (e) => e.stopPropagation());
+ t.append(rm);
+ t.addEventListener('keydown', (e) => keyMove(e, id));
+ }
+ press(t, id);
+ return t;
+ }
+
+ function keyMove(e, id) {
+ const i = L.home.indexOf(id); let j = i;
+ const cols = Math.max(1, getComputedStyle(el.querySelector('.ovh-grid:not(.ovh-recent)')).gridTemplateColumns.split(' ').length);
+ if (e.key === 'ArrowLeft') j = i - 1; else if (e.key === 'ArrowRight') j = i + 1;
+ else if (e.key === 'ArrowUp') j = i - cols; else if (e.key === 'ArrowDown') j = i + cols;
+ else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); L.home.splice(i, 1); save(); paint(); const n = el.querySelector('.ovh-grid:not(.ovh-recent) .ovh-app'); if (n) n.focus(); return; }
+ else return;
+ e.preventDefault(); e.stopPropagation();
+ j = Math.max(0, Math.min(L.home.length - 1, j));
+ if (j === i) return;
+ L.home.splice(i, 1); L.home.splice(j, 0, id); save(); paint();
+ const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.focus();
+ }
+
+ // Long-press starts Edit (and keeps hold of the app, so a finger can drag at once); in Edit a press drags.
+ function press(t, id) {
+ t.addEventListener('contextmenu', (e) => { if (state.edit || state.drag) e.preventDefault(); });
+ t.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0) return;
+ const start = { x: e.clientX, y: e.clientY, id: e.pointerId };
+ let timer = 0;
+ const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
+ const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };
+ window.addEventListener('pointermove', early);
+ window.addEventListener('pointerup', cancel);
+ window.addEventListener('pointercancel', cancel);
+ if (!state.edit) {
+ timer = setTimeout(() => {
+ cancel();
+ state.suppress = Date.now() + 700;
+ try { if (navigator.vibrate) navigator.vibrate(12); } catch { /* */ }
+ setEdit(true);
+ // The finger is still down: the next move drags this app.
+ const follow = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 6) { window.removeEventListener('pointermove', follow); beginDrag(id, m); } };
+ window.addEventListener('pointermove', follow);
+ window.addEventListener('pointerup', () => window.removeEventListener('pointermove', follow), { once: true });
+ }, LONG_MS);
+ }
+ });
+ // A long-press must not also open the app.
+ t.addEventListener('click', (e) => { if (Date.now() < state.suppress && !(e.target.closest && e.target.closest('.ovh-badge'))) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);
+ }
+
+ function beginDrag(id, e) {
+ if (state.drag) return;
+ const ghost = h('div', 'ovh-ghost');
+ const src = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"] .ovh-ic`);
+ if (src) ghost.append(src.cloneNode(true).firstChild || h('span'));
+ document.body.append(ghost);
+ state.drag = { id, ghost };
+ const at = (m) => { ghost.style.left = `${m.clientX}px`; ghost.style.top = `${m.clientY}px`; };
+ at(e);
+ const lift = () => { const n = el.querySelector(`.ovh-grid:not(.ovh-recent) [data-id="${CSS.escape(id)}"]`); if (n) n.classList.add('is-lifted'); };
+ lift();
+ const move = (m) => {
+ at(m);
+ const under = document.elementFromPoint(m.clientX, m.clientY);
+ const cell = under && under.closest && under.closest('.ovh-grid:not(.ovh-recent) .ovh-app');
+ if (!cell || !el.contains(cell)) return;
+ const to = L.home.indexOf(cell.getAttribute('data-id')), from = L.home.indexOf(id);
+ if (to < 0 || to === from) return;
+ L.home.splice(from, 1); L.home.splice(to, 0, id);
+ const grid = el.querySelector('.ovh-grid:not(.ovh-recent)');
+ grid.textContent = ''; L.home.forEach((x, i) => grid.append(homeTile(x, i)));
+ icons(); lift();
+ };
+ const end = () => {
+ window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end);
+ ghost.remove(); state.drag = null; state.suppress = Date.now() + 300; save(); paint();
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', end);
+ window.addEventListener('pointercancel', end);
+ }
+
+ function setEdit(on) { state.edit = on; paint(); const b = el.querySelector('.ovh-sec button'); if (b && !on) b.focus(); }
+
+ function paintFolder() {
+ const f = L.folders.find((x) => x.id === state.folder);
+ if (!f) { state.view = 'home'; return paintHome(); }
+ el.textContent = '';
+ el.append(backHead(f.name));
+ const grid = h('div', 'ovh-grid'); grid.setAttribute('aria-label', f.name);
+ const ranked = f.apps.map((id) => L.byId.get(id) || (o.tools || []).find((t) => t.id === id)).filter(Boolean);
+ const shown = f.id === 'tools' ? ranked.slice(0, 24) : ranked;
+ shown.forEach((a) => grid.append(appTile(o, a)));
+ el.append(grid);
+ if (f.more) { const m = h('a', 'ovh-all', `${f.more.label} →`); m.href = f.more.href; el.append(m); }
+ icons();
+ }
+
+ function paintDrawer() {
+ el.textContent = '';
+ el.append(backHead('All apps'));
+ const field = h('input', 'ovh-filter'); field.type = 'search'; field.placeholder = 'Find an app'; field.setAttribute('aria-label', 'Find an app'); field.value = state.filter;
+ el.append(field);
+ const body = h('div');
+ el.append(body);
+ const groups = (o.groups && o.groups.length ? o.groups : [{ id: '', name: 'OpenVibe' }]).slice();
+ const draw = () => {
+ body.textContent = '';
+ const q = state.filter.trim().toLowerCase();
+ const all = [...L.byId.values()].filter((a) => !q || a.title.toLowerCase().includes(q)).sort((a, b) => a.title.localeCompare(b.title));
+ const yours = all.filter((a) => a.id.startsWith('you:'));
+ const sets = [];
+ if (yours.length) sets.push(['Yours', yours]);
+ groups.forEach((g, gi) => {
+ const list = all.filter((a) => !a.id.startsWith('you:') && (g.id ? a.group === g.id : true) && (g.id || gi === groups.length - 1 || true));
+ if (list.length) sets.push([g.name, list]);
+ });
+ const placed = new Set(sets.flatMap(([, list]) => list.map((a) => a.id)));
+ const rest = all.filter((a) => !placed.has(a.id));
+ if (rest.length) sets.push(['More', rest]);
+ L.folders.forEach((f) => { if (!q || f.name.toLowerCase().includes(q)) sets.push([`${f.name} folder`, [{ folder: f }]]); });
+ if (!sets.length) { body.append(h('p', 'ovh-note', 'No app has that name.')); return; }
+ sets.forEach(([name, list]) => {
+ body.append(sec(`${name} (${list.length})`));
+ const grid = h('div', 'ovh-grid');
+ list.forEach((a) => {
+ const id = a.folder ? `folder:${a.folder.id}` : a.id;
+ const t = a.folder ? folderTile(o, a.folder, L.byId, 'a') : appTile(o, a);
+ if (a.folder) t.addEventListener('click', (e) => { e.preventDefault(); state.view = 'folder'; state.folder = a.folder.id; paint(); });
+ if (!L.home.includes(id) && L.home.length < HOME_MAX) {
+ const add = h('button', 'ovh-badge is-add', '+'); add.type = 'button';
+ add.setAttribute('aria-label', `Put ${a.folder ? a.folder.name : a.title} on Home`);
+ add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); L.home.push(id); save(); draw(); icons(); });
+ t.append(add);
+ }
+ grid.append(t);
+ });
+ body.append(grid);
+ });
+ icons();
+ };
+ field.addEventListener('input', () => { state.filter = field.value; draw(); });
+ draw();
+ }
+
+ function backHead(title) {
+ const head = h('div', 'ovh-head');
+ const back = h('button', 'ovh-back', '← Home'); back.type = 'button';
+ back.addEventListener('click', () => { state.view = 'home'; state.folder = null; paint(); const b = el.querySelector('.ovh-all'); if (b) b.focus(); });
+ head.append(back, h('b', '', title));
+ return head;
+ }
+ function sec(text) { const s = h('div', 'ovh-sec'); s.append(h('span', '', text)); return s; }
+
+ function paint() { if (state.view === 'folder') return paintFolder(); if (state.view === 'drawer') return paintDrawer(); return paintHome(); }
+ paint();
+ // Network's copy wins when it answers and differs.
+ pullRemote(o).then((r) => {
+ if (!r) return;
+ const now = JSON.stringify(L.home);
+ const known = r.home.filter((id) => L.byId.has(id) || (id.startsWith('folder:') && L.folders.some((f) => `folder:${f.id}` === id)));
+ if (!known.length || JSON.stringify(known) === now) return;
+ writeLocal({ home: known, folders: r.folders });
+ L = layoutOf(o);
+ if (!state.drag && el.isConnected) paint();
+ });
+ return {
+ repaint: paint,
+ /** The menu learned more (history, the catalogue): redraw with it, unless the person is arranging. */
+ update(next) { o = next; L = layoutOf(o); if (!state.drag && !state.edit && state.view === 'home') paint(); },
+ };
+ }
+
+ // ── Your Actor, on top of You ──────────────────────────────────────────────────────────────────────────────
+ async function actor(box, o) {
+ const base = String(o.actorBase || ACTOR).replace(/\/+$/, '');
+ let a = null;
+ try {
+ const r = await fetch(`${base}/api/v1/me/actor`, { credentials: 'omit', headers: { Accept: 'application/json', Authorization: `Bearer ${o.token}` } });
+ if (r.ok) a = await r.json();
+ } catch { a = null; }
+ if (!box.isConnected) return;
+ box.textContent = '';
+ if (a && a.id && a.enabled !== false) {
+ const asks = Array.isArray(a.asks) ? a.asks : [];
+ const st = asks.length ? 'waiting' : ['working', 'resting', 'paused'].includes(a.status) ? a.status : 'resting';
+ const label = { working: 'Working', resting: 'Resting', paused: 'Paused', waiting: 'Waiting for you' }[st];
+ const b = h('button', 'ovh-actor'); b.type = 'button';
+ const face = h('span', 'ovh-face');
+ const pic = a.avatar && /^https:\/\//.test(String(a.avatar.url || '')) ? a.avatar.url : '';
+ if (pic) { const img = h('img'); img.alt = ''; img.src = pic; face.append(img); } else face.textContent = String(a.name || 'A').trim().charAt(0).toUpperCase() || 'A';
+ const txt = h('span', 'ovh-actor-txt');
+ const name = h('b', '', a.name || 'Your Actor');
+ const chip = h('span', `ovh-chip is-${st}`); chip.append(h('span', 'ovh-dot'), document.createTextNode(label));
+ name.append(chip);
+ const now = a.now || {};
+ txt.append(name, h('small', '', asks.length ? `It asks you: ${asks[0].text || ''}` : now.current_step ? String(now.current_step) : st === 'paused' ? 'Paused until you resume it' : 'Resting until its next goal is due'));
+ b.append(face, txt, h('span', 'ovh-go', asks.length ? 'Answer' : 'Open'));
+ b.addEventListener('click', () => {
+ if (root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function') { if (o.close) o.close(); try { root.OpenVibeNavbar.openDock('agent'); return; } catch { /* */ } }
+ location.href = `${base}/you`;
+ });
+ box.append(b);
+ return;
+ }
+ // No Actor yet: one invitation, which the person can put away.
+ try { if (localStorage.getItem(INVITE_KEY) === 'off') return; } catch { /* */ }
+ const card = h('div', 'ovh-actor'); card.setAttribute('role', 'group'); card.setAttribute('aria-label', 'Your own AI');
+ const face = h('span', 'ovh-face'); face.append(siteIcon('actor', 28));
+ const txt = h('span', 'ovh-actor-txt');
+ txt.append(h('b', '', 'Your own AI'), h('small', '', 'It works for you while you watch: finds work, posts, runs your channel.'));
+ const go = h('a', 'ovh-go', 'Set it up'); go.href = `${base}/you`;
+ const no = h('button', 'ovh-x', 'Not now'); no.type = 'button';
+ no.addEventListener('click', () => { try { localStorage.setItem(INVITE_KEY, 'off'); } catch { /* */ } box.textContent = ''; });
+ card.append(face, txt, go, no);
+ box.append(card);
+ if (typeof o.mountIcons === 'function') { try { o.mountIcons(box); } catch { /* */ } }
+ }
+
+ // ── Feed: what's new with you, and today ───────────────────────────────────────────────────────────────────
+ function wallTop(el, o) {
+ styles();
+ el.classList.add('ovh');
+ el.setAttribute('data-sec', 'wall-top');
+ el.textContent = '';
+ const compose = h('div'); const today = h('div');
+ el.append(compose, today);
+ // The composer shows when Community says this person can post (pulse viewer.can_post), so it never offers a
+ // post that would be refused.
+ o.pulseReady.then((p) => { if (p && p.viewer && p.viewer.can_post && signedIn(o) && compose.isConnected) composer(compose, o); });
+ fetch(`${o.apiBase || 'https://openvibe.network'}/api/v1/frame/today`, { credentials: 'omit', headers: { Accept: 'application/json' } })
+ .then((r) => (r.ok ? r.json() : null)).then((t) => { if (t && today.isConnected) paintToday(today, t); }).catch(() => {});
+ }
+ 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 first = String((o.user && (o.user.display_name || o.user.username)) || '').split(/\s+/)[0];
+ 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");
+ const post = h('button', 'ovh-post', 'Post'); post.type = 'submit'; post.disabled = true;
+ ta.addEventListener('input', () => { post.disabled = !ta.value.trim(); ta.style.height = 'auto'; ta.style.height = `${Math.min(160, ta.scrollHeight + 2)}px`; });
+ form.append(face, ta, post);
+ const note = h('p', 'ovh-note'); note.hidden = true;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const text = ta.value.trim(); if (!text) return;
+ post.disabled = true;
+ try {
+ const r = await fetch(`${o.communityBase || COMMUNITY}/api/v1/me/status`, { method: 'POST', credentials: 'omit', headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
+ 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 = ''; note.hidden = false; note.textContent = 'Posted. It is on your profile and in your followers’ feeds.';
+ if (o.onPosted) o.onPosted(j);
+ } catch (err) { note.hidden = false; note.textContent = String(err.message || err); post.disabled = false; }
+ });
+ box.append(form, note);
+ }
+ function spark(series) {
+ const v = (Array.isArray(series) ? series : []).map(Number).filter((x) => Number.isFinite(x)).slice(-14);
+ if (v.length < 2) return null;
+ const max = Math.max(...v, 1), w = 100, hgt = 18;
+ const pts = v.map((x, i) => `${(i / (v.length - 1)) * w},${hgt - (x / max) * (hgt - 2) - 1}`).join(' ');
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('aria-hidden', 'true');
+ const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ pl.setAttribute('points', pts); pl.setAttribute('fill', 'none'); pl.setAttribute('stroke', 'currentColor'); pl.setAttribute('stroke-width', '1.6'); pl.setAttribute('vector-effect', 'non-scaling-stroke');
+ svg.append(pl);
+ return svg;
+ }
+ function paintToday(box, t) {
+ const rows = [['people', 'People'], ['streams', 'Streams'], ['posts', 'Posts'], ['videos', 'New videos']]
+ .map(([k, label]) => [label, t[k] && typeof t[k] === 'object' ? t[k] : { today: t[k] }]).filter(([, v]) => v && Number.isFinite(Number(v.today)));
+ if (!rows.length) return;
+ const s = h('div', 'ovh-sec'); s.append(h('span', '', 'Today on OpenVibe')); box.append(s);
+ const grid = h('div', 'ovh-today');
+ rows.forEach(([label, v]) => {
+ const c = h('div', 'ovh-stat');
+ c.append(h('b', '', num(v.today)), h('small', '', label));
+ const sp = spark(v.series); if (sp) c.append(sp);
+ grid.append(c);
+ });
+ box.append(grid);
+ }
+
+ // ── Feed: the wall ─────────────────────────────────────────────────────────────────────────────────────────
+ const LIVE_RE = /^(.{1,60}?) went live(?::\s*(.*))?$/;
+ function pulseUrl(o, cursor) {
+ const p = new URLSearchParams({ limit: '20' });
+ if (signedIn(o)) p.set('for', 'me');
+ if (cursor) p.set('after', cursor);
+ return `${o.communityBase || COMMUNITY}/api/v1/pulse?${p}`;
+ }
+ function loadPulse(o, cursor) {
+ return fetch(pulseUrl(o, cursor), { credentials: 'omit', headers: Object.assign({ Accept: 'application/json' }, signedIn(o) ? { Authorization: `Bearer ${o.token}` } : {}) })
+ .then((r) => (r.ok ? r.json() : null)).catch(() => null);
+ }
+ function timeline(el, o) {
+ styles();
+ el.classList.add('ovh');
+ el.setAttribute('data-sec', 'wall');
+ el.textContent = '';
+ const s = h('div', 'ovh-sec'); s.append(h('span', '', 'On OpenVibe'));
+ const list = h('div', 'ovh-wall');
+ for (let k = 0; k < 3; k++) list.append(h('span', 'ovh-skel'));
+ el.append(s, list);
+ let next = null;
+ const more = h('button', 'ovh-all', 'Show more'); more.type = 'button'; more.hidden = true;
+ el.append(more);
+ const draw = (j, append) => {
+ if (!append) list.textContent = '';
+ const items = (j && Array.isArray(j.items) ? j.items : []).filter((x) => x && typeof x.title === 'string' && safe(x.url));
+ if (!items.length && !append) { list.append(h('p', 'ovh-note', 'Nothing new on the network yet today.')); return; }
+ // Went live, back to back: one row of names.
+ let lives = null;
+ items.forEach((x) => {
+ const m = LIVE_RE.exec(x.title.trim());
+ if (m) {
+ if (!lives) {
+ const row = h('div', 'ovh-entry');
+ const ic = h('span', 'ovh-ic'); ic.append(siteIcon('live', 26));
+ const txt = h('span', 'ovh-entry-txt'); txt.append(h('b', '', 'Went live recently'));
+ lives = h('span', 'ovh-lives'); txt.append(lives, h('small', '', ago(x.occurred_at || x.at)));
+ row.append(ic, txt); list.append(row);
+ }
+ if (lives.childNodes.length < 8) { const a = h('a', '', m[1]); a.href = safe(x.url); a.title = m[2] || ''; lives.append(a); }
+ return;
+ }
+ lives = null;
+ const a = h('a', 'ovh-entry'); a.href = safe(x.url);
+ const svc = String((x.ref && x.ref.service) || x.service || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 30);
+ const ic = h('span', 'ovh-ic'); ic.append(siteIcon(svc || 'ov', 26));
+ const txt = h('span', 'ovh-entry-txt');
+ txt.append(h('b', '', x.title.slice(0, 220)));
+ const meta = [ago(x.occurred_at || x.at), x.via ? `via ${String(x.via).slice(0, 40)}` : '', x.origin === 'ai' ? 'made by AI' : ''].filter(Boolean).join(' · ');
+ if (meta) txt.append(h('small', '', meta));
+ a.append(ic, txt); list.append(a);
+ });
+ next = j && j.next_cursor ? j.next_cursor : null;
+ more.hidden = !next;
+ if (typeof o.mountIcons === 'function') { try { o.mountIcons(el); } catch { /* */ } }
+ };
+ o.pulseReady.then((j) => { if (el.isConnected) draw(j, false); });
+ more.addEventListener('click', async () => { more.disabled = true; const j = await loadPulse(o, next); more.disabled = false; if (j) draw(j, true); });
+ }
+
+ root.OpenVibeHome = { home, wallTop, timeline, loadPulse, _defaultHome: defaultHome };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -60,6 +60,7 @@
@@ -60,6 +60,7 @@
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
+ "./ov-home": "./ov-home.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-glyphs": "./ov-glyphs.js",
"./ov-forms": "./ov-forms.js",
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-qr": "./ov-qr.js",
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
@@ -13,7 +13,7 @@ const ROOT = path.join(__dirname, '..');
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// went out without ?v=, so a release reached phones up to a day late (max-age=300, stale-while-revalidate=86400): the
// owner still saw the old menu hours after the new one shipped (11-07).
// owner still saw the old menu hours after the new one shipped (11-07).
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov-fx.js', 'ov-labels.js', 'ov-forms.js', 'ov-forms.css', 'ov-share.js', 'ov-share.css',
- 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js'];
+ 'search.js', 'dock.js', 'presence.js', 'panels.js', 'notification-ui.js', 'history.js', 'site-switcher.js', 'appbar.js', 'release-watch.js', 'ov-icons.js', 'sso-client.js', 'overlay.js', 'ov-home.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'action-center.js', 'dock-timeline.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
@@ -544,7 +544,18 @@
@@ -544,7 +544,18 @@
function paint(groups, note) {
function paint(groups, note) {
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
- groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds'].includes(g.as)).forEach((g) => {
+ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card', 'display', 'feeds', 'home', 'walltop', 'wall'].includes(g.as)).forEach((g) => {
+ if (g.as === 'home' || g.as === 'walltop' || g.as === 'wall') {
+ const H = root.OpenVibeHome; if (!H) return;
+ const kept = homeViews[g.as];
+ if (kept) { if (kept.api && kept.api.update) { try { kept.api.update(homeOpts()); } catch { /* keep what shows */ } } listEl.appendChild(kept.li); return; }
+ const li = document.createElement('li'); li.setAttribute('role', 'presentation');
+ let api = null;
+ try { api = (g.as === 'home' ? H.home : g.as === 'walltop' ? H.wallTop : H.timeline)(li, homeOpts()); } catch { return; }
+ homeViews[g.as] = { li, api };
+ listEl.appendChild(li);
+ return;
+ }
if (g.as === 'route') {
if (g.as === 'route') {
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
return;
return;
@@ -565,7 +576,7 @@
@@ -565,7 +576,7 @@
});
});
li.appendChild(row);
li.appendChild(row);
});
});
- const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = `Up to ${FEEDS_MAX}, in your order. Kept on this device.`; li.appendChild(note);
+ const note = document.createElement('p'); note.className = 'ovs-feeds-note'; note.textContent = 'Every feed shows unless you turn it off; the arrows set the order.'; li.appendChild(note);
listEl.appendChild(li);
listEl.appendChild(li);
return;
return;
}
}
@@ -694,7 +705,10 @@
@@ -694,7 +705,10 @@
// the title, the address (openvibe.website → Website, connect.openvibe.link → Connect), then a short title segment.
// the title, the address (openvibe.website → Website, connect.openvibe.link → Connect), then a short title segment.
const SHORT_NAME = 18;
const SHORT_NAME = 18;
const shortName = (t) => String(t || '').split(/\s[·|–—-]\s|:\s/)[0].replace(/^OpenVibe\./, '').trim();
const shortName = (t) => String(t || '').split(/\s[·|–—-]\s|:\s/)[0].replace(/^OpenVibe\./, '').trim();
- const brief = (t) => { const v = shortName(t); return v && v.length <= SHORT_NAME ? v : ''; };
+ // A page's word for itself ("Home", "Welcome") never names the site: the first tab is the site's name (owner, 11-09:
+ // "the first tab should be named after the site you're on… I meant the home options", which are Home in its list).
+ const GENERIC = /^(?:home|homepage|index|welcome|start|main|untitled|dashboard)$/i;
+ const brief = (t) => { const v = shortName(t); return v && v.length <= SHORT_NAME && !GENERIC.test(v) ? v : ''; };
const capital = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : '');
const capital = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : '');
function addressName(host) {
function addressName(host) {
const p = String(host || '').toLowerCase().replace(/^www\./, '').split('.');
const p = String(host || '').toLowerCase().replace(/^www\./, '').split('.');
@@ -802,7 +816,9 @@
@@ -802,7 +816,9 @@
let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;
let allOpen = false, hereOpen = false, leadHere = false, discoverPick = null, pulseData = null, feedsOpen = false;
// Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'
// Your feeds (11-07, the owner: "the user should be able to configure which data feed streams are shown"): the sites'
// cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.
// cards you chose and their order, on this device ({ on: ['media-hub:trending', …] }); none chosen = Network's first two.
- const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 4;
+ // Every feed shows until the person turns some off (11-09); this device keeps up to twelve in their order. Network's
+ // menu-feeds@1 keeps the first four until @2 (order and off) is live.
+ const FEEDS_KEY = 'ovs-feeds', FEEDS_MAX = 12, FEEDS_SYNC = 4;
const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
const feedId = (c) => `${String(c.site || '').slice(0, 30)}:${String(c.id || '').slice(0, 30)}`;
const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };
const feedPref = () => { try { const v = JSON.parse(localStorage.getItem(FEEDS_KEY) || 'null'); return v && Array.isArray(v.on) ? v.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX) : null; } catch { return null; } };
const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
const saveFeedsLocal = (on) => { try { localStorage.setItem(FEEDS_KEY, JSON.stringify({ on: on.slice(0, FEEDS_MAX) })); } catch { /* private window */ } };
@@ -814,7 +830,7 @@
@@ -814,7 +830,7 @@
function pushFeeds(on) {
function pushFeeds(on) {
if (!signedInCtx() || typeof fetch !== 'function') return;
if (!signedInCtx() || typeof fetch !== 'function') return;
clearTimeout(feedsPut);
clearTimeout(feedsPut);
- feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_MAX) }) }).catch(() => {}); }, 800);
+ feedsPut = setTimeout(() => { fetch(feedsUrl(), { method: 'PUT', headers: { Authorization: `Bearer ${ctx.token}`, 'Content-Type': 'application/json' }, credentials: 'omit', body: JSON.stringify({ on: on.slice(0, FEEDS_SYNC) }) }).catch(() => {}); }, 800);
}
}
const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };
const saveFeeds = (on) => { saveFeedsLocal(on); pushFeeds(on); };
async function syncFeeds() {
async function syncFeeds() {
@@ -827,6 +843,8 @@
@@ -827,6 +843,8 @@
const local = feedPref();
const local = feedPref();
if (Array.isArray(j.on)) {
if (Array.isArray(j.on)) {
const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);
const remote = j.on.filter((x) => typeof x === 'string').slice(0, FEEDS_MAX);
+ // Network's v1 copy holds the first four: a device whose longer list starts with them keeps its own.
+ if (local && remote.length === FEEDS_SYNC && local.length > remote.length && remote.every((id, i) => local[i] === id)) return;
if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }
if (JSON.stringify(remote) !== JSON.stringify(local)) { saveFeedsLocal(remote); if (el && el.classList.contains('is-open') && !input.value) render(''); }
} else if (local) pushFeeds(local);
} else if (local) pushFeeds(local);
} catch { /* the device copy stands */ }
} catch { /* the device copy stands */ }
@@ -1003,12 +1021,16 @@
@@ -1003,12 +1021,16 @@
function feedGroups() {
function feedGroups() {
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();
+ 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' });
// The network's cards: text and links only (never another site's code), checked here again, with an entry's https
// The network's cards: text and links only (never another site's code), checked here again, with an entry's https
// picture when it sent one; the person's choice and order, else Network's first two.
// picture when it sent one; the person's choice and order, else Network's first two.
const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
const feeds = f && Array.isArray(f.cards) ? f.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 12) : [];
const chosen = feedPref();
const chosen = feedPref();
- const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds.slice(0, 2);
+ // Every feed shows until the person chooses (owner, 11-09: "by default all the feeds show, but you can configure the order").
+ const netCards = chosen ? chosen.map((id) => feeds.find((c) => feedId(c) === id)).filter(Boolean) : feeds;
netCards.forEach((c) => {
netCards.forEach((c) => {
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url),
@@ -1025,13 +1047,19 @@
@@ -1025,13 +1047,19 @@
if (!site || (site.group === 'build' && !useOf(site.id))) continue;
if (!site || (site.group === 'build' && !useOf(site.id))) continue;
newest.set(e.service, { e, site });
newest.set(e.service, { e, site });
}
}
- const rows = [...newest.values()].sort((a, b) => Date.parse(b.e.deployed_at || b.e.committed_at || 0) - Date.parse(a.e.deployed_at || a.e.committed_at || 0)).slice(0, 4).map(({ e, site }) => {
- const at = e.deployed_at || e.committed_at;
+ // Each row (owner, 11-09: "keep the recently updated… see some info about the last update"): the site, what
+ // changed in plain words, when, "New" since this person's last visit and "Big update" for a major one; the
+ // heading counts today's real changes across the network.
+ const at = (e) => e.deployed_at || e.committed_at;
+ const rows = [...newest.values()].sort((a, b) => Date.parse(at(b.e) || 0) - Date.parse(at(a.e) || 0)).slice(0, 6).map(({ e, site }) => {
const seen = lastVisit(site.id);
const seen = lastVisit(site.id);
- const fresh = seen && Date.parse(at) > seen;
- return { title: site.name, meta: `${plainChange(e.subject)} · ${ago(at)}`, url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };
- }).filter((x) => x.url);
- if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
+ const fresh = seen && Date.parse(at(e)) > seen;
+ return { title: site.name, meta: [`Updated ${ago(at(e))}`, e.major ? 'Big update' : ''].filter(Boolean).join(' · '), snippet: plainChange(e.subject),
+ url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };
+ }).filter((x) => x.url && x.snippet);
+ const dayAgo = Date.now() - 86400000;
+ const today = changesData.filter((e) => e && e.service && !HOUSEKEEPING.test(String(e.subject || '')) && Date.parse(at(e) || 0) > dayAgo).length;
+ if (rows.length) groups.push({ title: today ? `What's new (${today} today)` : "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
}
}
// Two places new to this person, rotating fairly through the network (developer services only for builders).
// Two places new to this person, rotating fairly through the network (developer services only for builders).
const builder = all.some((x) => x.group === 'build' && useOf(x.id));
const builder = all.some((x) => x.group === 'build' && useOf(x.id));
@@ -1057,6 +1085,15 @@
@@ -1057,6 +1085,15 @@
function youGroups() {
function youGroups() {
const { take, ranked } = basis();
const { take, ranked } = basis();
const groups = [];
const groups = [];
+ if (homeLib()) {
+ groups.push({ as: 'home', items: [], section: 'home' });
+ const seenH = new Set();
+ const backH = mine ? mine.items.filter((x) => { if (seenH.has(x.url)) return false; seenH.add(x.url); return true; }).slice(0, 5)
+ .map((x) => ({ title: x.title, meta: [x.site, ago(x.at)].filter(Boolean).join(' · '), url: x.url, icon: 'back', kind: 'history' })) : [];
+ groups.push({ title: 'Jump back in', items: backH, section: 'back' });
+ if (typeof ctx.display === 'function') groups.push({ title: '', as: 'display', items: [], section: 'display' });
+ return groups;
+ }
if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
const yours = mine && mine.services.some((x) => x.count > 0);
const yours = mine && mine.services.some((x) => x.count > 0);
// Without their own history, developer services are not places to visit: they come up here once used.
// Without their own history, developer services are not places to visit: they come up here once used.
@@ -1075,6 +1112,40 @@
@@ -1075,6 +1112,40 @@
return groups;
return groups;
}
}
+ // ── The home screen and the wall (ov-home.js, 2026-11-09): loaded beside this file the first time You or Feed
+ // paints; until it arrives (or if it cannot), the tabs paint as before.
+ // One view of each per opening: a repaint (history or the catalogue arriving) keeps edit mode, a drag, a half-typed post.
+ let homeLoading = false, pulseReady = null, homeViews = {};
+ function homeLib() {
+ if (root.OpenVibeHome) return root.OpenVibeHome;
+ if (homeLoading || typeof ctx.sibling !== 'function') return null;
+ homeLoading = true;
+ try {
+ const sc = document.createElement('script'); sc.src = ctx.sibling('ov-home.js'); sc.async = true;
+ sc.onload = () => { if (el && el.classList.contains('is-open') && !input.value && (tab === 'you' || tab === 'feed')) render(''); };
+ document.head.appendChild(sc);
+ } catch { /* the old tabs stand */ }
+ return null;
+ }
+ const slugOf = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
+ /** What ov-home.js needs: every app (ranked), the person's own places, the tools, what they opened lately. */
+ function homeOpts() {
+ const { all, ranked, f } = basis();
+ const apps = ranked.map((x) => ({ id: x.id, title: x.name, url: x.url, site: x.icon || x.id, group: x.group })).filter((x) => x.url);
+ const yours = ctx.token && ctx.user && !ctx.user.is_anon ? youItems().filter((y) => y.title !== 'Agents').map((y) => ({ id: `you:${slugOf(y.title)}`, title: y.title, url: y.url, site: y.site, group: 'you' })) : [];
+ const tools = catalog ? catalog.tools.map((t) => [toolUse(t), t]).sort((a, b) => b[0] - a[0]).map(([, t]) => ({ id: `tool:${slugOf(t.name)}`, title: t.name, url: t.go || t.url, site: t.icon || 'tools' })) : [];
+ const seen = new Set(), recentIds = [];
+ for (const h of (mine ? mine.items : [])) {
+ const id = h.service === 'media' ? 'media-hub' : h.service;
+ if (!id || seen.has(id) || !all.some((x) => x.id === id)) continue;
+ seen.add(id); recentIds.push(id);
+ if (recentIds.length >= 5) break;
+ }
+ return { apps, yours, tools, toolCount: catalog ? catalog.tools.length : 0, recent: recentIds, groups: f && Array.isArray(f.groups) ? f.groups : [],
+ token: ctx.token, apiBase: ctx.apiBase, user: ctx.user, mountIcons: (scope) => { if (typeof ctx.icons === 'function') ctx.icons(scope); }, close,
+ pulseReady: pulseReady || (pulseReady = root.OpenVibeHome.loadPulse({ token: ctx.token, user: ctx.user })) };
+ }
+
function renderTab() {
function renderTab() {
paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
paint(tab === 'feed' ? feedGroups() : tab === 'you' ? youGroups() : hereGroups());
}
}
@@ -1234,6 +1305,7 @@
@@ -1234,6 +1305,7 @@
}
}
function open(opts = {}) {
function open(opts = {}) {
+ homeViews = {}; pulseReady = null; // a fresh home screen and wall each time the menu opens
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
ctx = opts || {};
ctx = opts || {};
if (!el) build();
if (!el) build();
@@ -68,18 +68,24 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -68,18 +68,24 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');
assert.deepStrictEqual(await p.evaluate(SHOWN), [], 'the feeds are the Feed tab\'s, not this site\'s');
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await p.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click(), true`);
await wait(150);
await wait(150);
- assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'by default, the Network\'s first two');
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds', 'Top stories', 'Today'], 'by default, every feed (owner, 11-09)');
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// 4.139.0: a row with a picture has no site icon beside it; in its group a row without one carries the icon in a
// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.
// picture-sized tile, so the titles line up. A group with no pictures keeps plain icons.
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
const looks = await p.evaluate(`[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="net:"]')].map((g) => [...document.querySelectorAll('.ovs-list li.ovs-item')].filter((li) => li.getAttribute('data-sec') === g.getAttribute('data-sec')).map((li) => { const a = li.firstElementChild; const first = a && a.firstElementChild; return first ? (first.tagName === 'IMG' ? 'img' : first.classList.contains('ovs-thumb--ico') ? 'tile' : first.classList.contains('ovs-ic') ? 'icon' : first.className) : '?'; }))`);
- assert.deepStrictEqual(looks, [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');
+ assert.deepStrictEqual(looks.slice(0, 2), [['img', 'img', 'tile'], ['icon', 'icon']], 'pictures replace icons; titles line up');
const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);
const fold = await p.evaluate(`(document.querySelector('[data-ovs-fold="feeds"]') || {}).textContent`);
- assert.strictEqual(fold, 'Choose your feeds (2 of 4)', 'every card with entries can be chosen; the empty one is not offered');
+ assert.strictEqual(fold, 'Choose your feeds (4 of 4)', 'every card with entries can be chosen; the empty one is not offered');
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-fold="feeds"]').click(), true`);
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
assert.ok(await until(p, `!!document.querySelector('.ovs-feeds')`), 'the chooser opens in the menu');
assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);
assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('.ovs-feed b')].map((b) => b.textContent)`), ['Trending', 'New worlds', 'Top stories', 'Today']);
- assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, false, false]);
+ assert.deepStrictEqual(await p.evaluate(`[...document.querySelectorAll('[data-ovs-feed]')].map((x) => x.checked)`), [true, true, true, true]);
+ // Turn two off: the rest show.
+ await p.evaluate(`document.querySelector('[data-ovs-feed="news:top"]').click(), true`);
+ await wait(80);
+ await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
+ await wait(100);
+ assert.deepStrictEqual(await p.evaluate(SHOWN), ['Trending', 'New worlds'], 'a feed turned off goes');
// Tick Today: it shows, after the two.
// Tick Today: it shows, after the two.
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
await p.evaluate(`document.querySelector('[data-ovs-feed="today:today"]').click(), true`);
@@ -125,7 +131,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
@@ -125,7 +131,7 @@ const SHOWN = `[...document.querySelectorAll('.ovs-list .ovs-group[data-sec^="ne
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
await q.close();
await q.close();
- console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
+ console.log('menu-feeds-chrome: every feed by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
} finally {
} finally {
await browser.close();
await browser.close();
server.close();
server.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+// The menu's You and Feed tabs, redone (plan 2026-11-09, ov-home.js), in a real headless Chrome:
+// You: the home grid of the most popular places until the person arranges it, the Tools and Build folders; a
+// long-press starts Edit and the same press drags an app to a new place; in Edit the arrow keys move the focused
+// app and − takes it off Home; All apps (N) puts it back with +; the Tools folder shows tools with their own icons
+// and the count of all; the arrangement is kept on the device and sent to Network (PUT /api/v1/me/home-screen);
+// signed in, the Actor strip is on top (or the invitation) and Recent shows the last places opened.
+// Feed: today's numbers when Network answers, the wall from Community's pulse with back-to-back "went live" items in
+// one row of names, Show more when there is more, and the composer only when Community says the person can post.
+// Nothing runs off a 390 px screen. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const ROOT = path.join(__dirname, '..');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('menu-home-screen-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const SITES = ['live', 'chat', 'community', 'video', 'pics', 'download', 'design', 'games', 'space', 'codes', 'news', 'tools', 'bot', 'fun']
+ .map((id, i) => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), url: `https://openvibe.${id}/`, icon: id, group: i < 10 ? 'play' : 'more' }))
+ .concat([{ id: 'ai', name: 'AI', url: 'https://openvibe.exchange/', icon: 'ai', group: 'build' }, { id: 'run', name: 'Run', url: 'https://openvibe.run/', icon: 'run', group: 'build' }]);
+const FRAME = { nav: SITES, groups: [{ id: 'play', name: 'Play' }, { id: 'more', name: 'More' }, { id: 'build', name: 'Build' }], soon: [], footer: {} };
+const CATALOG = { families: [], tools: Array.from({ length: 30 }, (_, i) => ({ name: `Tool ${i + 1}`, icon: ['pdf', 'image', 'qr', 'audio'][i % 4], url: `https://openvibe.tools/t${i + 1}`, tagline: 'a tool' })) };
+const PULSE = { items: [
+ { title: 'ada went live: Coding at night', url: 'https://openvibe.live/ada', occurred_at: new Date(Date.now() - 60e3).toISOString(), ref: { service: 'live' } },
+ { title: 'bo went live: Morning stream', url: 'https://openvibe.live/bo', occurred_at: new Date(Date.now() - 120e3).toISOString(), ref: { service: 'live' } },
+ { 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 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:,">
+<script>
+ window.__calls = [];
+ const J = (b, ok = true, status = 200) => Promise.resolve({ ok, status, json: async () => b });
+ const realFetch = window.fetch;
+ window.fetch = (u, o) => {
+ const s = String(u); window.__calls.push({ u: s, m: (o && o.method) || 'GET', body: o && o.body, auth: o && o.headers && (o.headers.Authorization || o.headers.authorization) || '' });
+ if (s.startsWith('https://openvibe.network/api/frame?')) return J(${JSON.stringify(FRAME)});
+ if (s.startsWith('https://openvibe.network/api/catalog.json')) return J(${JSON.stringify(CATALOG)});
+ if (s.startsWith('https://openvibe.network/api/v1/frame/today')) return J(${JSON.stringify(TODAY)});
+ if (s.startsWith('https://openvibe.network/api/v1/changelog')) return J({ entries: [
+ { service: 'live', subject: 'Go live from a phone: the Dock no longer covers the button (#711)', deployed_at: new Date(Date.now() - 3600e3).toISOString(), major: true },
+ { service: 'live', subject: 'openvibe-shared v4.158.0 (was v4.157.0) (#712)', deployed_at: new Date(Date.now() - 600e3).toISOString() },
+ { service: 'video', subject: 'Clips play from where you left off.', deployed_at: new Date(Date.now() - 7200e3).toISOString() } ] });
+ 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.community/api/v1/pulse')) 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);
+ return realFetch(u, o);
+ };
+</script>
+<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
+<script>document.addEventListener('DOMContentLoaded', function () { try { localStorage.clear(); sessionStorage.clear(); } catch (e) {} OpenVibeNavbar.init({ service: 'site', user: ${signed ? "{ id: 'u1', username: 'ada', display_name: 'Ada' }" : 'null'}, token: ${signed ? "'tok-1'" : 'null'}, links: [{ label: 'Rooms', href: '/rooms' }], countViews: false, notifications: false }); });</script>
+</body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const m = /^\/shared\/([a-z0-9-]+\.(?:js|css|woff2))$/.exec(u.pathname);
+ if (m) {
+ if (!fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'content-type': m[1].endsWith('.js') ? 'application/javascript' : 'application/octet-stream' }); return res.end(fs.readFileSync(path.join(ROOT, m[1])));
+ }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(page(u.searchParams.has('signed')));
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-home-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (p, expr, ms = 6000) => { const end = Date.now() + ms; for (;;) { const v = await p.evaluate(expr); if (v || Date.now() > end) return v; await wait(50); } };
+ const openTab = async (p, which) => {
+ await p.evaluate(`(document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn')).click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovs.is-open [data-tab="${which}"]')`), 'the menu opened');
+ await p.evaluate(`document.querySelector('.ovs [data-tab="${which}"]').click(), true`);
+ };
+ const order = (p) => p.evaluate(`[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id'))`);
+ const center = (p, sel) => p.evaluate(`(() => { const r = document.querySelector(${JSON.stringify(sel)}).getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);
+ try {
+ const context = await browser.newContext();
+ const blocked = ['*openvibe.tools*', '*openvibe.media*', '*jsdelivr*'];
+
+ // ── Signed out: the most popular places, the folders, edit, drag, keys, remove and put back.
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });
+ await p.goto(`${base}/`, { maxMs: 6000 });
+ assert.ok(await until(p, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
+ await openTab(p, 'you');
+ assert.ok(await until(p, `document.querySelectorAll('.ovh-grid .ovh-app').length >= 12`), 'the home grid painted');
+ const first = await order(p);
+ assert.strictEqual(first.length, 12, `twelve on Home by default (${first})`);
+ assert.deepStrictEqual(first.slice(-2), ['folder:tools', 'folder:build'], 'Tools and Build are folders at the end');
+ assert.ok(!first.includes('ai') && !first.includes('run'), 'developer services wait in the Build folder');
+ assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Popular on OpenVibe', 'until arranged, it says what it is');
+ assert.notStrictEqual(await p.evaluate(`document.querySelector('.ovs [data-tab="here"]').textContent`), 'Home', 'a page titled Home never names the first tab');
+ assert.ok(await p.evaluate(`!document.querySelector('[data-sec="recent"]') && ![...document.querySelectorAll('.ovs-group')].some((g) => /Recent searches/.test(g.textContent))`), 'no Recent searches');
+ assert.ok(await p.evaluate(`/All apps \\(\\d+\\)/.test(document.querySelector('.ovh-all').textContent)`), 'All apps says how many');
+ assert.ok(await p.evaluate(`!document.querySelector('.ovh-actor')`), 'signed out: no Actor strip');
+
+ // A long-press starts Edit, and the same press drags the first app onto the third's place.
+ await wait(400); // the menu's opening animation and the catalogue's repaint are over before measuring
+ const a0 = await center(p, `.ovh-grid .ovh-app[data-id="${first[0]}"]`);
+ const a2 = await center(p, `.ovh-grid .ovh-app[data-id="${first[2]}"]`);
+ await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x, y: a0.y });
+ await p.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a0.x, y: a0.y, button: 'left', clickCount: 1 });
+ await wait(650);
+ assert.ok(await p.evaluate(`!!document.querySelector('.ovh.is-edit')`), 'a long-press starts Edit');
+ for (let k = 1; k <= 6; k++) { await p.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a0.x + ((a2.x - a0.x) * k) / 6, y: a0.y + ((a2.y - a0.y) * k) / 6, button: 'left' }); await wait(40); }
+ await p.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: a2.x, y: a2.y, button: 'left', clickCount: 1 });
+ await wait(450); // a person's pause after a drop (a tap within 300 ms of it is taken as part of the drag)
+ const dragged = await order(p);
+ assert.strictEqual(dragged.indexOf(first[0]), 2, `the app moved to where it was dropped (${dragged})`);
+ assert.ok(await p.evaluate(`location.pathname === '/'`), 'the long-press did not open the app');
+ const stored = await p.evaluate(`JSON.parse(localStorage.getItem('ovs-home')).home`);
+ assert.deepStrictEqual(stored, dragged, 'the arrangement is kept on the device');
+ assert.strictEqual(await p.evaluate(`(document.querySelector('.ovh-sec span') || {}).textContent`), 'Your home', 'once arranged it is yours');
+
+ // Keys: the focused app moves; Delete takes it off Home.
+ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${dragged[0]}"]').focus(), true`);
+ await p.evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })), true`);
+ const keyed = await order(p);
+ assert.strictEqual(keyed.indexOf(dragged[0]), 1, 'ArrowRight moves the focused app one later');
+ assert.strictEqual(await p.evaluate(`document.activeElement && document.activeElement.getAttribute('data-id')`), dragged[0], 'and it keeps the focus');
+ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge').click(), true`);
+ const removed = await order(p);
+ assert.ok(!removed.includes(keyed[0]) && removed.length === 11, '− takes it off Home');
+ // Done, then All apps puts it back.
+ await p.evaluate(`[...document.querySelectorAll('.ovh-sec button')].find((b) => b.textContent === 'Done').click(), true`);
+ await p.evaluate(`document.querySelector('.ovh-all').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovh-filter')`), 'the drawer opened');
+ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="${keyed[0]}"] .ovh-badge.is-add').click(), true`);
+ await p.evaluate(`document.querySelector('.ovh-back').click(), true`);
+ assert.ok((await order(p)).includes(keyed[0]), 'All apps put it back on Home');
+
+ // The Tools folder: tools with their own icons, and the count of all.
+ await p.evaluate(`document.querySelector('.ovh-grid .ovh-app[data-id="folder:tools"]').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovh-head b') && document.querySelector('.ovh-head b').textContent === 'Tools'`), 'the folder opened');
+ const folder = await p.evaluate(`({ n: document.querySelectorAll('.ovh-grid .ovh-app').length, icons: [...document.querySelectorAll('.ovh-grid .ovh-app .ov-icon')].slice(0, 4).map((i) => i.getAttribute('data-icon')), more: (document.querySelector('a.ovh-all') || {}).textContent })`);
+ assert.strictEqual(folder.n, 24, 'the folder shows two dozen tools');
+ assert.deepStrictEqual(folder.icons, ['pdf', 'image', 'qr', 'audio'], 'each tool wears its own icon');
+ assert.strictEqual(folder.more, 'All 30 tools →', 'and says how many there are in all');
+ assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on You');
+
+ // ── Feed, signed out: today, the wall, went live in one row, text only, Show more; no composer.
+ await p.evaluate(`document.querySelector('.ovs [data-tab="feed"]').click(), true`);
+ assert.ok(await until(p, `!!document.querySelector('.ovh-wall .ovh-entry')`), 'the wall painted');
+ const wall = await p.evaluate(`({ today: [...document.querySelectorAll('.ovh-stat b')].map((b) => b.textContent), lives: [...document.querySelectorAll('.ovh-lives a')].map((a) => a.textContent),
+ entries: [...document.querySelectorAll('.ovh-wall .ovh-entry-txt b')].map((b) => b.textContent), imgs: document.querySelectorAll('.ovh-wall img').length, pwned: !!window.pwned,
+ compose: !!document.querySelector('.ovh-compose'), more: !document.querySelector('.ovh-all[hidden]') && !!document.querySelector('[data-sec="wall"] .ovh-all') })`);
+ assert.deepStrictEqual(wall.today, ['1,204', '37', '210', '12'], 'today on OpenVibe');
+ assert.deepStrictEqual(wall.lives, ['ada', 'bo'], 'back-to-back went live is one row of names');
+ assert.strictEqual(wall.entries[0], 'Went live recently');
+ assert.ok(wall.entries.includes('<img src=x onerror="window.pwned=1"> a post') && wall.imgs === 0 && !wall.pwned, 'what people wrote is text');
+ assert.strictEqual(wall.compose, false, 'signed out: no composer');
+ assert.ok(wall.more, 'Show more when there is more');
+ await p.evaluate(`document.querySelector('[data-sec="wall"] .ovh-all').click(), true`);
+ assert.ok(await until(p, `[...document.querySelectorAll('.ovh-wall .ovh-entry-txt b')].some((b) => b.textContent === 'An older post')`), 'Show more adds the next page');
+ const news = await p.evaluate(`({ head: ([...document.querySelectorAll('.ovs-group')].find((g) => /What's new/.test(g.textContent)) || {}).textContent || '',
+ rows: [...document.querySelectorAll('.ovs-item[data-sec="news"]')].map((r) => [r.querySelector('.ovs-title').firstChild.textContent, (r.querySelector('.ovs-meta') || {}).textContent, (r.querySelector('.ovs-snip') || {}).textContent]) })`);
+ assert.match(news.head, /What's new \(2 today\)/, 'the recently updated stay on the Feed, counted (library bumps left out)');
+ assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
+ assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
+ assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed');
+ await p.close();
+
+ // ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
+ const s = await h.openPage(browser, context, { width: 390, height: 844, block: blocked });
+ await s.goto(`${base}/?signed=1`, { maxMs: 6000 });
+ assert.ok(await until(s, `!!document.getElementById('openvibe-burger')`), 'the navbar is up');
+ await openTab(s, 'you');
+ assert.ok(await until(s, `!!document.querySelector('.ovh-actor')`), 'the Actor strip is on top');
+ assert.match(await s.evaluate(`document.querySelector('.ovh-actor').textContent`), /Juniper.*Working.*Reading job posts/, 'it names the Actor, its state and its step');
+ assert.ok(await until(s, `document.querySelectorAll('.ovh-recent .ovh-app').length === 2`), 'Recent shows the places opened lately');
+ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-recent .ovh-app')].map((a) => a.getAttribute('data-id'))`), ['live', 'pics']);
+ assert.strictEqual(await s.evaluate(`(document.querySelector('.ovh-sec:not(:first-child) span, .ovh-grid:not(.ovh-recent)') && [...document.querySelectorAll('.ovh-sec span')].map((x) => x.textContent).find((x) => /home|Popular/i.test(x)))`), 'Your home', 'signed in, Home is theirs from the start');
+ assert.deepStrictEqual(await s.evaluate(`[...document.querySelectorAll('.ovh-folder[data-id="folder:tools"] .ov-icon')].map((i) => i.getAttribute('data-icon'))`), ['pdf', 'image', 'qr', 'audio'], 'the Tools folder shows its tools\' own icons');
+ await s.evaluate(`document.querySelector('.ovh-sec button').click(), 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');
+ 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');
+ assert.strictEqual(await s.evaluate(`document.querySelector('.ovh-compose textarea').placeholder`), "What's new, Ada?");
+ await s.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, Network sync ok');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+// ov-home.js, read as source (the Chrome test, menu-home-screen-chrome.test.js, drives it): what it must never do and
+// the shapes it keeps. Text is set as text; links are https or same-site; the arrangement is kept on the device and on
+// Network; the composer only posts to Community with the person's bearer and no cookies; the wall reads the pulse.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'ov-home.js'), 'utf8');
+assert.ok(/root\.OpenVibeHome = \{ home, wallTop, timeline, loadPulse/.test(src), 'it exposes the home screen and the wall');
+assert.ok(!/\.innerHTML\s*=/.test(src) && !/insertAdjacentHTML/.test(src), 'it never writes markup');
+assert.ok(/const safe = \(u\) =>/.test(src) && /x\.protocol === 'https:' \|\| x\.origin === location\.origin/.test(src), 'links are https or this site only');
+assert.ok(/localStorage\.setItem\(HOME_KEY/.test(src) && /\/api\/v1\/me\/home-screen/.test(src), 'the arrangement is kept here and on Network');
+assert.ok(/credentials: 'omit'/.test(src) && !/credentials: 'include'/.test(src), 'no cookies go with any call');
+assert.ok(/\/api\/v1\/me\/status/.test(src) && /viewer\.can_post/.test(src), 'the composer posts to Community only when it says the person can');
+assert.ok(/\/api\/v1\/pulse\?/.test(src) && /for', 'me'/.test(src), 'the wall reads the pulse, personal when signed in');
+assert.ok(/LONG_MS = 450/.test(src) && /ArrowLeft/.test(src) && /Delete/.test(src), 'long-press, and keys for people who cannot drag');
+assert.ok(/prefers-reduced-motion: reduce/.test(src), 'the wiggle and the pulse stop for reduced motion');
+assert.ok(!/@keyframes [a-z-]*spin|ovh-spin|rotate\(360deg\)/i.test(src), 'no spinners');
+
+// The default Home without the network's frame: the person's places first, then the popular places, then the folders.
+global.window = undefined;
+const sandbox = {};
+new Function('globalThis', 'document', src.replace("if (typeof document === 'undefined' || root.OpenVibeHome) return;", 'if (root.OpenVibeHome) return;'))(sandbox, {});
+const H = sandbox.OpenVibeHome;
+const apps = ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot'].map((id) => ({ id, group: 'play' })).concat([{ id: 'ai', group: 'build' }]);
+assert.deepStrictEqual(H._defaultHome({ apps, yours: [] }), ['live', 'chat', 'pics', 'video', 'games', 'space', 'design', 'news', 'fun', 'bot', 'folder:tools', 'folder:build'], 'signed out: ten popular places and the two folders');
+assert.deepStrictEqual(H._defaultHome({ apps, yours: [{ id: 'you:vibe' }, { id: 'you:files' }, { id: 'you:account' }] }).slice(0, 3), ['you:vibe', 'you:files', 'live'], 'signed in: their own places first');
+console.log('ov-home: text only, safe links, kept here and on Network, no cookies, the composer and wall, long-press and keys, the default Home');