diff --git a/CHANGELOG/pages/proto-home-v5.md b/CHANGELOG/pages/proto-home-v5.md index d66c67807..50b9edb2d 100644 --- a/CHANGELOG/pages/proto-home-v5.md +++ b/CHANGELOG/pages/proto-home-v5.md @@ -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 ``, +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 diff --git a/convex/events.ts b/convex/events.ts index 919bbb5cb..371001ecd 100644 --- a/convex/events.ts +++ b/convex/events.ts @@ -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). diff --git a/public/og-scratchnode.png b/public/og-scratchnode.png index 866520902..852e57fed 100644 Binary files a/public/og-scratchnode.png and b/public/og-scratchnode.png differ diff --git a/public/og-scratchnode.svg b/public/og-scratchnode.svg index f7487909b..efb8bcd6e 100644 --- a/public/og-scratchnode.svg +++ b/public/og-scratchnode.svg @@ -1,11 +1,17 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630"> <rect width="1200" height="630" fill="#151413"/> <rect x="40" y="40" width="1120" height="550" rx="24" fill="#1c1b1a" stroke="rgba(255,255,255,0.08)" stroke-width="1"/> - <circle cx="112" cy="232" r="32" fill="#d97757"/> - <text x="80" y="320" fill="#e8e6e3" font-family="Manrope, system-ui, sans-serif" font-weight="700" font-size="52">ScratchNode</text> - <text x="80" y="378" fill="#a8a5a0" font-family="Manrope, system-ui, sans-serif" font-weight="500" font-size="28">Turn live events into public knowledge</text> - <text x="80" y="430" fill="#6b6864" font-family="Manrope, system-ui, sans-serif" font-weight="400" font-size="20">Join with a room code. Ask anything. The room compacts into a wiki at event end.</text> - <rect x="80" y="480" width="200" height="48" rx="10" fill="#d97757"/> - <text x="180" y="510" text-anchor="middle" fill="white" font-family="Manrope, system-ui, sans-serif" font-weight="600" font-size="17">scratchnode.live</text> - <text x="80" y="556" fill="#6b6864" font-family="JetBrains Mono, ui-monospace, monospace" font-weight="400" font-size="14">no account · no app · public by default</text> + <!-- brand wordmark --> + <circle cx="94" cy="120" r="13" fill="#d97757"/> + <text x="118" y="131" fill="#e8e6e3" font-family="Manrope, system-ui, sans-serif" font-weight="800" font-size="30">Scratch<tspan fill="#d97757">Node</tspan></text> + <!-- hero headline (matches the landing) --> + <text x="78" y="300" fill="#f0eeec" font-family="Manrope, system-ui, sans-serif" font-weight="800" font-size="60" letter-spacing="-1.5">The room remembers everything.</text> + <!-- supporting copy --> + <text x="80" y="356" fill="#a8a5a0" font-family="Manrope, system-ui, sans-serif" font-weight="500" font-size="25">Drop a code, chat live, /ask for sourced answers —</text> + <text x="80" y="392" fill="#a8a5a0" font-family="Manrope, system-ui, sans-serif" font-weight="500" font-size="25">and walk away with a wiki of the whole event.</text> + <!-- url pill --> + <rect x="80" y="452" width="208" height="50" rx="12" fill="#d97757"/> + <text x="184" y="484" text-anchor="middle" fill="white" font-family="Manrope, system-ui, sans-serif" font-weight="600" font-size="18">scratchnode.live</text> + <!-- footer --> + <text x="80" y="558" fill="#6b6864" font-family="JetBrains Mono, ui-monospace, monospace" font-weight="400" font-size="15">no account · no app · public by default</text> </svg> diff --git a/public/proto/home-v5.html b/public/proto/home-v5.html index df2c15d17..479fe5ceb 100644 --- a/public/proto/home-v5.html +++ b/public/proto/home-v5.html @@ -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 - +ScratchNode — the room remembers everything + - - + + @@ -28,8 +28,8 @@ - - + + @@ -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; @@ -1893,6 +1953,8 @@ .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, @@ -1900,6 +1962,8 @@ .landing-create input, .landing-create button, .landing-secondary { transition: none; } + .landing-pulse, + .landing-pulse__dot { animation: none; } } @@ -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(); +}
+ + -

A disposable sidecar room for live event memory.

-

Bring your Luma, Slack, or Eventbrite crowd. Anyone with a room code can join, chat publicly, use /ask for sourced answers, and keep private notes that never enter the public room.

+

The room remembers everything.

+

Drop a code, chat live, hit /ask for sourced answers — and walk away with a wiki of everything that happened. No account, no app.

diff --git a/scripts/ui/renderOgImage.mjs b/scripts/ui/renderOgImage.mjs new file mode 100644 index 000000000..d45f0bc99 --- /dev/null +++ b/scripts/ui/renderOgImage.mjs @@ -0,0 +1,53 @@ +// Rasterize the ScratchNode OG card SVG -> PNG (1200x630). +// +// Crawlers (Slack/Discord/X/iMessage/LinkedIn) prefer a real PNG over SVG for +// link previews, so og:image points at og-scratchnode.png. This script renders +// public/og-scratchnode.svg through the already-installed Playwright Chromium, +// loading the brand fonts (Manrope + JetBrains Mono) from Google Fonts so the +// card matches the live site. If the font CDN is unreachable the SVG's own +// fallback chain (system-ui / ui-monospace) keeps the render legible. +// +// Run: node scripts/ui/renderOgImage.mjs +// Re-run whenever public/og-scratchnode.svg changes. + +import { chromium } from "@playwright/test"; +import { readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +const SVG_PATH = resolve("public/og-scratchnode.svg"); +const PNG_PATH = resolve("public/og-scratchnode.png"); + +const svg = readFileSync(SVG_PATH, "utf8"); +const html = ` + + + + +${svg}`; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ + viewport: { width: 1200, height: 630 }, + deviceScaleFactor: 1, + }); + // networkidle so the Google Fonts stylesheet + font files settle; tolerate a + // timeout (offline CI) and fall back to the SVG's own font chain. + try { + await page.setContent(html, { waitUntil: "networkidle", timeout: 15000 }); + } catch { + await page.setContent(html, { waitUntil: "domcontentloaded" }); + } + try { + await page.evaluate(() => (document.fonts ? document.fonts.ready : null)); + } catch { + /* fonts API unavailable — proceed with whatever loaded */ + } + const el = await page.$("svg"); + if (!el) throw new Error("svg element not found in render page"); + await el.screenshot({ path: PNG_PATH, type: "png" }); + const { size } = statSync(PNG_PATH); + console.log(`wrote ${PNG_PATH} (${size} bytes, 1200x630)`); +} finally { + await browser.close(); +} diff --git a/tests/e2e/scratchnode-live-route-honesty.spec.ts b/tests/e2e/scratchnode-live-route-honesty.spec.ts index 5f83c7e9c..2dd5ac6e3 100644 --- a/tests/e2e/scratchnode-live-route-honesty.spec.ts +++ b/tests/e2e/scratchnode-live-route-honesty.spec.ts @@ -114,6 +114,10 @@ async function fulfillScratchNodePage( if (name === 'events:getMembers') { setTimeout(() => cb([{ displayName: 'Mock Host' }, { displayName: 'Mock Guest' }]), 0); } + if (name === 'events:getLandingStats') { + const s = window.__snLandingStats || { roomsCreated: 0, liveNow: 0, capped: false }; + setTimeout(() => cb(s), 0); + } } } `, @@ -375,4 +379,49 @@ test.describe("ScratchNode live route honesty", () => { ).toBeNull(); await expect(page.locator("#landing-create-btn")).toBeEnabled(); }); + + test("landing surfaces a live 'big number' room counter from real backend data", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.addInitScript(() => { + (window as any).__snLandingStats = { roomsCreated: 1342, liveNow: 7, capped: false }; + }); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#landing-pulse")).toBeVisible({ timeout: 6_000 }); + // The big number is the EXACT reactive backend count — never a marketing figure. + await expect + .poll(() => page.locator("#landing-pulse-num").textContent(), { timeout: 6_000 }) + .toBe("1,342"); + await expect(page.locator("#landing-pulse-live")).toHaveText("7"); + await expect(page.locator("#landing-pulse-suffix")).toHaveText(""); + }); + + test("landing big-number renders a '+' suffix when the scan cap is hit", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.addInitScript(() => { + (window as any).__snLandingStats = { roomsCreated: 5000, liveNow: 23, capped: true }; + }); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#landing-pulse")).toBeVisible({ timeout: 6_000 }); + await expect + .poll(() => page.locator("#landing-pulse-num").textContent(), { timeout: 6_000 }) + .toBe("5,000"); + await expect(page.locator("#landing-pulse-suffix")).toHaveText("+"); + }); + + test("landing big-number stays hidden (never fabricated) when zero rooms exist", async ({ page }) => { + await fulfillScratchNodePage(page); + await page.addInitScript(() => { + (window as any).__snLandingStats = { roomsCreated: 0, liveNow: 0, capped: false }; + }); + + await page.goto("https://scratchnode.live/", { waitUntil: "domcontentloaded" }); + // Give the reactive subscription time to deliver the zero value, then assert + // the counter is NOT shown — an empty backend never produces a fake number. + await page.waitForTimeout(1_500); + await expect(page.locator("#landing-pulse")).toBeHidden(); + // The landing still works — Join + Create remain available. + await expect(page.locator("#landing-create-btn")).toBeVisible(); + }); });