openvibers/openvibe.live Commits
The home's money four say 'None yet' as one chip, not four zeros (#566)
Changes
@@@ -688,6 +688,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }}.hero-stat > i { grid-row: 1 / 2; align-self: center; justify-self: center; }.hero-stat-num { white-space: nowrap; line-height: 1.12; }++.hero-stat-word { font-size: 1.05rem; font-weight: 800; color: var(--text-primary); white-space: nowrap; line-height: 1.12; letter-spacing: -0.01em; }.hero-stat-label { white-space: normal; overflow-wrap: anywhere; line-height: 1.15; }.hero-stat-delta { grid-column: 1 / -1; justify-self: start; margin-top: 1px; }.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }@@@ -342,12 +342,25 @@ function _renderHeroStats(stats) {});// ── Economy ──────────────────────────────────────────────────++ // The money four (tips, supporters, Vibes bought, subs) count from the Vibes relaunch. While all four are still++ // zero they are one chip that says so ("none yet since 17 Oct"), not four bare zeros that read as a dead site; the++ // first tip brings the four back. Every one of them is still said: nothing is hidden.++ const money = [++ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },++ { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },++ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },++ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },++ ];++ const moneyFresh = money.every(r => r.num === 0);++ const since = stats.economySince && !isNaN(Date.parse(stats.economySince))++ ? new Date(stats.economySince + 'T12:00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '';++ const freshTip = { label: 'Tips and subs', title: `No tips, supporters, Vibes bought or subscriptions yet${since ? ` since the Vibes relaunch on ${since}` : ''}. Tip a streamer from their channel: the first one brings these four numbers back.`, desc: '', recent: null, metric: null };++ const fresh = {++ html: `<div class="hero-stat hero-stat--fresh" data-tip="${esc(JSON.stringify(freshTip))}"><i class="fa-solid fa-hand-holding-heart"></i><div class="hero-stat-meta"><span class="hero-stat-word">None yet</span><span class="hero-stat-label">Tips and subs${since ? ` · since ${esc(since)}` : ''}</span></div></div>`,++ };groups.push({kicker: 'Economy', icon: 'fa-coins', rows: [-- { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },-- { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },-- { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },-- { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },++ ...(moneyFresh ? [fresh] : money),// OpenCoins are network-wide; channel points below are per channel. Shown only when the wallet service// answered: zeroed chips would read as "nobody has any". (Coins held was coins earned less 50 spent: gone.)...(stats.coinsEarned != null ? [@@@ -22,7 +22,7 @@ const BUDGETS = {htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1276, // 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsRawKB: 1278, // 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)jsBrotliKB: 299, // 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)@@@ -1709,6 +1709,9 @@ async function _computeHomeStats() {// Distinct people who have tipped someone.supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),vibesBought: null,++ // The day the Vibes numbers count from (the 10-17 relaunch reset, site setting stats_vibes_reset_at), so the++ // home can say "none yet since 17 Oct" instead of four bare zeros. Null when there was no reset.++ economySince: vibesSince.startsWith('1970') ? null : vibesSince.slice(0, 10),// Donation goals: currently running + ever reached.goalsActive: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),goalsReached: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),nnew file mode 100644@@@ -0,0 +1,58 @@++/**++ * The home's Economy row (public/js/app-home.js): the money four (Vibes tipped, Supporters, Vibes bought, Subs) count++ * from the Vibes relaunch. While all four are zero they are one chip, "None yet · Tips and subs · since 17 Oct", not++ * four bare zeros that read as a dead site; any one of them above zero brings all four back. Nothing is hidden: the++ * chip's tip names each of the four.++ *++ * node test/home-economy-fresh.test.js++ */++'use strict';++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', 'app-home.js'), 'utf8');++++/** Render the stat board for `stats` with a stand-in page; returns the board's markup. */++function board(stats) {++ const el = () => ({++ innerHTML: '', hidden: true, style: {}, dataset: {}, textContent: '', offsetHeight: 0,++ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },++ querySelector: () => el(), querySelectorAll: () => [], addEventListener() {}, setAttribute() {}, getAttribute() { return null; },++ });++ const wrap = el();++ const document = { getElementById: (id) => (id === 'hero-stats' ? wrap : null), querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, documentElement: el(), body: el(), readyState: 'complete' };++ const window = { matchMedia: () => ({ matches: true, addEventListener() {} }), addEventListener() {}, location: { pathname: '/' } };++ const ctx = vm.createContext({ document, window, console, requestAnimationFrame: () => {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, localStorage: { getItem: () => null, setItem() {} }, navigator: {},++ // app.js's globals the board uses.++ esc: (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),++ _fmtCount: (n) => String(Number(n) || 0) });++ vm.runInContext(src, ctx, { filename: 'app-home.js' });++ vm.runInContext('_renderHeroStats', ctx)(stats);++ return wrap.innerHTML;++}++++const FOUR = ['Vibes tipped', 'Supporters', 'Vibes bought', 'Subs'];++const base = { recent: {}, concurrency: {}, liveNow: 0, viewersNow: 0, coinsEarned: null, pointsEarned: 5, pointsSpent: 1, goalsActive: 0, economySince: '2026-10-17' };++++const zero = board({ ...base, vibesTipped: 0, supporters: 0, vibesBought: 0, activeSubs: 0 });++assert.ok(zero.includes('hero-stat--fresh'), 'all four at zero: one chip');++assert.ok(zero.includes('<span class="hero-stat-word">None yet</span>'));++assert.ok(zero.includes('Tips and subs · since 17 Oct'), 'it says since when');++FOUR.forEach((word) => assert.ok(!zero.includes(`<span class="hero-stat-label">${word}`), `${word}: not a bare zero of its own`));++assert.match(zero, /No tips, supporters, Vibes bought or subscriptions yet since the Vibes relaunch on 17 Oct/, 'the tip names all four');++++const one = board({ ...base, vibesTipped: 500, supporters: 1, vibesBought: 0, activeSubs: 0 });++assert.ok(!one.includes('hero-stat--fresh'), 'a first tip brings the four back');++FOUR.forEach((word) => assert.ok(one.includes(`<span class="hero-stat-label">${word}`), word));++++// Billing not answering (null) is not "none yet": the chips stay as they were.++const unknown = board({ ...base, vibesTipped: 0, supporters: 0, vibesBought: null, activeSubs: null });++assert.ok(!unknown.includes('hero-stat--fresh'));++++// No reset date: the chip leaves the date out.++const noDate = board({ ...base, economySince: null, vibesTipped: 0, supporters: 0, vibesBought: 0, activeSubs: 0 });++assert.ok(noDate.includes('<span class="hero-stat-label">Tips and subs</span>'));++++console.log('home economy fresh: all checks passed');@@@ -65,6 +65,7 @@ await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, sourcelet stats = await db.getHomeStats();assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');++assert.strictEqual(stats.economySince, null, 'no reset → no "since" date for the home to show');assert.strictEqual(stats.supporters, 2);assert.strictEqual((await db.getHomeStatSeries('vibes', 30)).total, 750);@@@ -75,6 +76,7 @@ if (!stats) { // fall back: the cache TTL keeps the old value; verify via the seassert.ok(await db.vibesStatsSince() > '2000-01-01', 'cutoff parsed');} else {assert.strictEqual(stats.vibesTipped, 50, 'only vibes after the reset count');++ assert.match(stats.economySince, /^\d{4}-\d{2}-\d{2}$/, 'the day the Vibes count from, for "none yet since …"');assert.strictEqual(stats.supporters, 1);assert.strictEqual(stats.recent.vibes.d, 50);assert.strictEqual(stats.recent.vibes.m, 50, 'the 30-day window is also cut at the reset');
@@ -688,6 +688,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -688,6 +688,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
}
.hero-stat > i { grid-row: 1 / 2; align-self: center; justify-self: center; }
.hero-stat > i { grid-row: 1 / 2; align-self: center; justify-self: center; }
.hero-stat-num { white-space: nowrap; line-height: 1.12; }
.hero-stat-num { white-space: nowrap; line-height: 1.12; }
+.hero-stat-word { font-size: 1.05rem; font-weight: 800; color: var(--text-primary); white-space: nowrap; line-height: 1.12; letter-spacing: -0.01em; }
.hero-stat-label { white-space: normal; overflow-wrap: anywhere; line-height: 1.15; }
.hero-stat-label { white-space: normal; overflow-wrap: anywhere; line-height: 1.15; }
.hero-stat-delta { grid-column: 1 / -1; justify-self: start; margin-top: 1px; }
.hero-stat-delta { grid-column: 1 / -1; justify-self: start; margin-top: 1px; }
.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }
.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }
@@ -342,12 +342,25 @@ function _renderHeroStats(stats) {
@@ -342,12 +342,25 @@ function _renderHeroStats(stats) {
});
});
// ── Economy ──────────────────────────────────────────────────
// ── Economy ──────────────────────────────────────────────────
+ // The money four (tips, supporters, Vibes bought, subs) count from the Vibes relaunch. While all four are still
+ // zero they are one chip that says so ("none yet since 17 Oct"), not four bare zeros that read as a dead site; the
+ // first tip brings the four back. Every one of them is still said: nothing is hidden.
+ const money = [
+ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },
+ { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },
+ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
+ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
+ ];
+ const moneyFresh = money.every(r => r.num === 0);
+ const since = stats.economySince && !isNaN(Date.parse(stats.economySince))
+ ? new Date(stats.economySince + 'T12:00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '';
+ const freshTip = { label: 'Tips and subs', title: `No tips, supporters, Vibes bought or subscriptions yet${since ? ` since the Vibes relaunch on ${since}` : ''}. Tip a streamer from their channel: the first one brings these four numbers back.`, desc: '', recent: null, metric: null };
+ const fresh = {
+ html: `<div class="hero-stat hero-stat--fresh" data-tip="${esc(JSON.stringify(freshTip))}"><i class="fa-solid fa-hand-holding-heart"></i><div class="hero-stat-meta"><span class="hero-stat-word">None yet</span><span class="hero-stat-label">Tips and subs${since ? ` · since ${esc(since)}` : ''}</span></div></div>`,
+ };
groups.push({
groups.push({
kicker: 'Economy', icon: 'fa-coins', rows: [
kicker: 'Economy', icon: 'fa-coins', rows: [
- { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },
- { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },
- { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
- { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
+ ...(moneyFresh ? [fresh] : money),
// OpenCoins are network-wide; channel points below are per channel. Shown only when the wallet service
// OpenCoins are network-wide; channel points below are per channel. Shown only when the wallet service
// answered: zeroed chips would read as "nobody has any". (Coins held was coins earned less 50 spent: gone.)
// answered: zeroed chips would read as "nobody has any". (Coins held was coins earned less 50 spent: gone.)
...(stats.coinsEarned != null ? [
...(stats.coinsEarned != null ? [
@@ -22,7 +22,7 @@ const BUDGETS = {
@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1276, // 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsRawKB: 1278, // 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 299, // 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
jsBrotliKB: 299, // 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
@@ -1709,6 +1709,9 @@ async function _computeHomeStats() {
@@ -1709,6 +1709,9 @@ async function _computeHomeStats() {
// Distinct people who have tipped someone.
// Distinct people who have tipped someone.
supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),
supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),
vibesBought: null,
vibesBought: null,
+ // The day the Vibes numbers count from (the 10-17 relaunch reset, site setting stats_vibes_reset_at), so the
+ // home can say "none yet since 17 Oct" instead of four bare zeros. Null when there was no reset.
+ economySince: vibesSince.startsWith('1970') ? null : vibesSince.slice(0, 10),
// Donation goals: currently running + ever reached.
// Donation goals: currently running + ever reached.
goalsActive: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
goalsActive: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
goalsReached: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),
goalsReached: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+/**
+ * The home's Economy row (public/js/app-home.js): the money four (Vibes tipped, Supporters, Vibes bought, Subs) count
+ * from the Vibes relaunch. While all four are zero they are one chip, "None yet · Tips and subs · since 17 Oct", not
+ * four bare zeros that read as a dead site; any one of them above zero brings all four back. Nothing is hidden: the
+ * chip's tip names each of the four.
+ *
+ * node test/home-economy-fresh.test.js
+ */
+'use strict';
+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', 'app-home.js'), 'utf8');
+
+/** Render the stat board for `stats` with a stand-in page; returns the board's markup. */
+function board(stats) {
+ const el = () => ({
+ innerHTML: '', hidden: true, style: {}, dataset: {}, textContent: '', offsetHeight: 0,
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ querySelector: () => el(), querySelectorAll: () => [], addEventListener() {}, setAttribute() {}, getAttribute() { return null; },
+ });
+ const wrap = el();
+ const document = { getElementById: (id) => (id === 'hero-stats' ? wrap : null), querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, documentElement: el(), body: el(), readyState: 'complete' };
+ const window = { matchMedia: () => ({ matches: true, addEventListener() {} }), addEventListener() {}, location: { pathname: '/' } };
+ const ctx = vm.createContext({ document, window, console, requestAnimationFrame: () => {}, setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {}, localStorage: { getItem: () => null, setItem() {} }, navigator: {},
+ // app.js's globals the board uses.
+ esc: (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
+ _fmtCount: (n) => String(Number(n) || 0) });
+ vm.runInContext(src, ctx, { filename: 'app-home.js' });
+ vm.runInContext('_renderHeroStats', ctx)(stats);
+ return wrap.innerHTML;
+}
+
+const FOUR = ['Vibes tipped', 'Supporters', 'Vibes bought', 'Subs'];
+const base = { recent: {}, concurrency: {}, liveNow: 0, viewersNow: 0, coinsEarned: null, pointsEarned: 5, pointsSpent: 1, goalsActive: 0, economySince: '2026-10-17' };
+
+const zero = board({ ...base, vibesTipped: 0, supporters: 0, vibesBought: 0, activeSubs: 0 });
+assert.ok(zero.includes('hero-stat--fresh'), 'all four at zero: one chip');
+assert.ok(zero.includes('<span class="hero-stat-word">None yet</span>'));
+assert.ok(zero.includes('Tips and subs · since 17 Oct'), 'it says since when');
+FOUR.forEach((word) => assert.ok(!zero.includes(`<span class="hero-stat-label">${word}`), `${word}: not a bare zero of its own`));
+assert.match(zero, /No tips, supporters, Vibes bought or subscriptions yet since the Vibes relaunch on 17 Oct/, 'the tip names all four');
+
+const one = board({ ...base, vibesTipped: 500, supporters: 1, vibesBought: 0, activeSubs: 0 });
+assert.ok(!one.includes('hero-stat--fresh'), 'a first tip brings the four back');
+FOUR.forEach((word) => assert.ok(one.includes(`<span class="hero-stat-label">${word}`), word));
+
+// Billing not answering (null) is not "none yet": the chips stay as they were.
+const unknown = board({ ...base, vibesTipped: 0, supporters: 0, vibesBought: null, activeSubs: null });
+assert.ok(!unknown.includes('hero-stat--fresh'));
+
+// No reset date: the chip leaves the date out.
+const noDate = board({ ...base, economySince: null, vibesTipped: 0, supporters: 0, vibesBought: 0, activeSubs: 0 });
+assert.ok(noDate.includes('<span class="hero-stat-label">Tips and subs</span>'));
+
+console.log('home economy fresh: all checks passed');
@@ -65,6 +65,7 @@ await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source
@@ -65,6 +65,7 @@ await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source
let stats = await db.getHomeStats();
let stats = await db.getHomeStats();
assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');
assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');
+assert.strictEqual(stats.economySince, null, 'no reset → no "since" date for the home to show');
assert.strictEqual(stats.supporters, 2);
assert.strictEqual(stats.supporters, 2);
assert.strictEqual((await db.getHomeStatSeries('vibes', 30)).total, 750);
assert.strictEqual((await db.getHomeStatSeries('vibes', 30)).total, 750);
@@ -75,6 +76,7 @@ if (!stats) { // fall back: the cache TTL keeps the old value; verify via the se
@@ -75,6 +76,7 @@ if (!stats) { // fall back: the cache TTL keeps the old value; verify via the se
assert.ok(await db.vibesStatsSince() > '2000-01-01', 'cutoff parsed');
assert.ok(await db.vibesStatsSince() > '2000-01-01', 'cutoff parsed');
} else {
} else {
assert.strictEqual(stats.vibesTipped, 50, 'only vibes after the reset count');
assert.strictEqual(stats.vibesTipped, 50, 'only vibes after the reset count');
+ assert.match(stats.economySince, /^\d{4}-\d{2}-\d{2}$/, 'the day the Vibes count from, for "none yet since …"');
assert.strictEqual(stats.supporters, 1);
assert.strictEqual(stats.supporters, 1);
assert.strictEqual(stats.recent.vibes.d, 50);
assert.strictEqual(stats.recent.vibes.d, 50);
assert.strictEqual(stats.recent.vibes.m, 50, 'the 30-day window is also cut at the reset');
assert.strictEqual(stats.recent.vibes.m, 50, 'the 30-day window is also cut at the reset');