Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG/pages/proto-home-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions public/proto/home-v5.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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() {
Expand Down
31 changes: 31 additions & 0 deletions tests/e2e/scratchnode-live-route-honesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading