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
39 changes: 39 additions & 0 deletions CHANGELOG/pages/proto-home-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@

Append-only lane for the ScratchNode live-event prototype and production static surface.

## 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
it) with an outcome-first, say-it-out-loud headline: **"The room remembers everything."**
Lineage: Linear ("the issue tracker you'll enjoy using" — emotional outcome), Slido
("audience interaction made easy" — the closest live-event-Q&A comp), Luma ("delightful
events start here"). The features (`/ask`, wiki handoff, no-account) move into the
sub-headline where they belong: "Drop a code, chat live, hit /ask for sourced answers —
and walk away with a wiki of everything that happened. No account, no app." Updated `<title>`,
meta description, and all OG/Twitter card tags to match so shared links carry the new hook.
Also regenerated the OG share card (`public/og-scratchnode.svg` → `og-scratchnode.png`,
1200×630) with the new headline via a new reusable rasterizer `scripts/ui/renderOgImage.mjs`
(Playwright + brand fonts), so Slack/Discord/X/iMessage link previews match the site.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.

## 2026-06-02 — Live "big number" room counter on the landing
Added an animated, reactive hero stat to the apex landing: a large room-count that
**ticks up the instant anyone, anywhere, creates a room** — driven by a new reactive
Convex query `events:getLandingStats` (bounded scan, returns `{ roomsCreated, liveNow,
capped }`), not a client timer. A pulsing "● N live right now" sub-line shows open rooms.

The counter gently floats with a soft accent glow; the number animates up via a cubic
ease when the reactive value changes. Motion-safe: subtle translate + small status-dot
ring (no flashing), all gated under `prefers-reduced-motion`. Mobile scales the digits down.

Honesty (`agentic_reliability` HONEST_SCORES): every figure is a real row count — no
fabricated marketing number. The stat is **hidden entirely** until the backend reports
≥1 room (so an empty or offline backend never flashes a fake "0"), enforced by both the
`render()` guard and a `.landing-pulse[hidden]{display:none}` rule (a class selector
otherwise beats the UA `[hidden]` rule). The landing subscription is read-only and never
sets `data-sn-live`, so the apex stays honestly "not live". Bulletproof: any config/client
failure leaves the stat hidden and never throws to the page.

Covered by 3 new cases in `scratchnode-live-route-honesty.spec.ts` (real value renders
`1,342`, scan-cap renders `5,000+`, zero rooms stays hidden). 13/13 honesty suite green.

**Commit**: `this commit`. **Author**: Homen Shum + Claude.

## 2026-06-02 — Add a self-serve "Create a room" front-door to the landing
The apex landing only offered a "Join with a code" form — a first-time visitor had
no way to actually create a room. `events:createEvent` existed in the backend and a
Expand Down
34 changes: 34 additions & 0 deletions convex/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,40 @@ export const getEventBySlug = query({
},
});

// BOUND (agentic_reliability): cap the landing-stats scan. The count is a full
// table walk (no count aggregate in Convex); 5000 keeps it cheap even on a
// hot landing while staying far above current room volume. When the cap is hit
// the UI honestly renders "N+" rather than silently undercounting.
const MAX_LANDING_STATS_SCAN = 5000;

/**
* Live landing stats — powers the animated "big number" on the apex landing.
*
* Convex queries are REACTIVE: every subscribed landing visitor's counter ticks
* up the instant anyone creates a room — no polling, no client timers.
*
* Honesty (agentic_reliability HONEST_SCORES): every number is a real row count.
* No hardcoded floor, no inflation. `roomsCreated` counts every room ever made
* (ended rooms keep their row); `liveNow` counts rooms still open (status=live).
* When the backend is unreachable the landing HIDES the stat entirely rather
* than render a fabricated number (the client enforces the hide).
*/
export const getLandingStats = query({
args: {},
handler: async (ctx) => {
const rows = await ctx.db.query("liveEvents").take(MAX_LANDING_STATS_SCAN);
let liveNow = 0;
for (const row of rows) {
if (row.status === "live") liveNow += 1;
}
return {
roomsCreated: rows.length,
liveNow,
capped: rows.length >= MAX_LANDING_STATS_SCAN,
};
},
});

/**
* Realtime message stream. The Convex client re-runs this on every change.
* Ordered ascending by createdAt so the latest is last (matches UI append order).
Expand Down
Binary file modified public/og-scratchnode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 13 additions & 7 deletions public/og-scratchnode.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
177 changes: 169 additions & 8 deletions public/proto/home-v5.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#151413">
<title>ScratchNode — sidecar event rooms with memory</title>
<meta name="description" content="Join a disposable sidecar room with a code. Chat publicly, /ask for sourced answers, keep private anchored notes, and leave with a public wiki. No account needed.">
<title>ScratchNode — the room remembers everything</title>
<meta name="description" content="The room remembers everything. Drop a code, chat live, /ask for sourced answers, and walk away with a wiki of the whole event. No account, no app.">
<link rel="canonical" href="https://scratchnode.live/">

<!--
Expand All @@ -16,8 +16,8 @@
pre-render or an OG image function, both of which are post-launch work.
-->
<meta property="og:site_name" content="ScratchNode">
<meta property="og:title" content="ScratchNode — sidecar event rooms with memory">
<meta property="og:description" content="Join with a room code. Public chat, sourced /ask answers, private notes, and a wiki handoff. No account needed.">
<meta property="og:title" content="ScratchNode — the room remembers everything">
<meta property="og:description" content="Drop a code, chat live, /ask for sourced answers, and leave with a wiki of everything that happened. No account, no app.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://scratchnode.live/">
<meta property="og:image" content="https://scratchnode.live/og-scratchnode.png">
Expand All @@ -28,8 +28,8 @@
<!-- Twitter card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@scratchnode">
<meta name="twitter:title" content="ScratchNode — sidecar event rooms with memory">
<meta name="twitter:description" content="A disposable event brain: public chat, sourced /ask, private notes, and a wiki handoff. No account needed.">
<meta name="twitter:title" content="ScratchNode — the room remembers everything">
<meta name="twitter:description" content="The live event room that remembers: public chat, sourced /ask, private notes, and a wiki handoff. No account, no app.">
<meta name="twitter:image" content="https://scratchnode.live/og-scratchnode.png">
<meta name="twitter:image:alt" content="ScratchNode — live event knowledge room">

Expand Down Expand Up @@ -1703,6 +1703,66 @@
align-items: center;
gap: 18px;
}
/* Live "big number" — real, reactive room counter. Gently floats; the count
animates up when the reactive Convex query pushes a new value. Motion-safe:
no flashing, subtle translate + small status-dot ring, all gated under
prefers-reduced-motion. */
.landing-pulse {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-bottom: 4px;
animation: landing-pulse-float 5.5s ease-in-out infinite;
}
/* A class rule beats the UA [hidden]{display:none}, so make the hide explicit —
otherwise the counter would flash a dishonest "0" before real data arrives. */
.landing-pulse[hidden] { display: none; }
.landing-pulse__big {
font-family: var(--ui);
font-weight: 800;
font-size: 64px;
line-height: 1;
letter-spacing: -0.03em;
color: var(--accent);
text-shadow: 0 0 26px rgba(217,119,87,.42), 0 0 60px rgba(217,119,87,.16);
font-variant-numeric: tabular-nums;
}
.landing-pulse__suffix { font-size: 40px; opacity: .8; }
.landing-pulse__label {
font-family: var(--ui);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-muted);
}
.landing-pulse__live {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 6px;
font-family: var(--ui);
font-size: 13px;
color: var(--ink-faint);
}
.landing-pulse__live #landing-pulse-live { color: var(--ink); font-weight: 600; }
.landing-pulse__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #4ade80;
box-shadow: 0 0 0 0 rgba(74,222,128,.55);
animation: landing-pulse-dot 2.4s ease-out infinite;
}
@keyframes landing-pulse-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes landing-pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(74,222,128,.55); }
70% { box-shadow: 0 0 0 9px rgba(74,222,128,0); }
100% { box-shadow: 0 0 0 0 rgba(74,222,128,0); }
}
.landing-logo {
font-family: var(--ui);
font-weight: 800;
Expand Down Expand Up @@ -1893,13 +1953,17 @@
.landing-join button { width: 100%; }
.landing-create-sub { flex-direction: column; }
.landing-create button { width: 100%; }
.landing-pulse__big { font-size: 48px; }
.landing-pulse__suffix { font-size: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.landing-join input,
.landing-join button,
.landing-create input,
.landing-create button,
.landing-secondary { transition: none; }
.landing-pulse,
.landing-pulse__dot { animation: none; }
}

</style>
Expand Down Expand Up @@ -2097,14 +2161,111 @@
return fail((e && e.message) || 'Unexpected error creating the room.');
}
}

/**
* _snInitLandingStats — the live "big number" on the apex landing.
*
* Subscribes read-only to events:getLandingStats via a lazily-loaded Convex
* client and animates the room counter up when the reactive query pushes a new
* value (Convex re-runs the query for every subscriber the instant a room is
* created — true live updates, no client polling).
*
* Bulletproof + honest:
* - Runs ONLY in landing mode; never sets data-sn-live (apex stays "not live").
* - Never throws to the page; any failure leaves the stat hidden.
* - Hides entirely when roomsCreated < 1 — never a fabricated number on an
* empty or offline backend (agentic_reliability HONEST_SCORES).
*/
function _snFmtStat(n) {
try { return Number(n).toLocaleString('en-US'); } catch (e) { return String(n); }
}
function _snInitLandingStats() {
try {
if (!document.body || document.body.dataset.pageMode !== 'landing') return;
var wrap = document.getElementById('landing-pulse');
var numEl = document.getElementById('landing-pulse-num');
var liveEl = document.getElementById('landing-pulse-live');
var sufEl = document.getElementById('landing-pulse-suffix');
if (!wrap || !numEl || !liveEl) return;
var reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
var shownRooms = 0;
var raf = null;

function animateTo(target) {
if (raf) { cancelAnimationFrame(raf); raf = null; }
if (reduce || shownRooms === target) { numEl.textContent = _snFmtStat(target); shownRooms = target; return; }
var from = shownRooms;
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));
if (p < 1) { raf = requestAnimationFrame(step); }
else { raf = null; shownRooms = target; }
}
raf = requestAnimationFrame(step);
}

function render(stats) {
if (!stats || typeof stats.roomsCreated !== 'number') return;
var rooms = stats.roomsCreated;
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);
}

function connect() {
fetch('/api/scratchnode-config', { cache: 'no-store' })
.then(function (r) { if (!r.ok) throw new Error('cfg'); return r.json(); })
.then(function (cfg) {
if (!cfg || !cfg.convexUrl) throw new Error('nourl');
return import('https://esm.sh/convex@1.29.0/browser').then(function (mod) {
var ConvexClient = mod.ConvexClient;
if (!ConvexClient) throw new Error('noclient');
var client = new ConvexClient(cfg.convexUrl);
window._sn_landing_stats = { client: client };
client.onUpdate('events:getLandingStats', {}, function (stats) {
try { render(stats); } catch (e) {}
});
});
})
.catch(function () { /* honest: backend unreachable -> stat stays hidden */ });
}

if (window.requestIdleCallback) { requestIdleCallback(connect, { timeout: 2500 }); }
else { setTimeout(connect, 450); }
} catch (e) { /* never break the landing */ }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _snInitLandingStats);
} else {
_snInitLandingStats();
}
</script>

<!-- Landing-mode hero — visible only when data-page-mode === "landing" -->
<section class="landing" role="region" aria-label="ScratchNode landing">
<div class="landing-hero">
<!-- Live "big number" — real, reactive room count. Hidden until the live
backend reports >=1 room (no fabricated number on an empty/offline
backend). Ticks up the instant anyone, anywhere, creates a room. -->
<div class="landing-pulse" id="landing-pulse" aria-live="polite" hidden>
<div class="landing-pulse__big">
<span id="landing-pulse-num">0</span><span class="landing-pulse__suffix" id="landing-pulse-suffix"></span>
</div>
<div class="landing-pulse__label">rooms spun up on ScratchNode</div>
<div class="landing-pulse__live">
<span class="landing-pulse__dot" aria-hidden="true"></span>
<span id="landing-pulse-live">0</span> live right now
</div>
</div>
<span class="landing-logo">Scratch<span>Node</span></span>
<h1>A disposable sidecar room for live event memory.</h1>
<p>Bring your Luma, Slack, or Eventbrite crowd. Anyone with a room code can join, chat publicly, use <code style="font-family:var(--mono);background:rgba(217,119,87,.12);color:var(--accent);padding:1px 6px;border-radius:3px">/ask</code> for sourced answers, and keep private notes that never enter the public room.</p>
<h1>The room remembers everything.</h1>
<p>Drop a code, chat live, hit <code style="font-family:var(--mono);background:rgba(217,119,87,.12);color:var(--accent);padding:1px 6px;border-radius:3px">/ask</code> for sourced answersand walk away with a wiki of everything that happened. No account, no app.</p>
<form class="landing-join" onsubmit="return _landingJoin(event)" aria-label="Join an event by room code">
<input type="text" placeholder="Enter room code (e.g. ORBITAL)" aria-label="Room code" maxlength="60" autocomplete="off" autocapitalize="off" spellcheck="false">
<button type="submit">Join &rarr;</button>
Expand Down
Loading
Loading