diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index 50b9edb2d..f12100250 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -2,6 +2,19 @@ Append-only lane for the ScratchNode live-event prototype and production static surface. +## 2026-06-02 — Fix counter count-up flashing "more live than total" +Live-DOM Tier-B check on production caught the live counter momentarily rendering +`6 live · 4 total` during first load — impossible in steady state (live rooms are a +subset of total). Root cause: the **total** animated up from 0 over 750ms while +**liveNow** was set instantly, so mid-count-up the animating total was briefly less +than the instant live count — reading as a broken/fake number on the one stat that has +to be trustworthy. Fix: `animatePair()` eases both numbers from the same prior values +with identical easing, so total ≥ live at every frame (`Math.round` is monotonic, and +`roomsCreated ≥ liveNow` always). Guarded by a new e2e case that samples both numbers +14× across the animation and asserts `live ≤ total` at every frame. 14/14 suite green. + +**Commit**: `this commit`. **Author**: Homen Shum + Claude. + ## 2026-06-02 — Rewrite the landing headline for a viral hook Replaced the feature-describing hero line "A disposable sidecar room for live event memory" (jargon: "disposable", "sidecar"; no emotional/temporal hook — nobody screenshots diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index 479fe5ceb..466bde79c 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -2189,21 +2189,36 @@ if (!wrap || !numEl || !liveEl) return; var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); var shownRooms = 0; + var shownLive = 0; var raf = null; - function animateTo(target) { + // Animate rooms AND liveNow together with identical easing. Live rooms are + // always a subset of total rooms (liveNow <= roomsCreated), but if only the + // rooms count animated up from 0 while liveNow was set instantly, the first + // ~750ms could show "6 live · 4 total" — which reads as a broken/fake number + // on the one stat that must be trustworthy. Easing both from the same prior + // values keeps rooms >= live at every frame. + function animatePair(roomsTarget, liveTarget) { if (raf) { cancelAnimationFrame(raf); raf = null; } - if (reduce || shownRooms === target) { numEl.textContent = _snFmtStat(target); shownRooms = target; return; } - var from = shownRooms; + if (reduce || (shownRooms === roomsTarget && shownLive === liveTarget)) { + numEl.textContent = _snFmtStat(roomsTarget); + liveEl.textContent = _snFmtStat(liveTarget); + shownRooms = roomsTarget; + shownLive = liveTarget; + return; + } + var fromR = shownRooms; + var fromL = shownLive; var start = null; var dur = 750; function step(ts) { if (start === null) start = ts; var p = Math.min(1, (ts - start) / dur); var eased = 1 - Math.pow(1 - p, 3); - numEl.textContent = _snFmtStat(Math.round(from + (target - from) * eased)); + numEl.textContent = _snFmtStat(Math.round(fromR + (roomsTarget - fromR) * eased)); + liveEl.textContent = _snFmtStat(Math.round(fromL + (liveTarget - fromL) * eased)); if (p < 1) { raf = requestAnimationFrame(step); } - else { raf = null; shownRooms = target; } + else { raf = null; shownRooms = roomsTarget; shownLive = liveTarget; } } raf = requestAnimationFrame(step); } @@ -2214,8 +2229,7 @@ if (rooms < 1) { wrap.hidden = true; return; } // honest: hide empty, never fake wrap.hidden = false; if (sufEl) sufEl.textContent = stats.capped ? '+' : ''; - liveEl.textContent = _snFmtStat(Math.max(0, stats.liveNow || 0)); - animateTo(rooms); + animatePair(rooms, Math.max(0, stats.liveNow || 0)); } function connect() { diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 2dd5ac6e3..b80640eb1 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -424,4 +424,35 @@ test.describe("ScratchNode live route honesty", () => { // The landing still works — Join + Create remain available. await expect(page.locator("#landing-create-btn")).toBeVisible(); }); + + test("landing counter never shows more 'live' than 'total' during the count-up", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.addInitScript(() => { + // liveNow close to (but <=) total — the regression flashed "6 live · 4 total" + // because rooms animated from 0 while liveNow was set instantly. + (window as any).__snLandingStats = { roomsCreated: 8, liveNow: 6, capped: false }; + }); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#landing-pulse")).toBeVisible({ timeout: 6_000 }); + + // Sample both numbers repeatedly across the ~750ms count-up. Live rooms are a + // subset of total rooms, so the displayed live count must NEVER exceed the + // displayed total at any frame. + const parse = (s: string | null) => parseInt((s || "0").replace(/,/g, ""), 10); + for (let i = 0; i < 14; i++) { + const { rooms, live } = await page.evaluate(() => ({ + rooms: document.getElementById("landing-pulse-num")?.textContent ?? "0", + live: document.getElementById("landing-pulse-live")?.textContent ?? "0", + })); + expect(parse(live), `live(${live}) must never exceed total(${rooms})`).toBeLessThanOrEqual( + parse(rooms), + ); + await page.waitForTimeout(70); + } + + // …and it settles on the exact real values. + await expect.poll(() => page.locator("#landing-pulse-num").textContent()).toBe("8"); + await expect(page.locator("#landing-pulse-live")).toHaveText("6"); + }); });