openvibers/openvibe.network Commits

Your account home is "You, today": who you follow is live, where you left off, your sites, your money (L43) (#460)

OP OpenVibers committed 13 days ago · 6cab2e9e43 · 12 files, +601 −63

* Your account home is "You, today": who you follow is live, where you left off, your sites by your own use, your money (L43)

Replaces the Quick Access tiles with blocks built from real data, each left out when empty: live follows (new
GET /api/v1/me/live-follows, Live's public list held 10 s), history, sites ranked by pins/history/recents with two to
try, OpenCoins and Vibes (#coins, where the navbar's chip points), and your numbers. Settings follow below; the header
drops the email.

* Review the live-follows call to Live (SSRF inventory)

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex c784077d..867d76c9 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -220,39 +220,90 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@media(max-width:600px){
.page{padding:12px}
-- .page-header{flex-direction:column;text-align:center}
-- .page-header .info .badges{justify-content:center}
++ .page-header{gap:12px;margin-bottom:24px}
++ .page-header .avatar{width:56px;height:56px}
++ .page-header .info h1{font-size:20px}
.notif-grid{grid-template-columns:1fr auto auto}
.notif-grid .col-email,.notif-grid .col-toast,.notif-grid .col-digest{display:none}
}
--/* Dashboard Showcase */
--@keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-10px); } }
--@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
--.dashboard-showcase { margin: 32px 0; padding: 28px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
--.showcase-header { text-align: center; margin-bottom: 24px; }
--.showcase-header h2 { font-size: 1.3rem; font-weight: 800; margin-bottom: 6px; }
--.showcase-header p { font-size: 12px; color: var(--text-muted); }
--.showcase-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
--.showcase-card { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px; border-radius: 10px; background: var(--bg-input); border: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; gap: 8px; animation: float ease-in-out infinite; transition: all 0.3s ease; }
--.showcase-card:nth-child(1) { animation-delay: 0s; animation-duration: 3s; }
--.showcase-card:nth-child(2) { animation-delay: 0.15s; animation-duration: 3.3s; }
--.showcase-card:nth-child(3) { animation-delay: 0.3s; animation-duration: 3.6s; }
--.showcase-card:nth-child(4) { animation-delay: 0.45s; animation-duration: 3.2s; }
--.showcase-card:hover { transform: translateY(-4px); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
--.showcase-card-icon { font-size: 1.4rem; color: var(--accent-light); }
--.showcase-card-label { font-size: 11px; font-weight: 700; text-align: center; }
--.quick-access { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
--.quick-link { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); text-decoration: none; color: var(--text-secondary); font-size: 10px; font-weight: 600; text-align: center; transition: all 0.25s ease; animation: fadeInScale 0.5s ease both; }
--.quick-link i { font-size: 1rem; margin-bottom: 4px; }
--.quick-link:nth-child(1) { animation-delay: 0.1s; }
--.quick-link:nth-child(2) { animation-delay: 0.15s; }
--.quick-link:nth-child(3) { animation-delay: 0.2s; }
--.quick-link:nth-child(4) { animation-delay: 0.25s; }
--.quick-link:nth-child(5) { animation-delay: 0.3s; }
--.quick-link:nth-child(6) { animation-delay: 0.35s; }
--.quick-link:hover { border-color: var(--accent); color: var(--accent-light); background: color-mix(in srgb, var(--accent) 8%, transparent); }
--@media(max-width:600px){ .showcase-grid { grid-template-columns: repeat(2, 1fr); } .quick-access { grid-template-columns: repeat(3, 1fr); } }
++/* You, today (plan L43, 2026-10-31): the account home. What is live for you, what you were doing, your sites, your
++ money and your numbers, then Settings. Rails scroll sideways on a phone (never hidden content), two columns from 760 px. */
++.page-header .info .greeting { font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 2px; }
++.page-header .info .greeting:empty { display: none; }
++.page-header .info .badges:empty { display: none; }
++.td-settings-btn { flex: none; align-self: center; text-decoration: none; }
++.td { display: grid; gap: 22px; margin: -8px 0 36px; }
++.td-sec { display: grid; gap: 10px; min-width: 0; }
++.td-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
++.td-h h2 { display: flex; align-items: center; gap: 8px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
++.td-sub { font-size: 12.5px; color: var(--text-muted); }
++.td-more { font-size: 13px; font-weight: 650; color: var(--accent-light); text-decoration: none; white-space: nowrap; }
++.td-more:hover { text-decoration: underline; }
++.td-pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--live-red); box-shadow: 0 0 0 0 color-mix(in srgb, var(--live-red) 60%, transparent); animation: tdPulse 2s ease-out infinite; }
++@keyframes tdPulse { to { box-shadow: 0 0 0 8px transparent; } }
++.td-rail { display: grid; grid-auto-flow: column; gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: thin; padding: 2px 2px 8px; margin: 0 -2px; }
++.td-rail > * { scroll-snap-align: start; }
++#td-live { grid-auto-columns: minmax(220px, 260px); }
++#td-continue-list { grid-auto-columns: minmax(180px, 210px); }
++.td-live { display: grid; align-content: start; gap: 4px; color: var(--text); text-decoration: none; min-width: 0; }
++.td-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 30%, var(--bg-card)), var(--bg-card)); border: 1px solid var(--border); }
++.td-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }
++.td-live:hover .td-thumb img { transform: scale(1.03); }
++.td-live-dot { position: absolute; top: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: var(--live-red); color: #fff; font-size: 10.5px; font-weight: 800; letter-spacing: .06em; }
++.td-viewers { position: absolute; bottom: 8px; left: 8px; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .62); color: #fff; font-size: 11.5px; font-weight: 700; }
++.td-live-name { font-size: 14px; font-weight: 750; margin-top: 4px; }
++.td-live-title, .td-cont-t { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++.td-live-title { font-size: 12.5px; color: var(--text-secondary); line-height: 1.35; }
++.td-chips { display: flex; flex-wrap: wrap; gap: 8px; }
++.td-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px; font-weight: 650; text-decoration: none; cursor: pointer; }
++.td-chip:hover, .td-chip:focus-visible { border-color: var(--accent); }
++.td-chip-hot { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); background: color-mix(in srgb, var(--accent) 12%, var(--bg-card)); }
++.td-card, .td-box { border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.td-cont { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; padding: 12px; min-height: 124px; color: var(--text); text-decoration: none; min-width: 0; }
++.td-cont:hover, .td-site:hover, .td-try:hover { border-color: var(--accent); }
++.td-cont-ic, .td-site-ic { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); font-size: 14px; flex: none; }
++.td-cont-t { font-size: 13.5px; font-weight: 650; line-height: 1.35; }
++.td-cont-m { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.td-grid { display: grid; align-items: start; gap: 16px; }
++.td-box { display: grid; align-content: start; gap: 12px; padding: 16px; min-width: 0; }
++.td-sites { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
++.td-site { display: grid; justify-items: center; gap: 6px; padding: 10px 4px; border-radius: 12px; border: 1px solid transparent; color: var(--text); text-decoration: none; font-size: 12px; font-weight: 650; min-width: 0; }
++.td-site span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.td-site .td-site-ic { width: 40px; height: 40px; border-radius: 12px; font-size: 16px; }
++.td-tries { display: grid; gap: 8px; }
++.td-tries::before { content: "Try next"; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
++.td-try { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; border: 1px solid var(--border); color: var(--text); text-decoration: none; }
++.td-try-t { display: grid; gap: 1px; min-width: 0; }
++.td-try-t b { font-size: 13.5px; }
++.td-try-t small { font-size: 12px; color: var(--text-muted); }
++.td-bal { display: flex; align-items: baseline; gap: 8px; }
++.td-bal b { font-size: 1.6rem; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
++.td-bal span:last-child { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
++.td-coin { align-self: center; width: 14px; height: 14px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #fde68a, #f59e0b 60%, #b45309); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15); }
++.td-coin-v { background: radial-gradient(circle at 35% 35%, #bbf7d0, #22c55e 60%, #15803d); }
++.td-money-acts { display: flex; flex-wrap: wrap; gap: 8px; }
++.td-stuff { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding-top: 14px; border-top: 1px solid var(--border); }
++.td-stats { display: flex; flex-wrap: wrap; gap: 8px 22px; }
++.td-stats:empty { display: none; }
++.td-stat { display: inline-flex; align-items: baseline; gap: 6px; padding: 0; border: 0; background: none; color: var(--text-secondary); font: inherit; font-size: 13px; cursor: default; }
++button.td-stat { cursor: pointer; }
++button.td-stat:hover span { color: var(--accent-light); }
++.td-stat b { font-size: 1.05rem; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
++.td .recent-tools { margin-top: 0; }
++.settings-h { font-size: 1.05rem; font-weight: 800; margin: 0 0 12px; scroll-margin-top: 72px; }
++@media (min-width: 760px) {
++ .td-grid { grid-template-columns: 1.25fr 1fr; }
++}
++@media (max-width: 600px) {
++ .td-sites { grid-template-columns: repeat(4, minmax(0, 1fr)); }
++ .td-settings-btn { padding: 8px 10px; font-size: 0; gap: 0; }
++ .td-settings-btn i { font-size: 16px; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .td-pulse { animation: none; }
++ .td-thumb img { transition: none; }
++}
.recent-tools { margin-top: 14px; }
.recent-tools h3 { font-size: 12px; font-weight: 700; color: var(--text-secondary); margin: 0 0 8px; display: flex; gap: 6px; align-items: center; }
.recent-tools-list { display: flex; flex-wrap: wrap; gap: 6px; }
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
iindex 0423cce0..d4912a85 100644
---- a/public/js/my/boot.js
++++ b/public/js/my/boot.js
@@@ -9,8 +9,8 @@
OpenVibeAccountSwitcher.init({ apiBase: API });
const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
--buildDashboardShowcase();
if (_signedIn) {
++ loadToday();
openInitialSection();
refreshApprovalsBadge();
}
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 288bc126..28e4d951 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -109,11 +109,11 @@ function renderHeader(user) {
document.getElementById('user-name').textContent = user.display_name || user.username;
document.getElementById('user-subtitle').textContent = user.is_anon
? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
-- : '@' + user.username + (user.email ? ' · ' + user.email : '');
++ : '@' + user.username; // never the email: this page may be on a stream (it is in Profile below)
const badges = document.getElementById('user-badges');
badges.innerHTML = user.is_anon
? `<span class="badge badge-anon">anonymous</span>`
-- : `<span class="badge badge-${user.role}">${user.role}</span>`;
++ : (user.role && user.role !== 'user' ? `<span class="badge badge-${user.role}">${user.role.replace(/_/g, ' ')}</span>` : '');
if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
}
ddiff --git a/public/js/my/today.js b/public/js/my/today.js
nnew file mode 100644
iindex 00000000..a343ffd5
---- /dev/null
++++ b/public/js/my/today.js
@@@ -0,0 +1,190 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): "You, today" (plan L43, 2026-10-31).
++ The owner: "I hate this dashboard… redo the whole thing considering everything we have now and what's useful".
++ What is happening for you right now, then what you were doing, your money, your sites ranked by your own use, and
++ your numbers; the settings follow below. Every block is real data, and a block with nothing in it is left out.
++ Classic script, global scope: my/core.js's apiFetch and showSection; my/boot.js calls loadToday(). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// [label, icon, address] for each site the history knows.
++const TD_SITES = {
++ live: ['Live', 'fa-tower-broadcast', 'https://openvibe.live'], tools: ['Tools', 'fa-screwdriver-wrench', 'https://openvibe.tools'],
++ games: ['Games', 'fa-gamepad', 'https://openvibe.games'], media: ['Media', 'fa-photo-film', 'https://openvibe.media'],
++ network: ['Network', 'fa-circle-nodes', 'https://openvibe.network'], community: ['Community', 'fa-users', 'https://openvibe.community'],
++ space: ['Space', 'fa-shapes', 'https://openvibe.space'], chat: ['Chat', 'fa-comments', 'https://openvibe.chat'],
++ codes: ['Codes', 'fa-code', 'https://openvibe.codes'], blog: ['Blog', 'fa-pen-nib', 'https://openvibe.blog'],
++ wiki: ['Wiki', 'fa-book-open', 'https://openvibe.wiki'], news: ['News', 'fa-newspaper', 'https://openvibe.news'],
++ reviews: ['Reviews', 'fa-star', 'https://openvibe.reviews'], tips: ['Tips', 'fa-hand-holding-heart', 'https://openvibe.tips'],
++ vip: ['VIP', 'fa-crown', 'https://openvibe.vip'], trade: ['Trade', 'fa-chart-line', 'https://openvibe.trade'],
++ host: ['Host', 'fa-server', 'https://openvibe.host'], deals: ['Deals', 'fa-tags', 'https://openvibe.deals'],
++ coupons: ['Coupons', 'fa-ticket', 'https://openvibe.coupons'], openre: ['Restream', 'fa-arrows-split-up-and-left', 'https://openre.stream'],
++ design: ['Design', 'fa-palette', 'https://openvibe.design'], report: ['Report', 'fa-chart-pie', 'https://openvibe.report'],
++};
++// What to try next, most used across the network first, each with the reason it is worth a look.
++const TD_TRY = [
++ ['live', 'Go live from your browser', 'No OBS and no follower minimum'],
++ ['tools', 'Free tools, no sign-up', 'Hundreds of them, each open and simple'],
++ ['games', 'Play in your browser', 'Quick games, alone or with friends'],
++ ['design', 'Make your own look', 'Themes and icons that follow you everywhere'],
++ ['chat', 'One inbox for every site', 'Messages from Live, Games and the rest'],
++ ['codes', 'Code with AI in the cloud', 'Bring your own Claude Code, Codex and more'],
++ ['wiki', 'Look anything up', 'An open wiki the community keeps'],
++ ['news', 'The news, briefly', 'Many sources on one page'],
++];
++
++function tdEl(tag, cls, kids, attrs) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
++ (kids || []).forEach((c) => { if (c != null) n.append(c); });
++ return n;
++}
++const tdIcon = (name) => tdEl('i', `fa-solid ${name}`, [], { 'aria-hidden': 'true' });
++function tdAgo(at) {
++ const t = Date.parse(at && !/[zZ]|[+-]\d\d:?\d\d$/.test(at) ? String(at).replace(' ', 'T') + 'Z' : at);
++ if (!Number.isFinite(t)) return '';
++ const s = Math.max(0, (Date.now() - t) / 1000);
++ if (s < 60) return 'just now';
++ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
++ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
++ if (s < 86400 * 7) return `${Math.floor(s / 86400)} d ago`;
++ return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
++}
++const tdNum = (n) => Number(n || 0).toLocaleString();
++const tdSafeUrl = (u) => (/^https:\/\//.test(String(u || '')) ? String(u) : null);
++
++/** The greeting above your name, by your own clock. */
++function renderGreeting() {
++ const h = new Date().getHours();
++ const el = document.getElementById('user-greeting');
++ if (el) el.textContent = h < 5 ? 'Up late' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
++}
++
++/** Now: the people you follow who are live, your unread notifications, and Go live for a streamer. */
++function renderNow(live, unread) {
++ const box = document.getElementById('td-now');
++ const rail = document.getElementById('td-live');
++ const chips = document.getElementById('td-chips');
++ const people = (live && live.live) || [];
++ rail.replaceChildren(...people.map((p) => {
++ const thumb = tdEl('span', 'td-thumb', [tdEl('span', 'td-live-dot', ['LIVE'])]);
++ const src = tdSafeUrl(p.thumbnail);
++ if (src) { const img = tdEl('img', '', [], { src, alt: '', loading: 'lazy', decoding: 'async' }); img.onerror = () => img.remove(); thumb.prepend(img); }
++ if (p.viewers) thumb.append(tdEl('span', 'td-viewers', [tdIcon('fa-eye'), ` ${tdNum(p.viewers)}`]));
++ return tdEl('a', 'td-live', [thumb, tdEl('span', 'td-live-name', [p.name || p.username]), tdEl('span', 'td-live-title', [p.title || ''])], { href: p.url });
++ }));
++ box.hidden = people.length === 0;
++ const sub = document.getElementById('td-now-sub');
++ if (sub) sub.textContent = people.length ? `${people.length} of the ${tdNum(live.following)} you follow` : '';
++
++ const out = [];
++ const count = unread && Number(unread.count);
++ if (count > 0) {
++ const b = tdEl('button', 'td-chip td-chip-hot', [tdIcon('fa-bell'), ` ${tdNum(count)} new notification${count === 1 ? '' : 's'}`], { type: 'button' });
++ b.addEventListener('click', () => { const bell = document.querySelector('[data-ov-bell], .openvibe-navbar-bell, #openvibe-notif-btn'); if (bell) bell.click(); else showSection('notifications'); });
++ out.push(b);
++ }
++ const role = currentUser && currentUser.role;
++ if (role && role !== 'user') out.push(tdEl('a', 'td-chip', [tdIcon('fa-tower-broadcast'), ' Go live'], { href: 'https://openvibe.live/broadcast' }));
++ if (!people.length && live && live.anyone_live > 0) out.push(tdEl('a', 'td-chip', [tdIcon('fa-circle-play'), ` ${tdNum(live.anyone_live)} live on OpenVibe now`], { href: 'https://openvibe.live/' }));
++ chips.replaceChildren(...out);
++ chips.hidden = out.length === 0;
++}
++
++/** Continue: the newest things you opened across the sites (Network history), one card per address. */
++function renderContinue(hist) {
++ const box = document.getElementById('td-continue');
++ const list = document.getElementById('td-continue-list');
++ const seen = new Set();
++ const items = ((hist && hist.items) || []).filter((it) => { const u = tdSafeUrl(it.url); if (!u || seen.has(u)) return false; seen.add(u); return true; }).slice(0, 10);
++ list.replaceChildren(...items.map((it) => {
++ const site = TD_SITES[it.service] || [it.service_label || 'OpenVibe', 'fa-globe'];
++ const icon = /^fa-[a-z0-9-]+$/.test(String(it.icon || '')) ? it.icon : site[1];
++ return tdEl('a', 'td-card td-cont', [
++ tdEl('span', 'td-cont-ic', [tdIcon(icon)]),
++ tdEl('span', 'td-cont-t', [it.title || it.url]),
++ tdEl('span', 'td-cont-m', [`${it.service_label || site[0]} · ${tdAgo(it.updated_at || it.created_at)}`]),
++ ], { href: it.url });
++ }));
++ box.hidden = items.length === 0;
++}
++
++/** Your money: OpenCoins and Vibes, with the last change. */
++function renderMoney(coins, last) {
++ const box = document.getElementById('coins'); // the navbar's OpenCoins chip links to /my#coins
++ if (!coins || coins.balance == null) { box.hidden = true; return; }
++ document.getElementById('td-coins').textContent = tdNum(coins.balance);
++ const vibes = document.getElementById('td-vibes-row');
++ vibes.hidden = coins.vibes == null;
++ if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);
++ const t = last && last.transactions && last.transactions[0];
++ const line = document.getElementById('td-money-last');
++ line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';
++ line.hidden = !t;
++ box.hidden = false;
++}
++
++/** Your sites: the ones you use most (history counts, then the switcher's pins and recents), and two to try. */
++function renderSites(hist, sites) {
++ const box = document.getElementById('td-sites');
++ const grid = document.getElementById('td-sites-grid');
++ const counts = new Map();
++ ((sites && sites.pinned) || []).forEach((id) => counts.set(id, (counts.get(id) || 0) + 1000));
++ ((hist && hist.services) || []).forEach((s) => counts.set(s.service, (counts.get(s.service) || 0) + Number(s.count || 0)));
++ ((sites && sites.recent) || []).forEach((id, i) => counts.set(id, (counts.get(id) || 0) + (8 - i)));
++ const ranked = [...counts.entries()].filter(([id]) => TD_SITES[id] && id !== 'network').sort((a, b) => b[1] - a[1]).slice(0, 8).map(([id]) => id);
++ const mine = ranked.length > 4 ? ranked.slice(0, ranked.length - (ranked.length % 4)) : ranked; // whole rows of four; All sites has the rest
++ grid.replaceChildren(...mine.map((id) => {
++ const [label, icon, url] = TD_SITES[id];
++ return tdEl('a', 'td-site', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', '', [label])], { href: url });
++ }));
++ grid.hidden = mine.length === 0;
++ const tries = TD_TRY.filter(([id]) => !mine.includes(id) && !counts.has(id)).slice(0, 2);
++ const tryBox = document.getElementById('td-try');
++ tryBox.replaceChildren(...tries.map(([id, what, why]) => {
++ const [, icon, url] = TD_SITES[id];
++ return tdEl('a', 'td-try', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', 'td-try-t', [tdEl('b', '', [what]), tdEl('small', '', [why])])], { href: url });
++ }));
++ tryBox.hidden = tries.length === 0;
++ document.getElementById('td-sites-h').textContent = mine.length ? 'Your sites' : 'Start here';
++ box.hidden = mine.length === 0 && tries.length === 0;
++}
++
++/** Your numbers: follows, machines and history; the full picture is on openvibe.report/you (the link beside them). */
++function renderStuff(live, hist, nodes) {
++ const row = document.getElementById('td-stuff-row');
++ const cells = [];
++ const add = (n, label, act) => {
++ if (!(n > 0)) return;
++ const kids = [tdEl('b', '', [tdNum(n)]), tdEl('span', '', [label])];
++ if (!act) return cells.push(tdEl('span', 'td-stat', kids));
++ const b = tdEl('button', 'td-stat', kids, { type: 'button' });
++ b.addEventListener('click', act);
++ cells.push(b);
++ };
++ add(live && live.following, 'following');
++ add(nodes && nodes.nodes && nodes.nodes.filter((n) => !n.revoked_at).length, 'machines');
++ add(hist && hist.total, 'pages in your history', () => { showSection('history'); document.getElementById('settings').scrollIntoView({ behavior: 'smooth' }); });
++ row.replaceChildren(...cells);
++}
++
++async function loadToday() {
++ if (!document.getElementById('today')) return;
++ renderGreeting();
++ if (typeof isAnonSession === 'function' && isAnonSession()) return; // a guest identity keeps no history, follows or coins
++ const quiet = (p) => p.catch(() => null);
++ const [live, hist, coins, last, unread, sites, nodes] = await Promise.all([
++ quiet(apiFetch('/api/v1/me/live-follows')),
++ quiet(apiFetch('/api/history?limit=30')),
++ quiet(apiFetch('/api/coins/me?with=vibes')),
++ quiet(apiFetch('/api/coins/me/history?limit=1')),
++ quiet(apiFetch('/api/notifications/unread-count')),
++ quiet(apiFetch('/api/themes/me/sites')),
++ quiet(apiFetch('/api/v1/me/nodes')),
++ ]);
++ renderNow(live, unread);
++ renderContinue(hist);
++ renderMoney(coins, last);
++ renderSites(hist, sites);
++ renderStuff(live, hist, nodes);
++ document.getElementById('today').classList.add('td-ready');
++}
ddiff --git a/public/js/my/tools.js b/public/js/my/tools.js
iindex 711bb8cf..884edaae 100644
---- a/public/js/my/tools.js
++++ b/public/js/my/tools.js
@@@ -4,29 +4,6 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
═══════════════════════════════════════════════════════════════ */
--// Build dashboard showcase
--function buildDashboardShowcase() {
-- const grid = document.getElementById('my-showcase-grid');
-- const quickAccess = document.getElementById('my-quick-access');
-- if (!grid || !quickAccess) return;
-- const popular = [
-- { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },
-- { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },
-- { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },
-- { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },
-- ];
-- grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');
-- const quickLinks = [
-- { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },
-- { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },
-- { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },
-- { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },
-- { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },
-- { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },
-- ];
-- quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
--}
--
// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
ddiff --git a/public/my.html b/public/my.html
iindex 77a48a22..6b06941f 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -43,26 +43,53 @@
<div class="page-header" id="page-header">
<img class="avatar" id="user-avatar" src="" alt="">
<div class="info">
++ <div class="greeting" id="user-greeting"></div>
<h1 id="user-name">Loading...</h1>
<div class="subtitle" id="user-subtitle"></div>
<div class="badges" id="user-badges"></div>
</div>
++ <a class="btn btn-outline td-settings-btn" href="#settings"><i class="fa-solid fa-gear" aria-hidden="true"></i> Settings</a>
</div>
-- <div class="dashboard-showcase">
-- <div class="showcase-header">
-- <h2>Quick Access</h2>
-- <p>Your favorite services at a glance</p>
++ <!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your
++ numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->
++ <div class="td" id="today">
++ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
++ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live now</h2><span class="td-sub" id="td-now-sub"></span></div>
++ <div class="td-rail" id="td-live"></div>
++ </section>
++ <div class="td-chips" id="td-chips" hidden></div>
++ <section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">
++ <div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>
++ <div class="td-rail" id="td-continue-list"></div>
++ </section>
++ <div class="td-grid">
++ <section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">
++ <div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>
++ <div class="td-sites" id="td-sites-grid"></div>
++ <div class="td-tries" id="td-try" hidden></div>
++ </section>
++ <section class="td-box td-money" id="coins" hidden aria-labelledby="td-money-h">
++ <div class="td-h"><h2 id="td-money-h">Your money</h2><a class="td-more" href="https://openvibe.live/#vibes">Add Vibes</a></div>
++ <div class="td-bal"><span class="td-coin" aria-hidden="true"></span><b id="td-coins">0</b><span>OpenCoins</span></div>
++ <div class="td-bal" id="td-vibes-row" hidden><span class="td-coin td-coin-v" aria-hidden="true"></span><b id="td-vibes">0</b><span>Vibes</span></div>
++ <p class="td-sub" id="td-money-last" hidden></p>
++ <div class="td-money-acts"><a class="td-chip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a></div>
++ </section>
</div>
-- <div class="showcase-grid" id="my-showcase-grid"></div>
-- <div class="quick-access" id="my-quick-access"></div>
<div class="recent-tools" id="my-recent-tools" hidden>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
<p class="recent-tools-status" id="my-recent-tools-status" role="status"></p>
</div>
++ <div class="td-stuff">
++ <div class="td-stats" id="td-stuff-row"></div>
++ <a class="td-more" href="https://openvibe.report/you">Your numbers on openvibe.report <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>
++ </div>
</div>
++ <h2 class="settings-h" id="settings">Settings</h2>
++
<div class="section-tabs">
<button class="active" onclick="showSection('profile',this)"><i class="fa-solid fa-user"></i> Profile</button>
<button onclick="showSection('themes',this)"><i class="fa-solid fa-palette"></i> Themes</button>
@@@ -394,6 +421,7 @@
<script src="/js/my/core.js" defer></script>
<script src="/js/my/tools.js" defer></script>
++<script src="/js/my/today.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/consent.js" defer></script>
ddiff --git a/server/index.js b/server/index.js
iindex 18e67558..f5ab863f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -507,6 +507,8 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
const followRouters = require('./identity/follows').routers({ requireAuth, followsGuard: require('./identity/principals').guard('network.follows.read') });
app.use('/api/v1/me/follows', rateLimit({ windowMs: 60_000, max: 120 }), followRouters.me);
app.use('/api/v1/follows', rateLimit({ windowMs: 60_000, max: 300 }), followRouters.pub);
++ // Your account home's "Now" (L43): the people you follow who are live, from Live's public list (held 10 s).
++ app.use('/api/v1/me/live-follows', rateLimit({ windowMs: 60_000, max: 60 }), require('./today/live-follows').router({ requireAuth, liveUrl: config.services.live.internalUrl }));
// ADR-030 step 4: products record follows on a person's behalf (network.follows.write, service token only).
app.use('/internal/follows', require('./identity/principals').guard('network.follows.write'), followRouters.internal);
// A follow that starts notifies the followed person (FOLLOW), in the follow's transaction.
ddiff --git a/server/today/live-follows.js b/server/today/live-follows.js
nnew file mode 100644
iindex 00000000..7871c963
---- /dev/null
++++ b/server/today/live-follows.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * The people you follow who are live now: the "Now" row of your account home (/my, plan L43, 2026-10-31).
++ *
++ * GET /api/v1/me/live-follows → { live: [{ username, name, title, viewers, thumbnail, avatar, url, since }],
++ * following, anyone_live }
++ *
++ * Follows are Network's (subjects); who is live is Live's public stream list, read from Live's internal address and
++ * kept for ten seconds, so a hundred open account pages ask Live once. When Live does not answer, nobody is live:
++ * the home leaves the row out rather than failing.
++ */
++const express = require('express');
++
++const TTL_MS = 10_000;
++const TIMEOUT_MS = 2_500;
++const MAX = 12;
++let held = { at: 0, streams: [] };
++let pending = null;
++
++/** Live's public list of live streams (ten seconds old at most), or [] when Live is away. */
++async function liveStreams(liveUrl, { fetchImpl = fetch, now = Date.now } = {}) {
++ if (held.at && now() - held.at < TTL_MS) return held.streams;
++ if (pending) return pending;
++ pending = (async () => {
++ try {
++ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ const body = r.ok ? await r.json() : null;
++ held = { at: now(), streams: Array.isArray(body && body.streams) ? body.streams : [] };
++ } catch {
++ held = { at: now(), streams: [] };
++ } finally {
++ pending = null;
++ }
++ return held.streams;
++ })();
++ return pending;
++}
++
++const text = (v, n) => String(v == null ? '' : v).slice(0, n);
++const httpsUrl = (v) => (/^https:\/\/[^\s"'<>]+$/.test(String(v || '')) ? String(v) : null);
++
++function router({ requireAuth, liveUrl, publicLive = 'https://openvibe.live', fetchImpl } = {}) {
++ const r = express.Router();
++ r.get('/', requireAuth, async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ if (!req.user || req.user.is_anon) return res.json({ live: [], following: 0, anyone_live: 0 });
++ const db = req.app.locals.db;
++ const me = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(req.user.id);
++ const follows = me && me.subject_id ? await db.prepare(`SELECT LOWER(u.username) AS username FROM user_follows f JOIN users u ON u.subject_id = f.target_id
++ WHERE f.active = 1 AND f.follower_subject = ? AND f.target_type = 'channel'`).all(me.subject_id) : [];
++ const followed = new Set(follows.map((f) => f.username));
++ const streams = await liveStreams(liveUrl, { fetchImpl });
++ const seen = new Set();
++ const live = [];
++ for (const s of streams) {
++ const name = String(s && s.username || '').toLowerCase();
++ if (!followed.has(name) || seen.has(name) || live.length >= MAX) continue;
++ seen.add(name);
++ live.push({
++ username: text(s.username, 64),
++ name: text(s.display_name || s.username, 80),
++ title: text(s.ai_title || s.title, 140),
++ viewers: Math.max(0, Number(s.total_viewer_count != null ? s.total_viewer_count : s.viewer_count) || 0),
++ thumbnail: httpsUrl(s.thumbnail_url) || (String(s.thumbnail_url || '').startsWith('/') ? publicLive + s.thumbnail_url : null),
++ avatar: httpsUrl(s.avatar_url),
++ url: `${publicLive}/@${encodeURIComponent(String(s.username || ''))}`,
++ since: text(s.started_at, 40) || null,
++ });
++ }
++ res.json({ live, following: followed.size, anyone_live: new Set(streams.map((s) => String(s && s.username || '').toLowerCase())).size });
++ });
++ return r;
++}
++
++/** Tests: forget what Live said. */
++function forget() { held = { at: 0, streams: [] }; pending = null; }
++
++module.exports = { router, liveStreams, forget };
ddiff --git a/test/live-follows.test.js b/test/live-follows.test.js
nnew file mode 100644
iindex 00000000..9530e8a0
---- /dev/null
++++ b/test/live-follows.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// Your account home's "Now" (plan L43): the people you follow who are live, from Live's public list held ten seconds.
++// Only your own follows, only who is live, Live's secrets never pass, and a Live that does not answer means nobody.
++// node --import ./test/helpers/pg-preload.mjs test/live-follows.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const liveFollows = require('../server/today/live-follows');
++
++(async () => {
++ const log = console.log; console.log = () => {};
++ const db = getDb();
++ console.log = log;
++ const PEOPLE = [[71, 'wren'], [72, 'Goosely'], [73, 'maticus'], [74, 'quietone']];
++ for (const [id, name] of PEOPLE) await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (?, ?, 'x', 'user') ON CONFLICT DO NOTHING").run(id, name);
++ const S = {};
++ for (const [id, name] of PEOPLE) S[name] = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
++ // wren follows Goosely (live), maticus (live) and quietone (not live); one follow ended.
++ for (const [who, whom, active] of [['wren', 'Goosely', 1], ['wren', 'maticus', 1], ['wren', 'quietone', 1], ['quietone', 'maticus', 0]]) {
++ await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active) VALUES (?, 'channel', ?, ?) ON CONFLICT DO NOTHING").run(S[who], S[whom], active);
++ }
++
++ let asked = 0; let answer = null;
++ const fetchImpl = async (url) => {
++ asked++;
++ assert.strictEqual(url, 'http://live.internal/api/streams', 'Live\'s internal address');
++ if (!answer) throw new Error('down');
++ return { ok: true, json: async () => answer };
++ };
++ answer = { streams: [
++ { username: 'goosely', display_name: 'Goosely', title: 'Geese at dawn', ai_title: '', viewer_count: 3, total_viewer_count: 9, thumbnail_url: '/thumbs/g.jpg', avatar_url: 'https://openvibe.media/a/g.png', started_at: '2026-10-31T18:00:00Z', stream_key: 'sk_secret', managed_stream_key: 'mk_secret' },
++ { username: 'maticus', display_name: 'Maticus', title: 'LOOI', viewer_count: 12, thumbnail_url: 'javascript:alert(1)', avatar_url: 'http://insecure.example/a.png' },
++ { username: 'maticus', display_name: 'Maticus', title: 'LOOI, second slot', viewer_count: 2 },
++ { username: 'stranger', display_name: 'Stranger', title: 'Not followed', viewer_count: 40 },
++ ] };
++
++ // The session itself is Network's requireAuth (tested on its own); here a request names its person.
++ const requireAuth = (req, res, next) => { const id = Number(String(req.headers.authorization || '').replace(/^User /, '')); if (!id) return res.status(401).json({ error: 'sign in' }); req.user = { id }; next(); };
++ const app = express();
++ app.locals.db = db;
++ app.use('/api/v1/me/live-follows', liveFollows.router({ requireAuth, liveUrl: 'http://live.internal', fetchImpl }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = async (as) => fetch(`${base}/api/v1/me/live-follows`, { headers: as ? { authorization: `User ${as}` } : {} })
++ .then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
++
++ assert.strictEqual((await get()).status, 401, 'a person\'s own');
++
++ let r = await get(71);
++ assert.strictEqual(r.cache, 'private, no-store');
++ assert.deepStrictEqual(r.body.live.map((x) => x.username), ['goosely', 'maticus'], 'the people wren follows who are live, once each, in Live\'s order');
++ assert.deepStrictEqual([r.body.following, r.body.anyone_live], [3, 3]);
++ const g = r.body.live[0];
++ assert.deepStrictEqual([g.name, g.title, g.viewers, g.thumbnail, g.avatar, g.url], ['Goosely', 'Geese at dawn', 9, 'https://openvibe.live/thumbs/g.jpg', 'https://openvibe.media/a/g.png', 'https://openvibe.live/@goosely']);
++ assert.ok(!JSON.stringify(r.body).includes('secret'), 'no ingest key passes');
++ const m = r.body.live[1];
++ assert.deepStrictEqual([m.thumbnail, m.avatar], [null, null], 'only https pictures, and Live\'s own paths');
++
++ r = await get(73);
++ assert.deepStrictEqual([r.body.live, r.body.following], [[], 0], 'nobody followed: nobody live');
++ r = await get(74);
++ assert.deepStrictEqual(r.body.live, [], 'an ended follow does not count');
++ assert.strictEqual(asked, 1, 'Live was asked once for all of them');
++
++ // Live away: nobody is live, and the page still answers.
++ liveFollows.forget(); answer = null;
++ r = await get(71);
++ assert.deepStrictEqual([r.status, r.body.live, r.body.anyone_live], [200, [], 0]);
++
++ server.close();
++ console.log('live follows: your follows who are live, once each, no secrets, Live asked once per 10 s, Live away means nobody');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex ef44784e..8c3b533c 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
@@@ -41,7 +41,7 @@ for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge'])
const bootSrc = fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8');
assert.ok(bootSrc.includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
// Signed out (loadUser() redirects to /login), no section loads and no badge is asked for: each would answer 401.
--assert.match(bootSrc, /if \(_signedIn\) \{\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API');
++assert.match(bootSrc, /if \(_signedIn\) \{\s*loadToday\(\);\s*openInitialSection\(\);\s*refreshApprovalsBadge\(\);\s*\}/, 'signed out, boot.js asks no API (You, today included)');
// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
{
const vm = require('vm');
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
nnew file mode 100644
iindex 00000000..1404aeaf
---- /dev/null
++++ b/test/my-today.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++// The account home, "You, today" (plan L43; the owner, 10-31: "I hate this dashboard… redo the whole thing"). my/today.js
++// runs here on a tiny DOM with sample answers from each API:
++// - Now: the people you follow who are live, a notifications chip, Go live only for a streamer;
++// - Continue: the newest history, one card per address, https only;
++// - Your sites: ranked by your own use (pins, history counts, recents), Network itself left out, two to try that you
++// have not used; Your money from /api/coins; your numbers;
++// - an empty block stays out, and no API answer breaks the page.
++// It also checks the page: no Quick Access grid, no email beside your name, Settings below.
++// node test/my-today.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/today.js'), 'utf8');
++const HTML = fs.readFileSync(path.join(__dirname, '..', 'public/my.html'), 'utf8');
++const PROFILE = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/profile.js'), 'utf8');
++const BOOT = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/boot.js'), 'utf8');
++const TOOLS = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/tools.js'), 'utf8');
++
++class Node {
++ constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this.hidden = false; this.listeners = {}; this._text = ''; this.style = {}; }
++ setAttribute(k, v) { this.attrs[k] = String(v); }
++ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
++ append(...kids) { for (const k of kids) this.children.push(typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k); }
++ prepend(k) { this.children.unshift(k); }
++ replaceChildren(...kids) { this.children = []; this.append(...kids); }
++ remove() {}
++ addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
++ click() { (this.listeners.click || []).forEach((fn) => fn({})); }
++ scrollIntoView() { this.scrolled = true; }
++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
++ set textContent(v) { this._text = String(v); this.children = []; }
++ get href() { return this.attrs.href; }
++ get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
++ all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
++}
++
++function page() {
++ const ids = {};
++ for (const [, id] of HTML.matchAll(/id="((?:td-|user-greeting|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
++ const shown = [];
++ const sandbox = {
++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
++ console, Date, Number, String, Map, Set, Math, JSON, Promise,
++ currentUser: { role: 'user' },
++ showSection: (id) => shown.push(id),
++ isAnonSession: () => false,
++ };
++ vm.runInNewContext(SRC, sandbox);
++ return { ids, sandbox, shown };
++}
++const links = (n) => n.all((c) => c.tagName === 'A').map((a) => a.href);
++const ago = (min) => new Date(Date.now() - min * 60000).toISOString();
++
++// ── The page: no Quick Access grid, no email next to your name, Settings below ──
++assert.ok(!/Quick Access|showcase-grid|quick-access/.test(HTML), 'the Quick Access grid is gone');
++assert.ok(!/buildDashboardShowcase/.test(TOOLS + BOOT), 'and its builder');
++assert.match(HTML, /id="today"/);
++assert.ok(HTML.indexOf('id="today"') < HTML.indexOf('id="settings"') && HTML.indexOf('id="settings"') < HTML.indexOf('class="section-tabs"'), 'You, today first, then Settings');
++assert.ok(!/user\.email \? ' · '/.test(PROFILE), 'the email is not beside your name (this page may be on a stream)');
++assert.match(BOOT, /loadToday\(\)/, 'boot starts it');
++assert.match(HTML, /id="coins"/, 'the navbar\'s OpenCoins chip (/my#coins) lands on Your money');
++
++(async () => {
++ // ── Everything at once ──
++ let p = page();
++ p.sandbox.currentUser = { role: 'streamer' };
++ p.sandbox.renderNow({ live: [
++ { username: 'goosely', name: 'Goosely', title: 'Geese at dawn', viewers: 9, thumbnail: 'https://openvibe.live/t/g.jpg', url: 'https://openvibe.live/@goosely' },
++ { username: 'maticus', name: 'Maticus', title: 'LOOI', viewers: 0, thumbnail: 'javascript:alert(1)', url: 'https://openvibe.live/@maticus' },
++ ], following: 14, anyone_live: 5 }, { count: 3 });
++ assert.strictEqual(p.ids['td-now'].hidden, false);
++ assert.deepStrictEqual(links(p.ids['td-live']), ['https://openvibe.live/@goosely', 'https://openvibe.live/@maticus']);
++ assert.strictEqual(p.ids['td-live'].all((c) => c.tagName === 'IMG').length, 1, 'only an https thumbnail is shown');
++ assert.strictEqual(p.ids['td-now-sub'].textContent, '2 of the 14 you follow');
++ const chips = p.ids['td-chips'];
++ assert.match(chips.textContent, /3 new notifications/);
++ assert.match(chips.textContent, /Go live/, 'a streamer can go live from here');
++ assert.ok(!/live on OpenVibe now/.test(chips.textContent), 'not "who else is live" while people you follow are');
++
++ p.sandbox.renderContinue({ items: [
++ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', service_label: 'Live', updated_at: ago(5) },
++ { title: 'Geese at dawn', url: 'https://openvibe.live/@goosely', service: 'live', updated_at: ago(50) },
++ { title: 'My theme', url: 'https://openvibe.design/themes/mine', service: 'design', icon: 'fa-palette', updated_at: ago(130) },
++ { title: 'Odd', url: 'javascript:alert(1)', service: 'live', updated_at: ago(1) },
++ ] });
++ assert.deepStrictEqual(links(p.ids['td-continue-list']), ['https://openvibe.live/@goosely', 'https://openvibe.design/themes/mine'], 'one card per address, https only');
++ assert.match(p.ids['td-continue-list'].textContent, /Live · 5 min ago/);
++ assert.match(p.ids['td-continue-list'].textContent, /2 h ago/);
++
++ p.sandbox.renderSites(
++ { services: [{ service: 'network', count: 99 }, { service: 'live', count: 40 }, { service: 'tools', count: 12 }, { service: 'nope', count: 9 }] },
++ { pinned: ['design'], recent: ['games', 'live'] });
++ assert.deepStrictEqual(links(p.ids['td-sites-grid']), ['https://openvibe.design', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.games'], 'pins first, then your own use; Network and unknown sites left out');
++ const tries = links(p.ids['td-try']);
++ assert.strictEqual(tries.length, 2, 'two to try');
++ assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');
++ assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');
++
++ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });
++ assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);
++ assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);
++
++ p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });
++ assert.match(p.ids['td-stuff-row'].textContent, /14following/);
++ assert.match(p.ids['td-stuff-row'].textContent, /1machines/, 'revoked machines do not count');
++ p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
++ assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
++
++ // ── Nothing to show: each block stays out; a new account gets "Start here" ──
++ p = page();
++ p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });
++ assert.strictEqual(p.ids['td-now'].hidden, true, 'nobody you follow is live: no Live now row');
++ assert.match(p.ids['td-chips'].textContent, /4 live on OpenVibe now/, 'but who else is, in one chip');
++ assert.ok(!/Go live|notification/.test(p.ids['td-chips'].textContent), 'no Go live for a viewer, no zero count');
++ p.sandbox.renderContinue(null);
++ assert.strictEqual(p.ids['td-continue'].hidden, true);
++ p.sandbox.renderSites(null, null);
++ assert.deepStrictEqual([p.ids['td-sites'].hidden, p.ids['td-sites-h'].textContent, links(p.ids['td-try']).length], [false, 'Start here', 2]);
++ p.sandbox.renderMoney(null, null);
++ assert.strictEqual(p.ids.coins.hidden, true, 'no coins answer: no money block');
++ p.sandbox.renderStuff(null, null, null);
++ assert.strictEqual(p.ids['td-stuff-row'].children.length, 0);
++
++ // ── Every API down: the page still draws ──
++ p = page();
++ p.sandbox.apiFetch = () => Promise.reject(Object.assign(new Error('down'), { status: 503 }));
++ await p.sandbox.loadToday();
++ assert.ok(/td-ready/.test(p.ids.today.className), 'drawn');
++ assert.ok(p.ids['td-greeting'] === undefined && /Good|Up late/.test(p.ids['user-greeting'].textContent), 'with a greeting');
++
++ console.log('my today: live follows, continue, your sites by your own use, money and numbers; empty blocks stay out; APIs down still draws');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex afdd06fe..96962387 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -119,6 +119,7 @@ const REVIEWED = {
'server/discord/discord-service.js fetch': [2, 'discord.js channels.fetch: Discord API only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
++ 'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],