openvibers/openvibe.shared Commits

The menu's You is a home screen, Feed is a wall (#354)

OP OpenVibers committed 4 days ago · 6c8847fe63 · 10 files, +958 −20

You (ov-home.js, loaded the first time You or Feed opens): your Actor on top, Recent, then Home: your places in a
grid, the most popular until you arrange it, Tools (each tool with its own icon) and Build as folders. A long-press
or Edit starts arranging: drag to move, arrow keys move the focused app, minus takes it off Home, All apps (N) puts
it back. Kept on the device and on Network's /api/v1/me/home-screen. Recent searches leave You.

Feed: the composer (when Community says you can post), today's numbers, the live bar, the network's pulse with
back-to-back went-live in one row, Show more; every feed card shows until turned off; What's new names what changed,
when, New since your visit and Big update, and counts today's changes. The first tab is the site's name, never
Home or Welcome.

Tests: menu-home-screen-chrome (long-press and drag, keys, remove and put back, folders, the wall, today, the
composer, Network sync), ov-home, menu-feeds-chrome (every feed by default).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d2cc856..b67ecd0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/files.js b/files.js
iindex d985dc3..e3aa10d 100644
---- a/files.js
++++ b/files.js
@@@ -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
ddiff --git a/navbar.js b/navbar.js
iindex 9cdc417..ecac049 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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'); } });
ddiff --git a/ov-home.js b/ov-home.js
nnew file mode 100644
iindex 0000000..3ad667b
---- /dev/null
++++ b/ov-home.js
@@@ -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);
ddiff --git a/package.json b/package.json
iindex c835064..d0c7915 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex 8ffbdb0..e7a95be 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.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'] },
ddiff --git a/search.js b/search.js
iindex 16f4408..568f858 100644
---- a/search.js
++++ b/search.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 opens
if (typeof document === 'undefined') return;
ctx = opts || {};
if (!el) build();
ddiff --git a/test/menu-feeds-chrome.test.js b/test/menu-feeds-chrome.test.js
iindex 512056c..4949538 100644
---- a/test/menu-feeds-chrome.test.js
++++ b/test/menu-feeds-chrome.test.js
@@@ -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');
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^="ne
assert.ok(await until(q, `window.__puts.length > 0`, 4000), 'the change is saved to Network');
assert.deepStrictEqual(await q.evaluate(`window.__puts[window.__puts.length - 1]`), { on: ['news:top', 'today:today', 'games:new-worlds'] });
await q.close();
-- console.log('menu-feeds-chrome: two by default, every card choosable, tick, reorder, four at most, kept on this device and on Network for a signed-in person');
++ 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();
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
nnew file mode 100644
iindex 0000000..329bbfe
---- /dev/null
++++ b/test/menu-home-screen-chrome.test.js
@@@ -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); });
ddiff --git a/test/ov-home.test.js b/test/ov-home.test.js
nnew file mode 100644
iindex 0000000..e9b6db7
---- /dev/null
++++ b/test/ov-home.test.js
@@@ -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');