openvibers/openvibe.live Commits

Promo cinematics: WebGL aurora, glitch title, typewriter pleas, shake + coin burst, parallax, odometers, scoreboard, receipts wall, card foil

OP OpenVibers committed 11 Sept 2026 · 2438b4baa5 · 4 files, +241 −11

- takeover: fragment-shader aurora (falls back to the CSS one), scanlines, glitch-in title,
  cycling typewriter plea, screen shake + coin burst when the amount lands, pointer parallax,
  live odometers (converted / paid out / claims) from the ledger
- home: animated OpenVibe-vs-RobotStreamer scoreboard (checks pop, crosses shake, "?" for
  anything closed-source can't answer), odometers, receipts wall from GET /api/promo/receipts
  (paid rows only, no payout details), holographic foil on the bounty card

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/rs-promo.css b/public/css/rs-promo.css
iindex 2fdd948c..e9dbc181 100644
---- a/public/css/rs-promo.css
++++ b/public/css/rs-promo.css
@@@ -110,6 +110,34 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
@keyframes rsFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes rsTakeoverOut { to { opacity: 0; transform: scale(1.06); filter: blur(10px); } }
++/* WebGL aurora + scanlines + shake */
++.rs-tk-gl { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; filter: saturate(1.2); }
++.rs-tk-burst { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 3; }
++.rs-tk-scan { position: absolute; inset: 0; pointer-events: none; z-index: 1; background: repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 1px, transparent 1px 4px); mix-blend-mode: overlay; animation: rsScan 8s linear infinite; }
++@keyframes rsScan { to { background-position: 0 200px; } }
++.rs-takeover.rs-shake { animation: rsShakeIt 0.55s cubic-bezier(.36,.07,.19,.97) both; }
++@keyframes rsShakeIt { 10%,90% { transform: translate(-2px, 1px); } 20%,80% { transform: translate(4px, -2px); } 30%,50%,70% { transform: translate(-8px, 3px); } 40%,60% { transform: translate(8px, -3px); } }
++.rs-tk-content { transition: transform 0.12s ease-out; }
++.rs-tk-amount { transform-style: preserve-3d; }
++.rs-tk-amount .rs-money { display: inline-block; transform: var(--tilt, none); transition: transform 0.12s ease-out; }
++/* Glitch-in title */
++.rs-glitch { position: relative; }
++.rs-glitch::before, .rs-glitch::after { content: attr(data-text); position: absolute; left: 0; top: 0; width: 100%; color: #fff; opacity: 0; pointer-events: none; clip-path: inset(0 0 0 0); }
++.rs-glitch::before { text-shadow: -2px 0 #22d3ee; animation: rsGlitchA 3.2s steps(1) 0.6s 2; }
++.rs-glitch::after { text-shadow: 2px 0 #f472b6; animation: rsGlitchB 3.2s steps(1) 0.6s 2; }
++@keyframes rsGlitchA { 0%,100% { opacity: 0; } 6% { opacity: 1; clip-path: inset(10% 0 60% 0); transform: translate(-4px, 0); } 10% { opacity: 1; clip-path: inset(50% 0 20% 0); transform: translate(4px, 0); } 14% { opacity: 0; } 42% { opacity: 1; clip-path: inset(30% 0 40% 0); transform: translate(-3px, 0); } 46% { opacity: 0; } }
++@keyframes rsGlitchB { 0%,100% { opacity: 0; } 8% { opacity: 1; clip-path: inset(60% 0 5% 0); transform: translate(5px, 0); } 12% { opacity: 1; clip-path: inset(0 0 75% 0); transform: translate(-5px, 0); } 16% { opacity: 0; } 44% { opacity: 1; clip-path: inset(20% 0 55% 0); transform: translate(3px, 0); } 48% { opacity: 0; } }
++/* Typewriter plea */
++.rs-tk-type { min-height: 1.6em; margin: 4px 0 2px; font-size: clamp(1rem, 2.4vw, 1.35rem); font-weight: 800; color: #fde68a; font-family: var(--font-mono); letter-spacing: 0.02em; }
++.rs-tk-caret { animation: rsCaret 0.9s steps(1) infinite; color: #fbbf24; }
++@keyframes rsCaret { 50% { opacity: 0; } }
++/* Odometers */
++.rs-odos { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin: 0 0 20px; }
++.rs-odo { display: grid; justify-items: center; min-width: 120px; padding: 8px 14px; border-radius: 12px; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.16); }
++.rs-odo b { font-size: 1.6rem; font-weight: 900; color: #fde68a; font-variant-numeric: tabular-nums; text-shadow: 0 0 16px rgba(251,191,36,0.5); transition: transform 0.2s; }
++.rs-odo b.is-done { animation: rsAmountPop 0.5s cubic-bezier(.2,1.6,.3,1); }
++.rs-odo small { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; color: #cbd5e1; }
++
/* Aurora blobs drifting behind everything */
.rs-tk-aurora { position: absolute; inset: -20%; pointer-events: none; filter: blur(60px) saturate(1.3); opacity: 0.85; }
.rs-tk-aurora span {
@@@ -380,6 +408,42 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-card-replay { background: none; border: none; color: #94a3b8; font-size: 0.72rem; cursor: pointer; text-decoration: underline dotted; }
.rs-card-replay:hover { color: #fff; }
++/* ═══════════════════════════════════════════════════════════════
++ SCOREBOARD + RECEIPTS — home page, under the VIP card
++ ═══════════════════════════════════════════════════════════════ */
++.rs-board { position: relative; z-index: 1; margin-top: 14px; padding: 16px 18px 14px; border-radius: 20px; text-align: left; color: #fff;
++ background: linear-gradient(160deg, rgba(20,16,40,0.96), rgba(10,10,16,0.98)); border: 1px solid rgba(255,255,255,0.14); box-shadow: 0 20px 50px rgba(0,0,0,0.45); overflow: hidden; }
++.rs-board::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-gold)); background-size: 300% 100%; animation: rsTickerBg 6s linear infinite; }
++.rs-board-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-weight: 900; font-size: 1.05rem; margin-bottom: 10px; }
++.rs-board-head i { color: #fde68a; } .rs-board-head small { color: #94a3b8; font-weight: 500; font-size: 0.78rem; }
++.rs-board-cols, .rs-board-row { display: grid; grid-template-columns: 1fr 150px 150px; gap: 8px; align-items: center; }
++.rs-board-cols { padding: 4px 0 8px; border-bottom: 1px solid rgba(255,255,255,0.12); margin-bottom: 4px; }
++.rs-board-col { font-weight: 900; font-size: 0.82rem; letter-spacing: 0.06em; text-transform: uppercase; text-align: center; }
++.rs-board-us { color: #c4b5fd; } .rs-board-them { color: #94a3b8; }
++.rs-board-row { padding: 7px 0; border-bottom: 1px dashed rgba(255,255,255,0.07); opacity: 0; transform: translateX(-16px); transition: opacity 0.5s ease calc(var(--i, 0) * 90ms), transform 0.6s cubic-bezier(.2,1.3,.3,1) calc(var(--i, 0) * 90ms); }
++.rs-board.is-in .rs-board-row { opacity: 1; transform: none; }
++.rs-board-feat { font-size: 0.88rem; color: #e2e8f0; }
++.rs-yes, .rs-no, .rs-meh { display: inline-flex; align-items: center; justify-content: center; gap: 6px; margin: 0 auto; min-width: 34px; height: 30px; padding: 0 10px; border-radius: 999px; font-weight: 800; font-size: 0.8rem; }
++.rs-yes { color: #052e16; background: linear-gradient(135deg, #86efac, #22c55e); box-shadow: 0 0 14px rgba(34,197,94,0.45); transform: scale(0.6); }
++.rs-board.is-in .rs-yes { animation: rsYesPop 0.5s cubic-bezier(.2,1.7,.3,1) calc(var(--i, 0) * 90ms + 200ms) forwards; }
++@keyframes rsYesPop { to { transform: scale(1); } }
++.rs-yes-text { font-size: 0.72rem; white-space: nowrap; }
++.rs-no { color: #fecaca; background: rgba(239,68,68,0.2); border: 1px solid rgba(239,68,68,0.6); }
++.rs-board.is-in .rs-no { animation: rsNoShake 0.5s ease calc(var(--i, 0) * 90ms + 300ms); }
++@keyframes rsNoShake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px) rotate(-6deg); } 75% { transform: translateX(4px) rotate(6deg); } }
++.rs-meh { color: #94a3b8; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); cursor: help; }
++.rs-odos-home { margin: 14px 0 6px; }
++.rs-receipts-head { font-weight: 800; font-size: 0.9rem; margin: 8px 0 6px; } .rs-receipts-head i { color: #fde68a; } .rs-receipts-head small { color: #94a3b8; font-weight: 500; font-size: 0.74rem; margin-left: 6px; }
++.rs-receipts-row { display: flex; flex-wrap: wrap; gap: 6px; }
++.rs-receipt-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 5px; border-radius: 999px; font-size: 0.78rem; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.45); animation: rsFeatPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(var(--i, 0) * 60ms); }
++.rs-receipt-chip img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
++.rs-receipt-chip .rs-money { font-size: 0.86rem; } .rs-receipt-chip small { color: #94a3b8; }
++@media (max-width: 720px) { .rs-board-cols, .rs-board-row { grid-template-columns: 1fr 74px 74px; } .rs-yes-text { font-size: 0.62rem; white-space: normal; text-align: center; } .rs-board-col { font-size: 0.66rem; } }
++
++/* Holographic foil on the bounty card (moves with the pointer via --rs-mx/--rs-my) */
++.rs-card-foil { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: var(--rs-glare, 0); transition: opacity 0.3s; mix-blend-mode: color-dodge;
++ background: conic-gradient(from calc(var(--rs-mx, 50%) * 3.6deg) at var(--rs-mx, 50%) var(--rs-my, 50%), rgba(255,0,128,0.35), rgba(255,215,0,0.35), rgba(0,255,200,0.35), rgba(80,120,255,0.35), rgba(255,0,128,0.35)); filter: blur(18px) saturate(1.4); }
++
/* ═══════════════════════════════════════════════════════════════
VIP RECRUITER — the starfish bounty board under the hero card
═══════════════════════════════════════════════════════════════ */
@@@ -718,7 +782,8 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-modal, .rs-modal-card, .rs-modal-card::before, .rs-step, .rs-modal-fine i, .rs-wiggle,
.rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot,
.rs-flow2-node, .rs-flow2-hub, .rs-flow2-fan::before, .rs-packet, .rs-feat,
-- .rs-vip, .rs-vip-bubbles span, .rs-vip-star, .rs-vip-pupil, .rs-vip-sparkle {
++ .rs-vip, .rs-vip-bubbles span, .rs-vip-star, .rs-vip-pupil, .rs-vip-sparkle,
++ .rs-tk-scan, .rs-glitch::before, .rs-glitch::after, .rs-tk-caret, .rs-odo b, .rs-board-row, .rs-yes, .rs-no, .rs-receipt-chip, .rs-board::before {
animation: none !important;
}
.rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }
@@@ -726,6 +791,7 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-ticker-track .rs-ticker-copy:nth-child(2) { display: none; }
.rs-hero-card { transform: none !important; }
.rs-card-glare { display: none; }
-- .rs-tk-canvas, .rs-confetti { display: none; }
++ .rs-tk-canvas, .rs-confetti, .rs-tk-gl, .rs-tk-burst, .rs-tk-scan { display: none; }
++ .rs-board-row { opacity: 1; transform: none; } .rs-yes { transform: none; }
.rs-money { background: none; -webkit-text-fill-color: var(--rs-gold); color: var(--rs-gold); }
}
ddiff --git a/public/index.html b/public/index.html
iindex eb681548..4ff538b9 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -67,7 +67,7 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/rs-promo.css?v=7">
++ <link rel="stylesheet" href="/css/rs-promo.css?v=8">
<link rel="stylesheet" href="/css/home-fx.css?v=2">
<link rel="stylesheet" href="/css/i18n-star.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -5126,7 +5126,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
--<script src="/js/rs-promo.js?v=9" defer></script>
++<script src="/js/rs-promo.js?v=10" defer></script>
<script src="/js/home-fx.js?v=1" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
ddiff --git a/public/js/rs-promo.js b/public/js/rs-promo.js
iindex 11b8f82f..d551620c 100644
---- a/public/js/rs-promo.js
++++ b/public/js/rs-promo.js
@@@ -21,16 +21,17 @@
(function () {
'use strict';
-- const PROMO_VERSION = 'rs-desperate-v6'; // bump to re-show the takeover to everyone
++ const PROMO_VERSION = 'rs-desperate-v7'; // bump to re-show the takeover to everyone
const SEEN_KEY = 'ov_rs_promo_seen';
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
-- const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, vip: null, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };
++ const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, vip: null, totals: null, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };
const STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) state
let modalEl = null, takeoverEl = null, tickerEl = null;
-- let rainStop = null;
++ let rainStop = null, glStop = null;
++ const PLEAS = ['I am begging you.', 'I will do anything. Name it.', 'Out of my own pocket. No investors.', 'Rick will never offer you this.', 'Restream everywhere. From here. Paid.', 'Every line of this site is public code.', 'I genuinely care that you stream here.'];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const money = () => (cfg.amountMin && cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${cfg.amount}` : `$${cfg.amount}`);
@@@ -157,18 +158,27 @@
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', `RobotStreamer switch bonus: ${money()}`);
el.innerHTML = `
++ <canvas class="rs-tk-gl" aria-hidden="true"></canvas>
<div class="rs-tk-aurora" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
<div class="rs-tk-grid" aria-hidden="true"></div>
<canvas class="rs-tk-canvas" aria-hidden="true"></canvas>
++ <canvas class="rs-tk-burst" aria-hidden="true"></canvas>
++ <div class="rs-tk-scan" aria-hidden="true"></div>
<button class="rs-tk-close" type="button" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
<div class="rs-tk-content">
<div class="rs-tk-eyebrow"><i class="fa-solid fa-robot"></i> robotstreamer.com users</div>
-- <h1 class="rs-tk-title">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1>
++ <h1 class="rs-tk-title rs-glitch" data-text="${esc(titleWords.join(' '))}">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1>
++ <div class="rs-tk-type" aria-live="polite"><span class="rs-tk-type-text"></span><span class="rs-tk-caret">▌</span></div>
<div class="rs-tk-amount" aria-label="${money()}"><span class="rs-money" data-countup="${cfg.amount}">${cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$0` : '$0'}</span></div>
<p class="rs-tk-sub">I am <b>extremely desperate</b>. I will do <b>anything</b> to get you off RobotStreamer and onto <b>OpenVibe.Live</b>. Never stream there again, stream <b>only</b> here, and I personally send you <b>${money()}</b> — Zelle, PayPal or crypto, your pick. This is <b>out of my own pocket</b>. There are no investors. There is a wallet, and there are feelings.<br>Want a feature? <b>Name it. I build it.</b> Want me to grovel? This is the groveling. I genuinely care about you streaming here — that is the entire business plan. Not a corporation — one unhinged guy who wants you here more than Rick ever will.</p>
<div class="rs-tk-tour"><div class="rs-tk-tour-head"><i class="fa-solid fa-satellite-dish"></i> Stream here. Be everywhere. Keep the toys.</div>${restreamHtml(false)}</div>
<p class="rs-tk-trust"><i class="fa-brands fa-github"></i> <b>Trust what you can read.</b> Every line of this site is ${ghLink('public on GitHub')}. Theirs is closed source — you'll never see what it does. An open alternative is the only kind worth trusting.<br><i class="fa-solid fa-people-arrows"></i> <b>Already one of us?</b> Bring a RobotStreamer streamer over and you get <b>${referral()}</b> too.</p>
<div class="rs-chips">${chipsHtml()}</div>
++ <div class="rs-odos" aria-label="live totals">
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.converted || 0)}">0</b><small>streamers converted</small></div>
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.paid || 0)}" data-prefix="$">$0</b><small>paid out so far</small></div>
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.claims || 0)}">0</b><small>claims filed</small></div>
++ </div>
<div class="rs-flow" aria-hidden="true">
<span class="rs-flow-node rs-from"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
<span class="rs-flow-arrow"><span class="rs-walker">🤖</span></span>
@@@ -196,9 +206,15 @@
store.set(SEEN_KEY, PROMO_VERSION);
if (!REDUCED) {
rainStop = startMoneyRain(takeoverEl.querySelector('.rs-tk-canvas'));
-- countUp(takeoverEl.querySelector('[data-countup]'), cfg.amount, 1500, 500);
++ glStop = startShader(takeoverEl.querySelector('.rs-tk-gl'));
++ attachParallax(takeoverEl);
++ typewriter(takeoverEl.querySelector('.rs-tk-type-text'), PLEAS);
++ countUp(takeoverEl.querySelector('[data-countup]'), cfg.amount, 1500, 500, () => { shake(takeoverEl); coinBurst(takeoverEl.querySelector('.rs-tk-burst')); });
++ takeoverEl.querySelectorAll('[data-odo]').forEach(el => odometer(el, 900));
} else {
takeoverEl.querySelector('[data-countup]').textContent = money();
++ takeoverEl.querySelectorAll('[data-odo]').forEach(el => { el.textContent = (el.dataset.prefix || '') + el.dataset.odo; });
++ takeoverEl.querySelector('.rs-tk-type-text').textContent = PLEAS[0];
}
document.addEventListener('keydown', onTakeoverKey);
setTimeout(() => takeoverEl?.querySelector('[data-act="claim"]')?.focus({ preventScroll: true }), 900);
@@@ -210,11 +226,12 @@
document.removeEventListener('keydown', onTakeoverKey);
document.body.style.overflow = '';
if (rainStop) { rainStop(); rainStop = null; }
++ if (glStop) { glStop(); glStop = null; }
el.classList.add('leaving');
setTimeout(() => el.remove(), REDUCED ? 0 : 480);
}
-- function countUp(el, target, duration, delay) {
++ function countUp(el, target, duration, delay, onDone) {
if (!el) return;
const t0 = performance.now() + (delay || 0);
const ease = (t) => 1 - Math.pow(2, -10 * t); // easeOutExpo
@@@ -223,11 +240,104 @@
const fmt = (n) => (cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${n}` : `$${n}`);
el.textContent = fmt(Math.round(ease(t) * target));
if (t < 1) requestAnimationFrame(frame);
-- else { el.textContent = fmt(target); el.parentElement?.classList.add('pop'); }
++ else { el.textContent = fmt(target); el.parentElement?.classList.add('pop'); if (onDone) onDone(); }
}
requestAnimationFrame(frame);
}
++ // ── WebGL aurora: a tiny fragment shader (no library). Falls back to the CSS aurora silently. ──
++ function startShader(canvas) {
++ if (!canvas) return () => {};
++ let gl = null;
++ try { gl = canvas.getContext('webgl', { alpha: true, antialias: false, powerPreference: 'low-power' }); } catch { gl = null; }
++ if (!gl) { canvas.remove(); return () => {}; }
++ const vs = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';
++ const fs = `precision mediump float;uniform vec2 r;uniform float t;uniform vec2 m;
++ float n(vec2 p){return sin(p.x)*cos(p.y);}
++ void main(){vec2 uv=(gl_FragCoord.xy/r.xy);vec2 q=uv*2.-1.;q.x*=r.x/r.y;
++ float a=0.;vec2 mm=(m-.5)*.6;
++ for(int i=1;i<5;i++){float f=float(i);a+=n(q*f*1.3+vec2(t*.13*f+mm.x*f,t*.09*f+mm.y*f))/f;}
++ a=smoothstep(-.6,.9,a);
++ vec3 c1=vec3(.55,.36,.97);vec3 c2=vec3(.06,.65,.91);vec3 c3=vec3(.98,.75,.14);
++ vec3 col=mix(c1,c2,a);col=mix(col,c3,pow(max(0.,a-.55),2.)*1.8);
++ float v=smoothstep(1.35,.25,length(q*vec2(.8,1.)));
++ gl_FragColor=vec4(col*a*v*.85,a*v*.9);}`;
++ const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
++ const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(prog);
++ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { canvas.remove(); return () => {}; }
++ gl.useProgram(prog);
++ const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
++ const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
++ const uR = gl.getUniformLocation(prog, 'r'), uT = gl.getUniformLocation(prog, 't'), uM = gl.getUniformLocation(prog, 'm');
++ gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
++ let raf = 0, running = true, mx = 0.5, my = 0.5;
++ const resize = () => { const s = Math.min(1, 1200 / Math.max(1, canvas.clientWidth)); canvas.width = Math.max(1, Math.round(canvas.clientWidth * s * 0.6)); canvas.height = Math.max(1, Math.round(canvas.clientHeight * s * 0.6)); gl.viewport(0, 0, canvas.width, canvas.height); };
++ const onMove = (e) => { mx = e.clientX / window.innerWidth; my = 1 - e.clientY / window.innerHeight; };
++ const t0 = performance.now();
++ const frame = (now) => { if (!running) return; gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform1f(uT, (now - t0) / 1000); gl.uniform2f(uM, mx, my); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); raf = requestAnimationFrame(frame); };
++ const onVis = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else if (!running) { running = true; raf = requestAnimationFrame(frame); } };
++ resize(); window.addEventListener('resize', resize); window.addEventListener('pointermove', onMove); document.addEventListener('visibilitychange', onVis);
++ raf = requestAnimationFrame(frame);
++ return () => { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); window.removeEventListener('pointermove', onMove); document.removeEventListener('visibilitychange', onVis); };
++ }
++ // Mouse parallax: the content and the grid drift against the pointer.
++ function attachParallax(root) {
++ if (COARSE) return;
++ const content = root.querySelector('.rs-tk-content'), grid = root.querySelector('.rs-tk-grid'), amount = root.querySelector('.rs-tk-amount');
++ let raf = 0;
++ root.addEventListener('pointermove', (e) => {
++ const px = e.clientX / window.innerWidth - 0.5, py = e.clientY / window.innerHeight - 0.5;
++ cancelAnimationFrame(raf);
++ raf = requestAnimationFrame(() => {
++ if (content) content.style.transform = `translate(${(-px * 14).toFixed(1)}px, ${(-py * 10).toFixed(1)}px)`;
++ if (grid) grid.style.transform = `perspective(600px) rotateX(64deg) translateX(${(px * 40).toFixed(1)}px)`;
++ if (amount) amount.style.setProperty('--tilt', `rotateY(${(px * 18).toFixed(1)}deg) rotateX(${(-py * 12).toFixed(1)}deg)`);
++ });
++ });
++ }
++ // Typewriter cycling the pleas.
++ function typewriter(el, lines) {
++ if (!el) return;
++ let li = 0, ci = 0, del = false, t = null;
++ const step = () => {
++ if (!el.isConnected) return;
++ const line = lines[li % lines.length];
++ if (!del) { ci++; el.textContent = line.slice(0, ci); if (ci >= line.length) { del = true; t = setTimeout(step, 1400); return; } t = setTimeout(step, 34 + Math.random() * 40); }
++ else { ci--; el.textContent = line.slice(0, ci); if (ci <= 0) { del = false; li++; t = setTimeout(step, 260); return; } t = setTimeout(step, 16); }
++ };
++ t = setTimeout(step, 1200);
++ }
++ // Screen shake on the amount landing.
++ function shake(el) { if (!el) return; el.classList.remove('rs-shake'); void el.offsetWidth; el.classList.add('rs-shake'); setTimeout(() => el.classList.remove('rs-shake'), 600); }
++ // Coin burst from the centre.
++ function coinBurst(canvas) {
++ if (!canvas) return;
++ const ctx = canvas.getContext('2d');
++ const dpr = Math.min(2, window.devicePixelRatio || 1);
++ const w = canvas.clientWidth, h = canvas.clientHeight;
++ canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
++ const parts = [];
++ for (let i = 0; i < 70; i++) { const a = Math.random() * Math.PI * 2, sp = 260 + Math.random() * 520; parts.push({ x: w / 2, y: h * 0.42, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 200, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 14, s: 14 + Math.random() * 18, g: ['🪙', '💸', '💵', '💰'][(Math.random() * 4) | 0], t: 0, life: 1.3 + Math.random() * 0.6 }); }
++ let last = 0, raf = 0;
++ const tick = (now) => {
++ const dt = Math.min(0.04, (now - (last || now)) / 1000); last = now;
++ ctx.clearRect(0, 0, w, h); let alive = 0;
++ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
++ for (const p of parts) { p.t += dt; if (p.t > p.life) continue; alive++; p.vy += 900 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.vr * dt; ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p.t / p.life); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.font = `${p.s}px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif`; ctx.fillText(p.g, 0, 0); ctx.restore(); }
++ if (alive) raf = requestAnimationFrame(tick); else ctx.clearRect(0, 0, w, h);
++ };
++ raf = requestAnimationFrame(tick);
++ setTimeout(() => cancelAnimationFrame(raf), 2600);
++ }
++ // Odometer: numbers roll up with easing and a bump.
++ function odometer(el, delay) {
++ const target = Number(el.dataset.odo) || 0, prefix = el.dataset.prefix || '';
++ if (!target) { el.textContent = prefix + '0'; return; }
++ const t0 = performance.now() + (delay || 0), dur = 1400;
++ const ease = (t) => 1 - Math.pow(1 - t, 4);
++ (function frame(now) { const t = Math.min(1, Math.max(0, (now - t0) / dur)); const v = ease(t) * target; el.textContent = prefix + (Number.isInteger(target) ? Math.round(v) : v.toFixed(2)); if (t < 1) requestAnimationFrame(frame); else { el.textContent = prefix + target; el.classList.add('is-done'); } })(t0);
++ }
++
// Emoji particle rain: bills, coins, robots, hearts. Cheap (fillText), DPR-aware, pauses when hidden.
function startMoneyRain(canvas) {
if (!canvas) return () => {};
@@@ -298,6 +408,7 @@
<div class="rs-card-border">
<div class="rs-hero-card" id="rs-hero-card">
<div class="rs-card-glare" aria-hidden="true"></div>
++ <div class="rs-card-foil" aria-hidden="true"></div>
${coinHtml()}
<div class="rs-card-body">
<div class="rs-card-kicker"><i class="fa-solid fa-robot"></i> RobotStreamer switch bonus</div>
@@@ -347,6 +458,48 @@
mount.appendChild(el);
}
++ // ── Home: the scoreboard (OpenVibe vs RobotStreamer) + the receipts wall (who got paid) ──
++ async function mountScoreboard() {
++ const mount = document.getElementById('rs-hero-mount');
++ if (!mount || mount.querySelector('.rs-board')) return;
++ const rows = [
++ ['Every line of code public on GitHub', 'yes', 'no'],
++ ['Pays YOU to stream here', `${money()} + referrals`, 'no'],
++ ['Restream to Twitch, YouTube, Kick, RTMP — and RobotStreamer', 'yes, from one stream', 'no'],
++ ['Sub-second WebRTC latency', 'yes', '?'],
++ ['7TV / BTTV / FFZ + custom emotes', 'yes', '?'],
++ ['Sound commands & soundboard', 'yes', '?'],
++ ['Robot & hardware controls', 'yes', 'yes'],
++ ['Features built because you asked', 'yes, by me, this week', '?'],
++ ['Owner personally begging you to stream', 'yes (see above)', 'no'],
++ ];
++ const cell = (v, side) => v === 'yes' ? `<span class="rs-yes"><i class="fa-solid fa-check"></i></span>` : v === 'no' ? `<span class="rs-no"><i class="fa-solid fa-xmark"></i></span>` : v === '?' ? `<span class="rs-meh" title="couldn't tell you — it's closed source">?</span>` : `<span class="rs-yes rs-yes-text"><i class="fa-solid fa-check"></i> ${esc(v)}</span>`;
++ const el = document.createElement('div');
++ el.className = 'rs-board';
++ el.innerHTML = `
++ <div class="rs-board-head"><i class="fa-solid fa-scale-unbalanced"></i> The scoreboard <small>why streamers are leaving Rick's site</small></div>
++ <div class="rs-board-cols"><span></span><span class="rs-board-col rs-board-us"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span><span class="rs-board-col rs-board-them"><i class="fa-solid fa-robot"></i> RobotStreamer</span></div>
++ ${rows.map((r, i) => `<div class="rs-board-row" style="--i:${i}"><span class="rs-board-feat">${esc(r[0])}</span>${cell(r[1])}${cell(r[2])}</div>`).join('')}
++ <div class="rs-odos rs-odos-home">
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.converted || 0)}">0</b><small>converted</small></div>
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.paid || 0)}" data-prefix="$">$0</b><small>paid out</small></div>
++ <div class="rs-odo"><b data-odo="${Number(cfg.totals?.claims || 0)}">0</b><small>claims</small></div>
++ </div>
++ <div class="rs-receipts" id="rs-receipts"></div>`;
++ mount.appendChild(el);
++ const startOdo = () => el.querySelectorAll('[data-odo]').forEach(o => { if (REDUCED) o.textContent = (o.dataset.prefix || '') + o.dataset.odo; else odometer(o, 200); });
++ if ('IntersectionObserver' in window) { const io = new IntersectionObserver((en) => { if (en.some(x => x.isIntersecting)) { el.classList.add('is-in'); startOdo(); io.disconnect(); } }, { threshold: 0.2 }); io.observe(el); } else { el.classList.add('is-in'); startOdo(); }
++ try {
++ const r = await fetch('/api/promo/receipts', { credentials: 'same-origin' }); const j = r.ok ? await r.json() : null;
++ const box = el.querySelector('#rs-receipts');
++ if (j && j.receipts && j.receipts.length) {
++ box.innerHTML = `<div class="rs-receipts-head"><i class="fa-solid fa-receipt"></i> Receipts <small>real people, real money, out of my pocket</small></div><div class="rs-receipts-row">${j.receipts.map((x, i) => `<span class="rs-receipt-chip" style="--i:${i}">${x.avatar_url ? `<img src="${esc(x.avatar_url)}" alt="">` : '<i class="fa-solid fa-user"></i>'} <b>${esc(x.display_name || x.username)}</b> <span class="rs-money">$${esc(String(x.amount))}</span> <small>${x.kind === 'referral' ? 'referral' : 'switched'}</small></span>`).join('')}</div>`;
++ } else {
++ box.innerHTML = `<div class="rs-receipts-head"><i class="fa-solid fa-receipt"></i> Receipts <small>nobody has claimed yet — be the first name on this wall</small></div>`;
++ }
++ } catch { /* */ }
++ }
++
function attachTilt(zone, card) {
if (!zone || !card) return;
let raf = 0;
@@@ -647,6 +800,7 @@
if (Number.isFinite(j.amount_min) && j.amount_min > 0) cfg.amountMin = j.amount_min;
if (Number.isFinite(j.referral) && j.referral > 0) cfg.referral = j.referral;
if (j.vip && j.vip.username) cfg.vip = j.vip;
++ if (j.totals) cfg.totals = j.totals;
if (j.github !== undefined) cfg.github = j.github || '';
if (j.owner) cfg.owner = String(j.owner);
if (j.discord !== undefined) cfg.discord = j.discord || '';
@@@ -659,6 +813,7 @@
mountTicker();
mountHeroCard();
mountVipCard();
++ mountScoreboard().catch(() => {});
mountStreamAlert();
watchChannelPage();
// Let the page settle (fonts, hero paint) before the splash slams in.
ddiff --git a/server/promo/routes.js b/server/promo/routes.js
iindex 38713799..dc4e1d35 100644
---- a/server/promo/routes.js
++++ b/server/promo/routes.js
@@@ -28,6 +28,15 @@ router.get('/robotstreamer', (req, res) => {
res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, vip, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null, totals, cashout: true });
});
++// Public social proof: who got paid (username + amount + when). Only PAID rows, no payout details.
++router.get('/receipts', (req, res) => {
++ try {
++ res.set('Cache-Control', 'public, max-age=60');
++ const rows = claims.listAll({ status: 'paid', limit: 30 }).claims.map(c => ({ username: c.user.username, display_name: c.user.display_name, avatar_url: c.user.avatar_url, amount: c.amount, kind: c.kind, paid_at: c.paid_at }));
++ res.json({ receipts: rows, totals: claims.stats() });
++ } catch (err) { res.status(500).json({ error: err.message }); }
++});
++
router.get('/claims/mine', requireAuth, (req, res) => {
try { res.set('Cache-Control', 'no-store'); res.json(claims.mine(req.user.id)); } catch (err) { res.status(500).json({ error: err.message }); }
});