openvibers/openvibe.live Commits
Arena cleanup, step 1: its pages, scripts and styles go (590 lines of style.css), and what still read its mic moments reads The Rundown: the after-show report shows the stream's best takes, the home digest counts takes and quotes the best ones, the star pick and VOD stats count takes (#261)
Changes
@@@ -7,7 +7,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes afterkind `stream_recap`) from the real numbers; template copy when AI is off or the call fails.- **Numbers** — peak/avg viewers (from `viewer_snapshots`), chat lines and unique chatters, top 5chatters (the host excluded), busiest 5-minute window, sound commands, new follows during the-- stream, tips (`transactions` type donation), Arena mic moments, clips + the VOD (from Media).++ stream, tips (`transactions` type donation), The Rundown's best takes on the stream (server/rundown), clips + the VOD (from Media).- **The room, minute by minute** — viewer curve with chat bars, peak marker.- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row perstream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).@@@ -89,7 +89,7 @@ Migration `0010_rundown.sql`:- `rundown_progress`: how far the desk has read each stream.- `rundown_reactions`: readers' reactions.--The Arena's tables are no longer written; their code goes in a later change.++The Arena's tables are no longer written. Its pages, styles and readers are gone (the recap, the home digest, the star pick and the VOD stats read The Rundown); its server code and `/api/arena` go once no open tab of an earlier release calls them, and its tables after that.## Chatddeleted file mode 100644@@@ -1,130 +0,0 @@--/* ARENA ALERT — the Arena's drama desk (public/js/arena-alert.js). Tabloid red and siren yellow over the site theme. */--.aa { --aa-red: #e11d48; --aa-red-deep: #9f1239; --aa-yellow: #facc15; --aa-ink: #0b0b0f; padding-bottom: 32px; }----/* Masthead + ticker */--.aa-mast { margin: 18px 0 18px; padding: 18px 20px 0; border-radius: 18px; overflow: hidden; color: #fff;-- background: linear-gradient(135deg, var(--aa-red) 0%, var(--aa-red-deep) 70%, #4c0519 100%); box-shadow: 0 10px 30px rgba(225, 29, 72, 0.25); }--.aa-mast-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }--.aa-title { margin: 0; font-size: clamp(2rem, 6vw, 3.6rem); font-weight: 900; letter-spacing: -0.03em; line-height: 0.95; text-transform: uppercase; font-style: italic; }--.aa-title span { color: var(--aa-yellow); text-shadow: 0 2px 0 var(--aa-ink); }--.aa-siren { display: inline-block; animation: aa-siren 1s steps(2, end) infinite; }--@keyframes aa-siren { 50% { filter: hue-rotate(200deg) brightness(1.3); transform: rotate(-8deg) scale(1.08); } }--.aa-onair { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: rgba(0, 0, 0, 0.35); font-weight: 800; font-size: 0.8rem; letter-spacing: 0.08em; }--.aa-sub { margin: 10px 0 14px; max-width: 760px; font-size: 1.02rem; color: rgba(255, 255, 255, 0.9); }--.aa-ticker { display: flex; align-items: stretch; margin: 0 -20px; background: var(--aa-yellow); color: var(--aa-ink); font-weight: 800; font-size: 0.92rem; }--.aa-ticker-label { flex: none; padding: 9px 14px; background: var(--aa-ink); color: var(--aa-yellow); letter-spacing: 0.1em; font-style: italic; }--.aa-ticker-viewport { overflow: hidden; flex: 1; min-width: 0; mask-image: linear-gradient(90deg, transparent, #000 24px, #000 calc(100% - 24px), transparent); }--.aa-ticker-track { display: inline-flex; gap: 42px; padding: 9px 0; white-space: nowrap; animation: aa-crawl 60s linear infinite; }--.aa-ticker:hover .aa-ticker-track { animation-play-state: paused; }--@keyframes aa-crawl { from { transform: translateX(0); } to { transform: translateX(-50%); } }--.aa-tick { text-transform: uppercase; }----/* Section headings */--.aa-h { margin: 0 0 12px; font-size: 1.05rem; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; font-style: italic; }--.aa-h small { margin-left: 6px; font-weight: 600; font-style: normal; letter-spacing: 0; text-transform: none; color: var(--text-secondary); font-size: 0.8rem; }--.aa-feed > .aa-h { display: inline-block; padding: 4px 12px; background: var(--aa-red); color: #fff; border-radius: 6px; transform: skew(-8deg); }----/* Layout */--.aa-cams { margin: 0 0 18px; }--.aa-top { margin: 0 0 22px; }--.aa-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }--.aa-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 70px; }--.aa-box { padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.aa-quiet { color: var(--text-secondary); font-size: 0.92rem; margin: 0; }--.aa-quiet-big { padding: 28px 20px; border: 2px dashed var(--border); border-radius: 16px; text-align: center; }--.aa-more { display: block; margin: 14px auto 0; }----/* Story cards */--.aa-story { position: relative; margin: 0 0 14px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); border-left: 5px solid var(--border-light); }--.aa-story.aa-alert-3 { border-left-color: var(--aa-yellow); }--.aa-story.aa-alert-4, .aa-story.aa-alert-5 { border-left-color: var(--aa-red); box-shadow: 0 0 0 1px color-mix(in srgb, var(--aa-red) 35%, transparent); }--.aa-story-big { padding: 22px; border-left-width: 8px; background-image: radial-gradient(80% 60% at 100% 0%, color-mix(in srgb, var(--aa-red) 16%, transparent), transparent); }--.aa-story-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }--.aa-badge { padding: 3px 9px; border-radius: 6px; background: var(--aa-ink); color: var(--aa-yellow); font-size: 0.72rem; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; font-style: italic; }--.aa-kind-beef_open .aa-badge, .aa-kind-beef_win .aa-badge, .aa-kind-beef_forfeit .aa-badge { background: var(--aa-red); color: #fff; }--.aa-kind-viewer_spike .aa-badge, .aa-kind-peak_record .aa-badge, .aa-kind-streak .aa-badge, .aa-kind-the_return .aa-badge { background: #15803d; color: #fff; }--.aa-sirens { font-size: 0.85rem; letter-spacing: 1px; }--.aa-time { margin-left: auto; color: var(--text-secondary); font-size: 0.8rem; }--.aa-story-body { display: flex; gap: 14px; align-items: flex-start; }--.aa-who { display: flex; align-items: center; gap: 6px; flex: none; }--.aa-vs { font-weight: 900; font-style: italic; font-size: 0.75rem; color: var(--aa-red); }--.aa-av { display: inline-flex; width: 44px; height: 44px; border-radius: 12px; overflow: hidden; background: var(--bg-secondary, #222); align-items: center; justify-content: center; font-weight: 900; color: var(--text-primary); text-decoration: none; border: 2px solid var(--border); }--.aa-av img { width: 100%; height: 100%; object-fit: cover; }--.aa-story-big .aa-av { width: 72px; height: 72px; border-radius: 16px; border-color: var(--aa-yellow); }--.aa-row .aa-av { width: 32px; height: 32px; border-radius: 9px; }--.aa-story-text { min-width: 0; flex: 1; }--.aa-headline { margin: 0 0 6px; font-size: 1.15rem; line-height: 1.2; font-weight: 900; letter-spacing: -0.01em; text-transform: uppercase; overflow-wrap: anywhere; }--.aa-headline a { color: var(--text-primary); text-decoration: none; }--.aa-headline a:hover { color: var(--aa-red); }--.aa-story-big .aa-headline { font-size: clamp(1.4rem, 3.4vw, 2.2rem); }--.aa-blurb { margin: 0 0 8px; color: var(--text-secondary); overflow-wrap: anywhere; }--.aa-story-big .aa-blurb { font-size: 1.05rem; color: var(--text-primary); }--.aa-quote { display: flex; align-items: flex-start; gap: 8px; margin: 6px 0 8px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--aa-yellow) 12%, transparent); border-left: 3px solid var(--aa-yellow); font-style: italic; font-weight: 600; overflow-wrap: anywhere; }--.aa-quote span::before { content: '“'; } .aa-quote span::after { content: '”'; }--.aa-byline { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }--.aa-name { font-weight: 800; color: var(--text-primary); text-decoration: none; }--.aa-name:hover { color: var(--aa-red); }--.aa-story-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }--.aa-react { display: inline-flex; gap: 6px; flex-wrap: wrap; }--.aa-react-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 34px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-primary); font-weight: 800; cursor: pointer; transition: transform 0.12s, border-color 0.12s, background 0.12s; }--.aa-react-btn:hover { border-color: var(--aa-red); }--.aa-react-btn.is-mine { background: var(--aa-red); border-color: var(--aa-red); color: #fff; }--.aa-react-btn.is-pop { transform: scale(1.18); }--.aa-react-n { font-variant-numeric: tabular-nums; font-size: 0.85rem; }--.aa-watch { font-size: 0.85rem; font-weight: 700; }--.aa-ai { margin-left: auto; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }----/* Boards */--.aa-board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }--.aa-row { display: grid; grid-template-columns: 28px 32px minmax(0, 1fr) auto; gap: 10px; align-items: center; }--.aa-rank { font-weight: 900; text-align: center; color: var(--text-secondary); }--.aa-rank.is-crown { font-size: 1.3rem; }--.aa-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }--.aa-row-main .aa-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.aa-row-sub { font-size: 0.75rem; color: var(--text-secondary); overflow-wrap: anywhere; }--.aa-score { font-weight: 900; font-variant-numeric: tabular-nums; }--.aa-meter { display: block; height: 7px; border-radius: 99px; background: var(--border); overflow: hidden; }--.aa-meter span { display: block; height: 100%; width: var(--m); background: repeating-linear-gradient(135deg, #f5f5f4 0 8px, #1c1917 8px 16px); }--.aa-move { display: inline-block; margin-left: 6px; font-size: 0.72rem; font-weight: 900; color: var(--text-secondary); }--.aa-move.is-up { color: #16a34a; } .aa-move.is-down { color: var(--aa-red); } .aa-move.is-new { color: var(--aa-yellow); }--.aa-tags { display: flex; flex-wrap: wrap; gap: 4px; }--.aa-tag { padding: 1px 6px; border-radius: 5px; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.05em; background: var(--border); color: var(--text-primary); }--.aa-tag-cooked { background: var(--aa-red); color: #fff; }--.aa-tag-on-the-rise, .aa-tag-undefeated { background: #15803d; color: #fff; }--.aa-tag-falling { background: #78350f; color: #fff; }--.aa-mic, .aa-beefs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }--.aa-mic q { font-style: italic; font-weight: 600; overflow-wrap: anywhere; }--.aa-beefs li { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }--.aa-settled { margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; }--.aa-link { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer; }----/* Rap sheet, receipts, how it works */--.aa-rap { margin: 16px 0; }--.aa-rap-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }--.aa-rap-stats div { padding: 10px; border-radius: 12px; background: color-mix(in srgb, var(--aa-red) 8%, transparent); text-align: center; }--.aa-rap-stats b { display: block; font-size: 1.5rem; font-weight: 900; }--.aa-rap-stats span { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }--.aa-facts dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; }--.aa-facts dt { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }--.aa-facts dd { margin: 2px 0 0; font-weight: 800; overflow-wrap: anywhere; }--.aa-how { margin-top: 26px; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border); }--.aa-how ul { margin: 0; padding-left: 18px; color: var(--text-secondary); }--.aa-how li { margin-bottom: 8px; }--.aa-how b { color: var(--text-primary); }----@media (max-width: 960px) {-- .aa-grid { grid-template-columns: 1fr; }-- .aa-side { position: static; }--}--@media (max-width: 560px) {-- .aa-mast { padding: 14px 14px 0; border-radius: 14px; }-- .aa-ticker { margin: 0 -14px; }-- .aa-story { padding: 12px; }-- .aa-story-body { gap: 10px; }-- .aa-av { width: 36px; height: 36px; border-radius: 10px; }-- .aa-story-big .aa-av { width: 52px; height: 52px; border-radius: 12px; }-- .aa-who { flex-direction: column; }-- .aa-time { margin-left: 0; }-- .aa-rap-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }--}--@media (prefers-reduced-motion: reduce) { .aa-siren, .aa-ticker-track { animation: none; } }ddeleted file mode 100644@@@ -1,280 +0,0 @@--/* arena — rules used only by this feature, split out of the global stylesheets so other routes do not-- download them. Loaded with the feature (public/features.json), directly after style.css in the cascade.-- Split by class/id ownership with cascade-order checks; edit freely. */--/* ── from style.css ── */--.arena-loading { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 60px 0; color: var(--text-secondary); font-size: 1rem; }--.arena-empty { text-align: center; padding: 60px 20px; color: var(--text-secondary); }--.arena-back { margin: 8px 0 16px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }----.arena-hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; padding: 24px 0 16px; }--.arena-hero h1 i { color: var(--accent); }--.arena-hero-actions { display: flex; align-items: center; gap: 12px; }--.arena-portrait-avatar img { filter: saturate(1.25) contrast(1.1); }--.arena-portrait-initial { display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 900; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }--.arena-live { margin: 8px 0 28px; }--.arena-board-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }--.arena-list { display: flex; flex-direction: column; gap: 8px; }--.arena-row { display: grid; grid-template-columns: 44px 64px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; color: var(--text-primary); text-decoration: none; transition: transform 0.12s, border-color 0.12s; }--.arena-row:hover { transform: translateY(-1px); border-color: var(--accent); }--.arena-row.is-live { border-color: rgba(231, 76, 60, 0.5); }--.arena-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-secondary); text-align: center; }--.arena-row-sub { font-size: 0.8rem; color: var(--text-secondary); }--.arena-row-taunt { font-size: 0.8rem; color: var(--text-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-row-stats { display: flex; align-items: center; gap: 16px; }--.arena-power { display: flex; flex-direction: column; align-items: center; line-height: 1; }--.arena-record { font-size: 0.85rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }--.arena-record-lg { font-size: 1rem; }--.arena-profile { display: grid; grid-template-columns: 320px 1fr; gap: 28px; padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; background-image: radial-gradient(60% 40% at 0% 0%, color-mix(in srgb, var(--fc, var(--accent)) 18%, transparent), transparent); }--.arena-profile-portrait { display: flex; flex-direction: column; align-items: center; gap: 10px; }--.arena-profile-rank { font-weight: 800; font-size: 1.1rem; }--.arena-handle { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; }--.arena-profile-power { display: flex; align-items: center; gap: 24px; }--.arena-profile-stats { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; }--.arena-radar { width: 100%; height: auto; max-width: 200px; }--.arena-radar-ring { fill: none; stroke: var(--border-light); stroke-width: 1; }--.arena-radar-fill { stroke-width: 2; }--.arena-quip { display: flex; flex-direction: column; font-size: 0.85rem; }--.arena-challenge { margin-top: 24px; }--.arena-row { cursor: pointer; }--.arena-row.is-open .arena-chevron { transform: rotate(90deg); }--.arena-row-expand { display: none; grid-column: 1 / -1; padding: 12px 6px 4px; border-top: 1px dashed var(--border); margin-top: 8px; }--.arena-row.is-open .arena-row-expand { display: grid; grid-template-columns: 180px 1fr auto; gap: 16px; align-items: start; }--.arena-row-expand .arena-quips { grid-template-columns: repeat(2, 1fr); font-size: 0.8rem; }--.arena-row-expand-actions { display: flex; flex-direction: column; gap: 8px; }--.arena-mini-record { font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }--.arena-quip.is-clickable, .arena-bar.is-clickable { cursor: pointer; border-radius: 8px; padding: 4px 6px; margin: -4px -6px; }--.arena-quip.is-clickable:hover, .arena-bar.is-clickable:hover { background: var(--bg-tertiary); }--.arena-stat-detail { grid-column: 1 / -1; margin-top: 8px; padding: 14px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); animation: arena-in 0.25s ease-out; }--.arena-stat-detail-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }--.arena-stat-detail-close { background: none; border: 0; color: var(--text-muted); cursor: pointer; font-size: 1rem; }--.arena-spark { width: 100%; height: 70px; display: block; }--.arena-spark polyline { fill: none; stroke: var(--fc, var(--accent)); stroke-width: 2; }--.arena-spark .arena-spark-area { fill: color-mix(in srgb, var(--fc, var(--accent)) 25%, transparent); stroke: none; }--.arena-spark circle { fill: var(--fc, var(--accent)); }--.arena-spark-caption { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-muted); }--.arena-stat-top { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }--.arena-stat-detail .arena-weight { font-size: 0.75rem; color: var(--text-muted); }--.arena-voice { padding: 14px 16px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); }--.arena-voice-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }--.arena-voice-head h3 i { color: var(--accent); }--.arena-voice-meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 10px; }--.arena-voice-meter { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); }--.arena-quote { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); border-left: 3px solid var(--fc, var(--accent)); }--.arena-quote-actions { display: flex; gap: 4px; }--.arena-quote-actions button.is-speaking { color: var(--accent-light); border-color: var(--accent); }--.arena-speak { background: none; border: 0; color: var(--text-muted); cursor: pointer; padding: 0 4px; }--.arena-voice-empty { font-size: 0.85rem; color: var(--text-muted); }--.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }--.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }--.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }--.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }--.arena-lede { margin: 0; max-width: 720px; color: var(--text-secondary); font-size: 0.95rem; line-height: 1.5; }--.arena-hero-actions .arena-note { display: inline-flex; align-items: center; gap: 6px; }--.arena-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; white-space: nowrap; }--a.arena-tag:hover { color: var(--accent-light); }--.arena-tag-hot { background: rgba(239, 68, 68, 0.16); color: #f87171; }--.arena-tag-gold { background: rgba(245, 197, 66, 0.16); color: #f5c542; }--.arena-tag-dim { opacity: 0.7; font-weight: 600; }--.arena-lvl-big { font-size: 0.85rem; padding: 4px 10px; }--.arena-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-play { display: inline-flex; align-items: center; gap: 4px; color: var(--accent-light); text-decoration: none; font-size: 0.8rem; }--.arena-play:hover { text-decoration: underline; }--.arena-mic-card.is-quiet { border-color: var(--border); }--.arena-mic-thumb .arena-live-thumb { width: 112px; height: 63px; }--.arena-mic-head { display: flex; flex-direction: column; gap: 2px; }--.arena-mic-meta { font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }--.arena-mic-line { font-size: 0.88rem; line-height: 1.35; quotes: "“" "”"; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }--.arena-mic-line-empty { color: var(--text-muted); font-style: italic; }--.arena-mic-tags { display: flex; gap: 6px; flex-wrap: wrap; }--.arena-beefs { margin: 8px 0 28px; }--.arena-beef { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-beef.is-open { border-color: rgba(239, 68, 68, 0.45); box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.12), 0 10px 30px rgba(0, 0, 0, 0.25); }--.arena-beef.is-done { opacity: 0.85; }--.arena-beef-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }--.arena-beef-headline { font-weight: 800; font-size: 1.02rem; line-height: 1.3; }--.arena-beef-tags { display: flex; gap: 6px; flex-wrap: wrap; }--.arena-beef-sides { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; }--.arena-beef-side strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-beef-side-b { flex-direction: row-reverse; text-align: right; }--.arena-beef-vs { font-weight: 900; font-size: 0.8rem; letter-spacing: 0.1em; color: var(--text-muted); }--.arena-tug { display: flex; height: 22px; border-radius: 11px; overflow: hidden; background: var(--bg-tertiary); font-size: 0.7rem; font-weight: 800; }--.arena-tug-a { background: linear-gradient(90deg, color-mix(in srgb, var(--fc) 80%, #000), var(--fc)); justify-content: flex-start; }--.arena-tug-b { background: linear-gradient(90deg, var(--fc), color-mix(in srgb, var(--fc) 80%, #000)); justify-content: flex-end; }--.arena-tug-big { height: 34px; font-size: 0.9rem; }--.arena-beef-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }--.arena-clock { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }--.arena-beef-last { display: block; font-size: 0.85rem; color: var(--text-secondary); quotes: "“" "”"; border-left: 3px solid var(--border-light); padding-left: 10px; }--.arena-beef-empty { display: flex; align-items: center; gap: 14px; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }--.arena-done { margin-top: 12px; }--.btn.is-hyped { color: #f87171; border-color: rgba(239, 68, 68, 0.5); }--.arena-topic.is-hot { border-color: rgba(249, 115, 22, 0.55); box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.12); }--.arena-heat.is-hot { color: #f97316; }--.arena-debate-big .arena-note { grid-column: 1 / -1; margin: 6px 0 0; }--.arena-mini-row .arena-rank { font-size: 1rem; }--.arena-rules { margin: 8px 0 30px; padding: 16px 18px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-rules code { font-size: 0.8em; background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; }--.arena-beef-page { display: flex; flex-direction: column; gap: 16px; }--.arena-beef-opener { margin: 0; color: var(--text-secondary); font-size: 0.92rem; }--.arena-beef-tape-side { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); text-align: center; }--.arena-beef-tape-side .arena-portrait-md { max-width: 180px; }--.arena-beef-tape-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; }--.arena-beef-tape-nums { display: flex; gap: 14px; }--.arena-beef-tape-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }--.arena-beef-status { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 0.85rem; }--.arena-beef-aside, .arena-console-aside { display: flex; flex-direction: column; gap: 12px; }--.arena-feed-line { display: flex; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }--.arena-feed-line:first-of-type { border-top: 0; }--.arena-feed-line.is-b { flex-direction: row-reverse; text-align: right; }--.arena-feed-line.is-system { justify-content: center; text-align: center; color: var(--text-muted); font-size: 0.82rem; }--.arena-feed-line.is-b .arena-feed-body small { justify-content: flex-end; }--.arena-feed-announcer { font-weight: 700; color: #fbbf24; font-size: 0.85rem; }--.arena-receipt { padding: 8px 0; border-top: 1px solid var(--border); }--.arena-receipt small a { color: var(--accent-light); }--.arena-member.is-active .arena-chip strong { color: #f87171; }--.arena-rivalries { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }--.arena-rivalry { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-rivalry.is-open { border-color: rgba(239, 68, 68, 0.45); }--.arena-receipt-mini { display: block; font-size: 0.8rem; color: var(--text-secondary); quotes: "“" "”"; }--.arena-level-card { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }--.arena-level-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }--.arena-level-nums { display: flex; gap: 18px; flex-wrap: wrap; }--.arena-console { display: flex; flex-direction: column; gap: 14px; }--.arena-console-head { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-console-status { font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; }--.arena-console-level .arena-xp-track { width: 100%; }--.arena-mic-line-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 8px; align-items: baseline; padding: 4px 0; font-size: 0.9rem; border-top: 1px solid var(--border); }--.arena-mic-line-row:first-child { border-top: 0; }--.arena-mic-time { font-size: 0.72rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }--.arena-judgement { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.85rem; color: var(--text-secondary); border-left: 3px solid var(--border-light); }--.arena-console-beef { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 1px solid var(--border); }--.arena-console-beef .arena-tug { height: 14px; font-size: 0.6rem; }----@media (max-width: 900px) {-- .arena-console-head { grid-template-columns: auto 1fr; }-- .arena-console-level { grid-column: 1 / -1; align-items: stretch; }--}--@media (max-width: 600px) {-- .arena-hero { padding: 14px 0 10px; } .arena-live h2 small, .arena-board-head h2 small { flex-basis: 100%; }-- .arena-board-head { flex-direction: column; align-items: flex-start; }-- .arena-hero-actions { flex-direction: column; align-items: stretch; }-- .arena-mic-thumb .arena-live-thumb { width: 100%; height: auto; aspect-ratio: 16 / 9; }-- .arena-beef-sides { grid-template-columns: 1fr; }-- .arena-beef-side-b { flex-direction: row; text-align: left; }-- .arena-beef-vs { display: none; }-- .arena-row { grid-template-columns: 28px 44px 1fr; grid-auto-rows: auto; gap: 8px 10px; padding: 10px; }-- .arena-row .arena-portrait-sm { width: 44px; height: 44px; font-size: 1.1rem; }-- .arena-row-stats { grid-column: 1 / -1; justify-content: space-between; }-- .arena-row-expand { grid-column: 1 / -1; }-- .arena-row-expand-actions { flex-wrap: wrap; }-- .arena-profile { grid-template-columns: 1fr; padding: 14px; }-- .arena-profile-stats { grid-template-columns: 1fr; }-- .arena-level-nums { gap: 12px; }--}----.arena-leaderboard { margin: 0 0 28px; }--.arena-pulse-stat.is-hot { background: rgba(249, 115, 22, 0.16); color: #fb923c; }--.arena-keywords .arena-note { padding: 0; background: none; font-family: inherit; }--.arena-topic-row.is-hot { border-color: rgba(249, 115, 22, 0.45); }--.arena-profile-custom { display: grid; grid-template-columns: 220px 1fr; gap: 18px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }--@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } }----.arena-focus { margin: 0 0 10px; padding: 10px 12px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.35); font-size: 0.88rem; }--.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }--.arena-focus .arena-note { margin: 6px 0 0; }--.arena-mini-row.is-earned .arena-rank { color: #f5c542; }--.arena-subject.is-hot { border-color: rgba(249, 115, 22, 0.5); }--.arena-subject-open { margin-left: auto; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; font-weight: 700; }--@media (max-width: 600px) {-- .arena-row-expand { grid-template-columns: 1fr; }-- .arena-row-expand .arena-radar { max-width: 170px; margin: 0 auto; }-- .arena-quip { font-size: 0.78rem; }-- .arena-row-expand-actions .btn { padding: 6px 10px; font-size: 0.82rem; }--}--.arena-radar-v2 { width: 100%; height: auto; max-width: 300px; }--.arena-radar-v2 .arena-radar-ring { fill: rgba(255, 255, 255, 0.02); stroke: var(--border-light); stroke-width: 1; }--.arena-radar-axis { stroke: var(--border); stroke-width: 1; }--.arena-radar-v2 .arena-radar-fill { stroke-width: 2.2; stroke-linejoin: round; }--.arena-radar-dot { stroke: var(--bg-card); stroke-width: 1.5; }--.arena-radar-label text { fill: var(--text-secondary); font-size: 10.5px; font-weight: 700; }--.arena-radar-label .arena-radar-val { font-size: 11px; font-weight: 900; }--.arena-profile-custom { grid-template-columns: 300px 1fr; }--.arena-profile-stats.is-bars { grid-template-columns: 1fr; gap: 8px; }--.arena-bars-head { font-size: 0.85rem; }--.arena-bar.is-clickable { cursor: pointer; }--@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } .arena-radar-v2 { max-width: 260px; margin: 0 auto; } }--.arena-me { margin: 0 0 20px; }--.arena-me-inner { display: grid; grid-template-columns: minmax(260px, 1fr) 1.4fr; gap: 14px; padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #f97316 40%, var(--border)); }--.arena-me-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }--.arena-me-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }--.arena-me-alert { padding: 6px 10px; border-radius: 10px; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }--.arena-me-guest { padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px dashed var(--border); color: var(--text-secondary); font-size: 0.9rem; }--.arena-profile-stats.is-bars .arena-bars { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }--.arena-profile-stats.is-bars .arena-bar { display: grid; grid-template-columns: 62px 1fr 34px; align-items: center; gap: 8px; }--.arena-profile-stats.is-bars .arena-bar-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }--.arena-profile-stats.is-bars .arena-bar-fill { display: block; height: 100%; border-radius: 4px; }--.arena-profile-stats.is-bars .arena-bar-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }--@media (max-width: 700px) { .arena-me-inner { grid-template-columns: 1fr; } .arena-profile-stats.is-bars .arena-bars { grid-template-columns: 1fr; } }--.arena-radar-v2 { overflow: visible; }--.arena-ach.is-locked { opacity: 0.55; filter: grayscale(0.6); }--.arena-ach.is-locked .arena-ach-icon { filter: grayscale(1); }--.arena-numgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }--.arena-num { display: grid; grid-template-columns: 60px 1fr 30px; align-items: center; gap: 8px; cursor: pointer; font-size: 0.84rem; }--.arena-num-label { color: var(--text-secondary); }--.arena-num-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }--.arena-num-fill { display: block; height: 100%; border-radius: 4px; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); }--.arena-num-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }--@media (max-width: 600px) {-- .arena-numgrid { grid-template-columns: 1fr; }-- .arena-profile-custom { grid-template-columns: 1fr; }--}--@media (max-width: 700px) {-- .arena-row.is-open .arena-row-expand { grid-template-columns: 1fr; gap: 12px; padding: 12px 0 4px; }-- .arena-row-expand > div:first-child { display: flex; flex-direction: column; align-items: center; }-- .arena-row-expand .arena-radar { max-width: 230px; width: 100%; margin: 0 auto; }-- .arena-row-expand .arena-mini-record { text-align: center; }-- .arena-row-expand .arena-quips, .arena-row-expand .arena-quips-custom { display: flex; flex-direction: column; gap: 6px; }-- .arena-row-expand .arena-quip { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: baseline; font-size: 0.86rem; }-- .arena-row-expand .arena-quip b { white-space: nowrap; }-- .arena-row-expand-actions { flex-direction: row; flex-wrap: wrap; }-- .arena-row-expand-actions .btn { flex: 1 1 auto; justify-content: center; }-- .arena-row-taunt { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }-- .arena-row-main strong { white-space: normal; }-- .arena-profile-custom .arena-quips-custom { display: flex; flex-direction: column; gap: 6px; }-- .arena-profile-custom .arena-quip { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: baseline; }-- .arena-profile-custom .arena-quip b { white-space: nowrap; }--}--.arena-hero-cam h1 i { color: var(--live-red); }--.arena-cam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }--.arena-cam.is-locked { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 0 24px color-mix(in srgb, var(--live-red) 25%, transparent); }--.arena-cam.is-quiet { opacity: 0.75; }--.arena-cam-thumb img.arena-live-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }--.arena-cam-thumb .arena-portrait { width: 100%; height: 100%; }--/* The stream's frame when it loads; the portrait when there is none yet (a restart, a new stream) or it fails. */--.arena-cam-thumb img.arena-live-thumb[hidden] { display: none; }--.arena-cam-thumb img.arena-live-thumb:not([hidden]) ~ .arena-portrait { display: none; }--.arena-cam-lock { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: 0.74rem; font-weight: 700; color: #fecaca; background: color-mix(in srgb, var(--live-red) 18%, transparent); border: 1px solid color-mix(in srgb, var(--live-red) 45%, transparent); width: fit-content; }--.arena-cam-lock i { color: var(--live-red); }--.arena-cam-last q { display: block; font-size: 0.9rem; color: var(--text-primary); line-height: 1.35; quotes: '“' '”'; }--.arena-q { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); }--.arena-q.is-hot { color: #fde68a; border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.12); }--.arena-q.is-fire { color: #fff; border-color: var(--live-red); background: color-mix(in srgb, var(--live-red) 55%, transparent); }--.arena-aim { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 0.7rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; }--.arena-aim.is-fighter { color: #fecaca; border-color: color-mix(in srgb, var(--live-red) 50%, transparent); background: color-mix(in srgb, var(--live-red) 15%, transparent); }--.arena-aim.is-fighter:hover { background: color-mix(in srgb, var(--live-red) 30%, transparent); }--.arena-mic-feed-list { display: grid; gap: 8px; }--.arena-mic-feed-list.is-compact .arena-mic-moment { padding: 8px; }--.arena-mic-moment { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--fc, var(--accent)); animation: arenaMomentIn 0.4s ease; }--.arena-mic-moment.is-beef { border-left-color: var(--live-red); background: linear-gradient(90deg, color-mix(in srgb, var(--live-red) 8%, var(--bg-card)), var(--bg-card)); }--.arena-mic-moment.is-fire { box-shadow: 0 0 18px color-mix(in srgb, var(--live-red) 22%, transparent); }--.arena-mic-moment-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 0.82rem; margin-bottom: 3px; }--.arena-mic-moment-head a { color: var(--text-primary); text-decoration: none; }--.arena-mic-moment q { display: block; font-size: 0.98rem; line-height: 1.4; color: var(--text-primary); quotes: '“' '”'; }--.arena-mic-moment-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 4px; }--.arena-receipts-section { margin: 14px 0; }--@media (max-width: 720px) {-- .arena-cam { grid-template-columns: 1fr; }-- .arena-cam-thumb { aspect-ratio: 16/9; }--}--@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }--.arena-me-left > .arena-portrait { flex: 0 0 64px !important; width: 64px !important; height: 64px !important; display: block; }--.arena-me-left > .arena-portrait-initial { display: flex; }@@@ -262,7 +262,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.digest-recap { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; margin-left: -4px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.78rem; text-decoration: none; align-self: center; }.digest-recap:hover { color: var(--accent); border-color: var(--accent); }.digest-hot { display: grid; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--border)); }--.digest-hot-line { font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; }++.digest-hot-line { display: block; font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; text-decoration: none; }++.digest-hot-line:hover b { text-decoration: underline; }@media (max-width: 640px) { .digest-extra { white-space: normal; } }.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }.streamer-group-goal .goal-bar { flex: 1; }@@@ -41,7 +41,6 @@.recap-mic { display: block; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.88rem; line-height: 1.4; color: var(--text-primary); text-decoration: none; font-style: italic; position: relative; }.recap-mic i.fa-play { color: var(--g); font-style: normal; margin-right: 4px; font-size: 0.75rem; }.recap-mic:hover { border-color: var(--g); }--.recap-mic-q { position: absolute; right: 8px; top: 6px; font-size: 0.66rem; font-weight: 800; color: var(--g); font-style: normal; }.recap-chatters { list-style: none; margin: 0; padding: 0; display: grid; gap: 7px; }.recap-chatters li { display: grid; grid-template-columns: 26px 26px minmax(70px, 1fr) minmax(60px, 2fr) 40px; align-items: center; gap: 8px; font-size: 0.9rem; }.recap-medal { text-align: center; font-weight: 800; color: var(--text-muted); }@@@ -967,7 +967,6 @@ body.ov-nav-overlay .navbar.ovnav-top {}.nav-brand[data-brand-tooltip]:hover::after { opacity: 1; }--.nav-link {display: flex; align-items: center; gap: 6px;padding: 8px 14px; border-radius: var(--radius);@@@ -988,17 +987,6 @@ body.ov-nav-overlay .navbar.ovnav-top {color: var(--accent-light);}----------------------/* Live indicator on Go Live nav link */.nav-live-dot {font-size: 0.45rem; color: #e74c3c; margin-left: 4px;@@@ -1035,8 +1023,6 @@ body.ov-nav-overlay .navbar.ovnav-top {cursor: pointer;}----/* ═══════════════════════════════════════════════════════════════BUTTONS═══════════════════════════════════════════════════════════════ */@@@ -4768,7 +4754,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }to { opacity: 1; transform: none; }}--/* "Go Live" is the only remaining nav dropdown — keep its trigger visible. *//* Nav-right stays visible, pushed to end before hamburger */@@@ -4776,7 +4761,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }/* Compact currency badges */.nav-balance, .nav-coins { padding: 4px 7px; font-size: 0.75rem; gap: 3px; border-radius: 10px; }--}/* 900px: Compact currency, hide brand text (keep "OpenVibe" on mobile) */@@@ -4829,9 +4813,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }.nav-right { gap: 4px; }.nav-balance, .nav-coins { padding: 3px 6px; font-size: 0.72rem; gap: 2px; }------.hero h1 { font-size: 1.6rem; }.hero { padding-left: 16px; padding-right: 16px; }.comment-item { gap: 10px; }@@@ -4977,7 +4958,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }.brand-text { font-size: 0.88rem; }.nav-balance, .nav-coins { padding: 2px 5px; font-size: 0.68rem; }--.stream-card { border-radius: 16px; }.modal { width: 95%; border-radius: var(--radius); }.admin-stats { grid-template-columns: 1fr; }@@@ -10170,123 +10150,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.aiv-slot-grid label > span b { color:var(--accent); }.aiv-slot-grid input[type=range] { accent-color:var(--accent); }--/* ═══════════════════════════════════════════════════════════════-- ARENA — streamer vs streamer (public/js/arena.js)-- ═══════════════════════════════════════════════════════════════ */--.arena-container { max-width: 1180px; }--.arena-loading i { font-size: 2rem; color: var(--accent); }--.arena-loading small { color: var(--text-muted); }--.arena-empty i { font-size: 2.4rem; color: var(--accent); margin-bottom: 12px; display: block; }--.arena-note { font-size: 0.8rem; color: var(--text-muted); }--.arena-back a { color: var(--text-secondary); text-decoration: none; }--.arena-back a:hover { color: var(--accent-light); }--.arena-hero h1 { margin: 0 0 6px; font-size: 2rem; display: flex; align-items: center; gap: 10px; }----/* portraits */--.arena-portrait { position: relative; border-radius: 14px; overflow: hidden; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--fc, var(--accent)) 45%, #000) 0%, #0a0a0e 70%); box-shadow: 0 0 0 2px color-mix(in srgb, var(--fc, var(--accent)) 60%, transparent), 0 10px 30px rgba(0, 0, 0, 0.45); flex-shrink: 0; }--.arena-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }--.arena-portrait-glow { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 55%, color-mix(in srgb, var(--fc, var(--accent)) 55%, transparent)); pointer-events: none; }--.arena-portrait-xs { width: 40px; height: 40px; border-radius: 10px; font-size: 1.1rem; }--.arena-portrait-sm { width: 64px; height: 64px; font-size: 1.6rem; }--.arena-portrait-md { width: 100%; max-width: 260px; aspect-ratio: 1; margin: 0 auto; font-size: 4rem; }--.arena-portrait-lg { width: 100%; max-width: 320px; aspect-ratio: 1; font-size: 5rem; }--.arena-portrait-pending { margin-top: 8px; font-size: 0.8rem; color: var(--accent-light); text-align: center; }----/* live matchups */--.arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }--.arena-live h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }--.arena-live-dot { width: 10px; height: 10px; border-radius: 50%; background: #e74c3c; box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); animation: arena-pulse 1.6s infinite; }--@keyframes arena-pulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); } 50% { box-shadow: 0 0 0 8px rgba(231, 76, 60, 0.08); } }--.arena-live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 14px; }--.arena-live-match { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; }--.arena-live-fighter { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }--.arena-live-b { flex-direction: row-reverse; text-align: right; }--.arena-live-thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }--.arena-live-meta { display: flex; flex-direction: column; min-width: 0; }--.arena-live-meta strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-live-meta span { font-size: 0.78rem; color: var(--text-secondary); }--.arena-live-vs { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: #fff; font-weight: 900; text-decoration: none; font-size: 0.95rem; line-height: 1; box-shadow: 0 6px 18px rgba(139, 92, 246, 0.35); }--.arena-live-vs small { font-size: 0.55rem; font-weight: 600; opacity: 0.85; text-transform: uppercase; }--.arena-live-vs:hover { transform: scale(1.06); }--.arena-live-vs-idle { background: var(--bg-tertiary); color: var(--text-muted); box-shadow: none; width: auto; height: auto; padding: 10px 12px; border-radius: 12px; }--.arena-live-vs-idle small { white-space: nowrap; }--.arena-live-pill { display: inline-block; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; background: #e74c3c; color: #fff; padding: 2px 6px; border-radius: 4px; vertical-align: middle; margin-left: 6px; }----/* leaderboard */--.arena-board-head h2 { margin: 0; font-size: 1.15rem; }--.arena-board-head input { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); min-width: 220px; }--.arena-rank-1 { color: #f5c542; } .arena-rank-2 { color: #cfd6dd; } .arena-rank-3 { color: #d09056; }--.arena-row-main { display: flex; flex-direction: column; min-width: 0; gap: 2px; }--.arena-row-main strong { font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-power b { font-size: 1.5rem; color: var(--accent-light); }--.arena-power small { font-size: 0.6rem; letter-spacing: 0.1em; color: var(--text-muted); }--.arena-power-lg b { font-size: 2.6rem; }----/* profile */--.arena-profile-rank small { color: var(--text-muted); font-weight: 400; }--.arena-profile-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; }--.arena-profile-name h1 { margin: 0; font-size: 2rem; line-height: 1.15; }--.arena-title { margin: 4px 0 0; color: var(--accent-light); font-weight: 600; }--.arena-handle a { color: inherit; }--.arena-radar text { fill: var(--text-secondary); font-size: 10px; font-weight: 600; }--.arena-quips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 14px; }--.arena-quip b { color: var(--text-primary); }--.arena-quip span { color: var(--text-secondary); }--.arena-lore-text { font-size: 1rem; line-height: 1.55; margin: 0 0 12px; }--.arena-moves { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }--.arena-move { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.85rem; }--.arena-move-kind { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-light); }--.arena-move-weak .arena-move-kind { color: #e74c3c; }--.arena-flavor { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 0.85rem; color: var(--text-secondary); }--.arena-flavor i { color: var(--accent); margin-right: 4px; }--.arena-numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }--.arena-number { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); }--.arena-number b { font-size: 1.1rem; }--.arena-number span { font-size: 0.72rem; color: var(--text-muted); }--.arena-challenge h2 { font-size: 1.15rem; margin: 0 0 12px; }--.arena-opponents { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }--.arena-opponent { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; }--.arena-opponent:hover { border-color: var(--accent); }--.arena-opponent span { display: flex; flex-direction: column; min-width: 0; }--.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-opponent small { color: var(--text-muted); }----@keyframes arena-hit { 0% { transform: scale(1); } 30% { transform: scale(1.03) rotate(-0.6deg); } 100% { transform: scale(1); } }--@keyframes arena-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }--/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */--.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }--.arena-row-lore { font-size: 0.88rem; color: var(--text-secondary); margin: 0 0 8px; }--.arena-stat-detail-head h3 { margin: 0; font-size: 1rem; }--.arena-stat-detail-head small { color: var(--text-muted); }--.arena-stat-top a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; font-size: 0.8rem; }--.arena-stat-top a b { color: var(--accent-light); }----/* voice */--.arena-voice-head h3 { margin: 0; font-size: 1rem; display: flex; align-items: center; gap: 8px; }--.arena-voice-style { font-size: 0.8rem; color: var(--accent-light); font-weight: 600; }--.arena-voice-verdict { font-size: 0.9rem; color: var(--text-secondary); font-style: italic; margin: 0 0 10px; }--.arena-voice-meter b { font-size: 1.05rem; }--.arena-voice-meter span { font-size: 0.7rem; color: var(--text-muted); }--.arena-sounds { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }--.arena-sounds span { font-size: 0.72rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); }--.arena-quotes { display: flex; flex-direction: column; gap: 6px; }--.arena-quote q { font-size: 0.92rem; quotes: "“" "”"; }--.arena-quote small { display: block; color: var(--text-muted); font-size: 0.72rem; margin-top: 2px; }--.arena-quote-actions a, .arena-quote-actions button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; cursor: pointer; font-size: 0.85rem; }--.arena-quote-actions a:hover, .arena-quote-actions button:hover { color: var(--accent-light); border-color: var(--accent); }--.arena-speak:hover, .arena-speak.is-speaking { color: var(--accent-light); }----/* portrait lightbox */--.arena-portrait-lg { cursor: zoom-in; }--.arena-lightbox { position: fixed; inset: 0; z-index: 3000; background: rgba(0, 0, 0, 0.82); display: flex; align-items: center; justify-content: center; padding: 24px; animation: arena-in 0.2s; }--.arena-lightbox-inner { max-width: 900px; width: 100%; display: grid; grid-template-columns: minmax(0, 520px) 1fr; gap: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px; }--.arena-lightbox-inner img { width: 100%; border-radius: 12px; display: block; }--.arena-lightbox-text h3 { margin: 0 0 8px; }--.arena-lightbox-text p { font-size: 0.88rem; color: var(--text-secondary); }--.arena-lightbox-prompt { font-size: 0.78rem; color: var(--text-muted); max-height: 220px; overflow: auto; background: var(--bg-tertiary); padding: 10px; border-radius: 8px; }--.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }--@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }--/* ── AI timeline transcript: speech lines vs. sound strips ── */.ai-tl-sound i { font-size: 0.68rem; opacity: 0.8; }.ai-tl-sound b { font-weight: 700; color: var(--text-muted); margin-left: 1px; }@@@ -10317,443 +10180,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.hero-bg-layer.active { animation: none !important; }}--@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }--.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }--.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }--.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }--@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }--.arena-xp { width: 140px; }--.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }--@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }--.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }--.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }--.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }--.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }--@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }----/* ── Arena v2: pulse, on the mic, beefs, the board, ladders, consoles ── */--.arena-pulse { margin: 4px 0 22px; }--.arena-pulse-inner { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 18%, var(--bg-card)), var(--bg-card)); }--.arena-pulse-kicker { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; letter-spacing: 0.08em; font-size: 0.72rem; text-transform: uppercase; color: var(--accent-light); }--.arena-pulse-text { flex: 1; min-width: 240px; font-size: 1.02rem; font-weight: 600; }--.arena-pulse-meta { font-size: 0.75rem; color: var(--text-muted); }--.arena-lvl { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }--.arena-chip { display: inline-flex; align-items: center; gap: 8px; color: var(--text-primary); text-decoration: none; min-width: 0; }--.arena-chip > span { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }--.arena-chip small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }--.arena-live-dot-sm { width: 7px; height: 7px; display: inline-block; margin-right: 4px; }----/* on the mic now */--.arena-mic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }--.arena-mic-card { display: grid; grid-template-columns: 112px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid rgba(231, 76, 60, 0.45); }--.arena-mic-thumb { display: block; }--.arena-mic-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }--.arena-mic-head a { color: var(--text-primary); text-decoration: none; }----/* beefs */--.arena-beefs h2, .arena-board-head h2, .arena-ladder h2, .arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }--.arena-beefs h2 small, .arena-board-head h2 small, .arena-ladder h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.82rem; }--.arena-beef-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; }--.arena-beef-head a { color: var(--text-primary); text-decoration: none; }--.arena-beef-side { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }--.arena-beef-side > span { display: flex; flex-direction: column; min-width: 0; }--.arena-beef-side small { color: var(--text-muted); font-size: 0.74rem; }--.arena-tug span { display: flex; align-items: center; padding: 0 8px; color: #fff; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); min-width: 0; overflow: hidden; }--.arena-beef-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }--.arena-clock b { color: var(--text-primary); }--.arena-clock.is-urgent { color: #f87171; } .arena-clock.is-urgent b { color: #f87171; animation: arena-blink 1s steps(2) infinite; }--@keyframes arena-blink { 50% { opacity: 0.35; } }--.arena-sides-tally { font-size: 0.78rem; color: var(--text-muted); display: inline-flex; gap: 5px; align-items: center; }--.arena-beef-empty i { font-size: 1.6rem; color: var(--accent); }--.arena-beef-empty p { margin: 0; }--.arena-done summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; margin-bottom: 10px; }--.arena-done summary small { color: var(--text-muted); font-weight: 400; }----/* the board */--.arena-board { margin: 8px 0 28px; }--.arena-topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }--.arena-topic { display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); position: relative; }--.arena-topic-debate { border-left: 4px solid #60a5fa; }--.arena-topic-phrase { border-left: 4px solid var(--accent-light, var(--accent)); }--.arena-topic-bounty { border-left: 4px solid #f5c542; }--.arena-topic-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }--.arena-kind { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.68rem; color: var(--accent-light); }--.arena-heat { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: var(--text-secondary); }--.arena-heat-bar { width: 46px; height: 5px; border-radius: 3px; background: var(--bg-tertiary); overflow: hidden; display: inline-block; }--.arena-heat-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }--.arena-topic-by { margin-left: auto; }--.arena-topic-headline { font-weight: 800; font-size: 0.82rem; letter-spacing: 0.02em; color: #fbbf24; text-transform: uppercase; }--.arena-topic-link { color: var(--text-primary); text-decoration: none; }--.arena-topic-link:hover .arena-topic-text { color: var(--accent-light); }--.arena-topic-text { font-size: 1.05rem; font-weight: 700; line-height: 1.35; }--.arena-topic-text b { font-weight: 900; }--.arena-debate-title em { font-style: italic; color: var(--text-muted); font-weight: 600; padding: 0 4px; }--.arena-topic-hint { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }--.arena-topic-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }--.arena-topic-who { display: flex; align-items: center; gap: 4px; }--.arena-topic-who a { display: inline-flex; } .arena-topic-who a.is-dim { opacity: 0.55; }--.arena-topic-stats { font-size: 0.75rem; color: var(--text-muted); }--.arena-topic-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }--.arena-debate { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }--.arena-debate .btn { white-space: normal; }--.arena-debate-tally { display: flex; flex-direction: column; align-items: center; gap: 3px; min-width: 90px; }--.arena-debate-bar { width: 90px; height: 6px; border-radius: 3px; background: #e74c3c; overflow: hidden; display: block; }--.arena-debate-bar span { display: block; height: 100%; background: #60a5fa; }--.arena-debate-nums { font-size: 0.72rem; color: var(--text-muted); }--.arena-debate-big { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-resolved { display: flex; flex-direction: column; gap: 6px; }--.arena-resolved-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 10px; background: var(--bg-card); border: 1px solid var(--border); font-size: 0.88rem; flex-wrap: wrap; }--.arena-resolved-row a { color: var(--text-primary); text-decoration: none; }--.arena-composer { display: flex; flex-direction: column; gap: 10px; max-width: 560px; }--.arena-composer textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }--.arena-composer-actions { display: flex; justify-content: flex-end; }----/* ladders */--.arena-ladders { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0 0 28px; }--.arena-ladder { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-mini-list { display: flex; flex-direction: column; gap: 6px; }--.arena-mini-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 10px; background: var(--bg-tertiary); }--.arena-rules h3 { margin: 0 0 8px; font-size: 1rem; }--.arena-rules ul, .arena-rules-list { margin: 0; padding-left: 18px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }----/* beef page */--.arena-beef-hero h1 { margin: 6px 0; font-size: 1.7rem; line-height: 1.2; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }--.arena-beef-opener q { quotes: "“" "”"; }--.arena-beef-tape { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }--.arena-beef-tape-side h2 { margin: 0; font-size: 1.3rem; }--.arena-beef-tape-nums span { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }--.arena-beef-tape-nums b { font-size: 1.3rem; } .arena-beef-tape-nums small { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }--.arena-beef-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }--.arena-feed, .arena-beef-aside section, .arena-console-aside section, .arena-console-main { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-feed h3, .arena-beef-aside h3, .arena-console-aside h3, .arena-console-main h3, .arena-angles h3, .arena-members h3, .arena-best-lines h3 { margin: 0 0 10px; font-size: 0.98rem; display: flex; align-items: center; gap: 8px; }--.arena-feed h3 small, .arena-beef-aside h3 small, .arena-console-main h3 small, .arena-angles h3 small, .arena-members h3 small, .arena-best-lines h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.78rem; }--.arena-feed-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }--.arena-feed-body q { font-size: 0.95rem; quotes: "“" "”"; }--.arena-feed-body small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }--.arena-receipt q { quotes: "“" "”"; font-size: 0.9rem; }--.arena-receipt small { display: block; color: var(--text-muted); font-size: 0.74rem; margin-top: 2px; }----/* topic page */--.arena-topic-page { display: flex; flex-direction: column; gap: 12px; padding: 18px; border-radius: 18px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-topic-page h1.arena-topic-text { margin: 0; font-size: 1.6rem; }--.arena-topic-result { padding: 10px 14px; border-radius: 12px; background: rgba(245, 197, 66, 0.12); color: #f5c542; font-weight: 700; }--.arena-bounty-target { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 10px 12px; border-radius: 12px; background: rgba(245, 197, 66, 0.08); }--.arena-topic-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }--.arena-angle-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }--.arena-angle { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.88rem; }--.arena-angle span { color: var(--text-muted); font-size: 0.78rem; }--.arena-member { display: grid; grid-template-columns: minmax(180px, 1fr) 1.2fr auto; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }--.arena-member-progress { display: flex; gap: 6px; }--.arena-member-progress .arena-progress { flex: 1; height: 10px; }--.arena-progress.is-cleared .arena-progress-fill { background: #22c55e; }--.arena-angle-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }--.arena-angle-row { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; font-size: 0.82rem; }--.arena-angle-row .arena-progress { height: 8px; }--.arena-angle-row.is-cleared { color: #22c55e; }--.arena-angle-row small { color: var(--text-muted); }--.arena-level-nums span { display: flex; flex-direction: column; line-height: 1.1; }--.arena-level-nums b { font-size: 1.1rem; } .arena-level-nums small { font-size: 0.66rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }----/* the ears (live console) */--.arena-console-head h1 { margin: 0 0 4px; font-size: 1.4rem; display: flex; align-items: center; gap: 8px; }--.arena-console-status.is-on { color: #4ade80; }--.arena-console-level { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 200px; }--.arena-console-level small { font-size: 0.72rem; color: var(--text-muted); }--.arena-console-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }--.arena-judgement.is-hit { border-left-color: #f97316; color: var(--text-primary); }--.arena-console-beef a { color: var(--text-primary); text-decoration: none; }--.arena-expires { font-variant-numeric: tabular-nums; }----@media (max-width: 900px) {-- .arena-ladders, .arena-beef-cols, .arena-console-cols, .arena-beef-tape { grid-template-columns: 1fr; }-- .arena-member { grid-template-columns: 1fr; gap: 6px; }--}--@media (max-width: 600px) { .arena-hero h1 { font-size: 1.5rem; }-- .arena-live h2, .arena-beefs h2, .arena-board-head h2, .arena-ladder h2 { flex-wrap: wrap; }-- .arena-pulse-inner { padding: 10px 12px; }-- .arena-mic-grid, .arena-beef-grid, .arena-topic-grid { grid-template-columns: 1fr; }-- .arena-mic-card { grid-template-columns: 1fr; }-- .arena-beef-foot, .arena-topic-foot { flex-direction: column; align-items: flex-start; }-- .arena-topic-actions { margin-left: 0; }-- .arena-debate { grid-template-columns: 1fr; }-- .arena-debate-tally { flex-direction: row; justify-content: center; }-- .arena-numbers { grid-template-columns: repeat(2, 1fr); }-- .arena-quips { grid-template-columns: 1fr; }-- .arena-beef-hero h1 { font-size: 1.25rem; }-- .arena-topic-page { padding: 14px; } .arena-topic-page h1.arena-topic-text { font-size: 1.25rem; }-- .arena-lightbox-inner { padding: 14px; }--}--.arena-lore { display: flex; flex-direction: column; gap: 10px; }----/* ── Arena v2.1: lore board (pulse, featured subject, feed rows, moments) ── */--.arena-pulse-inner { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; padding: 14px 18px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); background: linear-gradient(100deg, color-mix(in srgb, var(--accent) 22%, var(--bg-card)) 0%, var(--bg-card) 60%); }--.arena-pulse-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 16px; background: color-mix(in srgb, var(--accent) 28%, transparent); color: var(--accent-light); font-weight: 900; font-size: 0.6rem; letter-spacing: 0.14em; }--.arena-pulse-icon i { font-size: 1.5rem; margin-bottom: 4px; }--.arena-pulse-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; }--.arena-pulse-text { font-size: 1.12rem; font-weight: 700; line-height: 1.35; }--.arena-pulse-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }--.arena-pulse-stat { display: inline-flex; gap: 5px; align-items: center; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); }--.arena-pulse-stat b { color: var(--text-primary); } .arena-pulse-stat.is-hot b { color: #fb923c; }--.arena-pulse-when { margin-left: auto; }----.arena-featured { display: grid; grid-template-columns: 1.6fr 1fr; gap: 0; border-radius: 18px; border: 1px solid rgba(249, 115, 22, 0.45); background: linear-gradient(135deg, rgba(249, 115, 22, 0.08), var(--bg-card) 45%); overflow: hidden; margin-bottom: 12px; }--.arena-featured-main { display: flex; flex-direction: column; gap: 10px; padding: 18px 20px; min-width: 0; }--.arena-featured-side { display: flex; flex-direction: column; gap: 8px; padding: 18px; border-left: 1px solid var(--border); background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); min-width: 0; }--.arena-featured-side h4 { margin: 0 0 2px; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); display: flex; gap: 6px; align-items: center; }--.arena-featured-headline { margin: 0; font-size: 1.55rem; line-height: 1.2; font-weight: 900; color: var(--text-primary); }--.arena-topic-link:hover .arena-featured-headline { color: #fb923c; }--.arena-featured-subject { font-size: 0.95rem; color: var(--text-secondary); }--.arena-featured-subject b { color: var(--text-primary); font-weight: 800; }--.arena-tagline { color: var(--text-muted); }--.arena-lore-p { margin: 0; font-size: 0.95rem; line-height: 1.55; color: var(--text-primary); }--.arena-lore-p.is-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-secondary); font-size: 0.86rem; }--.arena-lore-big { font-size: 1.05rem; line-height: 1.6; }--.arena-keywords { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }--.arena-keywords span { font-size: 0.7rem; padding: 2px 7px; border-radius: 6px; background: var(--bg-tertiary); color: var(--text-muted); font-family: ui-monospace, monospace; }--.arena-chips { display: inline-flex; gap: 6px; flex-wrap: wrap; }--.arena-chip-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 0.74rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-variant-numeric: tabular-nums; }--.arena-chip-stat.is-live { color: #f87171; background: rgba(239, 68, 68, 0.12); }--.arena-stack { display: inline-flex; align-items: center; }--.arena-stack a { display: inline-flex; margin-left: -8px; border-radius: 10px; box-shadow: 0 0 0 2px var(--bg-card); }--.arena-stack a:first-child { margin-left: 0; }--.arena-stack-more { margin-left: 6px; font-size: 0.72rem; color: var(--text-muted); }--.arena-moment { display: grid; grid-template-columns: 18px 1fr; gap: 8px; padding: 7px 0; border-top: 1px solid var(--border); align-items: start; }--.arena-moment:first-child { border-top: 0; }--.arena-moment > i { margin-top: 3px; font-size: 0.78rem; color: var(--text-muted); }--.arena-moment.is-mic > i { color: #f87171; }--.arena-moment.is-judged > i { color: #fb923c; }--.arena-moment-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }--.arena-moment-body q { font-size: 0.9rem; quotes: "“" "”"; line-height: 1.4; }--.arena-moment-body small { color: var(--text-muted); font-size: 0.72rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }--.arena-moment-body small b { color: var(--text-secondary); }--.arena-featured-side .arena-moment { border-top: 0; padding: 6px 8px; border-radius: 10px; background: var(--bg-card); }----.arena-topic-feed { display: flex; flex-direction: column; gap: 8px; }--.arena-topic-row { display: grid; grid-template-columns: 96px 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-topic-row.arena-topic-bounty { border-left: 4px solid #f5c542; }--.arena-topic-row.is-archived { grid-template-columns: 1fr; opacity: 0.8; }--.arena-topic-row-heat { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }--.arena-topic-row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }--.arena-topic-row-main strong { font-size: 1.02rem; line-height: 1.3; }--.arena-topic-row-sub { font-size: 0.8rem; color: var(--text-secondary); }--.arena-topic-row-sub .arena-kind { text-transform: none; letter-spacing: 0; font-size: 0.8rem; color: var(--text-primary); font-weight: 700; }--.arena-topic-row-last { display: block; font-size: 0.82rem; color: var(--text-muted); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-topic-row-last i { font-size: 0.7rem; margin-right: 4px; }--.arena-topic-row-side { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }--.arena-topic-headline-big { margin: 0; font-size: 1.7rem; line-height: 1.2; font-weight: 900; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }--.arena-topic-cols { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; margin-top: 6px; }--.arena-topic-main, .arena-topic-aside { display: flex; flex-direction: column; gap: 12px; min-width: 0; }--.arena-lore-box, .arena-moments, .arena-topic-aside section { padding: 14px; border-radius: 14px; background: var(--bg-tertiary); }--.arena-lore-box h3, .arena-moments h3, .arena-topic-aside h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; gap: 8px; align-items: center; }--.arena-lore-box h3 small, .arena-moments h3 small, .arena-topic-aside h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }--.arena-moment-list { max-height: 560px; overflow-y: auto; padding-right: 4px; }--.arena-topic-page.is-archived { opacity: 0.92; }--.arena-member { grid-template-columns: 1fr; gap: 4px; }--.arena-member .btn { justify-self: start; }----@media (max-width: 900px) {-- .arena-featured, .arena-topic-cols { grid-template-columns: 1fr; }-- .arena-featured-side { border-left: 0; border-top: 1px solid var(--border); }--}--@media (max-width: 600px) {-- .arena-pulse-inner { grid-template-columns: 1fr; gap: 10px; } .arena-pulse-icon { flex-direction: row; width: auto; height: auto; padding: 6px 10px; gap: 8px; } .arena-pulse-icon i { margin: 0; font-size: 1.1rem; }-- .arena-pulse-when { margin-left: 0; flex-basis: 100%; }-- .arena-featured-headline { font-size: 1.25rem; }-- .arena-topic-row { grid-template-columns: 1fr; gap: 8px; }-- .arena-topic-row-side { flex-direction: row; align-items: center; flex-wrap: wrap; }-- .arena-topic-headline-big { font-size: 1.3rem; }--}----/* ── Arena: per-fighter custom stats + taunts ── */--.arena-radar-custom text { font-size: 9px; }--.arena-quips-custom .arena-quip b { color: var(--accent-light); }--.arena-row-taunts { display: flex; flex-direction: column; gap: 3px; margin: 0 0 10px; }--.arena-row-taunts em { font-size: 0.85rem; color: var(--text-secondary); }--.arena-row-taunts small { color: var(--text-muted); font-size: 0.72rem; }--.arena-focus i { color: #f87171; }--.arena-focus small { color: var(--text-secondary); margin-left: 6px; }----/* ── Arena: yapper profiles ── */--.arena-ladder-yap { grid-column: 1 / -1; }--.arena-yap-podium { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }--.arena-yap-card { display: grid; grid-template-columns: auto auto 1fr; gap: 10px; align-items: start; padding: 12px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); position: relative; overflow: hidden; }--.arena-yap-card.is-top { border-color: color-mix(in srgb, var(--yc) 60%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 14%, var(--bg-tertiary)), var(--bg-tertiary) 60%); }--.arena-yap-card.is-top::after { content: ''; position: absolute; inset: -40%; background: conic-gradient(from 0deg, transparent 0 70%, color-mix(in srgb, var(--yc) 25%, transparent) 80%, transparent 90%); animation: arena-spin 6s linear infinite; pointer-events: none; }--@keyframes arena-spin { to { transform: rotate(360deg); } }--.arena-yap-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-muted); }--.arena-yap-card.is-top .arena-yap-rank { color: #f5c542; }--.arena-yap-ring { position: relative; display: inline-grid; place-items: center; flex-shrink: 0; }--.arena-yap-ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }--.arena-yap-ring-bg { fill: none; stroke: var(--border); stroke-width: 3; }--.arena-yap-ring-fg { fill: none; stroke: var(--yc, #f97316); stroke-width: 3; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--yc, #f97316) 70%, transparent)); }--.arena-yap-ring b { font-size: 0.9em; font-weight: 900; color: var(--text-primary); }--.arena-yap-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; position: relative; }--.arena-yap-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }--.arena-yap-name a { color: var(--text-primary); text-decoration: none; }--.arena-yap-title { font-weight: 800; color: var(--yc, #f97316); font-size: 0.92rem; }--.arena-yap-title small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }--.arena-yap-blurb { margin: 0; font-size: 0.84rem; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }--.arena-yap-line { display: block; font-size: 0.86rem; color: var(--text-secondary); quotes: "“" "”"; }--.arena-yap-stats { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }--.arena-yap-stats b { color: var(--text-primary); }--.arena-yap-row { grid-template-columns: 28px auto 1fr auto; }--.arena-yap-week { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }--.arena-streak i { animation: arena-flame 0.9s ease-in-out infinite alternate; }--@keyframes arena-flame { from { transform: scale(1); } to { transform: scale(1.25) rotate(-6deg); } }--.arena-flash { position: absolute; right: 14px; top: 10px; padding: 4px 10px; border-radius: 999px; background: #f97316; color: #1a1006; font-weight: 800; font-size: 0.72rem; animation: arena-pop 2.2s ease forwards; z-index: 2; }--@keyframes arena-pop { 0% { transform: scale(0.6); opacity: 0; } 15% { transform: scale(1.1); opacity: 1; } 80% { opacity: 1; } 100% { transform: translateY(-10px); opacity: 0; } }--.arena-ladder { position: relative; }--.arena-yap-page { display: flex; flex-direction: column; gap: 14px; }--.arena-yap-hero { display: grid; grid-template-columns: auto 1fr; gap: 18px; align-items: center; padding: 18px; border-radius: 18px; background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 16%, var(--bg-card)), var(--bg-card) 60%); border: 1px solid color-mix(in srgb, var(--yc) 45%, var(--border)); }--.arena-yap-hero-main { display: flex; flex-direction: column; gap: 8px; min-width: 0; }--.arena-yap-hero h1 { margin: 0; font-size: 1.6rem; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }--.arena-yap-hero-title { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }--.arena-yap-card-title { font-weight: 800; color: var(--yc); }--.arena-yap-cardbox { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-yap-cardbox h3 { margin: 0 0 8px; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }--.arena-yap-cardbox h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }--.arena-yap-cardbox p { margin: 0 0 8px; line-height: 1.55; }--@media (max-width: 900px) { .arena-yap-podium { grid-template-columns: 1fr; } .arena-yap-hero { grid-template-columns: 1fr; } }----/* ── chat context menu: Arena yap level ── */--.ctx-yap { display: flex; flex-direction: column; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--border); font-size: 0.78rem; }--.ctx-yap-link { display: flex; align-items: center; gap: 6px; color: var(--text-primary); text-decoration: none; }--.ctx-yap-lvl { font-weight: 900; font-size: 0.68rem; letter-spacing: 0.06em; padding: 1px 6px; border-radius: 5px; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }--.ctx-yap-title { font-weight: 700; }--.ctx-yap-streak { color: #fb923c; font-size: 0.72rem; }--.ctx-yap-bar { display: block; height: 4px; border-radius: 2px; background: var(--bg-tertiary); overflow: hidden; }--.ctx-yap-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }--.ctx-yap small { color: var(--text-muted); }----/* ── Arena board v3: subject cards + threads ── */--.arena-subject-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }--.arena-subject { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); min-width: 0; }--.arena-subject.is-featured { grid-column: 1 / -1; background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), var(--bg-card) 50%); }--.arena-subject.arena-topic-bounty { border-left: 4px solid #f5c542; }--.arena-subject-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }--.arena-subject-headline { margin: 0; font-size: 1.15rem; line-height: 1.25; font-weight: 900; color: var(--text-primary); }--.arena-subject.is-featured .arena-subject-headline { font-size: 1.45rem; }--.arena-topic-link:hover .arena-subject-headline { color: #fb923c; }--.arena-subject-sub { font-size: 0.86rem; color: var(--text-secondary); }--.arena-subject-sub b { color: var(--text-primary); }--.arena-threads { display: flex; gap: 6px; flex-wrap: wrap; }--.arena-thread { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.76rem; font-weight: 600; }--.arena-thread i { font-size: 0.65rem; color: #fb923c; }--.arena-thread b { color: var(--text-primary); }--.arena-thread.is-more { opacity: 0.6; }--a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 70%, #fb923c 30%); }--.arena-subject-last { display: block; font-size: 0.84rem; color: var(--text-secondary); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-subject-last i { font-size: 0.7rem; margin-right: 3px; color: var(--text-muted); }--.arena-subject-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }--.arena-strip { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 0; }--.arena-strip-people { display: inline-flex; align-items: center; gap: 8px; }--.arena-strip-people small { color: var(--text-muted); }--.arena-story { padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); border-left: 3px solid #fb923c; }--.arena-story h3 { margin: 0 0 6px; font-size: 0.9rem; display: flex; gap: 8px; align-items: center; }--.arena-story h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }--.arena-tabs { display: flex; gap: 6px; flex-wrap: wrap; }--.arena-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }--.arena-tab b { color: var(--text-primary); margin-left: 2px; }--.arena-tab.is-on { border-color: #fb923c; color: var(--text-primary); background: rgba(249, 115, 22, 0.12); }--.arena-tab-hint { margin: 0; }--.arena-row-lore.is-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }--.arena-row-taunts em { display: flex; align-items: center; gap: 6px; }--@media (max-width: 900px) { .arena-subject-grid { grid-template-columns: 1fr; } }--@media (max-width: 600px) {-- .arena-subject { padding: 12px; } .arena-subject.is-featured .arena-subject-headline { font-size: 1.2rem; }-- .arena-quips-custom, .arena-quips { grid-template-columns: 1fr 1fr; gap: 4px 10px; }-- .arena-topic-headline-big { font-size: 1.2rem; }--}----/* ── Arena radar v2 ── */ .arena-bars-head small { color: var(--text-muted); }----/* ── Arena: your arena strip + bars fix ── */--.arena-me-left { display: flex; gap: 12px; align-items: center; min-width: 0; }--.arena-me-left > div { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }--.arena-me-right { display: flex; flex-direction: column; gap: 6px; font-size: 0.86rem; min-width: 0; justify-content: center; }--.arena-me-alert a { color: var(--text-primary); text-decoration: none; }----/* ── Arena growth: tiers, achievements, history, rap sheet, bubbles, numbers ── */--.arena-tier { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: var(--tc, #cd7f32); background: color-mix(in srgb, var(--tc, #cd7f32) 16%, transparent); border: 1px solid color-mix(in srgb, var(--tc, #cd7f32) 55%, transparent); }--.arena-tier-big { font-size: 0.82rem; padding: 5px 12px; box-shadow: 0 0 14px color-mix(in srgb, var(--tc) 35%, transparent); }--.arena-progress-panel { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }--.arena-progress-head { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; }--.arena-progress-bar { display: flex; flex-direction: column; gap: 6px; min-width: 0; }--.arena-tier-track { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }--.arena-tier-step { height: 8px; border-radius: 4px; background: var(--bg-tertiary); }--.arena-tier-step.is-done { background: var(--tc); }--.arena-tier-step.is-now { box-shadow: 0 0 10px color-mix(in srgb, var(--tc) 60%, transparent); animation: arena-glow 1.6s ease-in-out infinite alternate; }--@keyframes arena-glow { from { filter: brightness(0.9); } to { filter: brightness(1.3); } }--.arena-ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }--.arena-ach { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 3px; padding: 10px 8px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); min-width: 0; }--.arena-ach-icon { font-size: 1.5rem; line-height: 1; }--.arena-ach-name { font-size: 0.78rem; font-weight: 800; }--.arena-ach-hint { font-size: 0.68rem; color: var(--text-muted); line-height: 1.25; }--.arena-ach.is-earned { border-color: rgba(245, 197, 66, 0.55); background: linear-gradient(180deg, rgba(245, 197, 66, 0.12), var(--bg-tertiary)); }--.arena-ach.is-mini { flex-direction: row; text-align: left; padding: 6px 10px; gap: 8px; }--.arena-ach.is-mini .arena-ach-icon { font-size: 1.1rem; } .arena-ach.is-mini .arena-ach-hint { display: none; }--.arena-me-ach { gap: 6px; }--.arena-history summary { font-weight: 700; }--.arena-history-list { display: flex; flex-direction: column; gap: 2px; }--.arena-history-row { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.84rem; }--.arena-history-kind { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.72rem; }--.arena-history-kind.achievement { color: #f5c542; } .arena-history-kind.beef_over, .arena-history-kind.beef { color: #f87171; } .arena-history-kind.tier { color: #8ab4ff; } .arena-history-kind.level { color: #fb923c; }--.arena-history-body { display: flex; flex-direction: column; min-width: 0; }--.arena-history-body a { color: var(--text-primary); text-decoration: none; }--.arena-history-body small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }--.arena-history-when { font-size: 0.72rem; color: var(--text-muted); white-space: nowrap; }--.arena-sheet, .arena-taunts { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }--.arena-sheet h3, .arena-taunts h3 { margin: 0; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }--.arena-taunts h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }--.arena-sheet-lore { margin: 0; font-size: 0.95rem; line-height: 1.5; }--.arena-sheet-rows { display: flex; flex-direction: column; gap: 6px; }--.arena-sheet-row { display: grid; grid-template-columns: 92px 1fr; gap: 10px; font-size: 0.86rem; align-items: baseline; }--.arena-sheet-k { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }--.arena-sheet-row.is-weak .arena-sheet-k { color: #f87171; }--.arena-bubbles { display: flex; flex-direction: column; gap: 8px; }--.arena-bubble { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 14px 14px 14px 4px; background: var(--bg-tertiary); border: 1px solid var(--border); }--.arena-bubble-who { display: block; font-size: 0.7rem; font-weight: 800; color: var(--accent-light); margin-bottom: 2px; }--.arena-bubble-text { font-size: 0.95rem; line-height: 1.4; }--.arena-bubble-speak { width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; }--.arena-bubble-speak.is-speaking, .arena-bubble-speak:hover { color: var(--accent-light); border-color: var(--accent); }--.arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }--@media (max-width: 600px) {-- .arena-progress-head { grid-template-columns: 1fr; }-- .arena-ach-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .arena-ach { padding: 8px 4px; } .arena-ach-hint { display: none; }-- .arena-sheet-row { grid-template-columns: 1fr; gap: 2px; }-- .arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }--}----/* ── Arena: phone layout for expanded ladder rows + quips (specificity matches the open rule) ── */--.arena-quip-val { display: inline-block; min-width: 2.1em; text-align: right; color: var(--accent-light); font-variant-numeric: tabular-nums; margin-right: 4px; }--.arena-row-bubbles { margin: 0 0 10px; }--.arena-row-bubbles .arena-bubble { padding: 8px 10px; }--.arena-row-bubbles .arena-bubble-text { font-size: 0.88rem; }--@media (max-width: 700px) {-- .arena-row-lore.is-clamp { -webkit-line-clamp: 4; font-size: 0.9rem; }-- .arena-row-expand .arena-quip span { color: var(--text-secondary); }--}----/* bubble: name on its own line, text + speaker side by side */--.arena-bubble { grid-template-columns: 1fr auto; grid-template-areas: "who who" "text speak"; }--.arena-bubble-who { grid-area: who; }--.arena-bubble-text { grid-area: text; }--.arena-bubble-speak { grid-area: speak; align-self: center; }----/* ── Arena: Battle Cam mode (pure mic) — live cams, the feed, callouts ── */--.arena-cam { position: relative; display: grid; grid-template-columns: 132px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; transition: border-color 0.2s, box-shadow 0.2s; }--.arena-cam-thumb { position: relative; display: block; border-radius: 10px; overflow: hidden; aspect-ratio: 16/10; background: var(--bg-tertiary); }--.arena-cam-rec { position: absolute; left: 6px; top: 6px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 999px; background: rgba(0,0,0,0.65); color: #fff; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.06em; }--.arena-cam-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }--.arena-cam-last small { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 3px; }--.arena-feed-section h2 small .arena-fresh { color: var(--success); }--@keyframes arenaMomentIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }--.arena-mic-moment-body { min-width: 0; }--.arena-mic-moment-head small { color: var(--text-muted); margin-left: auto; }--.arena-receipts-section h3 { margin: 0 0 8px; }----/* Arena "your arena" strip: the portrait is a div, so the generic `> div` flex rule was stretching it */--/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V isdrawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }@@@ -10854,7 +10280,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.hero-stat-more i { transition: none; }}--/* ── Hero polish ─────────────────────────────────────────────────────────────The rotating tagline is a single unbreakable line, so on a narrow screen a long phrase ran offboth edges of the page. It wraps now, and the hero's tail padding is tighter so the live grid@@@ -10969,12 +10394,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgThe triangle is drawn with borders rather than set as an icon glyph — at 5px an icon font isa blurry smudge, and this needs no font to have loaded. */--/* Height is pinned only while the board is swapping between the strip and the full set, so thecontainer can still grow freely the rest of the time. min-height would fight an explicitheight on the way down, so it is dropped for the duration of the animation. */--/* ── Stat chip layout ────────────────────────────────────────────────────────At three chips per row on a phone each one gets about 128px, and "27,751" next to"+168 7d | 147%" does not fit in 128px — the pill rode over the number and every label@@@ -11059,14 +10482,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg#openvibe-bell-mount { height: 32px; display: inline-flex; align-items: center; position: relative; }#openvibe-bell-mount > * { position: relative; }------/* Belt and braces for the About section's cards: whatever happens with animations or deferredrendering, a card is never allowed to end up as an empty outline. */.home-cta-feature > i, .home-cta-feature > strong, .home-cta-feature > span { opacity: 1; }--/* ── About section sub-headings ──────────────────────────────────────────────Three sub-sections inside one long panel, all introduced by the same small grey line of caps —scrolling through it there was nothing to tell you that you had moved from "ways to go live"@@@ -11093,7 +10512,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srghover and a real focus ring, because it is a control and needs to look like one.═══════════════════════════════════════════════════════════════ */--/* ═══════════════════════════════════════════════════════════════.ov-enter — cards arriving from a "Load more" press.Transform and opacity only, staggered by --ov-enter-i (capped at 11 so a big page does not end@@@ -11105,20 +10523,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgto { opacity: 1; transform: none; }}--/* The home page ends on the changelog card, and the shared footer already brings 40px of its ownmargin above itself. Another 32px of page padding on top of that was a band of nothing betweenthe last readable line and the footer rule. Other pages end on content that needs the breathingroom, so this is scoped to the home page rather than changed globally. */#page-home.page { padding-bottom: 10px; }--/* Route changes (the bar, the loading line, the fade-in) are openvibe-shared web-runtime enter() + route-transition.js. */@media (prefers-reduced-motion: reduce) {.brand-mark.is-spin svg, .brand-mark.is-settling svg { animation: none !important; }}--/* ═══════════════════════════════════════════════════════════════Off-screen sections animate nothing.@@@ -11141,8 +10556,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srganimation-play-state: paused !important;}----@keyframes udIn { from { opacity: 0; transform: translateY(-8px) scale(0.95); } to { opacity: 1; transform: none; } }.ud-wallet { grid-column: 2; display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; }@@@ -11160,11 +10573,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgcolor: var(--text-muted);}--/* Rows arrive just behind the panel, capped so a long menu never feels slow. */@keyframes udRow { from { opacity: 0; transform: translateX(6px); } to { opacity: 1; transform: none; } }--/* ═══════════════════════════════════════════════════════════════About section ("Streaming the way it should be") — layout pass.@@@ -11185,8 +10596,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg/* A sheen that crosses a card once on hover — transform only, clipped to the card. */@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }----/* ═══════════════════════════════════════════════════════════════CONNECTION PILL — one quiet, site-wide signal while a live connection is recovering(app.js ovConnectionPill). Transform and opacity only.@@@ -11215,7 +10624,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.ov-conn-pill .ov-conn-dot { animation: none !important; }}--body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }/* ── Live on the shared navbar ────────────────────────────────────────────────@@@ -751,9 +751,6 @@</section><!-- Documentation Page -->-- <!-- ═══════════════════════════════════════════════════════════ -->-- <!-- ARENA — streamer vs streamer (public/js/arena.js) -->-- <!-- ═══════════════════════════════════════════════════════════ --><section id="page-recap" class="page"><div class="content-container recap-container"><div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>@@@ -1359,9 +1359,9 @@ async function loadHomeDigest() {st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',-- st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',++ st.takes ? `<span class="digest-stat"><i class="fa-solid fa-newspaper"></i> <b>${n(st.takes)}</b> Rundown take${st.takes === 1 ? '' : 's'}</span>` : '',].filter(Boolean).join('');-- const hot = (d.hot || []).map(h => `<div class="digest-hot-line"><i class="fa-solid fa-microphone-lines"></i> “${esc(String(h.text || '').slice(0, 140))}” <b>— ${esc(h.display_name || h.username)}</b>${h.aimed_at ? ` <span class="muted">at ${esc(h.aimed_at)}</span>` : ''}</div>`).join('');++ const hot = (d.hot || []).map(h => { const href = h.slug ? `/rundown/t/${h.slug}` : `/rundown/${h.username}`; const t = esc(String(h.text || '').slice(0, 140)); return `<a class="digest-hot-line" href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"><i class="fa-solid fa-quote-left"></i> ${h.quoted ? `“${t}”` : t} <b>— ${esc(h.display_name || h.username)}</b></a>`; }).join('');const sinceLabel = d.since ? (away ? `since your last visit · ${esc(timeAgo(d.since))}` : `last ${Math.max(1, Math.round((Date.now() - Date.parse(d.since)) / 3600000))}h`) : '';box.innerHTML = `<div class="digest-head">ddeleted file mode 100644@@@ -1,233 +0,0 @@--/**-- * OpenVibe.Live — Arena v4: ARENA ALERT, the drama desk (server/arena/drama.js)-- *-- * Routes (from loadArenaPage in arena.js, which also owns the shared helpers _aEsc, _aA, _aAgo…):-- * /arena the desk: masthead + ticker, the top story, BREAKING, Lolcow Index, Clout Rankings,-- * Beef Watch, Hot Mic, the cams on air-- * /arena/drama/<id> one story-- * /arena/<username> the rap sheet on top of the fighter page-- *-- * Every story comes from something measured on the site (a stream's length, its viewer and chat snapshots, sounds and-- * speech on its timeline, a beef's verdict); headlines may be AI-written from those facts. All text goes through _aEsc.-- */--'use strict';----const AA_KIND = {-- rage_quit: ['RAGE QUIT', '🚪'], viewer_crash: ['VIEWER CRASH', '📉'], viewer_spike: ['BLOWING UP', '📈'], chat_explosion: ['CHAT GOES FERAL', '💬'],-- meltdown: ['MELTDOWN', '😤'], tears: ['TEARS', '😭'], marathon: ['MARATHON', '⏰'], ghost_town: ['GHOST TOWN', '👻'], the_return: ['THE RETURN', '👀'],-- title_drama: ['TITLE DRAMA', '🍿'], peak_record: ['NEW PEAK', '🏆'], fell_off: ['FELL OFF?', '🕳️'], streak: ['NO DAYS OFF', '🔥'],-- quote_of_day: ['QUOTE OF THE DAY', '🎤'], beef_open: ['BEEF', '🥩'], beef_win: ['COOKED', '🍳'], beef_forfeit: ['DUCKED', '🐔'],--};--const AA_REACT = [['fire', '🔥'], ['skull', '💀'], ['clown', '🤡'], ['w', 'W'], ['l', 'L']];--let _aaTickerTop = 0;----function _aaUser(u) { return u ? _aA(_aFighterLink(u), _aEsc(u.display_name || u.username), 'aa-name') : ''; }--function _aaAvatar(u, size = 40) {-- if (!u) return '';-- const inner = u.avatar_url ? `<img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_aEsc(_aInitial(u))}</span>`;-- return _aA(_aFighterLink(u), inner, 'aa-av', u.display_name || u.username);--}--function _aaSirens(n) { return `<span class="aa-sirens" aria-label="alert level ${n} of 5">${'🚨'.repeat(Math.max(1, Math.min(5, n)))}</span>`; }--function _aaWatch(s) {-- if (s.vod_id) return _aA(videoHref('vod', s.vod_id, Math.max(0, Math.floor(s.sec || 0))), '<i class="fa-solid fa-play"></i> Watch it happen', 'aa-watch');-- if (s.user && s.kind !== 'fell_off') return _aA(_aChannelLink(s.user), '<i class="fa-solid fa-tower-broadcast"></i> Their channel', 'aa-watch');-- return '';--}--function _aaReactions(s) {-- return `<div class="aa-react" data-story="${s.id}" role="group" aria-label="React">${AA_REACT.map(([k, label]) => `<button type="button" class="aa-react-btn${s.my_reaction === k ? ' is-mine' : ''}" data-react="${k}" aria-pressed="${s.my_reaction === k}"><span class="aa-react-i">${label}</span><span class="aa-react-n">${_aNum(s.reactions?.[k] || 0)}</span></button>`).join('')}</div>`;--}--function _aaStory(s, { big = false } = {}) {-- const [badge, icon] = AA_KIND[s.kind] || [String(s.kind || '').toUpperCase(), '🚨'];-- return `<article class="aa-story aa-alert-${s.alert} aa-kind-${_aEsc(s.kind)}${big ? ' aa-story-big' : ''}" id="aa-story-${s.id}">-- <div class="aa-story-head"><span class="aa-badge">${icon} ${_aEsc(badge)}</span>${_aaSirens(s.alert)}<time class="aa-time" datetime="${_aEsc(s.happened_at)}">${_aEsc(_aAgo(s.happened_at))}</time></div>-- <div class="aa-story-body">-- <div class="aa-who">${_aaAvatar(s.user, big ? 72 : 44)}${s.other ? `<span class="aa-vs">VS</span>${_aaAvatar(s.other, big ? 72 : 44)}` : ''}</div>-- <div class="aa-story-text">-- <h3 class="aa-headline">${_aA(`/arena/drama/${s.id}`, _aEsc(s.headline))}</h3>-- ${s.blurb ? `<p class="aa-blurb">${_aEsc(s.blurb)}</p>` : ''}-- ${s.quote ? `<blockquote class="aa-quote"><span>${_aEsc(s.quote)}</span> ${_aSpeakBtn(s.quote, 'arena-speak', s.user?.username)}</blockquote>` : ''}-- <p class="aa-byline">${_aaUser(s.user)}${s.other ? ` · ${_aaUser(s.other)}` : ''}</p>-- </div>-- </div>-- <div class="aa-story-foot">${_aaReactions(s)}${_aaWatch(s)}${s.ai ? '<span class="aa-ai" title="Headline written by AI from the measured facts">AI desk</span>' : ''}</div>-- </article>`;--}--function _aaTicker(stories) {-- if (!stories.length) return '';-- const items = stories.slice(0, 12).map((s) => `<span class="aa-tick">${_aEsc((AA_KIND[s.kind] || ['', '🚨'])[1])} ${_aEsc(s.headline)}</span>`).join('');-- return `<div class="aa-ticker" aria-label="Latest headlines"><span class="aa-ticker-label">BREAKING</span><div class="aa-ticker-viewport"><div class="aa-ticker-track">${items}${items}</div></div></div>`;--}--function _aaCow(index) {-- if (!index.length) return `<p class="aa-quiet">Nobody has been cooked this week. Yet.</p>`;-- return `<ol class="aa-board">${index.slice(0, 10).map((c, i) => `<li class="aa-row">-- <span class="aa-rank${i === 0 ? ' is-crown' : ''}">${i === 0 ? '🐄' : i + 1}</span>${_aaAvatar(c.user, 32)}-- <span class="aa-row-main">${_aaUser(c.user)}<span class="aa-meter" style="--m:${c.meter}%"><span></span></span><span class="aa-row-sub">${Object.entries(c.kinds).map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())}${n > 1 ? ` ×${n}` : ''}`).join(' · ')}</span></span>-- <span class="aa-score" title="Cow points this week">${c.meter}</span></li>`).join('')}</ol>`;--}--function _aaMove(c) {-- if (c.move == null) return '<span class="aa-move is-new">NEW</span>';-- if (c.move > 0) return `<span class="aa-move is-up">▲${c.move}</span>`;-- if (c.move < 0) return `<span class="aa-move is-down">▼${-c.move}</span>`;-- return '<span class="aa-move">–</span>';--}--function _aaClout(rankings) {-- if (!rankings.length) return `<p class="aa-quiet">Nobody streamed this week.</p>`;-- return `<ol class="aa-board">${rankings.slice(0, 12).map((c) => `<li class="aa-row">-- <span class="aa-rank">${c.rank}</span>${_aaAvatar(c.user, 32)}-- <span class="aa-row-main">${_aaUser(c.user)} ${_aaMove(c)}<span class="aa-row-sub">${c.facts.hours}h live · peak ${c.facts.peak} · ${_aNum(c.facts.messages)} chat${c.facts.beef_wins ? ` · ${c.facts.beef_wins} beef W` : ''}</span>${c.tags.length ? `<span class="aa-tags">${c.tags.map((t) => `<span class="aa-tag aa-tag-${_aEsc(t.toLowerCase().replace(/\s+/g, '-'))}">${_aEsc(t)}</span>`).join('')}</span>` : ''}</span>-- <span class="aa-score" title="Clout this week">${_aNum(c.clout)}</span></li>`).join('')}</ol>`;--}--function _aaHotMic(feed) {-- if (!feed.length) return `<p class="aa-quiet">The ears have not caught anything spicy yet.</p>`;-- return `<ul class="aa-mic">${feed.slice(0, 8).map((m) => `<li><q>${_aEsc(m.text)}</q> <span class="aa-row-sub">${m.user ? _aaUser(m.user) : ''}${m.aimed_at ? ` → ${_aEsc(m.aimed_at)}` : ''} · ${_aQ(m.quality)} ${_aPlay(m.vod_id, m.sec)}</span></li>`).join('')}</ul>`;--}--function _aaBeefs(beefs) {-- const open = beefs.open || [], done = (beefs.resolved || []).slice(0, 3);-- const line = (b) => `<li>${_aA(_aBeefLink(b), `<b>${_aEsc(b.a.user.display_name || b.a.user.username)}</b> <span class="aa-vs">VS</span> <b>${_aEsc(b.b.user.display_name || b.b.user.username)}</b>`)} ${b.status === 'open' ? _aClockTag(b) : `<span class="aa-row-sub">${b.winner_user_id ? `${_aEsc(((b.winner_user_id === b.a.user.id ? b.a : b.b).user.display_name) || (b.winner_user_id === b.a.user.id ? b.a : b.b).user.username)} won` : 'draw'}</span>`}</li>`;-- return `${open.length ? `<ul class="aa-beefs">${open.map(line).join('')}</ul>` : '<p class="aa-quiet">No open beef. One name said on mic changes that.</p>'}${done.length ? `<p class="aa-row-sub aa-settled">Settled</p><ul class="aa-beefs">${done.map(line).join('')}</ul>` : ''}`;--}--function _aaBindReactions(root) {-- root.querySelectorAll('.aa-react').forEach((group) => {-- if (group.dataset.bound) return;-- group.dataset.bound = '1';-- group.addEventListener('click', async (e) => {-- const btn = e.target.closest('.aa-react-btn');-- if (!btn) return;-- const id = group.dataset.story;-- try {-- const r = await api(`/arena/drama/${id}/react`, { method: 'POST', body: { reaction: btn.dataset.react } });-- group.querySelectorAll('.aa-react-btn').forEach((b) => {-- const mine = b === btn;-- b.classList.toggle('is-mine', mine);-- b.setAttribute('aria-pressed', String(mine));-- const n = b.querySelector('.aa-react-n');-- if (n) n.textContent = _aNum(r.reactions?.[b.dataset.react] || 0);-- });-- btn.classList.add('is-pop');-- setTimeout(() => btn.classList.remove('is-pop'), 400);-- } catch (err) { _aToast(err?.message || 'Could not react', 'error'); }-- });-- });-- _aBindSpeak(root);--}----async function _aRenderAlertHome(root) {-- root.innerHTML = _aSpinner('Checking the timeline for drama…');-- const safe = (p, d) => p.catch(() => d);-- const [drama, cow, clout, beefs, live, feed] = await Promise.all([-- api('/arena/drama?limit=30'), safe(api('/arena/lolcow'), { index: [] }), safe(api('/arena/clout'), { rankings: [] }),-- safe(api('/arena/beefs'), { open: [], recent: [] }), safe(api('/arena/live'), { live: [] }), safe(api('/arena/feed?limit=12'), { feed: [] }),-- ]);-- const stories = drama.stories || [];-- const dayAgo = Date.now() - 86400000;-- const top = [...stories].filter((s) => new Date(String(s.happened_at).replace(' ', 'T') + 'Z').getTime() >= dayAgo).sort((a, b) => b.alert - a.alert || b.id - a.id)[0] || stories[0] || null;-- _aaTickerTop = stories[0]?.id || 0;-- const rest = stories.filter((s) => !top || s.id !== top.id);-- root.innerHTML = `<div class="aa">-- <header class="aa-mast">-- <div class="aa-mast-row">-- <h1 class="aa-title"><span class="aa-siren" aria-hidden="true">🚨</span> ARENA <span>ALERT</span></h1>-- <span class="aa-onair"><span class="arena-live-dot arena-live-dot-sm"></span> ${(live.live || []).length ? `${live.live.length} ON AIR` : 'WATCHING'}</span>-- </div>-- <p class="aa-sub">Rage quits, meltdowns, viewer crashes, beefs and L's from every stream on OpenVibe. Measured on the site, timestamped, then roasted.</p>-- ${_aaTicker(stories)}-- </header>-- ${(live.live || []).length ? `<section class="arena-live arena-cams aa-cams" id="arena-live">${_aRenderCams(live.live)}</section>` : ''}-- ${top ? `<section class="aa-top" aria-label="Top story">${_aaStory(top, { big: true })}</section>` : ''}-- <div class="aa-grid">-- <section class="aa-feed" aria-labelledby="aa-feed-h">-- <h2 id="aa-feed-h" class="aa-h">BREAKING</h2>-- <div id="aa-feed-list">${rest.length ? rest.map((s) => _aaStory(s)).join('') : (top ? '' : `<div class="aa-quiet aa-quiet-big"><b>The desk is listening.</b> The next rage quit, viewer crash or meltdown on any stream lands here within five minutes.</div>`)}</div>-- ${stories.length >= 30 ? `<button type="button" class="btn aa-more" id="aa-more" data-before="${stories[stories.length - 1].id}">Older drama</button>` : ''}-- </section>-- <aside class="aa-side">-- <section class="aa-box aa-box-cow" aria-labelledby="aa-cow-h"><h2 id="aa-cow-h" class="aa-h">🐄 Lolcow Index <small>this week</small></h2>${_aaCow(cow.index || [])}</section>-- <section class="aa-box aa-box-clout" aria-labelledby="aa-clout-h"><h2 id="aa-clout-h" class="aa-h">📈 Clout Rankings <small>last 7 days</small></h2>${_aaClout(clout.rankings || [])}</section>-- <section class="aa-box" aria-labelledby="aa-beef-h"><h2 id="aa-beef-h" class="aa-h">🥩 Beef Watch</h2>${_aaBeefs(beefs)}</section>-- <section class="aa-box" aria-labelledby="aa-mic-h"><h2 id="aa-mic-h" class="aa-h">🎤 Hot Mic</h2>${_aaHotMic(feed.feed || [])}</section>-- </aside>-- </div>-- <section class="aa-how">-- <h2 class="aa-h">How the desk works</h2>-- <ul>-- <li><b>Measured, not made up.</b> Every five minutes the desk reads what happened on every stream: how long it lasted, its viewer and chat counts minute by minute, what the mic heard (speech and sounds like yelling or crying), titles, records and beefs. Each story states those numbers.</li>-- <li><b>Lolcow Index.</b> Rage quits, viewer crashes, meltdowns, tears, ghost-town streams, dramatic titles, falling off and losing beefs add cow points (newer counts more). The top cow wears the crown.</li>-- <li><b>Clout Rankings.</b> Hours live, viewers, chat, new followers, records, comebacks and beef wins over the last 7 days, with this week's moves. COOKED means your cow meter is 70+.</li>-- <li><b>Beefs.</b> Call another streamer out on mic and the beef opens itself; they are on the clock. Silence is a forfeit, and a forfeit is a story.</li>-- <li><b>React.</b> 🔥 💀 🤡 W or L, one per story. Streamers who would rather not be covered can ${_aMe() ? `<button type="button" class="aa-link" id="aa-optout">keep themselves out of the Arena</button>` : 'sign in and keep themselves out of the Arena'}.</li>-- </ul>-- </section>-- </div>`;-- _aaBindReactions(root);-- _aBindHome(root);-- document.getElementById('aa-more')?.addEventListener('click', async (e) => {-- const btn = e.currentTarget;-- btn.disabled = true;-- try {-- const older = await api(`/arena/drama?limit=30&before=${encodeURIComponent(btn.dataset.before)}`);-- const list = document.getElementById('aa-feed-list');-- list.insertAdjacentHTML('beforeend', (older.stories || []).map((s) => _aaStory(s)).join(''));-- _aaBindReactions(root);-- if ((older.stories || []).length < 30) btn.remove(); else { btn.dataset.before = older.stories[older.stories.length - 1].id; btn.disabled = false; }-- } catch { btn.disabled = false; }-- });-- document.getElementById('aa-optout')?.addEventListener('click', _aaOptOutDialog);-- _aEvery(30000, async () => {-- try {-- const d = await api('/arena/drama?limit=10');-- const fresh = (d.stories || []).filter((s) => s.id > _aaTickerTop);-- if (!fresh.length) return;-- _aaTickerTop = d.stories[0].id;-- const list = document.getElementById('aa-feed-list');-- if (list) { list.insertAdjacentHTML('afterbegin', fresh.map((s) => _aaStory(s)).join('')); _aaBindReactions(root); _aFlash(list, `${fresh.length} new stor${fresh.length === 1 ? 'y' : 'ies'}`); }-- } catch { /* keep the last render */ }-- });-- _aEvery(1000, () => _aTickClocks(root));--}----async function _aaOptOutDialog() {-- try {-- const cur = await api('/arena/opt-out');-- const out = !cur.opted_out;-- const ok = confirm(out ? 'Keep yourself out of the Arena? No new stories will be written about you and your old ones come down. You can come back any time.' : 'Come back into the Arena? New stories about your streams will be written again.');-- if (!ok) return;-- await api('/arena/opt-out', { method: 'PUT', body: { out } });-- _aToast(out ? 'You are out of the Arena.' : 'Welcome back to the Arena.', 'success');-- loadArenaPage(['arena']);-- } catch (err) { _aToast(err?.message || 'Could not change that', 'error'); }--}----async function _aRenderStory(root, id) {-- root.innerHTML = _aSpinner('Pulling the receipts…');-- const s = await api(`/arena/drama/${encodeURIComponent(id)}`);-- const more = s.user ? await api(`/arena/drama?limit=6&user=${encodeURIComponent(s.user.username)}`).catch(() => ({ stories: [] })) : { stories: [] };-- const facts = Object.entries(s.facts || {}).filter(([k, v]) => v != null && typeof v !== 'object' && k !== 'other');-- root.innerHTML = `<div class="aa">-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> ARENA ALERT')}</div>-- ${_aaStory(s, { big: true })}-- ${facts.length ? `<section class="aa-box aa-facts"><h2 class="aa-h">The receipts</h2><dl>${facts.map(([k, v]) => `<div><dt>${_aEsc(k.replace(/_/g, ' '))}</dt><dd>${_aEsc(String(v))}</dd></div>`).join('')}</dl></section>` : ''}-- ${(more.stories || []).filter((x) => x.id !== s.id).length ? `<section class="aa-feed"><h2 class="aa-h">More on ${_aEsc(s.user.display_name || s.user.username)}</h2>${more.stories.filter((x) => x.id !== s.id).map((x) => _aaStory(x)).join('')}</section>` : ''}-- </div>`;-- _aaBindReactions(root);--}----/** The rap sheet: shown above the fighter page at /arena/<username>. */--async function _aRenderRapSheet(el, username) {-- if (!el) return;-- let r;-- try { r = await api(`/arena/rap/${encodeURIComponent(username)}`); } catch { el.innerHTML = ''; return; }-- if (r.opted_out) { el.innerHTML = `<div class="aa-box aa-quiet">${_aEsc(r.user.display_name || r.user.username)} keeps themselves out of the Arena.</div>`; return; }-- const counts = Object.entries(r.counts || {}).sort((a, b) => b[1] - a[1]);-- el.innerHTML = `<section class="aa-rap aa-box">-- <h2 class="aa-h">📁 Rap sheet</h2>-- <div class="aa-rap-stats">-- <div><b>${r.cow ? r.cow.meter : 0}</b><span>cow meter</span></div>-- <div><b>${r.clout ? `#${r.clout.rank}` : '–'}</b><span>clout rank</span></div>-- <div><b>${r.record.w}–${r.record.l}</b><span>beefs W–L</span></div>-- <div><b>${r.stories.length}</b><span>stories</span></div>-- </div>-- ${counts.length ? `<p class="aa-row-sub">${counts.map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())} ×${n}`).join(' · ')}</p>` : '<p class="aa-quiet">Clean record. For now.</p>'}-- ${r.stories.slice(0, 5).map((s) => _aaStory(s)).join('')}-- </section>`;-- _aaBindReactions(el);--}ddeleted file mode 100644@@@ -1,564 +0,0 @@--/**-- * OpenVibe.Live — Arena tab. The home is ARENA ALERT, the drama desk (public/js/arena-alert.js, Arena v4); this file-- * keeps the shared helpers, beefs, the ears and the fighter pages.-- *-- * Routes (handled from app.js):-- * /arena ARENA ALERT (arena-alert.js)-- * /arena/drama/<id> one story (arena-alert.js)-- * /arena/beef/<id> one beef: tug-of-war, clock, ringside feed, receipts-- * /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)-- * /arena/<username> fighter profile (mic stats, voice + quotes, level, receipts, beefs, rivalries)-- *-- * Nothing here starts a fight and nothing in chat counts. Everything on this page is what-- * fighters SAID ON MIC (server/arena/listener.js → mic.js / beef.js). All text is transcribed or-- * AI-written and rendered through _aEsc — never trusted as HTML.-- */--'use strict';----let ARENA_STATS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];--let ARENA_STAT_LABEL = { heat: 'Heat', aim: 'Aim', kills: 'Kills', mouth: 'Mouth', clapback: 'Clapback', stamina: 'Stamina', pace: 'Pace' };--let _arenaRoster = null;--let _arenaTimers = [];--let _arenaImagePoll = null;--let _arenaFeedTop = 0;----function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }--function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(Number.isInteger(n) ? n : n.toFixed(1)); }--function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }--function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }--function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }--function _aAgo(d) {-- if (!d) return '';-- const ms = Date.now() - new Date(String(d).includes('T') ? d : String(d).replace(' ', 'T') + 'Z').getTime();-- const m = Math.round(ms / 60000);-- if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 48) return `${h}h ago`; return `${Math.round(h / 24)}d ago`;--}--function _aClock(sec) { sec = Math.max(0, Math.floor(sec || 0)); if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }--function _aToast(msg, type = 'info') { if (typeof toast === 'function') toast(msg, type); else console.log('[Arena]', msg); }--function _aMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }--function _aSpinner(text) { return `<div class="arena-loading"><i class="fa-solid fa-circle-notch fa-spin"></i><span>${_aEsc(text)}</span></div>`; }----function _aStopTimers() {-- for (const t of _arenaTimers) { clearInterval(t); clearTimeout(t); }-- _arenaTimers = [];-- if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }-- _aStopSpeaking();--}--function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }----// ── Speech: hear a line in that person's OpenVibe chat voice ──--let _arenaAudio = null;--function _aStopSpeaking() {-- try { if (_arenaAudio) { _arenaAudio.pause(); _arenaAudio.src = ''; } } catch { /* */ }-- _arenaAudio = null;-- try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }-- document.querySelectorAll('.is-speaking').forEach(el => el.classList.remove('is-speaking'));--}--function _aVoiceUrl(who, text) { return `/api/arena/voice/${encodeURIComponent(who || 'announcer')}?t=${encodeURIComponent(String(text).slice(0, 240))}`; }--function _aSpeak(text, btn, who) {-- if (!text) return;-- if (btn && btn.classList.contains('is-speaking')) { _aStopSpeaking(); return; }-- _aStopSpeaking();-- if (btn) btn.classList.add('is-speaking');-- const done = () => { if (btn) btn.classList.remove('is-speaking'); if (_arenaAudio === a) _arenaAudio = null; };-- const a = new Audio(_aVoiceUrl(who, text));-- _arenaAudio = a;-- a.onended = done;-- a.onerror = () => {-- if (_arenaAudio !== a) return;-- _arenaAudio = null;-- try {-- if (!window.speechSynthesis) return done();-- const u = new SpeechSynthesisUtterance(text); u.rate = 1.05; u.pitch = 0.9;-- u.onend = u.onerror = done;-- speechSynthesis.speak(u);-- } catch { done(); }-- };-- a.play().catch(() => a.onerror && a.onerror());--}--function _aSpeakBtn(text, cls = 'arena-speak', who = null) { return `<button type="button" class="${cls}" data-speak="${_aEsc(text)}" data-voice="${_aEsc(who || '')}" title="${who ? `Hear it in ${_aEsc(who)}'s chat voice` : 'Hear the announcer'}"><i class="fa-solid fa-volume-high"></i></button>`; }--function _aBindSpeak(root) {-- root.querySelectorAll('[data-speak]').forEach(btn => btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _aSpeak(btn.dataset.speak, btn, btn.dataset.voice || null); }));--}----/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */--function _aPortrait(f, size = 'md') {-- const u = f.user || f;-- const color = u.profile_color || '#8b5cf6';-- if (u.avatar_url) return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;-- return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;--}----/** A fighter's radar: the seven mic stats, or their AI characteristics when the persona has them. */--function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {-- const axes = ARENA_STATS.map(k => ({ name: ARENA_STAT_LABEL[k], value: Math.max(0, Math.min(99, Number(f.ratings?.[k]) || 0)), quip: _arenaRoster?.stat_meta?.[k]?.desc || '' }));-- const N = axes.length, c = size / 2, R = size / 2 - 62;-- const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / N;-- const pt = (i, v) => [c + (R * v / 99) * Math.cos(ang(i)), c + (R * v / 99) * Math.sin(ang(i))];-- const poly = (v) => axes.map((_, i) => pt(i, v).map(n => n.toFixed(1)).join(',')).join(' ');-- const shape = axes.map((a, i) => pt(i, a.value).map(n => n.toFixed(1)).join(',')).join(' ');-- const id = `rg${Math.floor(Math.random() * 1e6)}`;-- return `<svg class="arena-radar arena-radar-v2" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="stats radar">-- <defs><radialGradient id="${id}" cx="50%" cy="50%" r="60%"><stop offset="0%" stop-color="${_aEsc(color)}" stop-opacity="0.55"/><stop offset="100%" stop-color="${_aEsc(color)}" stop-opacity="0.12"/></radialGradient></defs>-- ${[33, 66, 99].map(v => `<polygon points="${poly(v)}" class="arena-radar-ring"></polygon>`).join('')}-- ${axes.map((_, i) => { const [x, y] = pt(i, 99); return `<line x1="${c}" y1="${c}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" class="arena-radar-axis"></line>`; }).join('')}-- <polygon points="${shape}" class="arena-radar-fill" style="fill:url(#${id});stroke:${_aEsc(color)}"></polygon>-- ${axes.map((a, i) => { const [x, y] = pt(i, a.value); return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3.5" class="arena-radar-dot" style="fill:${_aEsc(color)}"><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></circle>`; }).join('')}-- ${axes.map((a, i) => { const cos = Math.cos(ang(i)), sin = Math.sin(ang(i)); const lx = c + (R + 22) * cos, ly = c + (R + 22) * sin; const anchor = Math.abs(cos) < 0.25 ? 'middle' : (cos > 0 ? 'start' : 'end'); const name = a.name.length > 13 ? a.name.slice(0, 12) + '…' : a.name; return `<g class="arena-radar-label"><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? -6 : sin > 0.3 ? 2 : -4)).toFixed(1)}" text-anchor="${anchor}">${_aEsc(name)}</text><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? 7 : sin > 0.3 ? 15 : 9)).toFixed(1)}" text-anchor="${anchor}" class="arena-radar-val" style="fill:${_aEsc(color)}">${a.value}</text><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></g>`; }).join('')}-- </svg>`;--}----/** Sparkline of a per-stream series. */--function _aSpark(series, color) {-- const w = 320, h = 70, pad = 6;-- const vals = series.map(p => Number(p.value) || 0);-- if (!vals.length) return '<p class="arena-voice-empty">No stream history in the window.</p>';-- const max = Math.max(...vals, 1), min = 0;-- const x = (i) => vals.length === 1 ? w / 2 : pad + (i * (w - pad * 2)) / (vals.length - 1);-- const y = (v) => h - pad - ((v - min) / (max - min || 1)) * (h - pad * 2);-- const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');-- const area = `${x(0).toFixed(1)},${h - pad} ${pts} ${x(vals.length - 1).toFixed(1)},${h - pad}`;-- return `<svg class="arena-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="--fc:${_aEsc(color)}">-- <polygon class="arena-spark-area" points="${area}"></polygon>-- <polyline points="${pts}"></polyline>-- ${vals.map((v, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="2.5"><title>${_aEsc(series[i].title || '')} · ${_aEsc(_aDate(series[i].date))}: ${_aEsc(_aNum(v))}</title></circle>`).join('')}-- </svg>-- <div class="arena-spark-caption"><span>${_aEsc(_aDate(series[0].date))}</span><span>peak ${_aEsc(_aNum(max))}</span><span>${_aEsc(_aDate(series[series.length - 1].date))}</span></div>`;--}----function _aChannelLink(u, slot = null) { return typeof channelPath === 'function' ? channelPath(u.username, slot) : `/@${encodeURIComponent(u.username)}`; }--function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }--function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }--function _aBeefLink(b) { return `/arena/beef/${b.id}`; }--function _aVodLink(vodId, sec) { return vodId ? videoHref('vod', vodId, Math.max(0, Math.floor(sec || 0))) : null; }--// A link inside Live navigates the SPA; one that leaves (an OpenVibe.Video moment) is a plain link.--function _aA(href, inner, cls = '', title = '') { const out = /^https?:\/\//.test(href); return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''}${out ? '' : ` onclick="return handleLinkClick(event, '${_aEsc(href)}')"`}>${inner}</a>`; }--function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }--function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — XP from what you say on mic, nothing else">TL ${_aEsc(level ?? 1)}</span>`; }--function _aBriefChip(f, extra = '') {-- return _aA(_aFighterLink(f.user), `${_aPortrait(f, 'xs')}<span><strong>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>${f.rank ? `#${f.rank} · ` : ''}${_aLevelPill(f.level)}${extra}</small></span>`, 'arena-chip');--}--function _aQ(q) { return `<span class="arena-q ${q >= 8 ? 'is-fire' : q >= 6 ? 'is-hot' : ''}" title="judge score">${q}/10</span>`; }--function _aAim(m) {-- if (m.target) return _aA(_aFighterLink(m.target.user), `<i class="fa-solid fa-crosshairs"></i> ${_aEsc(m.target.fighter_name)}`, 'arena-aim is-fighter', 'called out on mic — this fed a beef');-- if (m.aimed_at && m.aimed_at !== 'nobody') return `<span class="arena-aim"><i class="fa-solid fa-bullseye"></i> at ${_aEsc(m.aimed_at)}</span>`;-- return '';--}----// ── Page entry ───────────────────────────────────────────────----async function loadArenaPage(segments = []) {-- _aStopTimers();-- const root = document.getElementById('arena-root');-- if (!root) return;-- const [, first, second] = segments;-- try {-- if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));-- if (first === 'live' && second) return await _aRenderConsole(root, second);-- if (first === 'drama' && second) return await _aRenderStory(root, second);-- if (first === 'topic' || first === 'chatter') return _aGone(root);-- if (first) {-- root.innerHTML = '<div id="aa-rap-slot"></div><div id="arena-fighter-slot"></div>';-- await Promise.all([_aRenderRapSheet(document.getElementById('aa-rap-slot'), first), _aRenderFighter(document.getElementById('arena-fighter-slot'), first)]);-- return;-- }-- return await _aRenderAlertHome(root);-- } catch (err) {-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-plug-circle-xmark"></i><p>${_aEsc(err?.message || 'The arena lights went out.')}</p></div>`;-- }--}--function _aGone(root) {-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-lines"></i><p>That part of the Arena is gone. The drama is on ARENA ALERT now.</p>${_aA('/arena', 'Back to ARENA ALERT', 'btn btn-primary')}</div>`;--}----function _aRenderCams(live) {-- if (!live.length) return `<h2><span class="arena-live-dot"></span> Live cams <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the ears pick them up here — name-drops open beefs, free shit talk hits the feed.</p>`;-- return `<h2><span class="arena-live-dot"></span> Live cams <small>${live.length} on · the ears are listening</small></h2>-- <div class="arena-cam-grid">${live.map(f => {-- const lock = f.ears && f.ears.focus;-- return `<div class="arena-cam ${f.transcribed ? '' : 'is-quiet'} ${lock ? 'is-locked' : ''}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">-- ${_aA(_aChannelLink(f.user, f.stream.slug || f.stream.managed_stream_id), `${typeof f.thumbnail_url === 'string' && f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="" onerror="this.hidden=true">` : ''}${_aPortrait(f, 'md')}<span class="arena-cam-rec"><span class="arena-live-dot arena-live-dot-sm"></span> LIVE · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>`, 'arena-cam-thumb')}-- <div class="arena-cam-body">-- <div class="arena-mic-head">-- ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.user.display_name || f.user.username)}</strong>`)}-- <span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · ${f.record.wins}W–${f.record.losses}L</span>-- </div>-- ${lock ? `<div class="arena-cam-lock"><i class="fa-solid fa-crosshairs fa-beat"></i> locked on <b>${_aEsc(lock.target || 'a fighter')}</b> · ${lock.hits} hit${lock.hits === 1 ? '' : 's'}</div>` : ''}-- ${f.last_moment ? `<div class="arena-cam-last"><q>${_aEsc(f.last_moment.text)}</q><small>${_aQ(f.last_moment.quality)} ${_aAim(f.last_moment)} · ${_aEsc(_aAgo(f.last_moment.at))} ${_aPlay(f.last_moment.vod_id, f.last_moment.sec)}</small></div>` : (f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — the cam needs audio transcription on'}</span>`)}-- <div class="arena-mic-tags">-- ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : '<span class="arena-tag arena-tag-dim">no beef open</span>'}-- ${f.ears ? `<span class="arena-tag arena-tag-dim" title="words buffered for the judge"><i class="fa-solid fa-ear-listen"></i> ${f.ears.pending_words} words pending</span>` : ''}-- ${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}-- </div>-- </div>-- </div>`; }).join('')}</div>`;--}----// ── The feed ─────────────────────────────────────────────────----function _aMomentRow(m) {-- return `<div class="arena-mic-moment ${m.kind === 'beef_hit' ? 'is-beef' : ''} ${m.quality >= 8 ? 'is-fire' : ''}" data-id="${m.id}">-- ${_aA(_aFighterLink(m.user), _aPortrait(m, 'xs'), '', m.fighter_name || (m.user && m.user.username) || 'Fighter')}-- <div class="arena-mic-moment-body">-- <div class="arena-mic-moment-head">${_aA(_aFighterLink(m.user), `<b>${_aEsc(m.fighter_name)}</b>`)}${m.live ? ' <span class="arena-live-pill">LIVE</span>' : ''} ${_aAim(m)} ${_aQ(m.quality)} <small>${_aEsc(_aAgo(m.at))}</small></div>-- <q>${_aEsc(m.text)}</q>-- ${m.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(m.announcer)}</div>` : ''}-- <small class="arena-mic-moment-foot">${m.about ? `${_aEsc(m.about)} · ` : ''}${m.kind === 'beef_hit' && m.beef_id ? `${_aA(_aBeefLink({ id: m.beef_id }), '<i class="fa-solid fa-fire-flame-curved"></i> beef', 'arena-tag arena-tag-hot')} ` : ''}${_aPlay(m.vod_id, m.sec)} ${_aSpeakBtn(m.text, '', m.user.username)}</small>-- </div>-- </div>`;--}--function _aRenderFeed(feed, { fresh = 0 } = {}) {-- return `<h2><i class="fa-solid fa-satellite-dish"></i> Shit talk on record <small>what was actually said on stream, newest first · ${feed.length ? `${feed.length} lines` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>-- ${feed.length ? `<div class="arena-mic-feed-list">${feed.map(_aMomentRow).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>Quiet. The moment a fighter on a live cam talks shit — at anyone — it lands here with a score.</p></div>`}`;--}----// ── Beefs ────────────────────────────────────────────────────----function _aTug(b, { big = false } = {}) {-- const ca = b.a.user.profile_color || '#8b5cf6', cb = b.b.user.profile_color || '#e74c3c';-- return `<div class="arena-tug ${big ? 'arena-tug-big' : ''}" title="${_aEsc(`${b.a.fighter_name} ${b.a.total} — ${b.b.total} ${b.b.fighter_name}`)}">-- <span class="arena-tug-a" style="width:${b.share_a}%;--fc:${_aEsc(ca)}"><b>${b.share_a >= 12 ? `${b.share_a}%` : ''}</b></span>-- <span class="arena-tug-b" style="width:${100 - b.share_a}%;--fc:${_aEsc(cb)}"><b>${100 - b.share_a >= 12 ? `${100 - b.share_a}%` : ''}</b></span>-- </div>`;--}--function _aClockTag(b) {-- if (b.status !== 'open' || !b.on_clock) return '';-- const who = b.on_clock === 'a' ? b.a : b.b;-- return `<span class="arena-clock ${b.clock_seconds_left < 120 ? 'is-urgent' : ''}" data-until="${_aEsc(b.clock_until)}" data-who="${_aEsc(who.fighter_name)}"><i class="fa-solid fa-stopwatch"></i> ${_aEsc(who.fighter_name)} has <b>${_aClock(b.clock_seconds_left)}</b> to answer${b.clock_is_live_window ? '' : ' (offline clock)'}</span>`;--}--function _aTickClocks(root) {-- root.querySelectorAll('.arena-clock[data-until]').forEach(el => {-- const left = Math.max(0, Math.round((Date.parse(el.dataset.until) - Date.now()) / 1000));-- const b = el.querySelector('b'); if (b) b.textContent = _aClock(left);-- el.classList.toggle('is-urgent', left < 120);-- if (left === 0) el.innerHTML = `<i class="fa-solid fa-hourglass-end"></i> ${_aEsc(el.dataset.who)} ran out of time…`;-- });--}--function _aBeefTags(b) {-- const tags = [];-- if (b.rematch) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-rotate-left"></i> REMATCH</span>');-- if (b.upset) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-bolt"></i> UPSET</span>');-- if (b.streaks?.a >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.a.fighter_name)} on a ${b.streaks.a}-beef streak</span>`);-- if (b.streaks?.b >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.b.fighter_name)} on a ${b.streaks.b}-beef streak</span>`);-- if (b.history?.fights) tags.push(`<span class="arena-tag arena-tag-dim">history ${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights}</span>`);-- return tags.join('');--}--function _aBeefCard(b) {-- const open = b.status === 'open';-- const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;-- return `<div class="arena-beef ${open ? 'is-open' : 'is-done'}" data-beef="${b.id}">-- <div class="arena-beef-head">-- ${_aA(_aBeefLink(b), `<span class="arena-beef-headline">${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</span>`)}-- <span class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : ''}` : 'draw'}</span>` : ''}</span>-- </div>-- <div class="arena-beef-sides">-- ${_aA(_aFighterLink(b.a.user), `${_aPortrait(b.a, 'sm')}<span><strong>${_aEsc(b.a.fighter_name)}${b.a.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.a.rank || '–'} · ${b.a.hits} hits · crowd ${b.a.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-a')}-- <span class="arena-beef-vs">${open ? 'VS' : 'FINAL'}</span>-- ${_aA(_aFighterLink(b.b.user), `${_aPortrait(b.b, 'sm')}<span><strong>${_aEsc(b.b.fighter_name)}${b.b.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.b.rank || '–'} · ${b.b.hits} hits · crowd ${b.b.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-b')}-- </div>-- ${_aTug(b)}-- <div class="arena-beef-foot">-- ${open ? _aClockTag(b) : `<span class="arena-note">ended ${_aEsc(_aAgo(b.resolved_at))}</span>`}-- ${open ? `<span class="arena-beef-actions">-- <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="a" title="Hype ${_aEsc(b.a.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.a.fighter_name)}</button>-- <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="b" title="Hype ${_aEsc(b.b.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.b.fighter_name)}</button>-- </span>` : ''}-- </div>-- ${b.feed?.length ? `<q class="arena-beef-last">${_aEsc(b.feed[b.feed.length - 1].announcer || b.feed[b.feed.length - 1].text || '')}</q>` : ''}-- </div>`;--}--function _aRenderBeefs(beefs) {-- const open = beefs.open || [], done = beefs.resolved || [];-- return `<h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs <small>${open.length ? `${open.length} open` : 'none open — say a name on mic'}</small></h2>-- ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A fighter only has to call another one out on mic — the ears do the rest.</p></div>`}-- ${done.length ? `<details class="arena-done"><summary>Settled beefs <small>${done.length}</small></summary><div class="arena-beef-grid">${done.slice(0, 6).map(_aBeefCard).join('')}</div></details>` : ''}`;--}--async function _aHype(beefId, side, btn) {-- try {-- const r = await api(`/arena/beefs/${beefId}/hype`, { method: 'POST', body: { side } });-- _aToast(r.added ? `🔥 Hyped! crowd ${r.crowd}/10 (${r.hypers} hyping)` : `You already hyped this side (${r.hypers} hyping)`, r.added ? 'success' : 'info');-- if (btn) btn.classList.add('is-hyped');-- } catch (err) { _aToast(err?.message || 'Hype failed', 'error'); }--}--function _aBindHome(root) {-- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = (e) => { e.preventDefault(); _aHype(btn.dataset.beef, btn.dataset.side, btn); });-- _aBindSpeak(root);--}--function _aFlash(el, text) {-- const f = document.createElement('div'); f.className = 'arena-flash'; f.textContent = text; el.prepend(f); setTimeout(() => f.remove(), 2200);--}----// ── Ladder ───────────────────────────────────────────────────----function _aRenderList(fighters) {-- const el = document.getElementById('arena-list');-- if (!el) return;-- if (!fighters.length) { el.innerHTML = '<div class="arena-empty"><p>No fighter matches that.</p></div>'; return; }-- el.innerHTML = fighters.map(f => `-- <div class="arena-row ${f.live ? 'is-live' : ''}" data-user="${_aEsc(f.user.username)}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">-- <span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>-- ${_aPortrait(f, 'sm')}-- <span class="arena-row-main">-- <strong>${_aEsc(f.user.display_name || f.user.username)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>-- <span class="arena-row-sub">${f.mic ? `<i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}/10 · ${f.mic.bangers} bangers · ${f.mic.beef_hits} callouts` : ''}${f.voice?.has_data ? ` · ${_aNum(f.voice.speech_minutes)} min on mic` : ''}</span>-- ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”${f.last_line.aimed_at ? ` <small>— at ${_aEsc(f.last_line.aimed_at)}</small>` : ''}</em>` : '<em class="arena-row-taunt arena-note">nothing judged yet</em>'}-- </span>-- <span class="arena-row-stats">-- <span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>-- <span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>-- </span>-- <div class="arena-row-expand">-- <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>-- <div>-- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || '')}</span></div>`).join('')}</div>-- </div>-- <div class="arena-row-expand-actions">-- ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-satellite-dish"></i> Their lines', 'btn btn-primary')}-- ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}-- ${f.last_line ? _aSpeakBtn(f.last_line.text, 'btn btn-ghost', f.user.username) : ''}-- </div>-- </div>-- </div>`).join('');-- el.querySelectorAll('.arena-row').forEach(row => row.addEventListener('click', (e) => {-- if (e.target.closest('a, button')) return;-- row.classList.toggle('is-open');-- }));-- _aBindSpeak(el);--}----// ── Beef detail ──────────────────────────────────────────────----function _aFeedLine(e, b) {-- const side = e.side === 'a' ? b.a : b.b;-- const sys = e.kind && !['hit', 'open', 'respond'].includes(e.kind);-- return `<div class="arena-feed-line ${sys ? 'is-system' : `is-${e.side}`}">-- ${sys ? '' : _aA(_aFighterLink(side.user), _aPortrait(side, 'xs'), '', side.fighter_name || (side.user && side.user.username) || 'Fighter')}-- <div class="arena-feed-body">-- ${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}-- ${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}-- <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.kind === 'open' ? ' · opened it' : e.kind === 'respond' ? ' · answered' : ''}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '', side.user.username) : ''}</small>-- </div>-- </div>`;--}--async function _aRenderBeef(root, id) {-- root.innerHTML = _aSpinner('Pulling the receipts…');-- const b = await api(`/arena/beefs/${id}`);-- const open = b.status === 'open';-- const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;-- const draw = () => {-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>-- <div class="arena-beef-page">-- <div class="arena-beef-hero">-- <div class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : b.resolution === 'score' ? ' on points' : ''}` : 'draw'}</span>` : '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> OPEN</span>'}</div>-- <h1>${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)} ${_aSpeakBtn((open ? b.headline : b.result_headline || b.headline) || '')}</h1>-- ${b.opener_line ? `<p class="arena-beef-opener">It started on mic with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}-- </div>-- <div class="arena-beef-tape">-- ${['a', 'b'].map(s => { const f = b[s]; return `<div class="arena-beef-tape-side arena-beef-tape-${s}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#e74c3c'))}">-- ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'), '', f.fighter_name || (f.user && f.user.username) || 'Fighter')}-- <h2>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</h2>-- <div class="arena-beef-tape-meta">#${f.rank || '–'} · ${_aLevelPill(f.level)}${b.streaks?.[s] >= 2 ? ` · ${b.streaks[s]}-beef streak` : ''}</div>-- <div class="arena-beef-tape-nums"><span><b>${f.hits}</b><small>hits</small></span><span><b>${f.score}</b><small>quality</small></span><span><b>${f.crowd}</b><small>crowd /${b.rules.crowd_max}</small></span><span><b>${f.total}</b><small>total</small></span></div>-- ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}-- ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> listen in', 'arena-tag') : ''}-- </div>`; }).join('')}-- </div>-- ${_aTug(b, { big: true })}-- <div class="arena-beef-status">-- ${open ? _aClockTag(b) : ''}-- ${b.ends_at && open ? `<span class="arena-note"><i class="fa-solid fa-hourglass-half"></i> hard end in ${_aEsc(_aClock((Date.parse(b.ends_at) - Date.now()) / 1000))}</span>` : ''}-- </div>-- <div class="arena-beef-cols">-- <section class="arena-feed">-- <h3><i class="fa-solid fa-bullhorn"></i> Ringside <small>every judged hit, newest last</small></h3>-- ${b.feed.length ? b.feed.map(e => _aFeedLine(e, b)).join('') : '<p class="arena-note">Nothing judged yet.</p>'}-- </section>-- <aside class="arena-beef-aside">-- ${b.history?.fights ? `<section><h3><i class="fa-solid fa-receipt"></i> Receipts <small>${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights} earlier beef${b.history.fights > 1 ? 's' : ''}</small></h3>-- ${b.history.receipts.map(r => { const f = r.side_user_id === b.a.user.id ? b.a : b.b; return `<div class="arena-receipt"><q>${_aEsc(r.text)}</q><small>${_aEsc(f.fighter_name)} · ${r.quality}/10 · ${_aA(_aBeefLink({ id: r.beef_id }), `beef #${r.beef_id}`)} ${_aPlay(r.vod_id, r.sec)}</small></div>`; }).join('') || '<p class="arena-note">No quotable receipts.</p>'}</section>` : '<section><h3><i class="fa-solid fa-receipt"></i> Receipts</h3><p class="arena-note">First time these two have beef.</p></section>'}-- <section><h3><i class="fa-solid fa-gavel"></i> Rules</h3><ul class="arena-rules-list">-- <li>Answer on your own cam within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>-- <li>Every judged answer resets the other side's clock. Beef hard-ends after ${b.rules.max_hours} h; higher total wins.</li>-- <li>Total = hit quality (AI-judged 1–10) + crowd hype (max ${b.rules.crowd_max}). No voting — hype is the only thing chat adds.</li>-- <li>Beat someone ranked 4+ spots above you → <b>upset</b>. Same two again → <b>rematch</b> (+ rivalry receipts).</li>-- </ul></section>-- </aside>-- </div>-- </div>`;-- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = () => _aHype(b.id, btn.dataset.side, btn));-- _aBindSpeak(root);-- };-- draw();-- _aEvery(1000, () => _aTickClocks(root));-- if (open) _aEvery(12000, async () => { try { const fresh = await api(`/arena/beefs/${id}`); if (JSON.stringify(fresh.feed) !== JSON.stringify(b.feed) || fresh.status !== b.status || fresh.share_a !== b.share_a) { Object.assign(b, fresh); draw(); } } catch { /* */ } });--}----// ── Live console ("the ears") ────────────────────────────────----async function _aRenderConsole(root, username) {-- root.innerHTML = _aSpinner('Putting the ears on…');-- let c = await api(`/arena/console/${encodeURIComponent(username)}`);-- const draw = () => {-- const f = c.fighter, L = c.listener || {}, lvl = c.level || {}, m = c.mic || {};-- const me = _aMe(), mine = me && me.id === f.user.id;-- const xpPct = lvl.xp_per_level ? Math.round((lvl.xp_into_level / lvl.xp_per_level) * 100) : 0;-- const J = L.last_mic_judgement;-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>-- <div class="arena-console" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">-- <div class="arena-console-head">-- ${_aPortrait(f, 'sm')}-- <div>-- <h1>${_aEsc(f.fighter_name)} ${c.live ? '<span class="arena-live-pill">LIVE</span>' : '<span class="arena-tag arena-tag-dim">offline</span>'}</h1>-- <div class="arena-console-status ${L.listening ? 'is-on' : ''}">-- <i class="fa-solid ${L.listening ? 'fa-ear-listen' : 'fa-ear-deaf'}"></i>-- ${!c.live ? 'Not live — the ears only work on a live, transcribed cam.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_mic_words || 0} words buffered for the mic judge${L.focus ? ` · locked on ${_aEsc(L.focus.target)}` : ''}${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this cam up on its next 15 s tick.'}-- </div>-- </div>-- <div class="arena-console-level">-- <span class="arena-lvl arena-lvl-big">TRASH LVL ${lvl.level || 1}</span>-- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${xpPct}%"></span></span>-- <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week · ${m.moments || 0} judged lines (30d)</small>-- </div>-- </div>-- <div class="arena-console-cols">-- <section class="arena-console-main">-- <h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>-- ${L.focus ? `<div class="arena-focus"><i class="fa-solid fa-crosshairs fa-beat"></i> <b>Locked on ${_aEsc(L.focus.target || 'a fighter')}</b> <small>${_aEsc(L.focus.how)} name-drop · ${L.focus.hits} hit${L.focus.hits === 1 ? '' : 's'} · ${L.focus.pending_words} words waiting for the judge · lock <span class="arena-clock" data-until="${new Date(Date.now() + L.focus.lock_seconds_left * 1000).toISOString()}" data-who="the lock"><b>${_aClock(L.focus.lock_seconds_left)}</b></span></small>${L.focus.context ? `<div class="arena-focus-ctx">so far: ${_aEsc(L.focus.context)}</div>` : ''}<p class="arena-note">Everything said now goes to the beef judge as a continuation — no need to say the name again. Two chunks about something else and the ears let go.</p></div>` : ''}-- <div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>-- ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT${L.last_beef_judgement.named === false ? ' (continuation, name not said)' : ''} · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}` : L.last_beef_judgement.about_target ? `still about them, not shit talk — ${_aEsc(L.last_beef_judgement.about || '')}` : `moved on — ${_aEsc(L.last_beef_judgement.about || 'not about them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}-- ${J ? `<div class="arena-judgement ${J.is_trash_talk ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Mic judge (${_aEsc(_aAgo(J.at))}):</b> ${J.is_trash_talk ? `SHIT TALK · ${J.quality}/10${J.aimed_at ? ` · at ${_aEsc(J.aimed_at)}` : ''} · ${_aEsc(J.about || '')}${J.opened ? ' · <b>callout — beef opened</b>' : J.target_id ? ' · <b>callout — fed the beef</b>' : ''}` : `not shit talk — ${_aEsc(J.about || 'gameplay / small talk')}`}${J.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}-- </section>-- <aside class="arena-console-aside">-- <section>-- <h3><i class="fa-solid fa-satellite-dish"></i> Their last lines in the feed</h3>-- ${c.recent_moments?.length ? `<div class="arena-mic-feed-list is-compact">${c.recent_moments.map(_aMomentRow).join('')}</div>` : `<p class="arena-note">${mine ? 'Nothing judged yet — talk your shit.' : 'Nothing judged yet.'}</p>`}-- </section>-- <section>-- <h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>-- ${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}-- </section>-- <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name (however the mic hears it — split, glued, misheard) locks the ears on them; ≥20 words go to the beef judge, and everything after counts as a continuation until they move on.</li><li>Not locked? ≥20 words go to the mic judge: is it shit talk, how good, and at whom. If "whom" is a fighter, it's a callout and feeds a beef anyway.</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>-- </aside>-- </div>-- </div>`;-- _aBindSpeak(root);-- };-- draw();-- _aEvery(1000, () => _aTickClocks(root));-- _aEvery(10000, async () => { try { const fresh = await api(`/arena/console/${encodeURIComponent(username)}`); const lvlUp = (fresh.level?.level || 1) > (c.level?.level || 1); c = fresh; draw(); if (lvlUp) _aLevelUp(c.level.level); } catch { /* */ } });--}----// ── Fighter profile ──────────────────────────────────────────----function _aVoiceCard(f) {-- const v = f.voice || {};-- const color = f.user.profile_color || '#8b5cf6';-- if (!v.has_data) {-- return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then the Arena can't hear them.</p></div>`;-- }-- return `<div class="arena-voice" style="--fc:${_aEsc(color)}">-- <div class="arena-voice-head"><h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3></div>-- <div class="arena-voice-meters">-- <div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div>-- <div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div>-- <div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>-- <div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div>-- </div>-- </div>`;--}----function _aLevelCard(f) {-- const l = f.level || {}, m = f.mic || {};-- const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;-- return `<div class="arena-level-card">-- <div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>-- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>-- <div class="arena-level-nums"><span><b>${m.moments || 0}</b><small>judged lines (30d)</small></span><span><b>${m.avg_quality ?? 0}</b><small>avg score</small></span><span><b>${m.bangers || 0}</b><small>bangers (7+)</small></span><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${m.answered || 0}/${m.targeted || 0}</b><small>answered when called out</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>-- ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '', f.user.username)}</div></div>` : ''}-- </div>`;--}----async function _aRenderFighter(root, username) {-- root.innerHTML = _aSpinner('Pulling their lines…');-- const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);-- if (f.not_on_roster) {-- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the cams', 'btn')}</div>`;-- return;-- }-- const color = f.user.profile_color || '#8b5cf6';-- const name = f.user.display_name || f.user.username;-- const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [], best = f.best_lines || [];-- root.innerHTML = `-- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div>-- <div class="arena-profile" style="--fc:${_aEsc(color)}">-- <div class="arena-profile-portrait" id="arena-profile-portrait">-- ${_aPortrait(f, 'lg')}-- <div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>-- </div>-- <div class="arena-profile-main">-- <div class="arena-profile-name">-- <h1>${_aEsc(name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>-- <p class="arena-handle">${_aA(_aChannelLink(f.user), `@${_aEsc(f.user.username)}`)} · ${f.record.wins}W–${f.record.losses}L · ${f.mic ? `${f.mic.moments} judged lines in 30 days` : ''}</p>-- </div>-- <div class="arena-profile-power">-- <div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>-- ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}-- <div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>-- </div>-- ${_aLevelCard(f)}-- ${best.length ? `<section class="arena-receipts-section"><h3><i class="fa-solid fa-fire"></i> Best shit talk <small>their highest-scored lines</small></h3><div class="arena-mic-feed-list">${best.map(_aMomentRow).join('')}</div></section>` : ''}-- <section class="arena-receipts-section">-- <h3><i class="fa-solid fa-satellite-dish"></i> On record <small>their judged lines, newest first</small></h3>-- ${moments.length ? `<div class="arena-mic-feed-list">${moments.map(_aMomentRow).join('')}</div>` : '<p class="arena-note">Nothing judged yet. The ears are waiting.</p>'}-- </section>-- <div class="arena-profile-stats is-bars">-- <div class="arena-bars-head"><b>The mic stats</b> <small>percentile across the roster · these make POWER · tap one</small></div>-- <div class="arena-numgrid">${ARENA_STATS.map(k => `<div class="arena-num is-clickable" data-stat="${k}" title="${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || ARENA_STAT_LABEL[k])}"><span class="arena-num-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-num-track"><span class="arena-num-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-num-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>-- <div id="arena-stat-detail"></div>-- </div>-- <div class="arena-profile-custom">-- <div>${_aCustomRadar(f, color, 280, { objective: true })}<div class="arena-mini-record">the seven mic stats</div></div>-- </div>-- ${_aVoiceCard(f)}-- </div>-- </div>-- ${rivalries.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-skull-crossbones"></i> Rivalries</h2><div class="arena-rivalries">${rivalries.map(r => `<div class="arena-rivalry ${r.open ? 'is-open' : ''}">${_aBriefChip(r.opponent, ` · ${r.wins}–${r.losses} in ${r.fights}${r.open ? ' · <b>beef open</b>' : ''}`)}${r.receipts.map(x => `<q class="arena-receipt-mini">${_aEsc(x.text)}</q>`).join('')}</div>`).join('')}</div></section>` : ''}-- ${beefs.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><div class="arena-beef-grid">${beefs.map(_aBeefCard).join('')}</div></section>` : `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><p class="arena-note">No beef on record. Someone only has to say their name…</p></section>`}`;-- _aBindSpeak(root);-- _aBindHome(root);-- _aEvery(1000, () => _aTickClocks(root));---- root.querySelectorAll('.arena-num.is-clickable').forEach(el => el.addEventListener('click', async () => {-- const stat = el.dataset.stat;-- const box = document.getElementById('arena-stat-detail');-- if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }-- box.dataset.stat = stat;-- box.innerHTML = `<div class="arena-stat-detail">${_aSpinner('Crunching…')}</div>`;-- try {-- const d = await api(`/arena/fighters/${encodeURIComponent(username)}/stat/${stat}`);-- if (box.dataset.stat !== stat) return;-- box.innerHTML = `<div class="arena-stat-detail" style="--fc:${_aEsc(color)}">-- <div class="arena-stat-detail-head">-- <h3>${_aEsc(d.label)} ${d.rating} <small>· #${d.position} of ${d.roster_size} · ${_aEsc(_aNum(d.value))} ${_aEsc(d.unit)}</small></h3>-- <button class="arena-stat-detail-close" title="Close">×</button>-- </div>-- <p class="arena-weight">${_aEsc(d.desc)} · ${Math.round(d.weight * 100)}% of POWER · rating = your percentile across the roster</p>-- ${_aSpark(d.series, color)}-- ${d.top.length ? `<div class="arena-stat-top"><span class="arena-weight">Top of the ladder:</span>${d.top.map((t, i) => _aA(_aFighterLink(t.user), `#${i + 1} ${_aEsc(t.fighter_name)} <b>${_aEsc(_aNum(t.value))}</b>`)).join('')}</div>` : ''}-- </div>`;-- box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });-- } catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }-- }));--}----function _aLevelUp(level) {-- const el = document.createElement('div');-- el.className = 'arena-levelup';-- el.innerHTML = `<i class="fa-solid fa-arrow-up"></i> TRASH LEVEL ${level}`;-- document.body.appendChild(el);-- setTimeout(() => el.remove(), 2300);--}----window.loadArenaPage = loadArenaPage;@@@ -4976,13 +4976,6 @@ function showChatContextMenu(event) {} else {loadContextMenuProfile(menu, coreUsername, userId, isAnon);}-- // Arena yap level (accounts, anons and relayed chatters all have one once they've hit a subject).-- try {-- let yapKey = null;-- if (isRelay) { const m = String(username || '').match(/^\[([A-Za-z]+)\]\s+(.+)$/); const plat = String(sourcePlatform || (m && m[1]) || '').toLowerCase().replace(/^yt$/, 'youtube').replace(/^robotstreamer$/, 'rs'); if (m && plat) yapKey = `relay:${plat}:${m[2].trim().toLowerCase()}`; }-- else if (isAnon) { const m = String(coreUsername || username || '').match(/^anon(\d+)$/i); if (m) yapKey = `anon:${m[1]}`; }-- else if (userId) yapKey = `user:${userId}`;-- } catch { /* optional */ }// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)menu.addEventListener('click', (e) => e.stopPropagation());@@@ -30,7 +30,7 @@const FEATURES = [['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Restream everywhere at once'], ['fa-scissors', 'VODs, clips, AI moments'],-- ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],++ ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-newspaper', 'The Rundown'],['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],];@@@ -1,7 +1,7 @@/*** recap.js — the after-show report page (/recap/:streamId).* Renders what /api/recap/:id returns: grade + headline + AI summary, stat tiles, the viewer-- * curve with chat bars, moment of the night + mic lines, top chatters, clips, the VOD, share.++ * curve with chat bars, moment of the night + the Rundown's takes, top chatters, clips, the VOD, share.*/(function () {'use strict';@@@ -39,6 +39,7 @@function render(root, data, id) {const r = data.recap, w = r.write || {}, s = r.stream, u = r.streamer;++ const takes = r.takes || []; // a recap written before the Rundown has noneconst g = GRADE[w.grade] || GRADE.B;const path = `/@${u.username}`;const owner = window.currentUser && (currentUser.id === u.id || currentUser.role === 'admin');@@@ -51,13 +52,13 @@r.love.tips ? ['fa-coins', n(r.love.tips_total), `in ${r.love.tips} tip${r.love.tips === 1 ? '' : 's'}`] : null,r.clips.length ? ['fa-scissors', n(r.clips.length), 'clips'] : null,r.chat.sounds ? ['fa-volume-high', n(r.chat.sounds), 'sound commands'] : null,-- r.mic.length ? ['fa-microphone-lines', n(r.mic.length), 'mic moments'] : null,++ takes.length ? ['fa-newspaper', n(takes.length), takes.length === 1 ? 'Rundown take' : 'Rundown takes'] : null,].filter(Boolean).map(([i, v, l]) => `<div class="recap-tile"><i class="fa-solid ${i}"></i><b>${v}</b><span>${esc(l)}</span></div>`).join('');const medals = ['🥇', '🥈', '🥉', '4', '5'];const topN = r.chat.top[0] ? r.chat.top[0].n : 1;const chatters = r.chat.top.length ? `<div class="recap-card"><h3><i class="fa-solid fa-ranking-star"></i> Loudest in chat</h3><ol class="recap-chatters">${r.chat.top.map((c, i) => `<li><span class="recap-medal">${medals[i]}</span>${av(c, 26)}<a class="recap-chatter-name" ${go(`/@${c.username}`)}>${esc(c.display_name || c.username)}</a><span class="recap-chatter-bar"><i style="width:${Math.max(6, Math.round(c.n / topN * 100))}%"></i></span><span class="recap-chatter-n">${n(c.n)}</span></li>`).join('')}</ol>${r.chat.busiest_at ? `<div class="recap-fine">Busiest stretch: ${esc(when(r.chat.busiest_at))} (${n(r.chat.busiest_n)} lines in 5 min)</div>` : ''}</div>` : '';-- const micLine = (m) => { const href = m.vod_id ? videoHref('vod', m.vod_id, Math.max(0, Math.round(m.sec || 0))) : null; const inner = `“${esc(m.text)}”${m.aimed_at ? ` <span class="muted">at ${esc(m.aimed_at)}</span>` : ''}<span class="recap-mic-q">${m.quality}/10</span>`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };-- const moment = (w.moment || r.mic.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${r.mic.length ? `<div class="recap-mics">${r.mic.map(micLine).join('')}</div>` : ''}</div>` : '';++ const takeLine = (t) => { const href = t.vod_id ? videoHref('vod', t.vod_id, Math.max(0, Math.round(t.sec || 0))) : null; const inner = `${t.text ? `“${esc(t.text)}”` : esc(t.title)}${t.topic ? ` <span class="muted">· ${esc(t.topic)}</span>` : ''}`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };++ const moment = (w.moment || takes.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${takes.length ? `<div class="recap-mics">${takes.map(takeLine).join('')}</div><div class="recap-fine"><a ${go(`/rundown/e/${s.id}`)}>The Rundown's episode</a></div>` : ''}</div>` : '';const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(videoHref('clip', c.id))}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';const vod = r.vod ? `<a class="recap-vod" ${go(videoHref('vod', r.vod.id))}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';const more = (data.more || []).length ? `<div class="recap-section"><h3><i class="fa-solid fa-clock-rotate-left"></i> More nights from ${esc(u.display_name)}</h3><div class="recap-more">${data.more.map(m => `<a class="recap-more-item" ${go(`/recap/${m.stream_id}`)}><span class="recap-grade-mini" style="--g:${(GRADE[m.grade] || GRADE.B).color}">${esc(m.grade)}</span><span class="recap-more-title">${esc(m.headline || m.title)}</span><span class="recap-more-meta">${esc(timeAgo(m.ended_at))} · ${dur(m.duration_seconds)} · peak ${n(m.peak_viewers)}</span></a>`).join('')}</div></div>` : '';@@@ -1,6 +1,6 @@/*** transcribe.js — free, LOCAL speech-to-text (no API, runs on the server CPU). Used for-- * live-stream memories, the live timeline (which the Arena listens to) and VOD/clip transcripts.++ * live-stream memories, the live timeline (which The Rundown reads) and VOD/clip transcripts.** Two engines, both from the whisper.cpp tree (ggml):* - NVIDIA Parakeet TDT 0.6B v3 (`parakeet-cli`), for English and 24 other European languages.@@@ -243,7 +243,7 @@ function _joinSegments(segments) {// word ("▁For"). A sentence ends at . ? or !, at a pause of 1.5 s, or after 20 s, so each timeline row stays a// line someone said, deep-linkable by its start.const TOKEN_LINE = /t0=\s*(-?\d+)\s+t1=\s*(-?\d+)\s+word_start=(true|false)\s+"(.*)"\s*$/;--// Fillers Parakeet writes out ("uh", "um") carry nothing for a reader or the Arena's quotes.++// Fillers Parakeet writes out ("uh", "um") carry nothing for a reader or The Rundown's quotes.const FILLER_RE = /(^|[\s,])(?:uh+|um+|uhm|erm|mm+)(?=[\s,.!?]|$)[,]?/gi;function parseParakeet(stderr, offsetSec = 0) {const out = [];@@@ -460,7 +460,7 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in--// users), the numbers, and the hottest Arena mic lines.++// users), the numbers, and the best Rundown takes.const { requireAuth, optionalAuth } = require('../auth/auth');router.get('/digest', optionalAuth, async (req, res) => {try {@@@ -501,12 +501,15 @@ router.get('/digest', optionalAuth, async (req, res) => {chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ?', [sinceSql]) || {}).n || 0),-- mic_moments: Number((await one('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE said_at >= ?', [sinceSql]) || {}).n || 0),++ takes: Number((await one("SELECT COUNT(*) AS n FROM rundown_segments WHERE status = 'live' AND said_at >= ?", [sinceSql]) || {}).n || 0),};++ // The best Rundown takes since then (server/rundown): the quote when there is one, else the headline.let hot = [];try {-- hot = (await db.all(`SELECT m.text, m.quality, m.said_at, m.aimed_at, u.username, u.display_name FROM arena_mic_moments m JOIN users u ON u.id = m.user_id-- WHERE m.said_at >= ? AND m.quality >= 7 ORDER BY m.quality DESC, m.said_at DESC LIMIT 3`, [sinceSql]) || []);++ hot = (await db.all(`SELECT s.title, s.quotes_json, s.said_at, t.title AS topic, t.slug, u.username, u.display_name FROM rundown_segments s++ JOIN users u ON u.id = s.user_id LEFT JOIN rundown_topics t ON t.id = s.topic_id++ WHERE s.status = 'live' AND s.said_at >= ? AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 ORDER BY s.score DESC, s.said_at DESC LIMIT 3`, [sinceSql]) || [])++ .map((r) => { let q = null; try { q = JSON.parse(r.quotes_json || '[]')[0] || null; } catch { q = null; } return { text: q ? q.text : r.title, quoted: !!q, topic: r.topic || null, slug: r.slug || null, said_at: r.said_at, username: r.username, display_name: r.display_name }; });} catch { hot = []; }res.set('Cache-Control', uid ? 'private, max-age=30' : 'public, max-age=60');res.json({ since, signed_in: !!uid, liveNow, streamed, stats, hot });@@@ -3,7 +3,7 @@** Candidates are streamers who went live in the last CANDIDATE_DAYS days (not banned). Each one* gets real stats — sessions, hours, peak/avg viewers, chat lines in their streams, new followers,-- * Arena mic moments, language, what they stream (AI overview) — and the shared LLM picks who gets++ * Rundown takes, language, what they stream (AI overview) — and the shared LLM picks who gets* the red carpet today and writes a one-line reason. Recent stars are excluded so it rotates* instead of parking on one person. With AI off (or a bad answer) a plain score picks instead.*@@@ -41,7 +41,7 @@ async function candidates() {MAX(s.started_at) AS last_live_at,(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers,(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,-- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.user_id = u.id AND m.said_at >= datetime('now', ?)) AS mic_moments,++ (SELECT COUNT(*) FROM rundown_segments r WHERE r.user_id = u.id AND r.status = 'live' AND r.said_at >= datetime('now', ?)) AS takes,(SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview,(SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS categoryFROM users u JOIN streams s ON s.user_id = u.id@@@ -57,7 +57,7 @@ async function candidates() {let i18n = null; try { i18n = require('../i18n/translate'); } catch { i18n = null; }for (const r of rows) {r.language = i18n ? (() => { try { return i18n.channelLanguage(r.id); } catch { return 'en'; } })() : 'en';-- r.score = Number(r.hours || 0) * 1.0 + Number(r.peak_viewers || 0) * 2 + Number(r.chat_lines || 0) / 40 + Number(r.new_followers || 0) * 3 + Number(r.mic_moments || 0) * 1.5 + Math.min(Number(r.sessions || 0), 10) * 0.5;++ r.score = Number(r.hours || 0) * 1.0 + Number(r.peak_viewers || 0) * 2 + Number(r.chat_lines || 0) / 40 + Number(r.new_followers || 0) * 3 + Number(r.takes || 0) * 1.5 + Math.min(Number(r.sessions || 0), 10) * 0.5;}return rows;}@@@ -73,7 +73,7 @@ async function aiPick(cands, exclude, previous) {if (!llm || !llm.isEnabled() || !llm.withinBudget()) return null;const list = cands.filter(c => !exclude.has(c.username.toLowerCase())).slice(0, 25).map(c => ({username: c.username, name: c.display_name || c.username, language: c.language, category: c.category || null,-- last_14d: { sessions: c.sessions, hours: c.hours, peak_viewers: c.peak_viewers, avg_peak_viewers: c.avg_peak, chat_lines: c.chat_lines, new_followers: c.new_followers, arena_mic_moments: c.mic_moments },++ last_14d: { sessions: c.sessions, hours: c.hours, peak_viewers: c.peak_viewers, avg_peak_viewers: c.avg_peak, chat_lines: c.chat_lines, new_followers: c.new_followers, rundown_takes: c.takes },followers: c.followers, last_live: c.last_live_at,about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,}));@@@ -442,7 +442,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {chat_messages: Number(chatStats.messages) || 0, chatters: Number(chatStats.chatters) || 0,peak_viewers: Number(stream.peak_viewers) || 0, avg_viewers: avg.k ? Number(avg.a) : null,sound_commands: Number(chatStats.sounds) || 0,-- mic_moments: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id])).n, 0),++ rundown_takes: await safe(async () => Number((await db.get("SELECT COUNT(*) AS n FROM rundown_segments WHERE stream_id = ? AND status = 'live'", [stream.id])).n), 0),follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),tips: await safe(async () => await db.get('SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM channel_tips WHERE stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?)', [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),top_chatters: topChatters,@@@ -1,7 +1,7 @@/*** recap.js — the after-show report. Every stream that ran long enough gets a recap page* (/recap/<streamId>) a few minutes after it ends: the viewer curve, chat numbers and top-- * chatters, the best Arena mic lines, clips, tips and follows gained, the VOD — and an AI++ * chatters, the stream's best Rundown takes, clips, tips and follows gained, the VOD — and an AI* headline + summary written from all of it (template copy when AI is off).** buildRecap(streamId) gather + (optionally) AI → { …recap } and store it@@@ -49,8 +49,12 @@ async function gather(streamId) {.slice(0, 5);const busiest = curve.length ? curve.reduce((best, p) => (Number(p.c) > Number(best.c) ? p : best), curve[0]) : null;-- // Arena mic lines said on this stream.-- const micLines = await safe(async () => await db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);++ // The Rundown's best takes on this stream (server/rundown), each with its quote when it has one.++ const takes = await safe(async () => (await db.all(`SELECT s.title, s.quotes_json, s.start_sec, s.vod_id, t.title AS topic, t.slug FROM rundown_segments s++ LEFT JOIN rundown_topics t ON t.id = s.topic_id WHERE s.stream_id = ? AND s.status = 'live' ORDER BY s.score DESC, s.start_sec ASC LIMIT 4`, [streamId])).map((s) => {++ let q = null; try { q = JSON.parse(s.quotes_json || '[]')[0] || null; } catch { q = null; }++ return { title: s.title, text: q ? q.text : null, en: q ? q.en || null : null, sec: q ? Number(q.sec) || 0 : Number(s.start_sec) || 0, vod_id: s.vod_id || null, topic: s.topic || null, slug: s.slug || null };++ }), []);// Money + love.// Tips from channel_tips: on-site Vibes, PowerChat and OpenVibe.Tips (a hidden amount counts as a tip, adds nothing).@@@ -90,7 +94,8 @@ async function gather(streamId) {streamer: { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, profile_color: user.profile_color || null },viewers: { avg: viewersAvg, peak_at: peakAt ? peakAt.t : null, curve: curve.map(p => [p.t, Number(p.v) || 0, Number(p.c) || 0]) },chat: { messages: Number(chat.messages) || 0, chatters: Number(chat.chatters) || 0, top: topChatters.map(r => ({ username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url || null, profile_color: r.profile_color || null, n: Number(r.count) })), busiest_at: busiest && Number(busiest.c) > 0 ? busiest.t : null, busiest_n: busiest ? Number(busiest.c) : 0, sounds: Number(chat.sounds) || 0 },-- mic: micLines.map(m => ({ text: m.text, quality: m.quality, kind: m.kind, aimed_at: m.aimed_at || null, sec: m.sec, vod_id: m.vod_id || null })),++ takes,++ mic: [], // the Arena's mic lines are gone; an open tab of the previous release still reads mic.lengthlove: { tips: Number(tips.n) || 0, tips_total: Number(tips.total) || 0, top_tipper: topTipper ? { username: topTipper.username, display_name: topTipper.display_name || topTipper.username, total: Number(topTipper.total) } : null, follows, followers_now: followersNow },speech: { lines: speech.length, sample: speechSample },vod, clips,@@@ -109,7 +114,7 @@ function templateWriteup(g) {headline: `${name} went ${fmtDur(s.duration_seconds)} on "${String(s.title).slice(0, 40)}"`,summary: bits.join(' ').slice(0, 420),moment: g.clips[0] ? `Clip: ${g.clips[0].title}` : (g.chat.busiest_n >= 10 ? `Chat hit ${g.chat.busiest_n} lines in five minutes.` : ''),-- tags: [s.category || 'stream', g.chat.messages >= 100 ? 'chatty' : 'chill', g.mic.length ? 'mic on fire' : 'good vibes'],++ tags: [s.category || 'stream', g.chat.messages >= 100 ? 'chatty' : 'chill', g.takes.length ? 'on the Rundown' : 'good vibes'],grade,};}@@@ -120,7 +125,7 @@ async function aiWriteup(g) {const facts = {streamer: g.streamer.display_name, title: g.stream.title, category: g.stream.category, duration: fmtDur(g.stream.duration_seconds),viewers: { peak: g.stream.peak_viewers, avg: g.viewers.avg }, chat: { messages: g.chat.messages, chatters: g.chat.chatters, top: g.chat.top.map(t => `${t.display_name} (${t.n})`), sound_commands: g.chat.sounds },-- mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,++ best_lines: g.takes.map(t => t.text || t.title), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,what_was_said: g.speech.sample || '(no transcript)',};// The prompt lives in OpenVibe.AI (workflow live.stream.recap).@@@ -61,7 +61,7 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();// The star: only a real, eligible candidate.const star = require('../server/home/star-job');-- const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, mic_moments: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },++ const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, takes: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },{ id: 2, username: 'bob', display_name: 'Bob', sessions: 1, hours: 1 }];answers['live.home.star'] = { username: 'ANN', headline: 'Rust all night', reason: 'You showed up five hours this week.' };const pick = await star._aiPick(cands, new Set(['bob']), 'bob');nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * The home digest ("Lately on OpenVibe") and the star pick read The Rundown, not the Arena's mic moments++ * (server/home/routes.js, server/home/star-job.js):++ * the digest counts the live takes since the window opened, and its hot lines are the best takes (score 4 and up):++ * the quote when there is one, else the headline, linked by topic; never a hidden take or a banned streamer++ * the star a candidate's takes over the last two weeks count toward the pick++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const db = require('../server/db/database');++++(async () => {++await db.initDb();++require('../server/chat/chat-reads').windowStatsPeek = async () => ({ messages: 0 });++require('../server/chat/chat-reads').windowStats = async () => ({ messages: 0 });++++const ago = (min) => new Date(Date.now() - min * 60000).toISOString().replace('T', ' ').slice(0, 19);++const mk = async (username, extra = {}) => {++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') });++ const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id;++ if (extra.banned) await db.run('UPDATE users SET is_banned = 1 WHERE id = ?', [id]);++ return id;++};++const stream = async (uid) => (await db.run('INSERT INTO streams (user_id, title, is_live, started_at, ended_at, duration_seconds) VALUES (?, ?, 0, ?, ?, 3600) RETURNING id',++ [uid, 'Night', ago(180), ago(120)])).lastInsertRowid;++const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('league-autofill', 'League autofill', 'games', ?, ?) RETURNING id", [ago(150), ago(150)])).lastInsertRowid;++const take = async (uid, sid, { title, score, quote = null, status = 'live', withTopic = true }) => db.run(++ 'INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, said_at, status) VALUES (?, ?, ?, 10, 60, ?, ?, ?, ?, ?, ?)',++ [sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);++++const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });++const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban);++await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });++await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });++await take(bob, sb, { title: 'A quiet stretch', score: 3 });++await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });++await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });++++const app = express();++app.use('/api/home', require('../server/home/routes'));++const server = http.createServer(app);++await new Promise((res) => server.listen(0, '127.0.0.1', res));++try {++ const d = await (await fetch(`http://127.0.0.1:${server.address().port}/api/home/digest`)).json();++ assert.strictEqual(d.stats.takes, 4, 'every live take since the window opened (the hidden one is not)');++ assert.ok(!('mic_moments' in d.stats), 'the Arena\'s mic moments are gone');++ assert.deepStrictEqual(d.hot.map((h) => [h.text, h.quoted, h.slug, h.username]), [++ ['autofill put me on support again chat', true, 'league-autofill', 'annstreams'],++ ['Bob on his new keyboard', false, null, 'bobstreams'],++ ], 'the best takes: the quote, else the headline; never hidden, a 3, or a banned streamer');++ console.log('✅ the digest reads The Rundown');++} finally {++ server.close();++}++++const cands = await require('../server/home/star-job').candidates();++const byName = Object.fromEntries(cands.map((c) => [c.username, c]));++assert.strictEqual(Number(byName.annstreams.takes), 1, 'a live take counts toward the star pick (a hidden one does not)');++assert.strictEqual(Number(byName.bobstreams.takes), 2);++assert.ok(!byName.bannedone, 'a banned streamer is no candidate');++console.log('✅ the star pick counts Rundown takes');++++console.log('home digest + star: ok');++process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -17,7 +17,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repchatReads.streamStats = async () => ({ messages: 14, chatters: 3, sounds: 0 });chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);-- await db.run('INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)', [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);++ // The Rundown's takes on the stream: the best quoted one leads; a hidden one is never shown.++ const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('soldering-irons', 'Soldering irons', 'tech', ?, ?) RETURNING id", [ts(40), ts(40)])).lastInsertRowid;++ await db.run('INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, vod_id, said_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)',++ [sid, host, topic, 1190, 1260, 'The iron with rizz', 'hostess rated their soldering iron.', 4, JSON.stringify([{ text: 'my soldering iron has more rizz than your whole setup', sec: 1200, en: null }]), 77, ts(40)]);++ await db.run("INSERT INTO rundown_segments (stream_id, user_id, start_sec, end_sec, title, what, score, said_at, status) VALUES (?,?,?,?,?,?,?,?,'hidden')", [sid, host, 10, 20, 'Taken down', 'x', 5, ts(60)]);await require('../server/monetization/channel-tips').record({ toUserId: host, fromUserId: fan, streamId: sid, amount: 5, source: 'vibes', ref: 'live-txn:1' });await require('../server/monetization/channel-tips').record({ toUserId: host, username: 'pcfan', streamId: sid, amount: 3, source: 'powerchat', ref: 'powerchat:evt_1' });await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);@@@ -34,7 +38,9 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repassert.strictEqual(r.love.follows, 1);assert.strictEqual(r.love.tips, 2); assert.strictEqual(r.love.tips_total, 8, 'on-site and PowerChat tips both count');assert.deepStrictEqual(r.love.top_tipper, { username: 'fan1', display_name: 'Fan1', total: 5 });-- assert.strictEqual(r.mic.length, 1);++ assert.deepStrictEqual(r.takes, [{ title: 'The iron with rizz', text: 'my soldering iron has more rizz than your whole setup', en: null, sec: 1200, vod_id: 77, topic: 'Soldering irons', slug: 'soldering-irons' }], 'the Rundown\'s takes, never a hidden one');++ assert.deepStrictEqual(r.mic, [], 'the Arena\'s mic lines are gone (kept empty for the previous release\'s open tabs)');++ assert.ok(r.write.tags.includes('on the Rundown'));assert.strictEqual(r.viewers.curve.length, 12);assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');assert.ok(['S', 'A', 'B', 'C'].includes(r.write.grade));
@@ -7,7 +7,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes after
@@ -7,7 +7,7 @@ Every stream that ran at least 8 minutes gets a report a couple of minutes after
kind `stream_recap`) from the real numbers; template copy when AI is off or the call fails.
kind `stream_recap`) from the real numbers; template copy when AI is off or the call fails.
- **Numbers** — peak/avg viewers (from `viewer_snapshots`), chat lines and unique chatters, top 5
- **Numbers** — peak/avg viewers (from `viewer_snapshots`), chat lines and unique chatters, top 5
chatters (the host excluded), busiest 5-minute window, sound commands, new follows during the
chatters (the host excluded), busiest 5-minute window, sound commands, new follows during the
- stream, tips (`transactions` type donation), Arena mic moments, clips + the VOD (from Media).
+ stream, tips (`transactions` type donation), The Rundown's best takes on the stream (server/rundown), clips + the VOD (from Media).
- **The room, minute by minute** — viewer curve with chat bars, peak marker.
- **The room, minute by minute** — viewer curve with chat bars, peak marker.
- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row per
- Announced in the channel's chat room when it lands. Stored in `stream_recaps` (one JSON row per
stream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).
stream). Owner/admin can rebuild it (`POST /api/recap/:id/regenerate`).
@@ -89,7 +89,7 @@ Migration `0010_rundown.sql`:
@@ -89,7 +89,7 @@ Migration `0010_rundown.sql`:
- `rundown_progress`: how far the desk has read each stream.
- `rundown_progress`: how far the desk has read each stream.
- `rundown_reactions`: readers' reactions.
- `rundown_reactions`: readers' reactions.
-The Arena's tables are no longer written; their code goes in a later change.
+The Arena's tables are no longer written. Its pages, styles and readers are gone (the recap, the home digest, the star pick and the VOD stats read The Rundown); its server code and `/api/arena` go once no open tab of an earlier release calls them, and its tables after that.
## Chat
## Chat
deleted file mode 100644
deleted file mode 100644
@@ -1,130 +0,0 @@
@@ -1,130 +0,0 @@
-/* ARENA ALERT — the Arena's drama desk (public/js/arena-alert.js). Tabloid red and siren yellow over the site theme. */
-.aa { --aa-red: #e11d48; --aa-red-deep: #9f1239; --aa-yellow: #facc15; --aa-ink: #0b0b0f; padding-bottom: 32px; }
-
-/* Masthead + ticker */
-.aa-mast { margin: 18px 0 18px; padding: 18px 20px 0; border-radius: 18px; overflow: hidden; color: #fff;
- background: linear-gradient(135deg, var(--aa-red) 0%, var(--aa-red-deep) 70%, #4c0519 100%); box-shadow: 0 10px 30px rgba(225, 29, 72, 0.25); }
-.aa-mast-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
-.aa-title { margin: 0; font-size: clamp(2rem, 6vw, 3.6rem); font-weight: 900; letter-spacing: -0.03em; line-height: 0.95; text-transform: uppercase; font-style: italic; }
-.aa-title span { color: var(--aa-yellow); text-shadow: 0 2px 0 var(--aa-ink); }
-.aa-siren { display: inline-block; animation: aa-siren 1s steps(2, end) infinite; }
-@keyframes aa-siren { 50% { filter: hue-rotate(200deg) brightness(1.3); transform: rotate(-8deg) scale(1.08); } }
-.aa-onair { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: rgba(0, 0, 0, 0.35); font-weight: 800; font-size: 0.8rem; letter-spacing: 0.08em; }
-.aa-sub { margin: 10px 0 14px; max-width: 760px; font-size: 1.02rem; color: rgba(255, 255, 255, 0.9); }
-.aa-ticker { display: flex; align-items: stretch; margin: 0 -20px; background: var(--aa-yellow); color: var(--aa-ink); font-weight: 800; font-size: 0.92rem; }
-.aa-ticker-label { flex: none; padding: 9px 14px; background: var(--aa-ink); color: var(--aa-yellow); letter-spacing: 0.1em; font-style: italic; }
-.aa-ticker-viewport { overflow: hidden; flex: 1; min-width: 0; mask-image: linear-gradient(90deg, transparent, #000 24px, #000 calc(100% - 24px), transparent); }
-.aa-ticker-track { display: inline-flex; gap: 42px; padding: 9px 0; white-space: nowrap; animation: aa-crawl 60s linear infinite; }
-.aa-ticker:hover .aa-ticker-track { animation-play-state: paused; }
-@keyframes aa-crawl { from { transform: translateX(0); } to { transform: translateX(-50%); } }
-.aa-tick { text-transform: uppercase; }
-
-/* Section headings */
-.aa-h { margin: 0 0 12px; font-size: 1.05rem; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; font-style: italic; }
-.aa-h small { margin-left: 6px; font-weight: 600; font-style: normal; letter-spacing: 0; text-transform: none; color: var(--text-secondary); font-size: 0.8rem; }
-.aa-feed > .aa-h { display: inline-block; padding: 4px 12px; background: var(--aa-red); color: #fff; border-radius: 6px; transform: skew(-8deg); }
-
-/* Layout */
-.aa-cams { margin: 0 0 18px; }
-.aa-top { margin: 0 0 22px; }
-.aa-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 22px; align-items: start; }
-.aa-side { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 70px; }
-.aa-box { padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.aa-quiet { color: var(--text-secondary); font-size: 0.92rem; margin: 0; }
-.aa-quiet-big { padding: 28px 20px; border: 2px dashed var(--border); border-radius: 16px; text-align: center; }
-.aa-more { display: block; margin: 14px auto 0; }
-
-/* Story cards */
-.aa-story { position: relative; margin: 0 0 14px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); border-left: 5px solid var(--border-light); }
-.aa-story.aa-alert-3 { border-left-color: var(--aa-yellow); }
-.aa-story.aa-alert-4, .aa-story.aa-alert-5 { border-left-color: var(--aa-red); box-shadow: 0 0 0 1px color-mix(in srgb, var(--aa-red) 35%, transparent); }
-.aa-story-big { padding: 22px; border-left-width: 8px; background-image: radial-gradient(80% 60% at 100% 0%, color-mix(in srgb, var(--aa-red) 16%, transparent), transparent); }
-.aa-story-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
-.aa-badge { padding: 3px 9px; border-radius: 6px; background: var(--aa-ink); color: var(--aa-yellow); font-size: 0.72rem; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; font-style: italic; }
-.aa-kind-beef_open .aa-badge, .aa-kind-beef_win .aa-badge, .aa-kind-beef_forfeit .aa-badge { background: var(--aa-red); color: #fff; }
-.aa-kind-viewer_spike .aa-badge, .aa-kind-peak_record .aa-badge, .aa-kind-streak .aa-badge, .aa-kind-the_return .aa-badge { background: #15803d; color: #fff; }
-.aa-sirens { font-size: 0.85rem; letter-spacing: 1px; }
-.aa-time { margin-left: auto; color: var(--text-secondary); font-size: 0.8rem; }
-.aa-story-body { display: flex; gap: 14px; align-items: flex-start; }
-.aa-who { display: flex; align-items: center; gap: 6px; flex: none; }
-.aa-vs { font-weight: 900; font-style: italic; font-size: 0.75rem; color: var(--aa-red); }
-.aa-av { display: inline-flex; width: 44px; height: 44px; border-radius: 12px; overflow: hidden; background: var(--bg-secondary, #222); align-items: center; justify-content: center; font-weight: 900; color: var(--text-primary); text-decoration: none; border: 2px solid var(--border); }
-.aa-av img { width: 100%; height: 100%; object-fit: cover; }
-.aa-story-big .aa-av { width: 72px; height: 72px; border-radius: 16px; border-color: var(--aa-yellow); }
-.aa-row .aa-av { width: 32px; height: 32px; border-radius: 9px; }
-.aa-story-text { min-width: 0; flex: 1; }
-.aa-headline { margin: 0 0 6px; font-size: 1.15rem; line-height: 1.2; font-weight: 900; letter-spacing: -0.01em; text-transform: uppercase; overflow-wrap: anywhere; }
-.aa-headline a { color: var(--text-primary); text-decoration: none; }
-.aa-headline a:hover { color: var(--aa-red); }
-.aa-story-big .aa-headline { font-size: clamp(1.4rem, 3.4vw, 2.2rem); }
-.aa-blurb { margin: 0 0 8px; color: var(--text-secondary); overflow-wrap: anywhere; }
-.aa-story-big .aa-blurb { font-size: 1.05rem; color: var(--text-primary); }
-.aa-quote { display: flex; align-items: flex-start; gap: 8px; margin: 6px 0 8px; padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--aa-yellow) 12%, transparent); border-left: 3px solid var(--aa-yellow); font-style: italic; font-weight: 600; overflow-wrap: anywhere; }
-.aa-quote span::before { content: '“'; } .aa-quote span::after { content: '”'; }
-.aa-byline { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }
-.aa-name { font-weight: 800; color: var(--text-primary); text-decoration: none; }
-.aa-name:hover { color: var(--aa-red); }
-.aa-story-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
-.aa-react { display: inline-flex; gap: 6px; flex-wrap: wrap; }
-.aa-react-btn { display: inline-flex; align-items: center; gap: 5px; min-height: 34px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-primary); font-weight: 800; cursor: pointer; transition: transform 0.12s, border-color 0.12s, background 0.12s; }
-.aa-react-btn:hover { border-color: var(--aa-red); }
-.aa-react-btn.is-mine { background: var(--aa-red); border-color: var(--aa-red); color: #fff; }
-.aa-react-btn.is-pop { transform: scale(1.18); }
-.aa-react-n { font-variant-numeric: tabular-nums; font-size: 0.85rem; }
-.aa-watch { font-size: 0.85rem; font-weight: 700; }
-.aa-ai { margin-left: auto; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); }
-
-/* Boards */
-.aa-board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-.aa-row { display: grid; grid-template-columns: 28px 32px minmax(0, 1fr) auto; gap: 10px; align-items: center; }
-.aa-rank { font-weight: 900; text-align: center; color: var(--text-secondary); }
-.aa-rank.is-crown { font-size: 1.3rem; }
-.aa-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
-.aa-row-main .aa-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.aa-row-sub { font-size: 0.75rem; color: var(--text-secondary); overflow-wrap: anywhere; }
-.aa-score { font-weight: 900; font-variant-numeric: tabular-nums; }
-.aa-meter { display: block; height: 7px; border-radius: 99px; background: var(--border); overflow: hidden; }
-.aa-meter span { display: block; height: 100%; width: var(--m); background: repeating-linear-gradient(135deg, #f5f5f4 0 8px, #1c1917 8px 16px); }
-.aa-move { display: inline-block; margin-left: 6px; font-size: 0.72rem; font-weight: 900; color: var(--text-secondary); }
-.aa-move.is-up { color: #16a34a; } .aa-move.is-down { color: var(--aa-red); } .aa-move.is-new { color: var(--aa-yellow); }
-.aa-tags { display: flex; flex-wrap: wrap; gap: 4px; }
-.aa-tag { padding: 1px 6px; border-radius: 5px; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.05em; background: var(--border); color: var(--text-primary); }
-.aa-tag-cooked { background: var(--aa-red); color: #fff; }
-.aa-tag-on-the-rise, .aa-tag-undefeated { background: #15803d; color: #fff; }
-.aa-tag-falling { background: #78350f; color: #fff; }
-.aa-mic, .aa-beefs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
-.aa-mic q { font-style: italic; font-weight: 600; overflow-wrap: anywhere; }
-.aa-beefs li { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
-.aa-settled { margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; }
-.aa-link { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer; }
-
-/* Rap sheet, receipts, how it works */
-.aa-rap { margin: 16px 0; }
-.aa-rap-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
-.aa-rap-stats div { padding: 10px; border-radius: 12px; background: color-mix(in srgb, var(--aa-red) 8%, transparent); text-align: center; }
-.aa-rap-stats b { display: block; font-size: 1.5rem; font-weight: 900; }
-.aa-rap-stats span { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }
-.aa-facts dl { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; }
-.aa-facts dt { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-secondary); }
-.aa-facts dd { margin: 2px 0 0; font-weight: 800; overflow-wrap: anywhere; }
-.aa-how { margin-top: 26px; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border); }
-.aa-how ul { margin: 0; padding-left: 18px; color: var(--text-secondary); }
-.aa-how li { margin-bottom: 8px; }
-.aa-how b { color: var(--text-primary); }
-
-@media (max-width: 960px) {
- .aa-grid { grid-template-columns: 1fr; }
- .aa-side { position: static; }
-}
-@media (max-width: 560px) {
- .aa-mast { padding: 14px 14px 0; border-radius: 14px; }
- .aa-ticker { margin: 0 -14px; }
- .aa-story { padding: 12px; }
- .aa-story-body { gap: 10px; }
- .aa-av { width: 36px; height: 36px; border-radius: 10px; }
- .aa-story-big .aa-av { width: 52px; height: 52px; border-radius: 12px; }
- .aa-who { flex-direction: column; }
- .aa-time { margin-left: 0; }
- .aa-rap-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-}
-@media (prefers-reduced-motion: reduce) { .aa-siren, .aa-ticker-track { animation: none; } }
deleted file mode 100644
deleted file mode 100644
@@ -1,280 +0,0 @@
@@ -1,280 +0,0 @@
-/* arena — rules used only by this feature, split out of the global stylesheets so other routes do not
- download them. Loaded with the feature (public/features.json), directly after style.css in the cascade.
- Split by class/id ownership with cascade-order checks; edit freely. */
-/* ── from style.css ── */
-.arena-loading { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 60px 0; color: var(--text-secondary); font-size: 1rem; }
-.arena-empty { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
-.arena-back { margin: 8px 0 16px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
-
-.arena-hero { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; padding: 24px 0 16px; }
-.arena-hero h1 i { color: var(--accent); }
-.arena-hero-actions { display: flex; align-items: center; gap: 12px; }
-.arena-portrait-avatar img { filter: saturate(1.25) contrast(1.1); }
-.arena-portrait-initial { display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 900; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
-.arena-live { margin: 8px 0 28px; }
-.arena-board-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
-.arena-list { display: flex; flex-direction: column; gap: 8px; }
-.arena-row { display: grid; grid-template-columns: 44px 64px 1fr auto; align-items: center; gap: 14px; padding: 10px 14px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; color: var(--text-primary); text-decoration: none; transition: transform 0.12s, border-color 0.12s; }
-.arena-row:hover { transform: translateY(-1px); border-color: var(--accent); }
-.arena-row.is-live { border-color: rgba(231, 76, 60, 0.5); }
-.arena-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-secondary); text-align: center; }
-.arena-row-sub { font-size: 0.8rem; color: var(--text-secondary); }
-.arena-row-taunt { font-size: 0.8rem; color: var(--text-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-row-stats { display: flex; align-items: center; gap: 16px; }
-.arena-power { display: flex; flex-direction: column; align-items: center; line-height: 1; }
-.arena-record { font-size: 0.85rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
-.arena-record-lg { font-size: 1rem; }
-.arena-profile { display: grid; grid-template-columns: 320px 1fr; gap: 28px; padding: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; background-image: radial-gradient(60% 40% at 0% 0%, color-mix(in srgb, var(--fc, var(--accent)) 18%, transparent), transparent); }
-.arena-profile-portrait { display: flex; flex-direction: column; align-items: center; gap: 10px; }
-.arena-profile-rank { font-weight: 800; font-size: 1.1rem; }
-.arena-handle { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; }
-.arena-profile-power { display: flex; align-items: center; gap: 24px; }
-.arena-profile-stats { display: grid; grid-template-columns: 200px 1fr; gap: 18px; align-items: center; }
-.arena-radar { width: 100%; height: auto; max-width: 200px; }
-.arena-radar-ring { fill: none; stroke: var(--border-light); stroke-width: 1; }
-.arena-radar-fill { stroke-width: 2; }
-.arena-quip { display: flex; flex-direction: column; font-size: 0.85rem; }
-.arena-challenge { margin-top: 24px; }
-.arena-row { cursor: pointer; }
-.arena-row.is-open .arena-chevron { transform: rotate(90deg); }
-.arena-row-expand { display: none; grid-column: 1 / -1; padding: 12px 6px 4px; border-top: 1px dashed var(--border); margin-top: 8px; }
-.arena-row.is-open .arena-row-expand { display: grid; grid-template-columns: 180px 1fr auto; gap: 16px; align-items: start; }
-.arena-row-expand .arena-quips { grid-template-columns: repeat(2, 1fr); font-size: 0.8rem; }
-.arena-row-expand-actions { display: flex; flex-direction: column; gap: 8px; }
-.arena-mini-record { font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }
-.arena-quip.is-clickable, .arena-bar.is-clickable { cursor: pointer; border-radius: 8px; padding: 4px 6px; margin: -4px -6px; }
-.arena-quip.is-clickable:hover, .arena-bar.is-clickable:hover { background: var(--bg-tertiary); }
-.arena-stat-detail { grid-column: 1 / -1; margin-top: 8px; padding: 14px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); animation: arena-in 0.25s ease-out; }
-.arena-stat-detail-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
-.arena-stat-detail-close { background: none; border: 0; color: var(--text-muted); cursor: pointer; font-size: 1rem; }
-.arena-spark { width: 100%; height: 70px; display: block; }
-.arena-spark polyline { fill: none; stroke: var(--fc, var(--accent)); stroke-width: 2; }
-.arena-spark .arena-spark-area { fill: color-mix(in srgb, var(--fc, var(--accent)) 25%, transparent); stroke: none; }
-.arena-spark circle { fill: var(--fc, var(--accent)); }
-.arena-spark-caption { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-muted); }
-.arena-stat-top { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
-.arena-stat-detail .arena-weight { font-size: 0.75rem; color: var(--text-muted); }
-.arena-voice { padding: 14px 16px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); }
-.arena-voice-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
-.arena-voice-head h3 i { color: var(--accent); }
-.arena-voice-meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 10px; }
-.arena-voice-meter { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); }
-.arena-quote { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 8px 10px; border-radius: 10px; background: var(--bg-card); border-left: 3px solid var(--fc, var(--accent)); }
-.arena-quote-actions { display: flex; gap: 4px; }
-.arena-quote-actions button.is-speaking { color: var(--accent-light); border-color: var(--accent); }
-.arena-speak { background: none; border: 0; color: var(--text-muted); cursor: pointer; padding: 0 4px; }
-.arena-voice-empty { font-size: 0.85rem; color: var(--text-muted); }
-.arena-mic-feed { margin-top: 10px; min-height: 90px; max-height: 220px; overflow-y: auto; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px dashed var(--border); font-size: 0.9rem; }
-.arena-talk-bonus { font-size: 0.85rem; font-weight: 700; color: #f97316; }
-.arena-xp-track { height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
-.arena-xp-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); transition: width 0.6s cubic-bezier(0.2, 0.8, 0.3, 1); }
-.arena-lede { margin: 0; max-width: 720px; color: var(--text-secondary); font-size: 0.95rem; line-height: 1.5; }
-.arena-hero-actions .arena-note { display: inline-flex; align-items: center; gap: 6px; }
-.arena-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 999px; font-size: 0.72rem; font-weight: 700; background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; white-space: nowrap; }
-a.arena-tag:hover { color: var(--accent-light); }
-.arena-tag-hot { background: rgba(239, 68, 68, 0.16); color: #f87171; }
-.arena-tag-gold { background: rgba(245, 197, 66, 0.16); color: #f5c542; }
-.arena-tag-dim { opacity: 0.7; font-weight: 600; }
-.arena-lvl-big { font-size: 0.85rem; padding: 4px 10px; }
-.arena-chip strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-play { display: inline-flex; align-items: center; gap: 4px; color: var(--accent-light); text-decoration: none; font-size: 0.8rem; }
-.arena-play:hover { text-decoration: underline; }
-.arena-mic-card.is-quiet { border-color: var(--border); }
-.arena-mic-thumb .arena-live-thumb { width: 112px; height: 63px; }
-.arena-mic-head { display: flex; flex-direction: column; gap: 2px; }
-.arena-mic-meta { font-size: 0.75rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
-.arena-mic-line { font-size: 0.88rem; line-height: 1.35; quotes: "“" "”"; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-.arena-mic-line-empty { color: var(--text-muted); font-style: italic; }
-.arena-mic-tags { display: flex; gap: 6px; flex-wrap: wrap; }
-.arena-beefs { margin: 8px 0 28px; }
-.arena-beef { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-beef.is-open { border-color: rgba(239, 68, 68, 0.45); box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.12), 0 10px 30px rgba(0, 0, 0, 0.25); }
-.arena-beef.is-done { opacity: 0.85; }
-.arena-beef-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
-.arena-beef-headline { font-weight: 800; font-size: 1.02rem; line-height: 1.3; }
-.arena-beef-tags { display: flex; gap: 6px; flex-wrap: wrap; }
-.arena-beef-sides { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; }
-.arena-beef-side strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-beef-side-b { flex-direction: row-reverse; text-align: right; }
-.arena-beef-vs { font-weight: 900; font-size: 0.8rem; letter-spacing: 0.1em; color: var(--text-muted); }
-.arena-tug { display: flex; height: 22px; border-radius: 11px; overflow: hidden; background: var(--bg-tertiary); font-size: 0.7rem; font-weight: 800; }
-.arena-tug-a { background: linear-gradient(90deg, color-mix(in srgb, var(--fc) 80%, #000), var(--fc)); justify-content: flex-start; }
-.arena-tug-b { background: linear-gradient(90deg, var(--fc), color-mix(in srgb, var(--fc) 80%, #000)); justify-content: flex-end; }
-.arena-tug-big { height: 34px; font-size: 0.9rem; }
-.arena-beef-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
-.arena-clock { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
-.arena-beef-last { display: block; font-size: 0.85rem; color: var(--text-secondary); quotes: "“" "”"; border-left: 3px solid var(--border-light); padding-left: 10px; }
-.arena-beef-empty { display: flex; align-items: center; gap: 14px; padding: 18px; border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); }
-.arena-done { margin-top: 12px; }
-.btn.is-hyped { color: #f87171; border-color: rgba(239, 68, 68, 0.5); }
-.arena-topic.is-hot { border-color: rgba(249, 115, 22, 0.55); box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.12); }
-.arena-heat.is-hot { color: #f97316; }
-.arena-debate-big .arena-note { grid-column: 1 / -1; margin: 6px 0 0; }
-.arena-mini-row .arena-rank { font-size: 1rem; }
-.arena-rules { margin: 8px 0 30px; padding: 16px 18px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-rules code { font-size: 0.8em; background: var(--bg-tertiary); padding: 1px 5px; border-radius: 4px; }
-.arena-beef-page { display: flex; flex-direction: column; gap: 16px; }
-.arena-beef-opener { margin: 0; color: var(--text-secondary); font-size: 0.92rem; }
-.arena-beef-tape-side { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); text-align: center; }
-.arena-beef-tape-side .arena-portrait-md { max-width: 180px; }
-.arena-beef-tape-meta { font-size: 0.8rem; color: var(--text-secondary); display: flex; gap: 4px; align-items: center; }
-.arena-beef-tape-nums { display: flex; gap: 14px; }
-.arena-beef-tape-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
-.arena-beef-status { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 0.85rem; }
-.arena-beef-aside, .arena-console-aside { display: flex; flex-direction: column; gap: 12px; }
-.arena-feed-line { display: flex; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
-.arena-feed-line:first-of-type { border-top: 0; }
-.arena-feed-line.is-b { flex-direction: row-reverse; text-align: right; }
-.arena-feed-line.is-system { justify-content: center; text-align: center; color: var(--text-muted); font-size: 0.82rem; }
-.arena-feed-line.is-b .arena-feed-body small { justify-content: flex-end; }
-.arena-feed-announcer { font-weight: 700; color: #fbbf24; font-size: 0.85rem; }
-.arena-receipt { padding: 8px 0; border-top: 1px solid var(--border); }
-.arena-receipt small a { color: var(--accent-light); }
-.arena-member.is-active .arena-chip strong { color: #f87171; }
-.arena-rivalries { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
-.arena-rivalry { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-rivalry.is-open { border-color: rgba(239, 68, 68, 0.45); }
-.arena-receipt-mini { display: block; font-size: 0.8rem; color: var(--text-secondary); quotes: "“" "”"; }
-.arena-level-card { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }
-.arena-level-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
-.arena-level-nums { display: flex; gap: 18px; flex-wrap: wrap; }
-.arena-console { display: flex; flex-direction: column; gap: 14px; }
-.arena-console-head { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-console-status { font-size: 0.85rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px; }
-.arena-console-level .arena-xp-track { width: 100%; }
-.arena-mic-line-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 8px; align-items: baseline; padding: 4px 0; font-size: 0.9rem; border-top: 1px solid var(--border); }
-.arena-mic-line-row:first-child { border-top: 0; }
-.arena-mic-time { font-size: 0.72rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
-.arena-judgement { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.85rem; color: var(--text-secondary); border-left: 3px solid var(--border-light); }
-.arena-console-beef { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 1px solid var(--border); }
-.arena-console-beef .arena-tug { height: 14px; font-size: 0.6rem; }
-
-@media (max-width: 900px) {
- .arena-console-head { grid-template-columns: auto 1fr; }
- .arena-console-level { grid-column: 1 / -1; align-items: stretch; }
-}
-@media (max-width: 600px) {
- .arena-hero { padding: 14px 0 10px; } .arena-live h2 small, .arena-board-head h2 small { flex-basis: 100%; }
- .arena-board-head { flex-direction: column; align-items: flex-start; }
- .arena-hero-actions { flex-direction: column; align-items: stretch; }
- .arena-mic-thumb .arena-live-thumb { width: 100%; height: auto; aspect-ratio: 16 / 9; }
- .arena-beef-sides { grid-template-columns: 1fr; }
- .arena-beef-side-b { flex-direction: row; text-align: left; }
- .arena-beef-vs { display: none; }
- .arena-row { grid-template-columns: 28px 44px 1fr; grid-auto-rows: auto; gap: 8px 10px; padding: 10px; }
- .arena-row .arena-portrait-sm { width: 44px; height: 44px; font-size: 1.1rem; }
- .arena-row-stats { grid-column: 1 / -1; justify-content: space-between; }
- .arena-row-expand { grid-column: 1 / -1; }
- .arena-row-expand-actions { flex-wrap: wrap; }
- .arena-profile { grid-template-columns: 1fr; padding: 14px; }
- .arena-profile-stats { grid-template-columns: 1fr; }
- .arena-level-nums { gap: 12px; }
-}
-
-.arena-leaderboard { margin: 0 0 28px; }
-.arena-pulse-stat.is-hot { background: rgba(249, 115, 22, 0.16); color: #fb923c; }
-.arena-keywords .arena-note { padding: 0; background: none; font-family: inherit; }
-.arena-topic-row.is-hot { border-color: rgba(249, 115, 22, 0.45); }
-.arena-profile-custom { display: grid; grid-template-columns: 220px 1fr; gap: 18px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); }
-@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } }
-
-.arena-focus { margin: 0 0 10px; padding: 10px 12px; border-radius: 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.35); font-size: 0.88rem; }
-.arena-focus-ctx { margin-top: 6px; font-size: 0.8rem; color: var(--text-muted); }
-.arena-focus .arena-note { margin: 6px 0 0; }
-.arena-mini-row.is-earned .arena-rank { color: #f5c542; }
-.arena-subject.is-hot { border-color: rgba(249, 115, 22, 0.5); }
-.arena-subject-open { margin-left: auto; font-size: 0.8rem; color: var(--accent-light); text-decoration: none; font-weight: 700; }
-@media (max-width: 600px) {
- .arena-row-expand { grid-template-columns: 1fr; }
- .arena-row-expand .arena-radar { max-width: 170px; margin: 0 auto; }
- .arena-quip { font-size: 0.78rem; }
- .arena-row-expand-actions .btn { padding: 6px 10px; font-size: 0.82rem; }
-}
-.arena-radar-v2 { width: 100%; height: auto; max-width: 300px; }
-.arena-radar-v2 .arena-radar-ring { fill: rgba(255, 255, 255, 0.02); stroke: var(--border-light); stroke-width: 1; }
-.arena-radar-axis { stroke: var(--border); stroke-width: 1; }
-.arena-radar-v2 .arena-radar-fill { stroke-width: 2.2; stroke-linejoin: round; }
-.arena-radar-dot { stroke: var(--bg-card); stroke-width: 1.5; }
-.arena-radar-label text { fill: var(--text-secondary); font-size: 10.5px; font-weight: 700; }
-.arena-radar-label .arena-radar-val { font-size: 11px; font-weight: 900; }
-.arena-profile-custom { grid-template-columns: 300px 1fr; }
-.arena-profile-stats.is-bars { grid-template-columns: 1fr; gap: 8px; }
-.arena-bars-head { font-size: 0.85rem; }
-.arena-bar.is-clickable { cursor: pointer; }
-@media (max-width: 600px) { .arena-profile-custom { grid-template-columns: 1fr; } .arena-radar-v2 { max-width: 260px; margin: 0 auto; } }
-.arena-me { margin: 0 0 20px; }
-.arena-me-inner { display: grid; grid-template-columns: minmax(260px, 1fr) 1.4fr; gap: 14px; padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid color-mix(in srgb, #f97316 40%, var(--border)); }
-.arena-me-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
-.arena-me-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
-.arena-me-alert { padding: 6px 10px; border-radius: 10px; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
-.arena-me-guest { padding: 12px 16px; border-radius: 16px; background: var(--bg-card); border: 1px dashed var(--border); color: var(--text-secondary); font-size: 0.9rem; }
-.arena-profile-stats.is-bars .arena-bars { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }
-.arena-profile-stats.is-bars .arena-bar { display: grid; grid-template-columns: 62px 1fr 34px; align-items: center; gap: 8px; }
-.arena-profile-stats.is-bars .arena-bar-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
-.arena-profile-stats.is-bars .arena-bar-fill { display: block; height: 100%; border-radius: 4px; }
-.arena-profile-stats.is-bars .arena-bar-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
-@media (max-width: 700px) { .arena-me-inner { grid-template-columns: 1fr; } .arena-profile-stats.is-bars .arena-bars { grid-template-columns: 1fr; } }
-.arena-radar-v2 { overflow: visible; }
-.arena-ach.is-locked { opacity: 0.55; filter: grayscale(0.6); }
-.arena-ach.is-locked .arena-ach-icon { filter: grayscale(1); }
-.arena-numgrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 18px; }
-.arena-num { display: grid; grid-template-columns: 60px 1fr 30px; align-items: center; gap: 8px; cursor: pointer; font-size: 0.84rem; }
-.arena-num-label { color: var(--text-secondary); }
-.arena-num-track { display: block; height: 8px; border-radius: 4px; background: var(--bg-tertiary); overflow: hidden; }
-.arena-num-fill { display: block; height: 100%; border-radius: 4px; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); }
-.arena-num-val { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
-@media (max-width: 600px) {
- .arena-numgrid { grid-template-columns: 1fr; }
- .arena-profile-custom { grid-template-columns: 1fr; }
-}
-@media (max-width: 700px) {
- .arena-row.is-open .arena-row-expand { grid-template-columns: 1fr; gap: 12px; padding: 12px 0 4px; }
- .arena-row-expand > div:first-child { display: flex; flex-direction: column; align-items: center; }
- .arena-row-expand .arena-radar { max-width: 230px; width: 100%; margin: 0 auto; }
- .arena-row-expand .arena-mini-record { text-align: center; }
- .arena-row-expand .arena-quips, .arena-row-expand .arena-quips-custom { display: flex; flex-direction: column; gap: 6px; }
- .arena-row-expand .arena-quip { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: baseline; font-size: 0.86rem; }
- .arena-row-expand .arena-quip b { white-space: nowrap; }
- .arena-row-expand-actions { flex-direction: row; flex-wrap: wrap; }
- .arena-row-expand-actions .btn { flex: 1 1 auto; justify-content: center; }
- .arena-row-taunt { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
- .arena-row-main strong { white-space: normal; }
- .arena-profile-custom .arena-quips-custom { display: flex; flex-direction: column; gap: 6px; }
- .arena-profile-custom .arena-quip { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: baseline; }
- .arena-profile-custom .arena-quip b { white-space: nowrap; }
-}
-.arena-hero-cam h1 i { color: var(--live-red); }
-.arena-cam-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
-.arena-cam.is-locked { border-color: color-mix(in srgb, var(--live-red) 60%, var(--border)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--live-red) 40%, transparent), 0 0 24px color-mix(in srgb, var(--live-red) 25%, transparent); }
-.arena-cam.is-quiet { opacity: 0.75; }
-.arena-cam-thumb img.arena-live-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
-.arena-cam-thumb .arena-portrait { width: 100%; height: 100%; }
-/* The stream's frame when it loads; the portrait when there is none yet (a restart, a new stream) or it fails. */
-.arena-cam-thumb img.arena-live-thumb[hidden] { display: none; }
-.arena-cam-thumb img.arena-live-thumb:not([hidden]) ~ .arena-portrait { display: none; }
-.arena-cam-lock { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: 0.74rem; font-weight: 700; color: #fecaca; background: color-mix(in srgb, var(--live-red) 18%, transparent); border: 1px solid color-mix(in srgb, var(--live-red) 45%, transparent); width: fit-content; }
-.arena-cam-lock i { color: var(--live-red); }
-.arena-cam-last q { display: block; font-size: 0.9rem; color: var(--text-primary); line-height: 1.35; quotes: '“' '”'; }
-.arena-q { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); }
-.arena-q.is-hot { color: #fde68a; border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.12); }
-.arena-q.is-fire { color: #fff; border-color: var(--live-red); background: color-mix(in srgb, var(--live-red) 55%, transparent); }
-.arena-aim { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 0.7rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; }
-.arena-aim.is-fighter { color: #fecaca; border-color: color-mix(in srgb, var(--live-red) 50%, transparent); background: color-mix(in srgb, var(--live-red) 15%, transparent); }
-.arena-aim.is-fighter:hover { background: color-mix(in srgb, var(--live-red) 30%, transparent); }
-.arena-mic-feed-list { display: grid; gap: 8px; }
-.arena-mic-feed-list.is-compact .arena-mic-moment { padding: 8px; }
-.arena-mic-moment { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--fc, var(--accent)); animation: arenaMomentIn 0.4s ease; }
-.arena-mic-moment.is-beef { border-left-color: var(--live-red); background: linear-gradient(90deg, color-mix(in srgb, var(--live-red) 8%, var(--bg-card)), var(--bg-card)); }
-.arena-mic-moment.is-fire { box-shadow: 0 0 18px color-mix(in srgb, var(--live-red) 22%, transparent); }
-.arena-mic-moment-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 0.82rem; margin-bottom: 3px; }
-.arena-mic-moment-head a { color: var(--text-primary); text-decoration: none; }
-.arena-mic-moment q { display: block; font-size: 0.98rem; line-height: 1.4; color: var(--text-primary); quotes: '“' '”'; }
-.arena-mic-moment-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 4px; }
-.arena-receipts-section { margin: 14px 0; }
-@media (max-width: 720px) {
- .arena-cam { grid-template-columns: 1fr; }
- .arena-cam-thumb { aspect-ratio: 16/9; }
-}
-@media (prefers-reduced-motion: reduce) { .arena-mic-moment { animation: none; } }
-.arena-me-left > .arena-portrait { flex: 0 0 64px !important; width: 64px !important; height: 64px !important; display: block; }
-.arena-me-left > .arena-portrait-initial { display: flex; }
@@ -262,7 +262,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -262,7 +262,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.digest-recap { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; margin-left: -4px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.78rem; text-decoration: none; align-self: center; }
.digest-recap { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; margin-left: -4px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.78rem; text-decoration: none; align-self: center; }
.digest-recap:hover { color: var(--accent); border-color: var(--accent); }
.digest-recap:hover { color: var(--accent); border-color: var(--accent); }
.digest-hot { display: grid; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--border)); }
.digest-hot { display: grid; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px dashed color-mix(in srgb, var(--accent) 30%, var(--border)); }
-.digest-hot-line { font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; }
+.digest-hot-line { display: block; font-size: 0.84rem; color: var(--text-secondary); font-style: italic; line-height: 1.4; text-decoration: none; }
+.digest-hot-line:hover b { text-decoration: underline; }
@media (max-width: 640px) { .digest-extra { white-space: normal; } }
@media (max-width: 640px) { .digest-extra { white-space: normal; } }
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal .goal-bar { flex: 1; }
.streamer-group-goal .goal-bar { flex: 1; }
@@ -41,7 +41,6 @@
@@ -41,7 +41,6 @@
.recap-mic { display: block; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.88rem; line-height: 1.4; color: var(--text-primary); text-decoration: none; font-style: italic; position: relative; }
.recap-mic { display: block; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.88rem; line-height: 1.4; color: var(--text-primary); text-decoration: none; font-style: italic; position: relative; }
.recap-mic i.fa-play { color: var(--g); font-style: normal; margin-right: 4px; font-size: 0.75rem; }
.recap-mic i.fa-play { color: var(--g); font-style: normal; margin-right: 4px; font-size: 0.75rem; }
.recap-mic:hover { border-color: var(--g); }
.recap-mic:hover { border-color: var(--g); }
-.recap-mic-q { position: absolute; right: 8px; top: 6px; font-size: 0.66rem; font-weight: 800; color: var(--g); font-style: normal; }
.recap-chatters { list-style: none; margin: 0; padding: 0; display: grid; gap: 7px; }
.recap-chatters { list-style: none; margin: 0; padding: 0; display: grid; gap: 7px; }
.recap-chatters li { display: grid; grid-template-columns: 26px 26px minmax(70px, 1fr) minmax(60px, 2fr) 40px; align-items: center; gap: 8px; font-size: 0.9rem; }
.recap-chatters li { display: grid; grid-template-columns: 26px 26px minmax(70px, 1fr) minmax(60px, 2fr) 40px; align-items: center; gap: 8px; font-size: 0.9rem; }
.recap-medal { text-align: center; font-weight: 800; color: var(--text-muted); }
.recap-medal { text-align: center; font-weight: 800; color: var(--text-muted); }
@@ -967,7 +967,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -967,7 +967,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
}
}
.nav-brand[data-brand-tooltip]:hover::after { opacity: 1; }
.nav-brand[data-brand-tooltip]:hover::after { opacity: 1; }
-
.nav-link {
.nav-link {
display: flex; align-items: center; gap: 6px;
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: var(--radius);
padding: 8px 14px; border-radius: var(--radius);
@@ -988,17 +987,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -988,17 +987,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
color: var(--accent-light);
color: var(--accent-light);
}
}
-
-
-
-
-
-
-
-
-
-
-
/* Live indicator on Go Live nav link */
/* Live indicator on Go Live nav link */
.nav-live-dot {
.nav-live-dot {
font-size: 0.45rem; color: #e74c3c; margin-left: 4px;
font-size: 0.45rem; color: #e74c3c; margin-left: 4px;
@@ -1035,8 +1023,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -1035,8 +1023,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
cursor: pointer;
cursor: pointer;
}
}
-
-
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
BUTTONS
BUTTONS
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
@@ -4768,7 +4754,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -4768,7 +4754,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
to { opacity: 1; transform: none; }
to { opacity: 1; transform: none; }
}
}
-
/* "Go Live" is the only remaining nav dropdown — keep its trigger visible. */
/* "Go Live" is the only remaining nav dropdown — keep its trigger visible. */
/* Nav-right stays visible, pushed to end before hamburger */
/* Nav-right stays visible, pushed to end before hamburger */
@@ -4776,7 +4761,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -4776,7 +4761,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
/* Compact currency badges */
/* Compact currency badges */
.nav-balance, .nav-coins { padding: 4px 7px; font-size: 0.75rem; gap: 3px; border-radius: 10px; }
.nav-balance, .nav-coins { padding: 4px 7px; font-size: 0.75rem; gap: 3px; border-radius: 10px; }
-
}
}
/* 900px: Compact currency, hide brand text (keep "OpenVibe" on mobile) */
/* 900px: Compact currency, hide brand text (keep "OpenVibe" on mobile) */
@@ -4829,9 +4813,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -4829,9 +4813,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.nav-right { gap: 4px; }
.nav-right { gap: 4px; }
.nav-balance, .nav-coins { padding: 3px 6px; font-size: 0.72rem; gap: 2px; }
.nav-balance, .nav-coins { padding: 3px 6px; font-size: 0.72rem; gap: 2px; }
-
-
-
.hero h1 { font-size: 1.6rem; }
.hero h1 { font-size: 1.6rem; }
.hero { padding-left: 16px; padding-right: 16px; }
.hero { padding-left: 16px; padding-right: 16px; }
.comment-item { gap: 10px; }
.comment-item { gap: 10px; }
@@ -4977,7 +4958,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -4977,7 +4958,6 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.brand-text { font-size: 0.88rem; }
.brand-text { font-size: 0.88rem; }
.nav-balance, .nav-coins { padding: 2px 5px; font-size: 0.68rem; }
.nav-balance, .nav-coins { padding: 2px 5px; font-size: 0.68rem; }
-
.stream-card { border-radius: 16px; }
.stream-card { border-radius: 16px; }
.modal { width: 95%; border-radius: var(--radius); }
.modal { width: 95%; border-radius: var(--radius); }
.admin-stats { grid-template-columns: 1fr; }
.admin-stats { grid-template-columns: 1fr; }
@@ -10170,123 +10150,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -10170,123 +10150,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.aiv-slot-grid label > span b { color:var(--accent); }
.aiv-slot-grid label > span b { color:var(--accent); }
.aiv-slot-grid input[type=range] { accent-color:var(--accent); }
.aiv-slot-grid input[type=range] { accent-color:var(--accent); }
-/* ═══════════════════════════════════════════════════════════════
- ARENA — streamer vs streamer (public/js/arena.js)
- ═══════════════════════════════════════════════════════════════ */
-.arena-container { max-width: 1180px; }
-.arena-loading i { font-size: 2rem; color: var(--accent); }
-.arena-loading small { color: var(--text-muted); }
-.arena-empty i { font-size: 2.4rem; color: var(--accent); margin-bottom: 12px; display: block; }
-.arena-note { font-size: 0.8rem; color: var(--text-muted); }
-.arena-back a { color: var(--text-secondary); text-decoration: none; }
-.arena-back a:hover { color: var(--accent-light); }
-.arena-hero h1 { margin: 0 0 6px; font-size: 2rem; display: flex; align-items: center; gap: 10px; }
-
-/* portraits */
-.arena-portrait { position: relative; border-radius: 14px; overflow: hidden; background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--fc, var(--accent)) 45%, #000) 0%, #0a0a0e 70%); box-shadow: 0 0 0 2px color-mix(in srgb, var(--fc, var(--accent)) 60%, transparent), 0 10px 30px rgba(0, 0, 0, 0.45); flex-shrink: 0; }
-.arena-portrait img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.arena-portrait-glow { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 55%, color-mix(in srgb, var(--fc, var(--accent)) 55%, transparent)); pointer-events: none; }
-.arena-portrait-xs { width: 40px; height: 40px; border-radius: 10px; font-size: 1.1rem; }
-.arena-portrait-sm { width: 64px; height: 64px; font-size: 1.6rem; }
-.arena-portrait-md { width: 100%; max-width: 260px; aspect-ratio: 1; margin: 0 auto; font-size: 4rem; }
-.arena-portrait-lg { width: 100%; max-width: 320px; aspect-ratio: 1; font-size: 5rem; }
-.arena-portrait-pending { margin-top: 8px; font-size: 0.8rem; color: var(--accent-light); text-align: center; }
-
-/* live matchups */
-.arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }
-.arena-live h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.85rem; }
-.arena-live-dot { width: 10px; height: 10px; border-radius: 50%; background: #e74c3c; box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); animation: arena-pulse 1.6s infinite; }
-@keyframes arena-pulse { 0%, 100% { box-shadow: 0 0 0 4px rgba(231, 76, 60, 0.25); } 50% { box-shadow: 0 0 0 8px rgba(231, 76, 60, 0.08); } }
-.arena-live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 14px; }
-.arena-live-match { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; }
-.arena-live-fighter { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
-.arena-live-b { flex-direction: row-reverse; text-align: right; }
-.arena-live-thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
-.arena-live-meta { display: flex; flex-direction: column; min-width: 0; }
-.arena-live-meta strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-live-meta span { font-size: 0.78rem; color: var(--text-secondary); }
-.arena-live-vs { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: #fff; font-weight: 900; text-decoration: none; font-size: 0.95rem; line-height: 1; box-shadow: 0 6px 18px rgba(139, 92, 246, 0.35); }
-.arena-live-vs small { font-size: 0.55rem; font-weight: 600; opacity: 0.85; text-transform: uppercase; }
-.arena-live-vs:hover { transform: scale(1.06); }
-.arena-live-vs-idle { background: var(--bg-tertiary); color: var(--text-muted); box-shadow: none; width: auto; height: auto; padding: 10px 12px; border-radius: 12px; }
-.arena-live-vs-idle small { white-space: nowrap; }
-.arena-live-pill { display: inline-block; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; background: #e74c3c; color: #fff; padding: 2px 6px; border-radius: 4px; vertical-align: middle; margin-left: 6px; }
-
-/* leaderboard */
-.arena-board-head h2 { margin: 0; font-size: 1.15rem; }
-.arena-board-head input { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); min-width: 220px; }
-.arena-rank-1 { color: #f5c542; } .arena-rank-2 { color: #cfd6dd; } .arena-rank-3 { color: #d09056; }
-.arena-row-main { display: flex; flex-direction: column; min-width: 0; gap: 2px; }
-.arena-row-main strong { font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-power b { font-size: 1.5rem; color: var(--accent-light); }
-.arena-power small { font-size: 0.6rem; letter-spacing: 0.1em; color: var(--text-muted); }
-.arena-power-lg b { font-size: 2.6rem; }
-
-/* profile */
-.arena-profile-rank small { color: var(--text-muted); font-weight: 400; }
-.arena-profile-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
-.arena-profile-name h1 { margin: 0; font-size: 2rem; line-height: 1.15; }
-.arena-title { margin: 4px 0 0; color: var(--accent-light); font-weight: 600; }
-.arena-handle a { color: inherit; }
-.arena-radar text { fill: var(--text-secondary); font-size: 10px; font-weight: 600; }
-.arena-quips { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 14px; }
-.arena-quip b { color: var(--text-primary); }
-.arena-quip span { color: var(--text-secondary); }
-.arena-lore-text { font-size: 1rem; line-height: 1.55; margin: 0 0 12px; }
-.arena-moves { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
-.arena-move { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.85rem; }
-.arena-move-kind { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-light); }
-.arena-move-weak .arena-move-kind { color: #e74c3c; }
-.arena-flavor { display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 0.85rem; color: var(--text-secondary); }
-.arena-flavor i { color: var(--accent); margin-right: 4px; }
-.arena-numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }
-.arena-number { display: flex; flex-direction: column; padding: 8px 10px; border-radius: 10px; background: var(--bg-tertiary); }
-.arena-number b { font-size: 1.1rem; }
-.arena-number span { font-size: 0.72rem; color: var(--text-muted); }
-.arena-challenge h2 { font-size: 1.15rem; margin: 0 0 12px; }
-.arena-opponents { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
-.arena-opponent { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; }
-.arena-opponent:hover { border-color: var(--accent); }
-.arena-opponent span { display: flex; flex-direction: column; min-width: 0; }
-.arena-opponent strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-opponent small { color: var(--text-muted); }
-
-@keyframes arena-hit { 0% { transform: scale(1); } 30% { transform: scale(1.03) rotate(-0.6deg); } 100% { transform: scale(1); } }
-@keyframes arena-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
-/* ── Arena: expandable rows, stat drill-down, voice, main event, tape ── */
-.arena-row .arena-chevron { color: var(--text-muted); transition: transform 0.15s; margin-left: 6px; }
-.arena-row-lore { font-size: 0.88rem; color: var(--text-secondary); margin: 0 0 8px; }
-.arena-stat-detail-head h3 { margin: 0; font-size: 1rem; }
-.arena-stat-detail-head small { color: var(--text-muted); }
-.arena-stat-top a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none; font-size: 0.8rem; }
-.arena-stat-top a b { color: var(--accent-light); }
-
-/* voice */
-.arena-voice-head h3 { margin: 0; font-size: 1rem; display: flex; align-items: center; gap: 8px; }
-.arena-voice-style { font-size: 0.8rem; color: var(--accent-light); font-weight: 600; }
-.arena-voice-verdict { font-size: 0.9rem; color: var(--text-secondary); font-style: italic; margin: 0 0 10px; }
-.arena-voice-meter b { font-size: 1.05rem; }
-.arena-voice-meter span { font-size: 0.7rem; color: var(--text-muted); }
-.arena-sounds { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
-.arena-sounds span { font-size: 0.72rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); }
-.arena-quotes { display: flex; flex-direction: column; gap: 6px; }
-.arena-quote q { font-size: 0.92rem; quotes: "“" "”"; }
-.arena-quote small { display: block; color: var(--text-muted); font-size: 0.72rem; margin-top: 2px; }
-.arena-quote-actions a, .arena-quote-actions button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); text-decoration: none; cursor: pointer; font-size: 0.85rem; }
-.arena-quote-actions a:hover, .arena-quote-actions button:hover { color: var(--accent-light); border-color: var(--accent); }
-.arena-speak:hover, .arena-speak.is-speaking { color: var(--accent-light); }
-
-/* portrait lightbox */
-.arena-portrait-lg { cursor: zoom-in; }
-.arena-lightbox { position: fixed; inset: 0; z-index: 3000; background: rgba(0, 0, 0, 0.82); display: flex; align-items: center; justify-content: center; padding: 24px; animation: arena-in 0.2s; }
-.arena-lightbox-inner { max-width: 900px; width: 100%; display: grid; grid-template-columns: minmax(0, 520px) 1fr; gap: 20px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px; }
-.arena-lightbox-inner img { width: 100%; border-radius: 12px; display: block; }
-.arena-lightbox-text h3 { margin: 0 0 8px; }
-.arena-lightbox-text p { font-size: 0.88rem; color: var(--text-secondary); }
-.arena-lightbox-prompt { font-size: 0.78rem; color: var(--text-muted); max-height: 220px; overflow: auto; background: var(--bg-tertiary); padding: 10px; border-radius: 8px; }
-.arena-lightbox-close { position: absolute; top: 16px; right: 20px; background: none; border: 0; color: #fff; font-size: 1.8rem; cursor: pointer; }
-@media (max-width: 720px) { .arena-lightbox-inner { grid-template-columns: 1fr; } }
-
/* ── AI timeline transcript: speech lines vs. sound strips ── */
/* ── AI timeline transcript: speech lines vs. sound strips ── */
.ai-tl-sound i { font-size: 0.68rem; opacity: 0.8; }
.ai-tl-sound i { font-size: 0.68rem; opacity: 0.8; }
.ai-tl-sound b { font-weight: 700; color: var(--text-muted); margin-left: 1px; }
.ai-tl-sound b { font-weight: 700; color: var(--text-muted); margin-left: 1px; }
@@ -10317,443 +10180,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -10317,443 +10180,6 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.hero-bg-layer.active { animation: none !important; }
.hero-bg-layer.active { animation: none !important; }
}
}
-@keyframes arena-stamp-in { from { transform: scale(2.2) rotate(-12deg); opacity: 0; } to { transform: scale(1) rotate(-3deg); opacity: 1; } }
-.arena-mic-status { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text-secondary); }
-.arena-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); }
-.arena-mic-status.is-live { color: #ef4444; } .arena-mic-status.is-live .arena-mic-dot { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.25); animation: arena-pulse 1.2s infinite; }
-@keyframes arena-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
-.arena-xp { width: 140px; }
-.arena-xp-label { font-size: 0.7rem; color: var(--text-muted); margin-top: 3px; text-align: right; }
-@keyframes arena-cleared { 0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.6); } 100% { box-shadow: 0 0 0 30px rgba(249, 115, 22, 0); } }
-.arena-progress { height: 14px; border-radius: 7px; background: var(--bg-tertiary); overflow: hidden; position: relative; }
-.arena-progress-fill { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24 70%, #22c55e); transition: width 0.8s cubic-bezier(0.2, 0.8, 0.3, 1); box-shadow: 0 0 12px rgba(249, 115, 22, 0.5); }
-.arena-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--text-muted); margin-top: 5px; }
-.arena-levelup { position: fixed; left: 50%; top: 22%; transform: translateX(-50%); z-index: 3500; padding: 14px 26px; border-radius: 16px; background: linear-gradient(135deg, #f97316, #fbbf24); color: #111; font-weight: 900; font-size: 1.4rem; letter-spacing: 0.06em; box-shadow: 0 20px 60px rgba(249, 115, 22, 0.45); animation: arena-levelup 2.2s ease-out forwards; pointer-events: none; }
-@keyframes arena-levelup { 0% { transform: translate(-50%, 20px) scale(0.6); opacity: 0; } 15% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } 80% { opacity: 1; } 100% { transform: translate(-50%, -30px) scale(1); opacity: 0; } }
-
-/* ── Arena v2: pulse, on the mic, beefs, the board, ladders, consoles ── */
-.arena-pulse { margin: 4px 0 22px; }
-.arena-pulse-inner { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 12px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 18%, var(--bg-card)), var(--bg-card)); }
-.arena-pulse-kicker { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; letter-spacing: 0.08em; font-size: 0.72rem; text-transform: uppercase; color: var(--accent-light); }
-.arena-pulse-text { flex: 1; min-width: 240px; font-size: 1.02rem; font-weight: 600; }
-.arena-pulse-meta { font-size: 0.75rem; color: var(--text-muted); }
-.arena-lvl { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.04em; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }
-.arena-chip { display: inline-flex; align-items: center; gap: 8px; color: var(--text-primary); text-decoration: none; min-width: 0; }
-.arena-chip > span { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
-.arena-chip small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 4px; align-items: center; flex-wrap: wrap; }
-.arena-live-dot-sm { width: 7px; height: 7px; display: inline-block; margin-right: 4px; }
-
-/* on the mic now */
-.arena-mic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
-.arena-mic-card { display: grid; grid-template-columns: 112px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid rgba(231, 76, 60, 0.45); }
-.arena-mic-thumb { display: block; }
-.arena-mic-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
-.arena-mic-head a { color: var(--text-primary); text-decoration: none; }
-
-/* beefs */
-.arena-beefs h2, .arena-board-head h2, .arena-ladder h2, .arena-live h2 { display: flex; align-items: center; gap: 10px; font-size: 1.15rem; margin: 0 0 12px; }
-.arena-beefs h2 small, .arena-board-head h2 small, .arena-ladder h2 small { color: var(--text-muted); font-weight: 400; font-size: 0.82rem; }
-.arena-beef-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; }
-.arena-beef-head a { color: var(--text-primary); text-decoration: none; }
-.arena-beef-side { display: flex; align-items: center; gap: 10px; color: var(--text-primary); text-decoration: none; min-width: 0; }
-.arena-beef-side > span { display: flex; flex-direction: column; min-width: 0; }
-.arena-beef-side small { color: var(--text-muted); font-size: 0.74rem; }
-.arena-tug span { display: flex; align-items: center; padding: 0 8px; color: #fff; transition: width 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); min-width: 0; overflow: hidden; }
-.arena-beef-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
-.arena-clock b { color: var(--text-primary); }
-.arena-clock.is-urgent { color: #f87171; } .arena-clock.is-urgent b { color: #f87171; animation: arena-blink 1s steps(2) infinite; }
-@keyframes arena-blink { 50% { opacity: 0.35; } }
-.arena-sides-tally { font-size: 0.78rem; color: var(--text-muted); display: inline-flex; gap: 5px; align-items: center; }
-.arena-beef-empty i { font-size: 1.6rem; color: var(--accent); }
-.arena-beef-empty p { margin: 0; }
-.arena-done summary { cursor: pointer; color: var(--text-secondary); font-weight: 600; margin-bottom: 10px; }
-.arena-done summary small { color: var(--text-muted); font-weight: 400; }
-
-/* the board */
-.arena-board { margin: 8px 0 28px; }
-.arena-topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
-.arena-topic { display: flex; flex-direction: column; gap: 8px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); position: relative; }
-.arena-topic-debate { border-left: 4px solid #60a5fa; }
-.arena-topic-phrase { border-left: 4px solid var(--accent-light, var(--accent)); }
-.arena-topic-bounty { border-left: 4px solid #f5c542; }
-.arena-topic-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }
-.arena-kind { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.68rem; color: var(--accent-light); }
-.arena-heat { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: var(--text-secondary); }
-.arena-heat-bar { width: 46px; height: 5px; border-radius: 3px; background: var(--bg-tertiary); overflow: hidden; display: inline-block; }
-.arena-heat-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }
-.arena-topic-by { margin-left: auto; }
-.arena-topic-headline { font-weight: 800; font-size: 0.82rem; letter-spacing: 0.02em; color: #fbbf24; text-transform: uppercase; }
-.arena-topic-link { color: var(--text-primary); text-decoration: none; }
-.arena-topic-link:hover .arena-topic-text { color: var(--accent-light); }
-.arena-topic-text { font-size: 1.05rem; font-weight: 700; line-height: 1.35; }
-.arena-topic-text b { font-weight: 900; }
-.arena-debate-title em { font-style: italic; color: var(--text-muted); font-weight: 600; padding: 0 4px; }
-.arena-topic-hint { margin: 0; font-size: 0.85rem; color: var(--text-secondary); }
-.arena-topic-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }
-.arena-topic-who { display: flex; align-items: center; gap: 4px; }
-.arena-topic-who a { display: inline-flex; } .arena-topic-who a.is-dim { opacity: 0.55; }
-.arena-topic-stats { font-size: 0.75rem; color: var(--text-muted); }
-.arena-topic-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
-.arena-debate { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
-.arena-debate .btn { white-space: normal; }
-.arena-debate-tally { display: flex; flex-direction: column; align-items: center; gap: 3px; min-width: 90px; }
-.arena-debate-bar { width: 90px; height: 6px; border-radius: 3px; background: #e74c3c; overflow: hidden; display: block; }
-.arena-debate-bar span { display: block; height: 100%; background: #60a5fa; }
-.arena-debate-nums { font-size: 0.72rem; color: var(--text-muted); }
-.arena-debate-big { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-resolved { display: flex; flex-direction: column; gap: 6px; }
-.arena-resolved-row { display: flex; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 10px; background: var(--bg-card); border: 1px solid var(--border); font-size: 0.88rem; flex-wrap: wrap; }
-.arena-resolved-row a { color: var(--text-primary); text-decoration: none; }
-.arena-composer { display: flex; flex-direction: column; gap: 10px; max-width: 560px; }
-.arena-composer textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font: inherit; resize: vertical; }
-.arena-composer-actions { display: flex; justify-content: flex-end; }
-
-/* ladders */
-.arena-ladders { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 0 0 28px; }
-.arena-ladder { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-mini-list { display: flex; flex-direction: column; gap: 6px; }
-.arena-mini-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 10px; background: var(--bg-tertiary); }
-.arena-rules h3 { margin: 0 0 8px; font-size: 1rem; }
-.arena-rules ul, .arena-rules-list { margin: 0; padding-left: 18px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }
-
-/* beef page */
-.arena-beef-hero h1 { margin: 6px 0; font-size: 1.7rem; line-height: 1.2; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
-.arena-beef-opener q { quotes: "“" "”"; }
-.arena-beef-tape { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
-.arena-beef-tape-side h2 { margin: 0; font-size: 1.3rem; }
-.arena-beef-tape-nums span { display: flex; flex-direction: column; align-items: center; line-height: 1.1; }
-.arena-beef-tape-nums b { font-size: 1.3rem; } .arena-beef-tape-nums small { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
-.arena-beef-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
-.arena-feed, .arena-beef-aside section, .arena-console-aside section, .arena-console-main { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-feed h3, .arena-beef-aside h3, .arena-console-aside h3, .arena-console-main h3, .arena-angles h3, .arena-members h3, .arena-best-lines h3 { margin: 0 0 10px; font-size: 0.98rem; display: flex; align-items: center; gap: 8px; }
-.arena-feed h3 small, .arena-beef-aside h3 small, .arena-console-main h3 small, .arena-angles h3 small, .arena-members h3 small, .arena-best-lines h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.78rem; }
-.arena-feed-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
-.arena-feed-body q { font-size: 0.95rem; quotes: "“" "”"; }
-.arena-feed-body small { color: var(--text-muted); font-size: 0.74rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
-.arena-receipt q { quotes: "“" "”"; font-size: 0.9rem; }
-.arena-receipt small { display: block; color: var(--text-muted); font-size: 0.74rem; margin-top: 2px; }
-
-/* topic page */
-.arena-topic-page { display: flex; flex-direction: column; gap: 12px; padding: 18px; border-radius: 18px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-topic-page h1.arena-topic-text { margin: 0; font-size: 1.6rem; }
-.arena-topic-result { padding: 10px 14px; border-radius: 12px; background: rgba(245, 197, 66, 0.12); color: #f5c542; font-weight: 700; }
-.arena-bounty-target { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 10px 12px; border-radius: 12px; background: rgba(245, 197, 66, 0.08); }
-.arena-topic-cta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
-.arena-angle-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }
-.arena-angle { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border-radius: 12px; background: var(--bg-tertiary); font-size: 0.88rem; }
-.arena-angle span { color: var(--text-muted); font-size: 0.78rem; }
-.arena-member { display: grid; grid-template-columns: minmax(180px, 1fr) 1.2fr auto; align-items: center; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
-.arena-member-progress { display: flex; gap: 6px; }
-.arena-member-progress .arena-progress { flex: 1; height: 10px; }
-.arena-progress.is-cleared .arena-progress-fill { background: #22c55e; }
-.arena-angle-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
-.arena-angle-row { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; font-size: 0.82rem; }
-.arena-angle-row .arena-progress { height: 8px; }
-.arena-angle-row.is-cleared { color: #22c55e; }
-.arena-angle-row small { color: var(--text-muted); }
-.arena-level-nums span { display: flex; flex-direction: column; line-height: 1.1; }
-.arena-level-nums b { font-size: 1.1rem; } .arena-level-nums small { font-size: 0.66rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
-
-/* the ears (live console) */
-.arena-console-head h1 { margin: 0 0 4px; font-size: 1.4rem; display: flex; align-items: center; gap: 8px; }
-.arena-console-status.is-on { color: #4ade80; }
-.arena-console-level { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; min-width: 200px; }
-.arena-console-level small { font-size: 0.72rem; color: var(--text-muted); }
-.arena-console-cols { display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px; }
-.arena-judgement.is-hit { border-left-color: #f97316; color: var(--text-primary); }
-.arena-console-beef a { color: var(--text-primary); text-decoration: none; }
-.arena-expires { font-variant-numeric: tabular-nums; }
-
-@media (max-width: 900px) {
- .arena-ladders, .arena-beef-cols, .arena-console-cols, .arena-beef-tape { grid-template-columns: 1fr; }
- .arena-member { grid-template-columns: 1fr; gap: 6px; }
-}
-@media (max-width: 600px) { .arena-hero h1 { font-size: 1.5rem; }
- .arena-live h2, .arena-beefs h2, .arena-board-head h2, .arena-ladder h2 { flex-wrap: wrap; }
- .arena-pulse-inner { padding: 10px 12px; }
- .arena-mic-grid, .arena-beef-grid, .arena-topic-grid { grid-template-columns: 1fr; }
- .arena-mic-card { grid-template-columns: 1fr; }
- .arena-beef-foot, .arena-topic-foot { flex-direction: column; align-items: flex-start; }
- .arena-topic-actions { margin-left: 0; }
- .arena-debate { grid-template-columns: 1fr; }
- .arena-debate-tally { flex-direction: row; justify-content: center; }
- .arena-numbers { grid-template-columns: repeat(2, 1fr); }
- .arena-quips { grid-template-columns: 1fr; }
- .arena-beef-hero h1 { font-size: 1.25rem; }
- .arena-topic-page { padding: 14px; } .arena-topic-page h1.arena-topic-text { font-size: 1.25rem; }
- .arena-lightbox-inner { padding: 14px; }
-}
-.arena-lore { display: flex; flex-direction: column; gap: 10px; }
-
-/* ── Arena v2.1: lore board (pulse, featured subject, feed rows, moments) ── */
-.arena-pulse-inner { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; padding: 14px 18px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 50%, var(--border)); background: linear-gradient(100deg, color-mix(in srgb, var(--accent) 22%, var(--bg-card)) 0%, var(--bg-card) 60%); }
-.arena-pulse-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 16px; background: color-mix(in srgb, var(--accent) 28%, transparent); color: var(--accent-light); font-weight: 900; font-size: 0.6rem; letter-spacing: 0.14em; }
-.arena-pulse-icon i { font-size: 1.5rem; margin-bottom: 4px; }
-.arena-pulse-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
-.arena-pulse-text { font-size: 1.12rem; font-weight: 700; line-height: 1.35; }
-.arena-pulse-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 0.75rem; color: var(--text-muted); }
-.arena-pulse-stat { display: inline-flex; gap: 5px; align-items: center; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); }
-.arena-pulse-stat b { color: var(--text-primary); } .arena-pulse-stat.is-hot b { color: #fb923c; }
-.arena-pulse-when { margin-left: auto; }
-
-.arena-featured { display: grid; grid-template-columns: 1.6fr 1fr; gap: 0; border-radius: 18px; border: 1px solid rgba(249, 115, 22, 0.45); background: linear-gradient(135deg, rgba(249, 115, 22, 0.08), var(--bg-card) 45%); overflow: hidden; margin-bottom: 12px; }
-.arena-featured-main { display: flex; flex-direction: column; gap: 10px; padding: 18px 20px; min-width: 0; }
-.arena-featured-side { display: flex; flex-direction: column; gap: 8px; padding: 18px; border-left: 1px solid var(--border); background: color-mix(in srgb, var(--bg-tertiary) 60%, transparent); min-width: 0; }
-.arena-featured-side h4 { margin: 0 0 2px; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); display: flex; gap: 6px; align-items: center; }
-.arena-featured-headline { margin: 0; font-size: 1.55rem; line-height: 1.2; font-weight: 900; color: var(--text-primary); }
-.arena-topic-link:hover .arena-featured-headline { color: #fb923c; }
-.arena-featured-subject { font-size: 0.95rem; color: var(--text-secondary); }
-.arena-featured-subject b { color: var(--text-primary); font-weight: 800; }
-.arena-tagline { color: var(--text-muted); }
-.arena-lore-p { margin: 0; font-size: 0.95rem; line-height: 1.55; color: var(--text-primary); }
-.arena-lore-p.is-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-secondary); font-size: 0.86rem; }
-.arena-lore-big { font-size: 1.05rem; line-height: 1.6; }
-.arena-keywords { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }
-.arena-keywords span { font-size: 0.7rem; padding: 2px 7px; border-radius: 6px; background: var(--bg-tertiary); color: var(--text-muted); font-family: ui-monospace, monospace; }
-.arena-chips { display: inline-flex; gap: 6px; flex-wrap: wrap; }
-.arena-chip-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 0.74rem; padding: 3px 8px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
-.arena-chip-stat.is-live { color: #f87171; background: rgba(239, 68, 68, 0.12); }
-.arena-stack { display: inline-flex; align-items: center; }
-.arena-stack a { display: inline-flex; margin-left: -8px; border-radius: 10px; box-shadow: 0 0 0 2px var(--bg-card); }
-.arena-stack a:first-child { margin-left: 0; }
-.arena-stack-more { margin-left: 6px; font-size: 0.72rem; color: var(--text-muted); }
-.arena-moment { display: grid; grid-template-columns: 18px 1fr; gap: 8px; padding: 7px 0; border-top: 1px solid var(--border); align-items: start; }
-.arena-moment:first-child { border-top: 0; }
-.arena-moment > i { margin-top: 3px; font-size: 0.78rem; color: var(--text-muted); }
-.arena-moment.is-mic > i { color: #f87171; }
-.arena-moment.is-judged > i { color: #fb923c; }
-.arena-moment-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
-.arena-moment-body q { font-size: 0.9rem; quotes: "“" "”"; line-height: 1.4; }
-.arena-moment-body small { color: var(--text-muted); font-size: 0.72rem; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
-.arena-moment-body small b { color: var(--text-secondary); }
-.arena-featured-side .arena-moment { border-top: 0; padding: 6px 8px; border-radius: 10px; background: var(--bg-card); }
-
-.arena-topic-feed { display: flex; flex-direction: column; gap: 8px; }
-.arena-topic-row { display: grid; grid-template-columns: 96px 1fr auto; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-topic-row.arena-topic-bounty { border-left: 4px solid #f5c542; }
-.arena-topic-row.is-archived { grid-template-columns: 1fr; opacity: 0.8; }
-.arena-topic-row-heat { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
-.arena-topic-row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
-.arena-topic-row-main strong { font-size: 1.02rem; line-height: 1.3; }
-.arena-topic-row-sub { font-size: 0.8rem; color: var(--text-secondary); }
-.arena-topic-row-sub .arena-kind { text-transform: none; letter-spacing: 0; font-size: 0.8rem; color: var(--text-primary); font-weight: 700; }
-.arena-topic-row-last { display: block; font-size: 0.82rem; color: var(--text-muted); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-topic-row-last i { font-size: 0.7rem; margin-right: 4px; }
-.arena-topic-row-side { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
-.arena-topic-headline-big { margin: 0; font-size: 1.7rem; line-height: 1.2; font-weight: 900; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
-.arena-topic-cols { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; margin-top: 6px; }
-.arena-topic-main, .arena-topic-aside { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
-.arena-lore-box, .arena-moments, .arena-topic-aside section { padding: 14px; border-radius: 14px; background: var(--bg-tertiary); }
-.arena-lore-box h3, .arena-moments h3, .arena-topic-aside h3 { margin: 0 0 8px; font-size: 0.95rem; display: flex; gap: 8px; align-items: center; }
-.arena-lore-box h3 small, .arena-moments h3 small, .arena-topic-aside h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }
-.arena-moment-list { max-height: 560px; overflow-y: auto; padding-right: 4px; }
-.arena-topic-page.is-archived { opacity: 0.92; }
-.arena-member { grid-template-columns: 1fr; gap: 4px; }
-.arena-member .btn { justify-self: start; }
-
-@media (max-width: 900px) {
- .arena-featured, .arena-topic-cols { grid-template-columns: 1fr; }
- .arena-featured-side { border-left: 0; border-top: 1px solid var(--border); }
-}
-@media (max-width: 600px) {
- .arena-pulse-inner { grid-template-columns: 1fr; gap: 10px; } .arena-pulse-icon { flex-direction: row; width: auto; height: auto; padding: 6px 10px; gap: 8px; } .arena-pulse-icon i { margin: 0; font-size: 1.1rem; }
- .arena-pulse-when { margin-left: 0; flex-basis: 100%; }
- .arena-featured-headline { font-size: 1.25rem; }
- .arena-topic-row { grid-template-columns: 1fr; gap: 8px; }
- .arena-topic-row-side { flex-direction: row; align-items: center; flex-wrap: wrap; }
- .arena-topic-headline-big { font-size: 1.3rem; }
-}
-
-/* ── Arena: per-fighter custom stats + taunts ── */
-.arena-radar-custom text { font-size: 9px; }
-.arena-quips-custom .arena-quip b { color: var(--accent-light); }
-.arena-row-taunts { display: flex; flex-direction: column; gap: 3px; margin: 0 0 10px; }
-.arena-row-taunts em { font-size: 0.85rem; color: var(--text-secondary); }
-.arena-row-taunts small { color: var(--text-muted); font-size: 0.72rem; }
-.arena-focus i { color: #f87171; }
-.arena-focus small { color: var(--text-secondary); margin-left: 6px; }
-
-/* ── Arena: yapper profiles ── */
-.arena-ladder-yap { grid-column: 1 / -1; }
-.arena-yap-podium { display: grid; grid-template-columns: 1.3fr 1fr 1fr; gap: 10px; margin-bottom: 10px; }
-.arena-yap-card { display: grid; grid-template-columns: auto auto 1fr; gap: 10px; align-items: start; padding: 12px; border-radius: 14px; background: var(--bg-tertiary); border: 1px solid var(--border); position: relative; overflow: hidden; }
-.arena-yap-card.is-top { border-color: color-mix(in srgb, var(--yc) 60%, var(--border)); background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 14%, var(--bg-tertiary)), var(--bg-tertiary) 60%); }
-.arena-yap-card.is-top::after { content: ''; position: absolute; inset: -40%; background: conic-gradient(from 0deg, transparent 0 70%, color-mix(in srgb, var(--yc) 25%, transparent) 80%, transparent 90%); animation: arena-spin 6s linear infinite; pointer-events: none; }
-@keyframes arena-spin { to { transform: rotate(360deg); } }
-.arena-yap-rank { font-weight: 900; font-size: 1.2rem; color: var(--text-muted); }
-.arena-yap-card.is-top .arena-yap-rank { color: #f5c542; }
-.arena-yap-ring { position: relative; display: inline-grid; place-items: center; flex-shrink: 0; }
-.arena-yap-ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }
-.arena-yap-ring-bg { fill: none; stroke: var(--border); stroke-width: 3; }
-.arena-yap-ring-fg { fill: none; stroke: var(--yc, #f97316); stroke-width: 3; stroke-linecap: round; transition: stroke-dashoffset 0.8s cubic-bezier(0.2, 0.8, 0.2, 1); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--yc, #f97316) 70%, transparent)); }
-.arena-yap-ring b { font-size: 0.9em; font-weight: 900; color: var(--text-primary); }
-.arena-yap-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; position: relative; }
-.arena-yap-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
-.arena-yap-name a { color: var(--text-primary); text-decoration: none; }
-.arena-yap-title { font-weight: 800; color: var(--yc, #f97316); font-size: 0.92rem; }
-.arena-yap-title small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
-.arena-yap-blurb { margin: 0; font-size: 0.84rem; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
-.arena-yap-line { display: block; font-size: 0.86rem; color: var(--text-secondary); quotes: "“" "”"; }
-.arena-yap-stats { display: flex; gap: 10px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }
-.arena-yap-stats b { color: var(--text-primary); }
-.arena-yap-row { grid-template-columns: 28px auto 1fr auto; }
-.arena-yap-week { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
-.arena-streak i { animation: arena-flame 0.9s ease-in-out infinite alternate; }
-@keyframes arena-flame { from { transform: scale(1); } to { transform: scale(1.25) rotate(-6deg); } }
-.arena-flash { position: absolute; right: 14px; top: 10px; padding: 4px 10px; border-radius: 999px; background: #f97316; color: #1a1006; font-weight: 800; font-size: 0.72rem; animation: arena-pop 2.2s ease forwards; z-index: 2; }
-@keyframes arena-pop { 0% { transform: scale(0.6); opacity: 0; } 15% { transform: scale(1.1); opacity: 1; } 80% { opacity: 1; } 100% { transform: translateY(-10px); opacity: 0; } }
-.arena-ladder { position: relative; }
-.arena-yap-page { display: flex; flex-direction: column; gap: 14px; }
-.arena-yap-hero { display: grid; grid-template-columns: auto 1fr; gap: 18px; align-items: center; padding: 18px; border-radius: 18px; background: linear-gradient(135deg, color-mix(in srgb, var(--yc) 16%, var(--bg-card)), var(--bg-card) 60%); border: 1px solid color-mix(in srgb, var(--yc) 45%, var(--border)); }
-.arena-yap-hero-main { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
-.arena-yap-hero h1 { margin: 0; font-size: 1.6rem; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
-.arena-yap-hero-title { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
-.arena-yap-card-title { font-weight: 800; color: var(--yc); }
-.arena-yap-cardbox { padding: 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-yap-cardbox h3 { margin: 0 0 8px; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }
-.arena-yap-cardbox h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.76rem; }
-.arena-yap-cardbox p { margin: 0 0 8px; line-height: 1.55; }
-@media (max-width: 900px) { .arena-yap-podium { grid-template-columns: 1fr; } .arena-yap-hero { grid-template-columns: 1fr; } }
-
-/* ── chat context menu: Arena yap level ── */
-.ctx-yap { display: flex; flex-direction: column; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--border); font-size: 0.78rem; }
-.ctx-yap-link { display: flex; align-items: center; gap: 6px; color: var(--text-primary); text-decoration: none; }
-.ctx-yap-lvl { font-weight: 900; font-size: 0.68rem; letter-spacing: 0.06em; padding: 1px 6px; border-radius: 5px; background: linear-gradient(90deg, #f97316, #fbbf24); color: #1a1006; }
-.ctx-yap-title { font-weight: 700; }
-.ctx-yap-streak { color: #fb923c; font-size: 0.72rem; }
-.ctx-yap-bar { display: block; height: 4px; border-radius: 2px; background: var(--bg-tertiary); overflow: hidden; }
-.ctx-yap-bar span { display: block; height: 100%; background: linear-gradient(90deg, #f97316, #fbbf24); }
-.ctx-yap small { color: var(--text-muted); }
-
-/* ── Arena board v3: subject cards + threads ── */
-.arena-subject-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
-.arena-subject { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); min-width: 0; }
-.arena-subject.is-featured { grid-column: 1 / -1; background: linear-gradient(135deg, rgba(249, 115, 22, 0.1), var(--bg-card) 50%); }
-.arena-subject.arena-topic-bounty { border-left: 4px solid #f5c542; }
-.arena-subject-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; color: var(--text-muted); }
-.arena-subject-headline { margin: 0; font-size: 1.15rem; line-height: 1.25; font-weight: 900; color: var(--text-primary); }
-.arena-subject.is-featured .arena-subject-headline { font-size: 1.45rem; }
-.arena-topic-link:hover .arena-subject-headline { color: #fb923c; }
-.arena-subject-sub { font-size: 0.86rem; color: var(--text-secondary); }
-.arena-subject-sub b { color: var(--text-primary); }
-.arena-threads { display: flex; gap: 6px; flex-wrap: wrap; }
-.arena-thread { display: inline-flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.76rem; font-weight: 600; }
-.arena-thread i { font-size: 0.65rem; color: #fb923c; }
-.arena-thread b { color: var(--text-primary); }
-.arena-thread.is-more { opacity: 0.6; }
-a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 70%, #fb923c 30%); }
-.arena-subject-last { display: block; font-size: 0.84rem; color: var(--text-secondary); quotes: "" ""; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-subject-last i { font-size: 0.7rem; margin-right: 3px; color: var(--text-muted); }
-.arena-subject-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: auto; }
-.arena-strip { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 8px 0; }
-.arena-strip-people { display: inline-flex; align-items: center; gap: 8px; }
-.arena-strip-people small { color: var(--text-muted); }
-.arena-story { padding: 12px 14px; border-radius: 14px; background: var(--bg-tertiary); border-left: 3px solid #fb923c; }
-.arena-story h3 { margin: 0 0 6px; font-size: 0.9rem; display: flex; gap: 8px; align-items: center; }
-.arena-story h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
-.arena-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
-.arena-tab { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
-.arena-tab b { color: var(--text-primary); margin-left: 2px; }
-.arena-tab.is-on { border-color: #fb923c; color: var(--text-primary); background: rgba(249, 115, 22, 0.12); }
-.arena-tab-hint { margin: 0; }
-.arena-row-lore.is-clamp { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
-.arena-row-taunts em { display: flex; align-items: center; gap: 6px; }
-@media (max-width: 900px) { .arena-subject-grid { grid-template-columns: 1fr; } }
-@media (max-width: 600px) {
- .arena-subject { padding: 12px; } .arena-subject.is-featured .arena-subject-headline { font-size: 1.2rem; }
- .arena-quips-custom, .arena-quips { grid-template-columns: 1fr 1fr; gap: 4px 10px; }
- .arena-topic-headline-big { font-size: 1.2rem; }
-}
-
-/* ── Arena radar v2 ── */ .arena-bars-head small { color: var(--text-muted); }
-
-/* ── Arena: your arena strip + bars fix ── */
-.arena-me-left { display: flex; gap: 12px; align-items: center; min-width: 0; }
-.arena-me-left > div { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
-.arena-me-right { display: flex; flex-direction: column; gap: 6px; font-size: 0.86rem; min-width: 0; justify-content: center; }
-.arena-me-alert a { color: var(--text-primary); text-decoration: none; }
-
-/* ── Arena growth: tiers, achievements, history, rap sheet, bubbles, numbers ── */
-.arena-tier { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: var(--tc, #cd7f32); background: color-mix(in srgb, var(--tc, #cd7f32) 16%, transparent); border: 1px solid color-mix(in srgb, var(--tc, #cd7f32) 55%, transparent); }
-.arena-tier-big { font-size: 0.82rem; padding: 5px 12px; box-shadow: 0 0 14px color-mix(in srgb, var(--tc) 35%, transparent); }
-.arena-progress-panel { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
-.arena-progress-head { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; }
-.arena-progress-bar { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
-.arena-tier-track { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }
-.arena-tier-step { height: 8px; border-radius: 4px; background: var(--bg-tertiary); }
-.arena-tier-step.is-done { background: var(--tc); }
-.arena-tier-step.is-now { box-shadow: 0 0 10px color-mix(in srgb, var(--tc) 60%, transparent); animation: arena-glow 1.6s ease-in-out infinite alternate; }
-@keyframes arena-glow { from { filter: brightness(0.9); } to { filter: brightness(1.3); } }
-.arena-ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
-.arena-ach { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 3px; padding: 10px 8px; border-radius: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); min-width: 0; }
-.arena-ach-icon { font-size: 1.5rem; line-height: 1; }
-.arena-ach-name { font-size: 0.78rem; font-weight: 800; }
-.arena-ach-hint { font-size: 0.68rem; color: var(--text-muted); line-height: 1.25; }
-.arena-ach.is-earned { border-color: rgba(245, 197, 66, 0.55); background: linear-gradient(180deg, rgba(245, 197, 66, 0.12), var(--bg-tertiary)); }
-.arena-ach.is-mini { flex-direction: row; text-align: left; padding: 6px 10px; gap: 8px; }
-.arena-ach.is-mini .arena-ach-icon { font-size: 1.1rem; } .arena-ach.is-mini .arena-ach-hint { display: none; }
-.arena-me-ach { gap: 6px; }
-.arena-history summary { font-weight: 700; }
-.arena-history-list { display: flex; flex-direction: column; gap: 2px; }
-.arena-history-row { display: grid; grid-template-columns: 28px 1fr auto; gap: 8px; align-items: center; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.84rem; }
-.arena-history-kind { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.72rem; }
-.arena-history-kind.achievement { color: #f5c542; } .arena-history-kind.beef_over, .arena-history-kind.beef { color: #f87171; } .arena-history-kind.tier { color: #8ab4ff; } .arena-history-kind.level { color: #fb923c; }
-.arena-history-body { display: flex; flex-direction: column; min-width: 0; }
-.arena-history-body a { color: var(--text-primary); text-decoration: none; }
-.arena-history-body small { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.arena-history-when { font-size: 0.72rem; color: var(--text-muted); white-space: nowrap; }
-.arena-sheet, .arena-taunts { padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; }
-.arena-sheet h3, .arena-taunts h3 { margin: 0; font-size: 0.98rem; display: flex; gap: 8px; align-items: center; }
-.arena-taunts h3 small { color: var(--text-muted); font-weight: 400; font-size: 0.74rem; }
-.arena-sheet-lore { margin: 0; font-size: 0.95rem; line-height: 1.5; }
-.arena-sheet-rows { display: flex; flex-direction: column; gap: 6px; }
-.arena-sheet-row { display: grid; grid-template-columns: 92px 1fr; gap: 10px; font-size: 0.86rem; align-items: baseline; }
-.arena-sheet-k { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
-.arena-sheet-row.is-weak .arena-sheet-k { color: #f87171; }
-.arena-bubbles { display: flex; flex-direction: column; gap: 8px; }
-.arena-bubble { display: grid; grid-template-columns: 1fr auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 14px 14px 14px 4px; background: var(--bg-tertiary); border: 1px solid var(--border); }
-.arena-bubble-who { display: block; font-size: 0.7rem; font-weight: 800; color: var(--accent-light); margin-bottom: 2px; }
-.arena-bubble-text { font-size: 0.95rem; line-height: 1.4; }
-.arena-bubble-speak { width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; }
-.arena-bubble-speak.is-speaking, .arena-bubble-speak:hover { color: var(--accent-light); border-color: var(--accent); }
-.arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }
-@media (max-width: 600px) {
- .arena-progress-head { grid-template-columns: 1fr; }
- .arena-ach-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .arena-ach { padding: 8px 4px; } .arena-ach-hint { display: none; }
- .arena-sheet-row { grid-template-columns: 1fr; gap: 2px; }
- .arena-numbers-compact { grid-template-columns: repeat(3, 1fr); }
-}
-
-/* ── Arena: phone layout for expanded ladder rows + quips (specificity matches the open rule) ── */
-.arena-quip-val { display: inline-block; min-width: 2.1em; text-align: right; color: var(--accent-light); font-variant-numeric: tabular-nums; margin-right: 4px; }
-.arena-row-bubbles { margin: 0 0 10px; }
-.arena-row-bubbles .arena-bubble { padding: 8px 10px; }
-.arena-row-bubbles .arena-bubble-text { font-size: 0.88rem; }
-@media (max-width: 700px) {
- .arena-row-lore.is-clamp { -webkit-line-clamp: 4; font-size: 0.9rem; }
- .arena-row-expand .arena-quip span { color: var(--text-secondary); }
-}
-
-/* bubble: name on its own line, text + speaker side by side */
-.arena-bubble { grid-template-columns: 1fr auto; grid-template-areas: "who who" "text speak"; }
-.arena-bubble-who { grid-area: who; }
-.arena-bubble-text { grid-area: text; }
-.arena-bubble-speak { grid-area: speak; align-self: center; }
-
-/* ── Arena: Battle Cam mode (pure mic) — live cams, the feed, callouts ── */
-.arena-cam { position: relative; display: grid; grid-template-columns: 132px 1fr; gap: 12px; padding: 10px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); overflow: hidden; transition: border-color 0.2s, box-shadow 0.2s; }
-.arena-cam-thumb { position: relative; display: block; border-radius: 10px; overflow: hidden; aspect-ratio: 16/10; background: var(--bg-tertiary); }
-.arena-cam-rec { position: absolute; left: 6px; top: 6px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 999px; background: rgba(0,0,0,0.65); color: #fff; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.06em; }
-.arena-cam-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
-.arena-cam-last small { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; margin-top: 3px; }
-.arena-feed-section h2 small .arena-fresh { color: var(--success); }
-@keyframes arenaMomentIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
-.arena-mic-moment-body { min-width: 0; }
-.arena-mic-moment-head small { color: var(--text-muted); margin-left: auto; }
-.arena-receipts-section h3 { margin: 0 0 8px; }
-
-/* Arena "your arena" strip: the portrait is a div, so the generic `> div` flex rule was stretching it */
-
/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is
/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is
drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */
drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */
.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }
.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }
@@ -10854,7 +10280,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -10854,7 +10280,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.hero-stat-more i { transition: none; }
.hero-stat-more i { transition: none; }
}
}
-
/* ── Hero polish ─────────────────────────────────────────────────────────────
/* ── Hero polish ─────────────────────────────────────────────────────────────
The rotating tagline is a single unbreakable line, so on a narrow screen a long phrase ran off
The rotating tagline is a single unbreakable line, so on a narrow screen a long phrase ran off
both edges of the page. It wraps now, and the hero's tail padding is tighter so the live grid
both edges of the page. It wraps now, and the hero's tail padding is tighter so the live grid
@@ -10969,12 +10394,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -10969,12 +10394,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
The triangle is drawn with borders rather than set as an icon glyph — at 5px an icon font is
The triangle is drawn with borders rather than set as an icon glyph — at 5px an icon font is
a blurry smudge, and this needs no font to have loaded. */
a blurry smudge, and this needs no font to have loaded. */
-
/* Height is pinned only while the board is swapping between the strip and the full set, so the
/* Height is pinned only while the board is swapping between the strip and the full set, so the
container can still grow freely the rest of the time. min-height would fight an explicit
container can still grow freely the rest of the time. min-height would fight an explicit
height on the way down, so it is dropped for the duration of the animation. */
height on the way down, so it is dropped for the duration of the animation. */
-
/* ── Stat chip layout ────────────────────────────────────────────────────────
/* ── Stat chip layout ────────────────────────────────────────────────────────
At three chips per row on a phone each one gets about 128px, and "27,751" next to
At three chips per row on a phone each one gets about 128px, and "27,751" next to
"+168 7d | 147%" does not fit in 128px — the pill rode over the number and every label
"+168 7d | 147%" does not fit in 128px — the pill rode over the number and every label
@@ -11059,14 +10482,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11059,14 +10482,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
#openvibe-bell-mount { height: 32px; display: inline-flex; align-items: center; position: relative; }
#openvibe-bell-mount { height: 32px; display: inline-flex; align-items: center; position: relative; }
#openvibe-bell-mount > * { position: relative; }
#openvibe-bell-mount > * { position: relative; }
-
-
-
/* Belt and braces for the About section's cards: whatever happens with animations or deferred
/* Belt and braces for the About section's cards: whatever happens with animations or deferred
rendering, a card is never allowed to end up as an empty outline. */
rendering, a card is never allowed to end up as an empty outline. */
.home-cta-feature > i, .home-cta-feature > strong, .home-cta-feature > span { opacity: 1; }
.home-cta-feature > i, .home-cta-feature > strong, .home-cta-feature > span { opacity: 1; }
-
/* ── About section sub-headings ──────────────────────────────────────────────
/* ── About section sub-headings ──────────────────────────────────────────────
Three sub-sections inside one long panel, all introduced by the same small grey line of caps —
Three sub-sections inside one long panel, all introduced by the same small grey line of caps —
scrolling through it there was nothing to tell you that you had moved from "ways to go live"
scrolling through it there was nothing to tell you that you had moved from "ways to go live"
@@ -11093,7 +10512,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11093,7 +10512,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
hover and a real focus ring, because it is a control and needs to look like one.
hover and a real focus ring, because it is a control and needs to look like one.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
.ov-enter — cards arriving from a "Load more" press.
.ov-enter — cards arriving from a "Load more" press.
Transform and opacity only, staggered by --ov-enter-i (capped at 11 so a big page does not end
Transform and opacity only, staggered by --ov-enter-i (capped at 11 so a big page does not end
@@ -11105,20 +10523,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11105,20 +10523,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
to { opacity: 1; transform: none; }
to { opacity: 1; transform: none; }
}
}
-
/* The home page ends on the changelog card, and the shared footer already brings 40px of its own
/* The home page ends on the changelog card, and the shared footer already brings 40px of its own
margin above itself. Another 32px of page padding on top of that was a band of nothing between
margin above itself. Another 32px of page padding on top of that was a band of nothing between
the last readable line and the footer rule. Other pages end on content that needs the breathing
the last readable line and the footer rule. Other pages end on content that needs the breathing
room, so this is scoped to the home page rather than changed globally. */
room, so this is scoped to the home page rather than changed globally. */
#page-home.page { padding-bottom: 10px; }
#page-home.page { padding-bottom: 10px; }
-
/* Route changes (the bar, the loading line, the fade-in) are openvibe-shared web-runtime enter() + route-transition.js. */
/* Route changes (the bar, the loading line, the fade-in) are openvibe-shared web-runtime enter() + route-transition.js. */
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.brand-mark.is-spin svg, .brand-mark.is-settling svg { animation: none !important; }
.brand-mark.is-spin svg, .brand-mark.is-settling svg { animation: none !important; }
}
}
-
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
Off-screen sections animate nothing.
Off-screen sections animate nothing.
@@ -11141,8 +10556,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11141,8 +10556,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
animation-play-state: paused !important;
animation-play-state: paused !important;
}
}
-
-
@keyframes udIn { from { opacity: 0; transform: translateY(-8px) scale(0.95); } to { opacity: 1; transform: none; } }
@keyframes udIn { from { opacity: 0; transform: translateY(-8px) scale(0.95); } to { opacity: 1; transform: none; } }
.ud-wallet { grid-column: 2; display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; }
.ud-wallet { grid-column: 2; display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; }
@@ -11160,11 +10573,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11160,11 +10573,9 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
color: var(--text-muted);
color: var(--text-muted);
}
}
-
/* Rows arrive just behind the panel, capped so a long menu never feels slow. */
/* Rows arrive just behind the panel, capped so a long menu never feels slow. */
@keyframes udRow { from { opacity: 0; transform: translateX(6px); } to { opacity: 1; transform: none; } }
@keyframes udRow { from { opacity: 0; transform: translateX(6px); } to { opacity: 1; transform: none; } }
-
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
About section ("Streaming the way it should be") — layout pass.
About section ("Streaming the way it should be") — layout pass.
@@ -11185,8 +10596,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11185,8 +10596,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* A sheen that crosses a card once on hover — transform only, clipped to the card. */
/* A sheen that crosses a card once on hover — transform only, clipped to the card. */
@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
-
-
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
CONNECTION PILL — one quiet, site-wide signal while a live connection is recovering
CONNECTION PILL — one quiet, site-wide signal while a live connection is recovering
(app.js ovConnectionPill). Transform and opacity only.
(app.js ovConnectionPill). Transform and opacity only.
@@ -11215,7 +10624,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11215,7 +10624,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.ov-conn-pill .ov-conn-dot { animation: none !important; }
.ov-conn-pill .ov-conn-dot { animation: none !important; }
}
}
-
body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }
body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }
/* ── Live on the shared navbar ────────────────────────────────────────────────
/* ── Live on the shared navbar ────────────────────────────────────────────────
@@ -751,9 +751,6 @@
@@ -751,9 +751,6 @@
</section>
</section>
<!-- Documentation Page -->
<!-- Documentation Page -->
- <!-- ═══════════════════════════════════════════════════════════ -->
- <!-- ARENA — streamer vs streamer (public/js/arena.js) -->
- <!-- ═══════════════════════════════════════════════════════════ -->
<section id="page-recap" class="page">
<section id="page-recap" class="page">
<div class="content-container recap-container">
<div class="content-container recap-container">
<div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
<div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
@@ -1359,9 +1359,9 @@ async function loadHomeDigest() {
@@ -1359,9 +1359,9 @@ async function loadHomeDigest() {
st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',
st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat line${st.chat_lines === 1 ? '' : 's'}</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
- st.mic_moments ? `<span class="digest-stat"><i class="fa-solid fa-microphone-lines"></i> <b>${n(st.mic_moments)}</b> Arena mic moment${st.mic_moments === 1 ? '' : 's'}</span>` : '',
+ st.takes ? `<span class="digest-stat"><i class="fa-solid fa-newspaper"></i> <b>${n(st.takes)}</b> Rundown take${st.takes === 1 ? '' : 's'}</span>` : '',
].filter(Boolean).join('');
].filter(Boolean).join('');
- const hot = (d.hot || []).map(h => `<div class="digest-hot-line"><i class="fa-solid fa-microphone-lines"></i> “${esc(String(h.text || '').slice(0, 140))}” <b>— ${esc(h.display_name || h.username)}</b>${h.aimed_at ? ` <span class="muted">at ${esc(h.aimed_at)}</span>` : ''}</div>`).join('');
+ const hot = (d.hot || []).map(h => { const href = h.slug ? `/rundown/t/${h.slug}` : `/rundown/${h.username}`; const t = esc(String(h.text || '').slice(0, 140)); return `<a class="digest-hot-line" href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"><i class="fa-solid fa-quote-left"></i> ${h.quoted ? `“${t}”` : t} <b>— ${esc(h.display_name || h.username)}</b></a>`; }).join('');
const sinceLabel = d.since ? (away ? `since your last visit · ${esc(timeAgo(d.since))}` : `last ${Math.max(1, Math.round((Date.now() - Date.parse(d.since)) / 3600000))}h`) : '';
const sinceLabel = d.since ? (away ? `since your last visit · ${esc(timeAgo(d.since))}` : `last ${Math.max(1, Math.round((Date.now() - Date.parse(d.since)) / 3600000))}h`) : '';
box.innerHTML = `
box.innerHTML = `
<div class="digest-head">
<div class="digest-head">
deleted file mode 100644
deleted file mode 100644
@@ -1,233 +0,0 @@
@@ -1,233 +0,0 @@
-/**
- * OpenVibe.Live — Arena v4: ARENA ALERT, the drama desk (server/arena/drama.js)
- *
- * Routes (from loadArenaPage in arena.js, which also owns the shared helpers _aEsc, _aA, _aAgo…):
- * /arena the desk: masthead + ticker, the top story, BREAKING, Lolcow Index, Clout Rankings,
- * Beef Watch, Hot Mic, the cams on air
- * /arena/drama/<id> one story
- * /arena/<username> the rap sheet on top of the fighter page
- *
- * Every story comes from something measured on the site (a stream's length, its viewer and chat snapshots, sounds and
- * speech on its timeline, a beef's verdict); headlines may be AI-written from those facts. All text goes through _aEsc.
- */
-'use strict';
-
-const AA_KIND = {
- rage_quit: ['RAGE QUIT', '🚪'], viewer_crash: ['VIEWER CRASH', '📉'], viewer_spike: ['BLOWING UP', '📈'], chat_explosion: ['CHAT GOES FERAL', '💬'],
- meltdown: ['MELTDOWN', '😤'], tears: ['TEARS', '😭'], marathon: ['MARATHON', '⏰'], ghost_town: ['GHOST TOWN', '👻'], the_return: ['THE RETURN', '👀'],
- title_drama: ['TITLE DRAMA', '🍿'], peak_record: ['NEW PEAK', '🏆'], fell_off: ['FELL OFF?', '🕳️'], streak: ['NO DAYS OFF', '🔥'],
- quote_of_day: ['QUOTE OF THE DAY', '🎤'], beef_open: ['BEEF', '🥩'], beef_win: ['COOKED', '🍳'], beef_forfeit: ['DUCKED', '🐔'],
-};
-const AA_REACT = [['fire', '🔥'], ['skull', '💀'], ['clown', '🤡'], ['w', 'W'], ['l', 'L']];
-let _aaTickerTop = 0;
-
-function _aaUser(u) { return u ? _aA(_aFighterLink(u), _aEsc(u.display_name || u.username), 'aa-name') : ''; }
-function _aaAvatar(u, size = 40) {
- if (!u) return '';
- const inner = u.avatar_url ? `<img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_aEsc(_aInitial(u))}</span>`;
- return _aA(_aFighterLink(u), inner, 'aa-av', u.display_name || u.username);
-}
-function _aaSirens(n) { return `<span class="aa-sirens" aria-label="alert level ${n} of 5">${'🚨'.repeat(Math.max(1, Math.min(5, n)))}</span>`; }
-function _aaWatch(s) {
- if (s.vod_id) return _aA(videoHref('vod', s.vod_id, Math.max(0, Math.floor(s.sec || 0))), '<i class="fa-solid fa-play"></i> Watch it happen', 'aa-watch');
- if (s.user && s.kind !== 'fell_off') return _aA(_aChannelLink(s.user), '<i class="fa-solid fa-tower-broadcast"></i> Their channel', 'aa-watch');
- return '';
-}
-function _aaReactions(s) {
- return `<div class="aa-react" data-story="${s.id}" role="group" aria-label="React">${AA_REACT.map(([k, label]) => `<button type="button" class="aa-react-btn${s.my_reaction === k ? ' is-mine' : ''}" data-react="${k}" aria-pressed="${s.my_reaction === k}"><span class="aa-react-i">${label}</span><span class="aa-react-n">${_aNum(s.reactions?.[k] || 0)}</span></button>`).join('')}</div>`;
-}
-function _aaStory(s, { big = false } = {}) {
- const [badge, icon] = AA_KIND[s.kind] || [String(s.kind || '').toUpperCase(), '🚨'];
- return `<article class="aa-story aa-alert-${s.alert} aa-kind-${_aEsc(s.kind)}${big ? ' aa-story-big' : ''}" id="aa-story-${s.id}">
- <div class="aa-story-head"><span class="aa-badge">${icon} ${_aEsc(badge)}</span>${_aaSirens(s.alert)}<time class="aa-time" datetime="${_aEsc(s.happened_at)}">${_aEsc(_aAgo(s.happened_at))}</time></div>
- <div class="aa-story-body">
- <div class="aa-who">${_aaAvatar(s.user, big ? 72 : 44)}${s.other ? `<span class="aa-vs">VS</span>${_aaAvatar(s.other, big ? 72 : 44)}` : ''}</div>
- <div class="aa-story-text">
- <h3 class="aa-headline">${_aA(`/arena/drama/${s.id}`, _aEsc(s.headline))}</h3>
- ${s.blurb ? `<p class="aa-blurb">${_aEsc(s.blurb)}</p>` : ''}
- ${s.quote ? `<blockquote class="aa-quote"><span>${_aEsc(s.quote)}</span> ${_aSpeakBtn(s.quote, 'arena-speak', s.user?.username)}</blockquote>` : ''}
- <p class="aa-byline">${_aaUser(s.user)}${s.other ? ` · ${_aaUser(s.other)}` : ''}</p>
- </div>
- </div>
- <div class="aa-story-foot">${_aaReactions(s)}${_aaWatch(s)}${s.ai ? '<span class="aa-ai" title="Headline written by AI from the measured facts">AI desk</span>' : ''}</div>
- </article>`;
-}
-function _aaTicker(stories) {
- if (!stories.length) return '';
- const items = stories.slice(0, 12).map((s) => `<span class="aa-tick">${_aEsc((AA_KIND[s.kind] || ['', '🚨'])[1])} ${_aEsc(s.headline)}</span>`).join('');
- return `<div class="aa-ticker" aria-label="Latest headlines"><span class="aa-ticker-label">BREAKING</span><div class="aa-ticker-viewport"><div class="aa-ticker-track">${items}${items}</div></div></div>`;
-}
-function _aaCow(index) {
- if (!index.length) return `<p class="aa-quiet">Nobody has been cooked this week. Yet.</p>`;
- return `<ol class="aa-board">${index.slice(0, 10).map((c, i) => `<li class="aa-row">
- <span class="aa-rank${i === 0 ? ' is-crown' : ''}">${i === 0 ? '🐄' : i + 1}</span>${_aaAvatar(c.user, 32)}
- <span class="aa-row-main">${_aaUser(c.user)}<span class="aa-meter" style="--m:${c.meter}%"><span></span></span><span class="aa-row-sub">${Object.entries(c.kinds).map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())}${n > 1 ? ` ×${n}` : ''}`).join(' · ')}</span></span>
- <span class="aa-score" title="Cow points this week">${c.meter}</span></li>`).join('')}</ol>`;
-}
-function _aaMove(c) {
- if (c.move == null) return '<span class="aa-move is-new">NEW</span>';
- if (c.move > 0) return `<span class="aa-move is-up">▲${c.move}</span>`;
- if (c.move < 0) return `<span class="aa-move is-down">▼${-c.move}</span>`;
- return '<span class="aa-move">–</span>';
-}
-function _aaClout(rankings) {
- if (!rankings.length) return `<p class="aa-quiet">Nobody streamed this week.</p>`;
- return `<ol class="aa-board">${rankings.slice(0, 12).map((c) => `<li class="aa-row">
- <span class="aa-rank">${c.rank}</span>${_aaAvatar(c.user, 32)}
- <span class="aa-row-main">${_aaUser(c.user)} ${_aaMove(c)}<span class="aa-row-sub">${c.facts.hours}h live · peak ${c.facts.peak} · ${_aNum(c.facts.messages)} chat${c.facts.beef_wins ? ` · ${c.facts.beef_wins} beef W` : ''}</span>${c.tags.length ? `<span class="aa-tags">${c.tags.map((t) => `<span class="aa-tag aa-tag-${_aEsc(t.toLowerCase().replace(/\s+/g, '-'))}">${_aEsc(t)}</span>`).join('')}</span>` : ''}</span>
- <span class="aa-score" title="Clout this week">${_aNum(c.clout)}</span></li>`).join('')}</ol>`;
-}
-function _aaHotMic(feed) {
- if (!feed.length) return `<p class="aa-quiet">The ears have not caught anything spicy yet.</p>`;
- return `<ul class="aa-mic">${feed.slice(0, 8).map((m) => `<li><q>${_aEsc(m.text)}</q> <span class="aa-row-sub">${m.user ? _aaUser(m.user) : ''}${m.aimed_at ? ` → ${_aEsc(m.aimed_at)}` : ''} · ${_aQ(m.quality)} ${_aPlay(m.vod_id, m.sec)}</span></li>`).join('')}</ul>`;
-}
-function _aaBeefs(beefs) {
- const open = beefs.open || [], done = (beefs.resolved || []).slice(0, 3);
- const line = (b) => `<li>${_aA(_aBeefLink(b), `<b>${_aEsc(b.a.user.display_name || b.a.user.username)}</b> <span class="aa-vs">VS</span> <b>${_aEsc(b.b.user.display_name || b.b.user.username)}</b>`)} ${b.status === 'open' ? _aClockTag(b) : `<span class="aa-row-sub">${b.winner_user_id ? `${_aEsc(((b.winner_user_id === b.a.user.id ? b.a : b.b).user.display_name) || (b.winner_user_id === b.a.user.id ? b.a : b.b).user.username)} won` : 'draw'}</span>`}</li>`;
- return `${open.length ? `<ul class="aa-beefs">${open.map(line).join('')}</ul>` : '<p class="aa-quiet">No open beef. One name said on mic changes that.</p>'}${done.length ? `<p class="aa-row-sub aa-settled">Settled</p><ul class="aa-beefs">${done.map(line).join('')}</ul>` : ''}`;
-}
-function _aaBindReactions(root) {
- root.querySelectorAll('.aa-react').forEach((group) => {
- if (group.dataset.bound) return;
- group.dataset.bound = '1';
- group.addEventListener('click', async (e) => {
- const btn = e.target.closest('.aa-react-btn');
- if (!btn) return;
- const id = group.dataset.story;
- try {
- const r = await api(`/arena/drama/${id}/react`, { method: 'POST', body: { reaction: btn.dataset.react } });
- group.querySelectorAll('.aa-react-btn').forEach((b) => {
- const mine = b === btn;
- b.classList.toggle('is-mine', mine);
- b.setAttribute('aria-pressed', String(mine));
- const n = b.querySelector('.aa-react-n');
- if (n) n.textContent = _aNum(r.reactions?.[b.dataset.react] || 0);
- });
- btn.classList.add('is-pop');
- setTimeout(() => btn.classList.remove('is-pop'), 400);
- } catch (err) { _aToast(err?.message || 'Could not react', 'error'); }
- });
- });
- _aBindSpeak(root);
-}
-
-async function _aRenderAlertHome(root) {
- root.innerHTML = _aSpinner('Checking the timeline for drama…');
- const safe = (p, d) => p.catch(() => d);
- const [drama, cow, clout, beefs, live, feed] = await Promise.all([
- api('/arena/drama?limit=30'), safe(api('/arena/lolcow'), { index: [] }), safe(api('/arena/clout'), { rankings: [] }),
- safe(api('/arena/beefs'), { open: [], recent: [] }), safe(api('/arena/live'), { live: [] }), safe(api('/arena/feed?limit=12'), { feed: [] }),
- ]);
- const stories = drama.stories || [];
- const dayAgo = Date.now() - 86400000;
- const top = [...stories].filter((s) => new Date(String(s.happened_at).replace(' ', 'T') + 'Z').getTime() >= dayAgo).sort((a, b) => b.alert - a.alert || b.id - a.id)[0] || stories[0] || null;
- _aaTickerTop = stories[0]?.id || 0;
- const rest = stories.filter((s) => !top || s.id !== top.id);
- root.innerHTML = `<div class="aa">
- <header class="aa-mast">
- <div class="aa-mast-row">
- <h1 class="aa-title"><span class="aa-siren" aria-hidden="true">🚨</span> ARENA <span>ALERT</span></h1>
- <span class="aa-onair"><span class="arena-live-dot arena-live-dot-sm"></span> ${(live.live || []).length ? `${live.live.length} ON AIR` : 'WATCHING'}</span>
- </div>
- <p class="aa-sub">Rage quits, meltdowns, viewer crashes, beefs and L's from every stream on OpenVibe. Measured on the site, timestamped, then roasted.</p>
- ${_aaTicker(stories)}
- </header>
- ${(live.live || []).length ? `<section class="arena-live arena-cams aa-cams" id="arena-live">${_aRenderCams(live.live)}</section>` : ''}
- ${top ? `<section class="aa-top" aria-label="Top story">${_aaStory(top, { big: true })}</section>` : ''}
- <div class="aa-grid">
- <section class="aa-feed" aria-labelledby="aa-feed-h">
- <h2 id="aa-feed-h" class="aa-h">BREAKING</h2>
- <div id="aa-feed-list">${rest.length ? rest.map((s) => _aaStory(s)).join('') : (top ? '' : `<div class="aa-quiet aa-quiet-big"><b>The desk is listening.</b> The next rage quit, viewer crash or meltdown on any stream lands here within five minutes.</div>`)}</div>
- ${stories.length >= 30 ? `<button type="button" class="btn aa-more" id="aa-more" data-before="${stories[stories.length - 1].id}">Older drama</button>` : ''}
- </section>
- <aside class="aa-side">
- <section class="aa-box aa-box-cow" aria-labelledby="aa-cow-h"><h2 id="aa-cow-h" class="aa-h">🐄 Lolcow Index <small>this week</small></h2>${_aaCow(cow.index || [])}</section>
- <section class="aa-box aa-box-clout" aria-labelledby="aa-clout-h"><h2 id="aa-clout-h" class="aa-h">📈 Clout Rankings <small>last 7 days</small></h2>${_aaClout(clout.rankings || [])}</section>
- <section class="aa-box" aria-labelledby="aa-beef-h"><h2 id="aa-beef-h" class="aa-h">🥩 Beef Watch</h2>${_aaBeefs(beefs)}</section>
- <section class="aa-box" aria-labelledby="aa-mic-h"><h2 id="aa-mic-h" class="aa-h">🎤 Hot Mic</h2>${_aaHotMic(feed.feed || [])}</section>
- </aside>
- </div>
- <section class="aa-how">
- <h2 class="aa-h">How the desk works</h2>
- <ul>
- <li><b>Measured, not made up.</b> Every five minutes the desk reads what happened on every stream: how long it lasted, its viewer and chat counts minute by minute, what the mic heard (speech and sounds like yelling or crying), titles, records and beefs. Each story states those numbers.</li>
- <li><b>Lolcow Index.</b> Rage quits, viewer crashes, meltdowns, tears, ghost-town streams, dramatic titles, falling off and losing beefs add cow points (newer counts more). The top cow wears the crown.</li>
- <li><b>Clout Rankings.</b> Hours live, viewers, chat, new followers, records, comebacks and beef wins over the last 7 days, with this week's moves. COOKED means your cow meter is 70+.</li>
- <li><b>Beefs.</b> Call another streamer out on mic and the beef opens itself; they are on the clock. Silence is a forfeit, and a forfeit is a story.</li>
- <li><b>React.</b> 🔥 💀 🤡 W or L, one per story. Streamers who would rather not be covered can ${_aMe() ? `<button type="button" class="aa-link" id="aa-optout">keep themselves out of the Arena</button>` : 'sign in and keep themselves out of the Arena'}.</li>
- </ul>
- </section>
- </div>`;
- _aaBindReactions(root);
- _aBindHome(root);
- document.getElementById('aa-more')?.addEventListener('click', async (e) => {
- const btn = e.currentTarget;
- btn.disabled = true;
- try {
- const older = await api(`/arena/drama?limit=30&before=${encodeURIComponent(btn.dataset.before)}`);
- const list = document.getElementById('aa-feed-list');
- list.insertAdjacentHTML('beforeend', (older.stories || []).map((s) => _aaStory(s)).join(''));
- _aaBindReactions(root);
- if ((older.stories || []).length < 30) btn.remove(); else { btn.dataset.before = older.stories[older.stories.length - 1].id; btn.disabled = false; }
- } catch { btn.disabled = false; }
- });
- document.getElementById('aa-optout')?.addEventListener('click', _aaOptOutDialog);
- _aEvery(30000, async () => {
- try {
- const d = await api('/arena/drama?limit=10');
- const fresh = (d.stories || []).filter((s) => s.id > _aaTickerTop);
- if (!fresh.length) return;
- _aaTickerTop = d.stories[0].id;
- const list = document.getElementById('aa-feed-list');
- if (list) { list.insertAdjacentHTML('afterbegin', fresh.map((s) => _aaStory(s)).join('')); _aaBindReactions(root); _aFlash(list, `${fresh.length} new stor${fresh.length === 1 ? 'y' : 'ies'}`); }
- } catch { /* keep the last render */ }
- });
- _aEvery(1000, () => _aTickClocks(root));
-}
-
-async function _aaOptOutDialog() {
- try {
- const cur = await api('/arena/opt-out');
- const out = !cur.opted_out;
- const ok = confirm(out ? 'Keep yourself out of the Arena? No new stories will be written about you and your old ones come down. You can come back any time.' : 'Come back into the Arena? New stories about your streams will be written again.');
- if (!ok) return;
- await api('/arena/opt-out', { method: 'PUT', body: { out } });
- _aToast(out ? 'You are out of the Arena.' : 'Welcome back to the Arena.', 'success');
- loadArenaPage(['arena']);
- } catch (err) { _aToast(err?.message || 'Could not change that', 'error'); }
-}
-
-async function _aRenderStory(root, id) {
- root.innerHTML = _aSpinner('Pulling the receipts…');
- const s = await api(`/arena/drama/${encodeURIComponent(id)}`);
- const more = s.user ? await api(`/arena/drama?limit=6&user=${encodeURIComponent(s.user.username)}`).catch(() => ({ stories: [] })) : { stories: [] };
- const facts = Object.entries(s.facts || {}).filter(([k, v]) => v != null && typeof v !== 'object' && k !== 'other');
- root.innerHTML = `<div class="aa">
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> ARENA ALERT')}</div>
- ${_aaStory(s, { big: true })}
- ${facts.length ? `<section class="aa-box aa-facts"><h2 class="aa-h">The receipts</h2><dl>${facts.map(([k, v]) => `<div><dt>${_aEsc(k.replace(/_/g, ' '))}</dt><dd>${_aEsc(String(v))}</dd></div>`).join('')}</dl></section>` : ''}
- ${(more.stories || []).filter((x) => x.id !== s.id).length ? `<section class="aa-feed"><h2 class="aa-h">More on ${_aEsc(s.user.display_name || s.user.username)}</h2>${more.stories.filter((x) => x.id !== s.id).map((x) => _aaStory(x)).join('')}</section>` : ''}
- </div>`;
- _aaBindReactions(root);
-}
-
-/** The rap sheet: shown above the fighter page at /arena/<username>. */
-async function _aRenderRapSheet(el, username) {
- if (!el) return;
- let r;
- try { r = await api(`/arena/rap/${encodeURIComponent(username)}`); } catch { el.innerHTML = ''; return; }
- if (r.opted_out) { el.innerHTML = `<div class="aa-box aa-quiet">${_aEsc(r.user.display_name || r.user.username)} keeps themselves out of the Arena.</div>`; return; }
- const counts = Object.entries(r.counts || {}).sort((a, b) => b[1] - a[1]);
- el.innerHTML = `<section class="aa-rap aa-box">
- <h2 class="aa-h">📁 Rap sheet</h2>
- <div class="aa-rap-stats">
- <div><b>${r.cow ? r.cow.meter : 0}</b><span>cow meter</span></div>
- <div><b>${r.clout ? `#${r.clout.rank}` : '–'}</b><span>clout rank</span></div>
- <div><b>${r.record.w}–${r.record.l}</b><span>beefs W–L</span></div>
- <div><b>${r.stories.length}</b><span>stories</span></div>
- </div>
- ${counts.length ? `<p class="aa-row-sub">${counts.map(([k, n]) => `${_aEsc((AA_KIND[k] || [k])[0].toLowerCase())} ×${n}`).join(' · ')}</p>` : '<p class="aa-quiet">Clean record. For now.</p>'}
- ${r.stories.slice(0, 5).map((s) => _aaStory(s)).join('')}
- </section>`;
- _aaBindReactions(el);
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,564 +0,0 @@
@@ -1,564 +0,0 @@
-/**
- * OpenVibe.Live — Arena tab. The home is ARENA ALERT, the drama desk (public/js/arena-alert.js, Arena v4); this file
- * keeps the shared helpers, beefs, the ears and the fighter pages.
- *
- * Routes (handled from app.js):
- * /arena ARENA ALERT (arena-alert.js)
- * /arena/drama/<id> one story (arena-alert.js)
- * /arena/beef/<id> one beef: tug-of-war, clock, ringside feed, receipts
- * /arena/live/<username> the ears: what the listener hears from a live fighter (auto-refresh)
- * /arena/<username> fighter profile (mic stats, voice + quotes, level, receipts, beefs, rivalries)
- *
- * Nothing here starts a fight and nothing in chat counts. Everything on this page is what
- * fighters SAID ON MIC (server/arena/listener.js → mic.js / beef.js). All text is transcribed or
- * AI-written and rendered through _aEsc — never trusted as HTML.
- */
-'use strict';
-
-let ARENA_STATS = ['heat', 'aim', 'kills', 'mouth', 'clapback', 'stamina', 'pace'];
-let ARENA_STAT_LABEL = { heat: 'Heat', aim: 'Aim', kills: 'Kills', mouth: 'Mouth', clapback: 'Clapback', stamina: 'Stamina', pace: 'Pace' };
-let _arenaRoster = null;
-let _arenaTimers = [];
-let _arenaImagePoll = null;
-let _arenaFeedTop = 0;
-
-function _aEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
-function _aNum(n) { n = Number(n) || 0; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(Number.isInteger(n) ? n : n.toFixed(1)); }
-function _aInitial(u) { return (u.display_name || u.username || '?').trim().charAt(0).toUpperCase(); }
-function _aStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:` : '') + `${h ? String(m).padStart(2, '0') : m}:${String(s).padStart(2, '0')}`; }
-function _aDate(d) { try { return new Date(String(d).replace(' ', 'T') + (String(d).endsWith('Z') ? '' : 'Z')).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return String(d || ''); } }
-function _aAgo(d) {
- if (!d) return '';
- const ms = Date.now() - new Date(String(d).includes('T') ? d : String(d).replace(' ', 'T') + 'Z').getTime();
- const m = Math.round(ms / 60000);
- if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 48) return `${h}h ago`; return `${Math.round(h / 24)}d ago`;
-}
-function _aClock(sec) { sec = Math.max(0, Math.floor(sec || 0)); if (sec >= 3600) return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }
-function _aToast(msg, type = 'info') { if (typeof toast === 'function') toast(msg, type); else console.log('[Arena]', msg); }
-function _aMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }
-function _aSpinner(text) { return `<div class="arena-loading"><i class="fa-solid fa-circle-notch fa-spin"></i><span>${_aEsc(text)}</span></div>`; }
-
-function _aStopTimers() {
- for (const t of _arenaTimers) { clearInterval(t); clearTimeout(t); }
- _arenaTimers = [];
- if (_arenaImagePoll) { clearInterval(_arenaImagePoll); _arenaImagePoll = null; }
- _aStopSpeaking();
-}
-function _aEvery(ms, fn) { const t = setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage !== 'arena') return _aStopTimers(); fn(); }, ms); _arenaTimers.push(t); return t; }
-
-// ── Speech: hear a line in that person's OpenVibe chat voice ──
-let _arenaAudio = null;
-function _aStopSpeaking() {
- try { if (_arenaAudio) { _arenaAudio.pause(); _arenaAudio.src = ''; } } catch { /* */ }
- _arenaAudio = null;
- try { if (window.speechSynthesis) speechSynthesis.cancel(); } catch { /* */ }
- document.querySelectorAll('.is-speaking').forEach(el => el.classList.remove('is-speaking'));
-}
-function _aVoiceUrl(who, text) { return `/api/arena/voice/${encodeURIComponent(who || 'announcer')}?t=${encodeURIComponent(String(text).slice(0, 240))}`; }
-function _aSpeak(text, btn, who) {
- if (!text) return;
- if (btn && btn.classList.contains('is-speaking')) { _aStopSpeaking(); return; }
- _aStopSpeaking();
- if (btn) btn.classList.add('is-speaking');
- const done = () => { if (btn) btn.classList.remove('is-speaking'); if (_arenaAudio === a) _arenaAudio = null; };
- const a = new Audio(_aVoiceUrl(who, text));
- _arenaAudio = a;
- a.onended = done;
- a.onerror = () => {
- if (_arenaAudio !== a) return;
- _arenaAudio = null;
- try {
- if (!window.speechSynthesis) return done();
- const u = new SpeechSynthesisUtterance(text); u.rate = 1.05; u.pitch = 0.9;
- u.onend = u.onerror = done;
- speechSynthesis.speak(u);
- } catch { done(); }
- };
- a.play().catch(() => a.onerror && a.onerror());
-}
-function _aSpeakBtn(text, cls = 'arena-speak', who = null) { return `<button type="button" class="${cls}" data-speak="${_aEsc(text)}" data-voice="${_aEsc(who || '')}" title="${who ? `Hear it in ${_aEsc(who)}'s chat voice` : 'Hear the announcer'}"><i class="fa-solid fa-volume-high"></i></button>`; }
-function _aBindSpeak(root) {
- root.querySelectorAll('[data-speak]').forEach(btn => btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); _aSpeak(btn.dataset.speak, btn, btn.dataset.voice || null); }));
-}
-
-/** Portrait: AI image when present, otherwise the avatar (or an initial) styled as a card. */
-function _aPortrait(f, size = 'md') {
- const u = f.user || f;
- const color = u.profile_color || '#8b5cf6';
- if (u.avatar_url) return `<div class="arena-portrait arena-portrait-${size} arena-portrait-avatar" style="--fc:${_aEsc(color)}"><img src="${_aEsc(u.avatar_url)}" alt="" loading="lazy"><span class="arena-portrait-glow"></span></div>`;
- return `<div class="arena-portrait arena-portrait-${size} arena-portrait-initial" style="--fc:${_aEsc(color)}"><span>${_aEsc(_aInitial(u))}</span></div>`;
-}
-
-/** A fighter's radar: the seven mic stats, or their AI characteristics when the persona has them. */
-function _aCustomRadar(f, color, size = 240, { objective = false } = {}) {
- const axes = ARENA_STATS.map(k => ({ name: ARENA_STAT_LABEL[k], value: Math.max(0, Math.min(99, Number(f.ratings?.[k]) || 0)), quip: _arenaRoster?.stat_meta?.[k]?.desc || '' }));
- const N = axes.length, c = size / 2, R = size / 2 - 62;
- const ang = (i) => -Math.PI / 2 + (i * 2 * Math.PI) / N;
- const pt = (i, v) => [c + (R * v / 99) * Math.cos(ang(i)), c + (R * v / 99) * Math.sin(ang(i))];
- const poly = (v) => axes.map((_, i) => pt(i, v).map(n => n.toFixed(1)).join(',')).join(' ');
- const shape = axes.map((a, i) => pt(i, a.value).map(n => n.toFixed(1)).join(',')).join(' ');
- const id = `rg${Math.floor(Math.random() * 1e6)}`;
- return `<svg class="arena-radar arena-radar-v2" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="stats radar">
- <defs><radialGradient id="${id}" cx="50%" cy="50%" r="60%"><stop offset="0%" stop-color="${_aEsc(color)}" stop-opacity="0.55"/><stop offset="100%" stop-color="${_aEsc(color)}" stop-opacity="0.12"/></radialGradient></defs>
- ${[33, 66, 99].map(v => `<polygon points="${poly(v)}" class="arena-radar-ring"></polygon>`).join('')}
- ${axes.map((_, i) => { const [x, y] = pt(i, 99); return `<line x1="${c}" y1="${c}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" class="arena-radar-axis"></line>`; }).join('')}
- <polygon points="${shape}" class="arena-radar-fill" style="fill:url(#${id});stroke:${_aEsc(color)}"></polygon>
- ${axes.map((a, i) => { const [x, y] = pt(i, a.value); return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3.5" class="arena-radar-dot" style="fill:${_aEsc(color)}"><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></circle>`; }).join('')}
- ${axes.map((a, i) => { const cos = Math.cos(ang(i)), sin = Math.sin(ang(i)); const lx = c + (R + 22) * cos, ly = c + (R + 22) * sin; const anchor = Math.abs(cos) < 0.25 ? 'middle' : (cos > 0 ? 'start' : 'end'); const name = a.name.length > 13 ? a.name.slice(0, 12) + '…' : a.name; return `<g class="arena-radar-label"><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? -6 : sin > 0.3 ? 2 : -4)).toFixed(1)}" text-anchor="${anchor}">${_aEsc(name)}</text><text x="${lx.toFixed(1)}" y="${(ly + (sin < -0.3 ? 7 : sin > 0.3 ? 15 : 9)).toFixed(1)}" text-anchor="${anchor}" class="arena-radar-val" style="fill:${_aEsc(color)}">${a.value}</text><title>${_aEsc(a.name)} ${a.value}${a.quip ? ` — ${_aEsc(a.quip)}` : ''}</title></g>`; }).join('')}
- </svg>`;
-}
-
-/** Sparkline of a per-stream series. */
-function _aSpark(series, color) {
- const w = 320, h = 70, pad = 6;
- const vals = series.map(p => Number(p.value) || 0);
- if (!vals.length) return '<p class="arena-voice-empty">No stream history in the window.</p>';
- const max = Math.max(...vals, 1), min = 0;
- const x = (i) => vals.length === 1 ? w / 2 : pad + (i * (w - pad * 2)) / (vals.length - 1);
- const y = (v) => h - pad - ((v - min) / (max - min || 1)) * (h - pad * 2);
- const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
- const area = `${x(0).toFixed(1)},${h - pad} ${pts} ${x(vals.length - 1).toFixed(1)},${h - pad}`;
- return `<svg class="arena-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="--fc:${_aEsc(color)}">
- <polygon class="arena-spark-area" points="${area}"></polygon>
- <polyline points="${pts}"></polyline>
- ${vals.map((v, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="2.5"><title>${_aEsc(series[i].title || '')} · ${_aEsc(_aDate(series[i].date))}: ${_aEsc(_aNum(v))}</title></circle>`).join('')}
- </svg>
- <div class="arena-spark-caption"><span>${_aEsc(_aDate(series[0].date))}</span><span>peak ${_aEsc(_aNum(max))}</span><span>${_aEsc(_aDate(series[series.length - 1].date))}</span></div>`;
-}
-
-function _aChannelLink(u, slot = null) { return typeof channelPath === 'function' ? channelPath(u.username, slot) : `/@${encodeURIComponent(u.username)}`; }
-function _aFighterLink(u) { return `/arena/${encodeURIComponent(u.username)}`; }
-function _aConsoleLink(u) { return `/arena/live/${encodeURIComponent(u.username)}`; }
-function _aBeefLink(b) { return `/arena/beef/${b.id}`; }
-function _aVodLink(vodId, sec) { return vodId ? videoHref('vod', vodId, Math.max(0, Math.floor(sec || 0))) : null; }
-// A link inside Live navigates the SPA; one that leaves (an OpenVibe.Video moment) is a plain link.
-function _aA(href, inner, cls = '', title = '') { const out = /^https?:\/\//.test(href); return `<a class="${cls}" href="${_aEsc(href)}" ${title ? `title="${_aEsc(title)}"` : ''}${out ? '' : ` onclick="return handleLinkClick(event, '${_aEsc(href)}')"`}>${inner}</a>`; }
-function _aPlay(vodId, sec, label = '') { const h = _aVodLink(vodId, sec); return h ? _aA(h, `<i class="fa-solid fa-play"></i>${label ? ` ${_aEsc(label)}` : ''}`, 'arena-play', 'Hear them say it (jumps to the VOD)') : ''; }
-function _aLevelPill(level) { return `<span class="arena-lvl" title="Trash Level — XP from what you say on mic, nothing else">TL ${_aEsc(level ?? 1)}</span>`; }
-function _aBriefChip(f, extra = '') {
- return _aA(_aFighterLink(f.user), `${_aPortrait(f, 'xs')}<span><strong>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>${f.rank ? `#${f.rank} · ` : ''}${_aLevelPill(f.level)}${extra}</small></span>`, 'arena-chip');
-}
-function _aQ(q) { return `<span class="arena-q ${q >= 8 ? 'is-fire' : q >= 6 ? 'is-hot' : ''}" title="judge score">${q}/10</span>`; }
-function _aAim(m) {
- if (m.target) return _aA(_aFighterLink(m.target.user), `<i class="fa-solid fa-crosshairs"></i> ${_aEsc(m.target.fighter_name)}`, 'arena-aim is-fighter', 'called out on mic — this fed a beef');
- if (m.aimed_at && m.aimed_at !== 'nobody') return `<span class="arena-aim"><i class="fa-solid fa-bullseye"></i> at ${_aEsc(m.aimed_at)}</span>`;
- return '';
-}
-
-// ── Page entry ───────────────────────────────────────────────
-
-async function loadArenaPage(segments = []) {
- _aStopTimers();
- const root = document.getElementById('arena-root');
- if (!root) return;
- const [, first, second] = segments;
- try {
- if (first === 'beef' && second) return await _aRenderBeef(root, Number(second));
- if (first === 'live' && second) return await _aRenderConsole(root, second);
- if (first === 'drama' && second) return await _aRenderStory(root, second);
- if (first === 'topic' || first === 'chatter') return _aGone(root);
- if (first) {
- root.innerHTML = '<div id="aa-rap-slot"></div><div id="arena-fighter-slot"></div>';
- await Promise.all([_aRenderRapSheet(document.getElementById('aa-rap-slot'), first), _aRenderFighter(document.getElementById('arena-fighter-slot'), first)]);
- return;
- }
- return await _aRenderAlertHome(root);
- } catch (err) {
- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-plug-circle-xmark"></i><p>${_aEsc(err?.message || 'The arena lights went out.')}</p></div>`;
- }
-}
-function _aGone(root) {
- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-lines"></i><p>That part of the Arena is gone. The drama is on ARENA ALERT now.</p>${_aA('/arena', 'Back to ARENA ALERT', 'btn btn-primary')}</div>`;
-}
-
-function _aRenderCams(live) {
- if (!live.length) return `<h2><span class="arena-live-dot"></span> Live cams <small>nobody from the roster is live</small></h2><p class="arena-note">When a fighter goes live with transcription on, the ears pick them up here — name-drops open beefs, free shit talk hits the feed.</p>`;
- return `<h2><span class="arena-live-dot"></span> Live cams <small>${live.length} on · the ears are listening</small></h2>
- <div class="arena-cam-grid">${live.map(f => {
- const lock = f.ears && f.ears.focus;
- return `<div class="arena-cam ${f.transcribed ? '' : 'is-quiet'} ${lock ? 'is-locked' : ''}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
- ${_aA(_aChannelLink(f.user, f.stream.slug || f.stream.managed_stream_id), `${typeof f.thumbnail_url === 'string' && f.thumbnail_url ? `<img class="arena-live-thumb" src="${_aEsc(f.thumbnail_url)}" alt="" onerror="this.hidden=true">` : ''}${_aPortrait(f, 'md')}<span class="arena-cam-rec"><span class="arena-live-dot arena-live-dot-sm"></span> LIVE · <i class="fa-solid fa-eye"></i> ${_aNum(f.stream.viewer_count)}</span>`, 'arena-cam-thumb')}
- <div class="arena-cam-body">
- <div class="arena-mic-head">
- ${_aA(_aFighterLink(f.user), `<strong>${_aEsc(f.user.display_name || f.user.username)}</strong>`)}
- <span class="arena-mic-meta">#${f.rank} · PWR ${f.ratings.power} · ${_aLevelPill(f.level)} · ${f.record.wins}W–${f.record.losses}L</span>
- </div>
- ${lock ? `<div class="arena-cam-lock"><i class="fa-solid fa-crosshairs fa-beat"></i> locked on <b>${_aEsc(lock.target || 'a fighter')}</b> · ${lock.hits} hit${lock.hits === 1 ? '' : 's'}</div>` : ''}
- ${f.last_moment ? `<div class="arena-cam-last"><q>${_aEsc(f.last_moment.text)}</q><small>${_aQ(f.last_moment.quality)} ${_aAim(f.last_moment)} · ${_aEsc(_aAgo(f.last_moment.at))} ${_aPlay(f.last_moment.vod_id, f.last_moment.sec)}</small></div>` : (f.hot_mic ? `<q class="arena-mic-line">${_aEsc(f.hot_mic.text)}</q>` : `<span class="arena-mic-line arena-mic-line-empty">${f.transcribed ? 'listening…' : 'no transcript yet — the cam needs audio transcription on'}</span>`)}
- <div class="arena-mic-tags">
- ${f.open_beefs ? `<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> ${f.open_beefs} beef${f.open_beefs > 1 ? 's' : ''} open</span>` : '<span class="arena-tag arena-tag-dim">no beef open</span>'}
- ${f.ears ? `<span class="arena-tag arena-tag-dim" title="words buffered for the judge"><i class="fa-solid fa-ear-listen"></i> ${f.ears.pending_words} words pending</span>` : ''}
- ${_aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> ears', 'arena-tag')}
- </div>
- </div>
- </div>`; }).join('')}</div>`;
-}
-
-// ── The feed ─────────────────────────────────────────────────
-
-function _aMomentRow(m) {
- return `<div class="arena-mic-moment ${m.kind === 'beef_hit' ? 'is-beef' : ''} ${m.quality >= 8 ? 'is-fire' : ''}" data-id="${m.id}">
- ${_aA(_aFighterLink(m.user), _aPortrait(m, 'xs'), '', m.fighter_name || (m.user && m.user.username) || 'Fighter')}
- <div class="arena-mic-moment-body">
- <div class="arena-mic-moment-head">${_aA(_aFighterLink(m.user), `<b>${_aEsc(m.fighter_name)}</b>`)}${m.live ? ' <span class="arena-live-pill">LIVE</span>' : ''} ${_aAim(m)} ${_aQ(m.quality)} <small>${_aEsc(_aAgo(m.at))}</small></div>
- <q>${_aEsc(m.text)}</q>
- ${m.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(m.announcer)}</div>` : ''}
- <small class="arena-mic-moment-foot">${m.about ? `${_aEsc(m.about)} · ` : ''}${m.kind === 'beef_hit' && m.beef_id ? `${_aA(_aBeefLink({ id: m.beef_id }), '<i class="fa-solid fa-fire-flame-curved"></i> beef', 'arena-tag arena-tag-hot')} ` : ''}${_aPlay(m.vod_id, m.sec)} ${_aSpeakBtn(m.text, '', m.user.username)}</small>
- </div>
- </div>`;
-}
-function _aRenderFeed(feed, { fresh = 0 } = {}) {
- return `<h2><i class="fa-solid fa-satellite-dish"></i> Shit talk on record <small>what was actually said on stream, newest first · ${feed.length ? `${feed.length} lines` : 'nothing yet'}${fresh ? ` · <b class="arena-fresh">+${fresh} new</b>` : ''}</small></h2>
- ${feed.length ? `<div class="arena-mic-feed-list">${feed.map(_aMomentRow).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>Quiet. The moment a fighter on a live cam talks shit — at anyone — it lands here with a score.</p></div>`}`;
-}
-
-// ── Beefs ────────────────────────────────────────────────────
-
-function _aTug(b, { big = false } = {}) {
- const ca = b.a.user.profile_color || '#8b5cf6', cb = b.b.user.profile_color || '#e74c3c';
- return `<div class="arena-tug ${big ? 'arena-tug-big' : ''}" title="${_aEsc(`${b.a.fighter_name} ${b.a.total} — ${b.b.total} ${b.b.fighter_name}`)}">
- <span class="arena-tug-a" style="width:${b.share_a}%;--fc:${_aEsc(ca)}"><b>${b.share_a >= 12 ? `${b.share_a}%` : ''}</b></span>
- <span class="arena-tug-b" style="width:${100 - b.share_a}%;--fc:${_aEsc(cb)}"><b>${100 - b.share_a >= 12 ? `${100 - b.share_a}%` : ''}</b></span>
- </div>`;
-}
-function _aClockTag(b) {
- if (b.status !== 'open' || !b.on_clock) return '';
- const who = b.on_clock === 'a' ? b.a : b.b;
- return `<span class="arena-clock ${b.clock_seconds_left < 120 ? 'is-urgent' : ''}" data-until="${_aEsc(b.clock_until)}" data-who="${_aEsc(who.fighter_name)}"><i class="fa-solid fa-stopwatch"></i> ${_aEsc(who.fighter_name)} has <b>${_aClock(b.clock_seconds_left)}</b> to answer${b.clock_is_live_window ? '' : ' (offline clock)'}</span>`;
-}
-function _aTickClocks(root) {
- root.querySelectorAll('.arena-clock[data-until]').forEach(el => {
- const left = Math.max(0, Math.round((Date.parse(el.dataset.until) - Date.now()) / 1000));
- const b = el.querySelector('b'); if (b) b.textContent = _aClock(left);
- el.classList.toggle('is-urgent', left < 120);
- if (left === 0) el.innerHTML = `<i class="fa-solid fa-hourglass-end"></i> ${_aEsc(el.dataset.who)} ran out of time…`;
- });
-}
-function _aBeefTags(b) {
- const tags = [];
- if (b.rematch) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-rotate-left"></i> REMATCH</span>');
- if (b.upset) tags.push('<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-bolt"></i> UPSET</span>');
- if (b.streaks?.a >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.a.fighter_name)} on a ${b.streaks.a}-beef streak</span>`);
- if (b.streaks?.b >= 2) tags.push(`<span class="arena-tag">${_aEsc(b.b.fighter_name)} on a ${b.streaks.b}-beef streak</span>`);
- if (b.history?.fights) tags.push(`<span class="arena-tag arena-tag-dim">history ${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights}</span>`);
- return tags.join('');
-}
-function _aBeefCard(b) {
- const open = b.status === 'open';
- const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;
- return `<div class="arena-beef ${open ? 'is-open' : 'is-done'}" data-beef="${b.id}">
- <div class="arena-beef-head">
- ${_aA(_aBeefLink(b), `<span class="arena-beef-headline">${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</span>`)}
- <span class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : ''}` : 'draw'}</span>` : ''}</span>
- </div>
- <div class="arena-beef-sides">
- ${_aA(_aFighterLink(b.a.user), `${_aPortrait(b.a, 'sm')}<span><strong>${_aEsc(b.a.fighter_name)}${b.a.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.a.rank || '–'} · ${b.a.hits} hits · crowd ${b.a.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-a')}
- <span class="arena-beef-vs">${open ? 'VS' : 'FINAL'}</span>
- ${_aA(_aFighterLink(b.b.user), `${_aPortrait(b.b, 'sm')}<span><strong>${_aEsc(b.b.fighter_name)}${b.b.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</strong><small>#${b.b.rank || '–'} · ${b.b.hits} hits · crowd ${b.b.crowd}/${b.rules.crowd_max}</small></span>`, 'arena-beef-side arena-beef-side-b')}
- </div>
- ${_aTug(b)}
- <div class="arena-beef-foot">
- ${open ? _aClockTag(b) : `<span class="arena-note">ended ${_aEsc(_aAgo(b.resolved_at))}</span>`}
- ${open ? `<span class="arena-beef-actions">
- <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="a" title="Hype ${_aEsc(b.a.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.a.fighter_name)}</button>
- <button class="btn btn-ghost btn-sm arena-hype-btn" data-beef="${b.id}" data-side="b" title="Hype ${_aEsc(b.b.fighter_name)}"><i class="fa-solid fa-fire"></i> ${_aEsc(b.b.fighter_name)}</button>
- </span>` : ''}
- </div>
- ${b.feed?.length ? `<q class="arena-beef-last">${_aEsc(b.feed[b.feed.length - 1].announcer || b.feed[b.feed.length - 1].text || '')}</q>` : ''}
- </div>`;
-}
-function _aRenderBeefs(beefs) {
- const open = beefs.open || [], done = beefs.resolved || [];
- return `<h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs <small>${open.length ? `${open.length} open` : 'none open — say a name on mic'}</small></h2>
- ${open.length ? `<div class="arena-beef-grid">${open.map(_aBeefCard).join('')}</div>` : `<div class="arena-beef-empty"><i class="fa-solid fa-microphone-lines"></i><p>No beef right now. A fighter only has to call another one out on mic — the ears do the rest.</p></div>`}
- ${done.length ? `<details class="arena-done"><summary>Settled beefs <small>${done.length}</small></summary><div class="arena-beef-grid">${done.slice(0, 6).map(_aBeefCard).join('')}</div></details>` : ''}`;
-}
-async function _aHype(beefId, side, btn) {
- try {
- const r = await api(`/arena/beefs/${beefId}/hype`, { method: 'POST', body: { side } });
- _aToast(r.added ? `🔥 Hyped! crowd ${r.crowd}/10 (${r.hypers} hyping)` : `You already hyped this side (${r.hypers} hyping)`, r.added ? 'success' : 'info');
- if (btn) btn.classList.add('is-hyped');
- } catch (err) { _aToast(err?.message || 'Hype failed', 'error'); }
-}
-function _aBindHome(root) {
- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = (e) => { e.preventDefault(); _aHype(btn.dataset.beef, btn.dataset.side, btn); });
- _aBindSpeak(root);
-}
-function _aFlash(el, text) {
- const f = document.createElement('div'); f.className = 'arena-flash'; f.textContent = text; el.prepend(f); setTimeout(() => f.remove(), 2200);
-}
-
-// ── Ladder ───────────────────────────────────────────────────
-
-function _aRenderList(fighters) {
- const el = document.getElementById('arena-list');
- if (!el) return;
- if (!fighters.length) { el.innerHTML = '<div class="arena-empty"><p>No fighter matches that.</p></div>'; return; }
- el.innerHTML = fighters.map(f => `
- <div class="arena-row ${f.live ? 'is-live' : ''}" data-user="${_aEsc(f.user.username)}" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
- <span class="arena-rank ${f.rank <= 3 ? `arena-rank-${f.rank}` : ''}">${f.rank}</span>
- ${_aPortrait(f, 'sm')}
- <span class="arena-row-main">
- <strong>${_aEsc(f.user.display_name || f.user.username)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''} ${_aLevelPill(f.level?.level)}<i class="fa-solid fa-chevron-right arena-chevron"></i></strong>
- <span class="arena-row-sub">${f.mic ? `<i class="fa-solid fa-microphone" title="judged lines, 30 days"></i> ${f.mic.moments} lines · avg ${f.mic.avg_quality}/10 · ${f.mic.bangers} bangers · ${f.mic.beef_hits} callouts` : ''}${f.voice?.has_data ? ` · ${_aNum(f.voice.speech_minutes)} min on mic` : ''}</span>
- ${f.last_line ? `<em class="arena-row-taunt">“${_aEsc(f.last_line.text)}”${f.last_line.aimed_at ? ` <small>— at ${_aEsc(f.last_line.aimed_at)}</small>` : ''}</em>` : '<em class="arena-row-taunt arena-note">nothing judged yet</em>'}
- </span>
- <span class="arena-row-stats">
- <span class="arena-power"><b>${f.ratings.power}</b><small>PWR</small></span>
- <span class="arena-record" title="beef record">${f.record.wins}W–${f.record.losses}L</span>
- </span>
- <div class="arena-row-expand">
- <div>${_aCustomRadar(f, f.user.profile_color || '#8b5cf6', 220, { objective: true })}<div class="arena-mini-record">the seven mic stats · last live ${_aEsc(f.last_live_at ? _aDate(f.last_live_at) : '—')}</div></div>
- <div>
- <div class="arena-quips">${ARENA_STATS.map(k => `<div class="arena-quip"><b>${_aEsc(ARENA_STAT_LABEL[k])} ${f.ratings[k]}</b><span>${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || '')}</span></div>`).join('')}</div>
- </div>
- <div class="arena-row-expand-actions">
- ${_aA(_aFighterLink(f.user), '<i class="fa-solid fa-satellite-dish"></i> Their lines', 'btn btn-primary')}
- ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in', 'btn btn-ghost') : ''}
- ${f.last_line ? _aSpeakBtn(f.last_line.text, 'btn btn-ghost', f.user.username) : ''}
- </div>
- </div>
- </div>`).join('');
- el.querySelectorAll('.arena-row').forEach(row => row.addEventListener('click', (e) => {
- if (e.target.closest('a, button')) return;
- row.classList.toggle('is-open');
- }));
- _aBindSpeak(el);
-}
-
-// ── Beef detail ──────────────────────────────────────────────
-
-function _aFeedLine(e, b) {
- const side = e.side === 'a' ? b.a : b.b;
- const sys = e.kind && !['hit', 'open', 'respond'].includes(e.kind);
- return `<div class="arena-feed-line ${sys ? 'is-system' : `is-${e.side}`}">
- ${sys ? '' : _aA(_aFighterLink(side.user), _aPortrait(side, 'xs'), '', side.fighter_name || (side.user && side.user.username) || 'Fighter')}
- <div class="arena-feed-body">
- ${e.announcer ? `<div class="arena-feed-announcer"><i class="fa-solid fa-bullhorn"></i> ${_aEsc(e.announcer)}</div>` : ''}
- ${e.text ? `<q>${_aEsc(e.text)}</q>` : ''}
- <small>${sys ? _aEsc(e.kind) : `${_aEsc(side.fighter_name)}${e.kind === 'open' ? ' · opened it' : e.kind === 'respond' ? ' · answered' : ''}${e.quality != null ? ` · ${e.quality}/10` : ''}${e.about ? ` · ${_aEsc(e.about)}` : ''}`} · ${_aEsc(_aAgo(e.at))} ${_aPlay(e.vod_id, e.sec)} ${e.text ? _aSpeakBtn(e.text, '', side.user.username) : ''}</small>
- </div>
- </div>`;
-}
-async function _aRenderBeef(root, id) {
- root.innerHTML = _aSpinner('Pulling the receipts…');
- const b = await api(`/arena/beefs/${id}`);
- const open = b.status === 'open';
- const winner = !open && b.winner_user_id ? (b.winner_user_id === b.a.user.id ? b.a : b.b) : null;
- const draw = () => {
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(b.a.user), _aEsc(b.a.fighter_name))} · ${_aA(_aFighterLink(b.b.user), _aEsc(b.b.fighter_name))}</div>
- <div class="arena-beef-page">
- <div class="arena-beef-hero">
- <div class="arena-beef-tags">${_aBeefTags(b)}${!open ? `<span class="arena-tag ${winner ? 'arena-tag-gold' : 'arena-tag-dim'}">${winner ? `${_aEsc(winner.fighter_name)} won${b.resolution === 'forfeit' ? ' by forfeit' : b.resolution === 'score' ? ' on points' : ''}` : 'draw'}</span>` : '<span class="arena-tag arena-tag-hot"><i class="fa-solid fa-fire"></i> OPEN</span>'}</div>
- <h1>${_aEsc((open ? b.headline : b.result_headline || b.headline) || `${b.a.fighter_name} vs ${b.b.fighter_name}`)} ${_aSpeakBtn((open ? b.headline : b.result_headline || b.headline) || '')}</h1>
- ${b.opener_line ? `<p class="arena-beef-opener">It started on mic with: <q>${_aEsc(b.opener_line)}</q></p>` : ''}
- </div>
- <div class="arena-beef-tape">
- ${['a', 'b'].map(s => { const f = b[s]; return `<div class="arena-beef-tape-side arena-beef-tape-${s}" style="--fc:${_aEsc(f.user.profile_color || (s === 'a' ? '#8b5cf6' : '#e74c3c'))}">
- ${_aA(_aFighterLink(f.user), _aPortrait(f, 'md'), '', f.fighter_name || (f.user && f.user.username) || 'Fighter')}
- <h2>${_aEsc(f.fighter_name)}${f.live ? ' <span class="arena-live-pill">LIVE</span>' : ''}</h2>
- <div class="arena-beef-tape-meta">#${f.rank || '–'} · ${_aLevelPill(f.level)}${b.streaks?.[s] >= 2 ? ` · ${b.streaks[s]}-beef streak` : ''}</div>
- <div class="arena-beef-tape-nums"><span><b>${f.hits}</b><small>hits</small></span><span><b>${f.score}</b><small>quality</small></span><span><b>${f.crowd}</b><small>crowd /${b.rules.crowd_max}</small></span><span><b>${f.total}</b><small>total</small></span></div>
- ${open ? `<div class="arena-beef-tape-actions"><button class="btn btn-primary btn-sm arena-hype-btn" data-beef="${b.id}" data-side="${s}"><i class="fa-solid fa-fire"></i> Hype ${_aEsc(f.fighter_name.split(' ')[0])}</button></div>` : ''}
- ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> listen in', 'arena-tag') : ''}
- </div>`; }).join('')}
- </div>
- ${_aTug(b, { big: true })}
- <div class="arena-beef-status">
- ${open ? _aClockTag(b) : ''}
- ${b.ends_at && open ? `<span class="arena-note"><i class="fa-solid fa-hourglass-half"></i> hard end in ${_aEsc(_aClock((Date.parse(b.ends_at) - Date.now()) / 1000))}</span>` : ''}
- </div>
- <div class="arena-beef-cols">
- <section class="arena-feed">
- <h3><i class="fa-solid fa-bullhorn"></i> Ringside <small>every judged hit, newest last</small></h3>
- ${b.feed.length ? b.feed.map(e => _aFeedLine(e, b)).join('') : '<p class="arena-note">Nothing judged yet.</p>'}
- </section>
- <aside class="arena-beef-aside">
- ${b.history?.fights ? `<section><h3><i class="fa-solid fa-receipt"></i> Receipts <small>${b.history.wins_1}–${b.history.wins_2} in ${b.history.fights} earlier beef${b.history.fights > 1 ? 's' : ''}</small></h3>
- ${b.history.receipts.map(r => { const f = r.side_user_id === b.a.user.id ? b.a : b.b; return `<div class="arena-receipt"><q>${_aEsc(r.text)}</q><small>${_aEsc(f.fighter_name)} · ${r.quality}/10 · ${_aA(_aBeefLink({ id: r.beef_id }), `beef #${r.beef_id}`)} ${_aPlay(r.vod_id, r.sec)}</small></div>`; }).join('') || '<p class="arena-note">No quotable receipts.</p>'}</section>` : '<section><h3><i class="fa-solid fa-receipt"></i> Receipts</h3><p class="arena-note">First time these two have beef.</p></section>'}
- <section><h3><i class="fa-solid fa-gavel"></i> Rules</h3><ul class="arena-rules-list">
- <li>Answer on your own cam within <b>${b.rules.response_live_min} min</b> if you're live, <b>${b.rules.response_offline_hours} h</b> if not — or forfeit.</li>
- <li>Every judged answer resets the other side's clock. Beef hard-ends after ${b.rules.max_hours} h; higher total wins.</li>
- <li>Total = hit quality (AI-judged 1–10) + crowd hype (max ${b.rules.crowd_max}). No voting — hype is the only thing chat adds.</li>
- <li>Beat someone ranked 4+ spots above you → <b>upset</b>. Same two again → <b>rematch</b> (+ rivalry receipts).</li>
- </ul></section>
- </aside>
- </div>
- </div>`;
- root.querySelectorAll('.arena-hype-btn').forEach(btn => btn.onclick = () => _aHype(b.id, btn.dataset.side, btn));
- _aBindSpeak(root);
- };
- draw();
- _aEvery(1000, () => _aTickClocks(root));
- if (open) _aEvery(12000, async () => { try { const fresh = await api(`/arena/beefs/${id}`); if (JSON.stringify(fresh.feed) !== JSON.stringify(b.feed) || fresh.status !== b.status || fresh.share_a !== b.share_a) { Object.assign(b, fresh); draw(); } } catch { /* */ } });
-}
-
-// ── Live console ("the ears") ────────────────────────────────
-
-async function _aRenderConsole(root, username) {
- root.innerHTML = _aSpinner('Putting the ears on…');
- let c = await api(`/arena/console/${encodeURIComponent(username)}`);
- const draw = () => {
- const f = c.fighter, L = c.listener || {}, lvl = c.level || {}, m = c.mic || {};
- const me = _aMe(), mine = me && me.id === f.user.id;
- const xpPct = lvl.xp_per_level ? Math.round((lvl.xp_into_level / lvl.xp_per_level) * 100) : 0;
- const J = L.last_mic_judgement;
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${_aA(_aFighterLink(f.user), _aEsc(f.fighter_name))} ${c.live ? _aA(_aChannelLink(f.user), '<i class="fa-solid fa-tv"></i> watch', '') : ''}</div>
- <div class="arena-console" style="--fc:${_aEsc(f.user.profile_color || '#8b5cf6')}">
- <div class="arena-console-head">
- ${_aPortrait(f, 'sm')}
- <div>
- <h1>${_aEsc(f.fighter_name)} ${c.live ? '<span class="arena-live-pill">LIVE</span>' : '<span class="arena-tag arena-tag-dim">offline</span>'}</h1>
- <div class="arena-console-status ${L.listening ? 'is-on' : ''}">
- <i class="fa-solid ${L.listening ? 'fa-ear-listen' : 'fa-ear-deaf'}"></i>
- ${!c.live ? 'Not live — the ears only work on a live, transcribed cam.' : !c.transcribed ? 'Live, but no transcript in the last 30 min. Turn on audio transcription in the dashboard and the Arena hears you.' : L.listening ? `Listening. ${L.pending_mic_words || 0} words buffered for the mic judge${L.focus ? ` · locked on ${_aEsc(L.focus.target)}` : ''}${L.last_judge_at ? ` · last judged ${_aEsc(_aAgo(L.last_judge_at))}` : ''}` : 'Live and transcribed — the listener picks this cam up on its next 15 s tick.'}
- </div>
- </div>
- <div class="arena-console-level">
- <span class="arena-lvl arena-lvl-big">TRASH LVL ${lvl.level || 1}</span>
- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${xpPct}%"></span></span>
- <small>${lvl.xp_into_level || 0}/${lvl.xp_per_level || 50} XP to level ${(lvl.level || 1) + 1} · ${lvl.recent_xp || 0} this week · ${m.moments || 0} judged lines (30d)</small>
- </div>
- </div>
- <div class="arena-console-cols">
- <section class="arena-console-main">
- <h3><i class="fa-solid fa-microphone"></i> Hot mic <small>last lines the ears heard</small></h3>
- ${L.focus ? `<div class="arena-focus"><i class="fa-solid fa-crosshairs fa-beat"></i> <b>Locked on ${_aEsc(L.focus.target || 'a fighter')}</b> <small>${_aEsc(L.focus.how)} name-drop · ${L.focus.hits} hit${L.focus.hits === 1 ? '' : 's'} · ${L.focus.pending_words} words waiting for the judge · lock <span class="arena-clock" data-until="${new Date(Date.now() + L.focus.lock_seconds_left * 1000).toISOString()}" data-who="the lock"><b>${_aClock(L.focus.lock_seconds_left)}</b></span></small>${L.focus.context ? `<div class="arena-focus-ctx">so far: ${_aEsc(L.focus.context)}</div>` : ''}<p class="arena-note">Everything said now goes to the beef judge as a continuation — no need to say the name again. Two chunks about something else and the ears let go.</p></div>` : ''}
- <div class="arena-mic-feed">${c.hot_mic?.length ? c.hot_mic.map(l => `<div class="arena-mic-line-row"><span class="arena-mic-time">${_aStamp(l.sec)}</span><span>${_aEsc(l.text)}</span>${_aPlay(l.vod_id, l.sec)}</div>`).join('') : '<p class="arena-note">Nothing heard yet.</p>'}</div>
- ${L.last_beef_judgement ? `<div class="arena-judgement ${L.last_beef_judgement.aimed_at_target ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Beef judge (${_aEsc(_aAgo(L.last_beef_judgement.at))}):</b> ${L.last_beef_judgement.aimed_at_target ? `HIT${L.last_beef_judgement.named === false ? ' (continuation, name not said)' : ''} · ${L.last_beef_judgement.quality}/10 · ${_aEsc(L.last_beef_judgement.about || '')}${L.last_beef_judgement.opened ? ' · <b>beef opened</b>' : ''}` : L.last_beef_judgement.about_target ? `still about them, not shit talk — ${_aEsc(L.last_beef_judgement.about || '')}` : `moved on — ${_aEsc(L.last_beef_judgement.about || 'not about them')}`}${L.last_beef_judgement.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
- ${J ? `<div class="arena-judgement ${J.is_trash_talk ? 'is-hit' : ''}"><b><i class="fa-solid fa-gavel"></i> Mic judge (${_aEsc(_aAgo(J.at))}):</b> ${J.is_trash_talk ? `SHIT TALK · ${J.quality}/10${J.aimed_at ? ` · at ${_aEsc(J.aimed_at)}` : ''} · ${_aEsc(J.about || '')}${J.opened ? ' · <b>callout — beef opened</b>' : J.target_id ? ' · <b>callout — fed the beef</b>' : ''}` : `not shit talk — ${_aEsc(J.about || 'gameplay / small talk')}`}${J.flagged ? ' · <span class="arena-tag arena-tag-dim">line not counted (threat/minor/dox)</span>' : ''}</div>` : ''}
- </section>
- <aside class="arena-console-aside">
- <section>
- <h3><i class="fa-solid fa-satellite-dish"></i> Their last lines in the feed</h3>
- ${c.recent_moments?.length ? `<div class="arena-mic-feed-list is-compact">${c.recent_moments.map(_aMomentRow).join('')}</div>` : `<p class="arena-note">${mine ? 'Nothing judged yet — talk your shit.' : 'Nothing judged yet.'}</p>`}
- </section>
- <section>
- <h3><i class="fa-solid fa-fire-flame-curved"></i> Open beefs</h3>
- ${c.open_beefs?.length ? c.open_beefs.map(b => `<div class="arena-console-beef">${_aA(_aBeefLink(b), `<b>${_aEsc(b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`)}</b>`)}${_aTug(b)}${_aClockTag(b)}</div>`).join('') : `<p class="arena-note">None. ${mine ? 'Say another fighter\'s name while talking shit and one opens.' : ''}</p>`}
- </section>
- <section><h3><i class="fa-solid fa-circle-info"></i> How it's judged</h3><ul class="arena-rules-list"><li>Every 15 s the ears read new transcript lines.</li><li>A fighter's name (however the mic hears it — split, glued, misheard) locks the ears on them; ≥20 words go to the beef judge, and everything after counts as a continuation until they move on.</li><li>Not locked? ≥20 words go to the mic judge: is it shit talk, how good, and at whom. If "whom" is a fighter, it's a callout and feeds a beef anyway.</li><li>Offensive language is fine. Threats, minors, doxxing → line ignored.</li></ul></section>
- </aside>
- </div>
- </div>`;
- _aBindSpeak(root);
- };
- draw();
- _aEvery(1000, () => _aTickClocks(root));
- _aEvery(10000, async () => { try { const fresh = await api(`/arena/console/${encodeURIComponent(username)}`); const lvlUp = (fresh.level?.level || 1) > (c.level?.level || 1); c = fresh; draw(); if (lvlUp) _aLevelUp(c.level.level); } catch { /* */ } });
-}
-
-// ── Fighter profile ──────────────────────────────────────────
-
-function _aVoiceCard(f) {
- const v = f.voice || {};
- const color = f.user.profile_color || '#8b5cf6';
- if (!v.has_data) {
- return `<div class="arena-voice" style="--fc:${_aEsc(color)}"><div class="arena-voice-head"><h3><i class="fa-solid fa-microphone-slash"></i> On the mic</h3></div><p class="arena-voice-empty">No transcript data yet — the audio transcription picks this up on their next streams. Until then the Arena can't hear them.</p></div>`;
- }
- return `<div class="arena-voice" style="--fc:${_aEsc(color)}">
- <div class="arena-voice-head"><h3><i class="fa-solid fa-microphone"></i> On the mic <span class="arena-power" style="margin-left:6px"><b style="font-size:1.1rem">${f.ratings.mouth}</b><small>MOUTH</small></span></h3></div>
- <div class="arena-voice-meters">
- <div class="arena-voice-meter"><b>${_aEsc(v.talk_ratio_pct)}%</b><span>of stream time talking</span></div>
- <div class="arena-voice-meter"><b>${_aEsc(_aNum(v.speech_minutes))} min</b><span>of speech heard (90d)</span></div>
- <div class="arena-voice-meter"><b>${_aEsc(v.wpm)}</b><span>words per minute</span></div>
- <div class="arena-voice-meter"><b>${_aEsc(v.streams_heard)}</b><span>streams transcribed</span></div>
- </div>
- </div>`;
-}
-
-function _aLevelCard(f) {
- const l = f.level || {}, m = f.mic || {};
- const pct = l.xp_per_level ? Math.round((l.xp_into_level / l.xp_per_level) * 100) : 0;
- return `<div class="arena-level-card">
- <div class="arena-level-head"><span class="arena-lvl arena-lvl-big">TRASH LVL ${l.level || 1}</span><span class="arena-note">${l.xp || 0} XP · ${l.recent_xp || 0} this week${f.ratings.talk_bonus ? ` · <b>+${f.ratings.talk_bonus} POWER</b> from the mouth` : ''}</span></div>
- <span class="arena-xp-track"><span class="arena-xp-fill" style="width:${pct}%"></span></span>
- <div class="arena-level-nums"><span><b>${m.moments || 0}</b><small>judged lines (30d)</small></span><span><b>${m.avg_quality ?? 0}</b><small>avg score</small></span><span><b>${m.bangers || 0}</b><small>bangers (7+)</small></span><span><b>${l.beef_hits || 0}</b><small>beef hits</small></span><span><b>${m.answered || 0}/${m.targeted || 0}</b><small>answered when called out</small></span><span><b>${f.record.wins}–${f.record.losses}${f.record.draws ? `–${f.record.draws}` : ''}</b><small>beef record</small></span></div>
- ${l.best_line ? `<div class="arena-quote"><div><q>${_aEsc(l.best_line.text)}</q><small>best line on record · ${l.best_line.score}/10</small></div><div class="arena-quote-actions">${_aPlay(l.best_line.vod_id, l.best_line.sec)} ${_aSpeakBtn(l.best_line.text, '', f.user.username)}</div></div>` : ''}
- </div>`;
-}
-
-async function _aRenderFighter(root, username) {
- root.innerHTML = _aSpinner('Pulling their lines…');
- const f = await api(`/arena/fighters/${encodeURIComponent(username)}`);
- if (f.not_on_roster) {
- root.innerHTML = `<div class="arena-empty"><i class="fa-solid fa-microphone-slash"></i><p><strong>${_aEsc(f.user.display_name)}</strong> is not on the roster — ${_aEsc(f.reason)}.</p>${_aA('/arena', 'Back to the cams', 'btn')}</div>`;
- return;
- }
- const color = f.user.profile_color || '#8b5cf6';
- const name = f.user.display_name || f.user.username;
- const beefs = f.beefs || [], rivalries = f.rivalries || [], moments = f.moments || [], best = f.best_lines || [];
- root.innerHTML = `
- <div class="arena-back">${_aA('/arena', '<i class="fa-solid fa-arrow-left"></i> Battle Cam')} ${f.live ? _aA(_aConsoleLink(f.user), '<i class="fa-solid fa-ear-listen"></i> Listen in live', '') : ''}</div>
- <div class="arena-profile" style="--fc:${_aEsc(color)}">
- <div class="arena-profile-portrait" id="arena-profile-portrait">
- ${_aPortrait(f, 'lg')}
- <div class="arena-profile-rank">#${f.rank} <small>of ${f.roster_size}</small></div>
- </div>
- <div class="arena-profile-main">
- <div class="arena-profile-name">
- <h1>${_aEsc(name)} ${f.live ? '<span class="arena-live-pill">LIVE</span>' : ''}</h1>
- <p class="arena-handle">${_aA(_aChannelLink(f.user), `@${_aEsc(f.user.username)}`)} · ${f.record.wins}W–${f.record.losses}L · ${f.mic ? `${f.mic.moments} judged lines in 30 days` : ''}</p>
- </div>
- <div class="arena-profile-power">
- <div class="arena-power arena-power-lg"><b>${f.ratings.power}</b><small>POWER</small></div>
- ${f.ratings.talk_bonus ? `<div class="arena-talk-bonus" title="Mouth bonus — recent Trash Level XP and beef wins, decays over a week"><i class="fa-solid fa-microphone-lines"></i> +${f.ratings.talk_bonus} mouth</div>` : ''}
- <div class="arena-record arena-record-lg" title="beef record">${f.record.wins}W – ${f.record.losses}L</div>
- </div>
- ${_aLevelCard(f)}
- ${best.length ? `<section class="arena-receipts-section"><h3><i class="fa-solid fa-fire"></i> Best shit talk <small>their highest-scored lines</small></h3><div class="arena-mic-feed-list">${best.map(_aMomentRow).join('')}</div></section>` : ''}
- <section class="arena-receipts-section">
- <h3><i class="fa-solid fa-satellite-dish"></i> On record <small>their judged lines, newest first</small></h3>
- ${moments.length ? `<div class="arena-mic-feed-list">${moments.map(_aMomentRow).join('')}</div>` : '<p class="arena-note">Nothing judged yet. The ears are waiting.</p>'}
- </section>
- <div class="arena-profile-stats is-bars">
- <div class="arena-bars-head"><b>The mic stats</b> <small>percentile across the roster · these make POWER · tap one</small></div>
- <div class="arena-numgrid">${ARENA_STATS.map(k => `<div class="arena-num is-clickable" data-stat="${k}" title="${_aEsc(_arenaRoster?.stat_meta?.[k]?.desc || ARENA_STAT_LABEL[k])}"><span class="arena-num-label">${_aEsc(ARENA_STAT_LABEL[k])}</span><span class="arena-num-track"><span class="arena-num-fill" style="width:${Math.max(0, Math.min(100, f.ratings[k] || 0))}%;background:${_aEsc(color)}"></span></span><span class="arena-num-val">${f.ratings[k] ?? '–'}</span></div>`).join('')}</div>
- <div id="arena-stat-detail"></div>
- </div>
- <div class="arena-profile-custom">
- <div>${_aCustomRadar(f, color, 280, { objective: true })}<div class="arena-mini-record">the seven mic stats</div></div>
- </div>
- ${_aVoiceCard(f)}
- </div>
- </div>
- ${rivalries.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-skull-crossbones"></i> Rivalries</h2><div class="arena-rivalries">${rivalries.map(r => `<div class="arena-rivalry ${r.open ? 'is-open' : ''}">${_aBriefChip(r.opponent, ` · ${r.wins}–${r.losses} in ${r.fights}${r.open ? ' · <b>beef open</b>' : ''}`)}${r.receipts.map(x => `<q class="arena-receipt-mini">${_aEsc(x.text)}</q>`).join('')}</div>`).join('')}</div></section>` : ''}
- ${beefs.length ? `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><div class="arena-beef-grid">${beefs.map(_aBeefCard).join('')}</div></section>` : `<section class="arena-challenge"><h2><i class="fa-solid fa-fire-flame-curved"></i> Beefs</h2><p class="arena-note">No beef on record. Someone only has to say their name…</p></section>`}`;
- _aBindSpeak(root);
- _aBindHome(root);
- _aEvery(1000, () => _aTickClocks(root));
-
- root.querySelectorAll('.arena-num.is-clickable').forEach(el => el.addEventListener('click', async () => {
- const stat = el.dataset.stat;
- const box = document.getElementById('arena-stat-detail');
- if (box.dataset.stat === stat) { box.innerHTML = ''; box.dataset.stat = ''; return; }
- box.dataset.stat = stat;
- box.innerHTML = `<div class="arena-stat-detail">${_aSpinner('Crunching…')}</div>`;
- try {
- const d = await api(`/arena/fighters/${encodeURIComponent(username)}/stat/${stat}`);
- if (box.dataset.stat !== stat) return;
- box.innerHTML = `<div class="arena-stat-detail" style="--fc:${_aEsc(color)}">
- <div class="arena-stat-detail-head">
- <h3>${_aEsc(d.label)} ${d.rating} <small>· #${d.position} of ${d.roster_size} · ${_aEsc(_aNum(d.value))} ${_aEsc(d.unit)}</small></h3>
- <button class="arena-stat-detail-close" title="Close">×</button>
- </div>
- <p class="arena-weight">${_aEsc(d.desc)} · ${Math.round(d.weight * 100)}% of POWER · rating = your percentile across the roster</p>
- ${_aSpark(d.series, color)}
- ${d.top.length ? `<div class="arena-stat-top"><span class="arena-weight">Top of the ladder:</span>${d.top.map((t, i) => _aA(_aFighterLink(t.user), `#${i + 1} ${_aEsc(t.fighter_name)} <b>${_aEsc(_aNum(t.value))}</b>`)).join('')}</div>` : ''}
- </div>`;
- box.querySelector('.arena-stat-detail-close').addEventListener('click', () => { box.innerHTML = ''; box.dataset.stat = ''; });
- } catch (err) { box.innerHTML = `<div class="arena-stat-detail">${_aEsc(err?.message || 'Failed')}</div>`; }
- }));
-}
-
-function _aLevelUp(level) {
- const el = document.createElement('div');
- el.className = 'arena-levelup';
- el.innerHTML = `<i class="fa-solid fa-arrow-up"></i> TRASH LEVEL ${level}`;
- document.body.appendChild(el);
- setTimeout(() => el.remove(), 2300);
-}
-
-window.loadArenaPage = loadArenaPage;
@@ -4976,13 +4976,6 @@ function showChatContextMenu(event) {
@@ -4976,13 +4976,6 @@ function showChatContextMenu(event) {
} else {
} else {
loadContextMenuProfile(menu, coreUsername, userId, isAnon);
loadContextMenuProfile(menu, coreUsername, userId, isAnon);
}
}
- // Arena yap level (accounts, anons and relayed chatters all have one once they've hit a subject).
- try {
- let yapKey = null;
- if (isRelay) { const m = String(username || '').match(/^\[([A-Za-z]+)\]\s+(.+)$/); const plat = String(sourcePlatform || (m && m[1]) || '').toLowerCase().replace(/^yt$/, 'youtube').replace(/^robotstreamer$/, 'rs'); if (m && plat) yapKey = `relay:${plat}:${m[2].trim().toLowerCase()}`; }
- else if (isAnon) { const m = String(coreUsername || username || '').match(/^anon(\d+)$/i); if (m) yapKey = `anon:${m[1]}`; }
- else if (userId) yapKey = `user:${userId}`;
- } catch { /* optional */ }
// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)
// Clicks inside the menu shouldn't dismiss it (needed for rename submenu toggle etc.)
menu.addEventListener('click', (e) => e.stopPropagation());
menu.addEventListener('click', (e) => e.stopPropagation());
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
const FEATURES = [
const FEATURES = [
['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],
['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],
['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Restream everywhere at once'], ['fa-scissors', 'VODs, clips, AI moments'],
['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Restream everywhere at once'], ['fa-scissors', 'VODs, clips, AI moments'],
- ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],
+ ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-newspaper', 'The Rundown'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
];
];
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* recap.js — the after-show report page (/recap/:streamId).
* recap.js — the after-show report page (/recap/:streamId).
* Renders what /api/recap/:id returns: grade + headline + AI summary, stat tiles, the viewer
* Renders what /api/recap/:id returns: grade + headline + AI summary, stat tiles, the viewer
- * curve with chat bars, moment of the night + mic lines, top chatters, clips, the VOD, share.
+ * curve with chat bars, moment of the night + the Rundown's takes, top chatters, clips, the VOD, share.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -39,6 +39,7 @@
@@ -39,6 +39,7 @@
function render(root, data, id) {
function render(root, data, id) {
const r = data.recap, w = r.write || {}, s = r.stream, u = r.streamer;
const r = data.recap, w = r.write || {}, s = r.stream, u = r.streamer;
+ const takes = r.takes || []; // a recap written before the Rundown has none
const g = GRADE[w.grade] || GRADE.B;
const g = GRADE[w.grade] || GRADE.B;
const path = `/@${u.username}`;
const path = `/@${u.username}`;
const owner = window.currentUser && (currentUser.id === u.id || currentUser.role === 'admin');
const owner = window.currentUser && (currentUser.id === u.id || currentUser.role === 'admin');
@@ -51,13 +52,13 @@
@@ -51,13 +52,13 @@
r.love.tips ? ['fa-coins', n(r.love.tips_total), `in ${r.love.tips} tip${r.love.tips === 1 ? '' : 's'}`] : null,
r.love.tips ? ['fa-coins', n(r.love.tips_total), `in ${r.love.tips} tip${r.love.tips === 1 ? '' : 's'}`] : null,
r.clips.length ? ['fa-scissors', n(r.clips.length), 'clips'] : null,
r.clips.length ? ['fa-scissors', n(r.clips.length), 'clips'] : null,
r.chat.sounds ? ['fa-volume-high', n(r.chat.sounds), 'sound commands'] : null,
r.chat.sounds ? ['fa-volume-high', n(r.chat.sounds), 'sound commands'] : null,
- r.mic.length ? ['fa-microphone-lines', n(r.mic.length), 'mic moments'] : null,
+ takes.length ? ['fa-newspaper', n(takes.length), takes.length === 1 ? 'Rundown take' : 'Rundown takes'] : null,
].filter(Boolean).map(([i, v, l]) => `<div class="recap-tile"><i class="fa-solid ${i}"></i><b>${v}</b><span>${esc(l)}</span></div>`).join('');
].filter(Boolean).map(([i, v, l]) => `<div class="recap-tile"><i class="fa-solid ${i}"></i><b>${v}</b><span>${esc(l)}</span></div>`).join('');
const medals = ['🥇', '🥈', '🥉', '4', '5'];
const medals = ['🥇', '🥈', '🥉', '4', '5'];
const topN = r.chat.top[0] ? r.chat.top[0].n : 1;
const topN = r.chat.top[0] ? r.chat.top[0].n : 1;
const chatters = r.chat.top.length ? `<div class="recap-card"><h3><i class="fa-solid fa-ranking-star"></i> Loudest in chat</h3><ol class="recap-chatters">${r.chat.top.map((c, i) => `<li><span class="recap-medal">${medals[i]}</span>${av(c, 26)}<a class="recap-chatter-name" ${go(`/@${c.username}`)}>${esc(c.display_name || c.username)}</a><span class="recap-chatter-bar"><i style="width:${Math.max(6, Math.round(c.n / topN * 100))}%"></i></span><span class="recap-chatter-n">${n(c.n)}</span></li>`).join('')}</ol>${r.chat.busiest_at ? `<div class="recap-fine">Busiest stretch: ${esc(when(r.chat.busiest_at))} (${n(r.chat.busiest_n)} lines in 5 min)</div>` : ''}</div>` : '';
const chatters = r.chat.top.length ? `<div class="recap-card"><h3><i class="fa-solid fa-ranking-star"></i> Loudest in chat</h3><ol class="recap-chatters">${r.chat.top.map((c, i) => `<li><span class="recap-medal">${medals[i]}</span>${av(c, 26)}<a class="recap-chatter-name" ${go(`/@${c.username}`)}>${esc(c.display_name || c.username)}</a><span class="recap-chatter-bar"><i style="width:${Math.max(6, Math.round(c.n / topN * 100))}%"></i></span><span class="recap-chatter-n">${n(c.n)}</span></li>`).join('')}</ol>${r.chat.busiest_at ? `<div class="recap-fine">Busiest stretch: ${esc(when(r.chat.busiest_at))} (${n(r.chat.busiest_n)} lines in 5 min)</div>` : ''}</div>` : '';
- const micLine = (m) => { const href = m.vod_id ? videoHref('vod', m.vod_id, Math.max(0, Math.round(m.sec || 0))) : null; const inner = `“${esc(m.text)}”${m.aimed_at ? ` <span class="muted">at ${esc(m.aimed_at)}</span>` : ''}<span class="recap-mic-q">${m.quality}/10</span>`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };
- const moment = (w.moment || r.mic.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${r.mic.length ? `<div class="recap-mics">${r.mic.map(micLine).join('')}</div>` : ''}</div>` : '';
+ const takeLine = (t) => { const href = t.vod_id ? videoHref('vod', t.vod_id, Math.max(0, Math.round(t.sec || 0))) : null; const inner = `${t.text ? `“${esc(t.text)}”` : esc(t.title)}${t.topic ? ` <span class="muted">· ${esc(t.topic)}</span>` : ''}`; return href ? `<a class="recap-mic" ${go(href)}><i class="fa-solid fa-play"></i> ${inner}</a>` : `<div class="recap-mic">${inner}</div>`; };
+ const moment = (w.moment || takes.length) ? `<div class="recap-card recap-moment"><h3><i class="fa-solid fa-fire"></i> Moment of the night</h3>${w.moment ? `<p class="recap-moment-text">${esc(w.moment)}</p>` : ''}${takes.length ? `<div class="recap-mics">${takes.map(takeLine).join('')}</div><div class="recap-fine"><a ${go(`/rundown/e/${s.id}`)}>The Rundown's episode</a></div>` : ''}</div>` : '';
const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(videoHref('clip', c.id))}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';
const clips = r.clips.length ? `<div class="recap-section"><h3><i class="fa-solid fa-scissors"></i> Clips from this stream</h3><div class="recap-clips">${r.clips.map(c => `<a class="recap-clip" ${go(videoHref('clip', c.id))}>${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="recap-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}<span class="recap-clip-dur">${dur(c.duration_seconds)}</span><span class="recap-clip-title">${esc(c.title)}</span>${c.view_count ? `<span class="recap-clip-views"><i class="fa-solid fa-eye"></i> ${n(c.view_count)}</span>` : ''}</a>`).join('')}</div></div>` : '';
const vod = r.vod ? `<a class="recap-vod" ${go(videoHref('vod', r.vod.id))}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';
const vod = r.vod ? `<a class="recap-vod" ${go(videoHref('vod', r.vod.id))}>${r.vod.thumbnail_url ? `<img src="${esc(r.vod.thumbnail_url)}" alt="" loading="lazy">` : ''}<span class="recap-vod-body"><b><i class="fa-solid fa-play"></i> Watch the VOD</b><span>${dur(r.vod.duration_seconds)} · the whole stream, with the transcript and AI moments</span></span></a>` : '';
const more = (data.more || []).length ? `<div class="recap-section"><h3><i class="fa-solid fa-clock-rotate-left"></i> More nights from ${esc(u.display_name)}</h3><div class="recap-more">${data.more.map(m => `<a class="recap-more-item" ${go(`/recap/${m.stream_id}`)}><span class="recap-grade-mini" style="--g:${(GRADE[m.grade] || GRADE.B).color}">${esc(m.grade)}</span><span class="recap-more-title">${esc(m.headline || m.title)}</span><span class="recap-more-meta">${esc(timeAgo(m.ended_at))} · ${dur(m.duration_seconds)} · peak ${n(m.peak_viewers)}</span></a>`).join('')}</div></div>` : '';
const more = (data.more || []).length ? `<div class="recap-section"><h3><i class="fa-solid fa-clock-rotate-left"></i> More nights from ${esc(u.display_name)}</h3><div class="recap-more">${data.more.map(m => `<a class="recap-more-item" ${go(`/recap/${m.stream_id}`)}><span class="recap-grade-mini" style="--g:${(GRADE[m.grade] || GRADE.B).color}">${esc(m.grade)}</span><span class="recap-more-title">${esc(m.headline || m.title)}</span><span class="recap-more-meta">${esc(timeAgo(m.ended_at))} · ${dur(m.duration_seconds)} · peak ${n(m.peak_viewers)}</span></a>`).join('')}</div></div>` : '';
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
/**
/**
* transcribe.js — free, LOCAL speech-to-text (no API, runs on the server CPU). Used for
* transcribe.js — free, LOCAL speech-to-text (no API, runs on the server CPU). Used for
- * live-stream memories, the live timeline (which the Arena listens to) and VOD/clip transcripts.
+ * live-stream memories, the live timeline (which The Rundown reads) and VOD/clip transcripts.
*
*
* Two engines, both from the whisper.cpp tree (ggml):
* Two engines, both from the whisper.cpp tree (ggml):
* - NVIDIA Parakeet TDT 0.6B v3 (`parakeet-cli`), for English and 24 other European languages.
* - NVIDIA Parakeet TDT 0.6B v3 (`parakeet-cli`), for English and 24 other European languages.
@@ -243,7 +243,7 @@ function _joinSegments(segments) {
@@ -243,7 +243,7 @@ function _joinSegments(segments) {
// word ("▁For"). A sentence ends at . ? or !, at a pause of 1.5 s, or after 20 s, so each timeline row stays a
// word ("▁For"). A sentence ends at . ? or !, at a pause of 1.5 s, or after 20 s, so each timeline row stays a
// line someone said, deep-linkable by its start.
// line someone said, deep-linkable by its start.
const TOKEN_LINE = /t0=\s*(-?\d+)\s+t1=\s*(-?\d+)\s+word_start=(true|false)\s+"(.*)"\s*$/;
const TOKEN_LINE = /t0=\s*(-?\d+)\s+t1=\s*(-?\d+)\s+word_start=(true|false)\s+"(.*)"\s*$/;
-// Fillers Parakeet writes out ("uh", "um") carry nothing for a reader or the Arena's quotes.
+// Fillers Parakeet writes out ("uh", "um") carry nothing for a reader or The Rundown's quotes.
const FILLER_RE = /(^|[\s,])(?:uh+|um+|uhm|erm|mm+)(?=[\s,.!?]|$)[,]?/gi;
const FILLER_RE = /(^|[\s,])(?:uh+|um+|uhm|erm|mm+)(?=[\s,.!?]|$)[,]?/gi;
function parseParakeet(stderr, offsetSec = 0) {
function parseParakeet(stderr, offsetSec = 0) {
const out = [];
const out = [];
@@ -460,7 +460,7 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.
@@ -460,7 +460,7 @@ if (!require('../drill').enabled) setTimeout(_refreshLatestUpdate, 1500).unref?.
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ── "While you were away" / "Lately on OpenVibe" digest — for everyone ────────
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
// ?since=<ISO> (the client remembers its own last visit; anonymous / first visit → last 48h).
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
// Site-wide activity since then: who's live, who streamed (followed channels first for signed-in
-// users), the numbers, and the hottest Arena mic lines.
+// users), the numbers, and the best Rundown takes.
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
router.get('/digest', optionalAuth, async (req, res) => {
router.get('/digest', optionalAuth, async (req, res) => {
try {
try {
@@ -501,12 +501,15 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -501,12 +501,15 @@ router.get('/digest', optionalAuth, async (req, res) => {
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
- mic_moments: Number((await one('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE said_at >= ?', [sinceSql]) || {}).n || 0),
+ takes: Number((await one("SELECT COUNT(*) AS n FROM rundown_segments WHERE status = 'live' AND said_at >= ?", [sinceSql]) || {}).n || 0),
};
};
+ // The best Rundown takes since then (server/rundown): the quote when there is one, else the headline.
let hot = [];
let hot = [];
try {
try {
- hot = (await db.all(`SELECT m.text, m.quality, m.said_at, m.aimed_at, u.username, u.display_name FROM arena_mic_moments m JOIN users u ON u.id = m.user_id
- WHERE m.said_at >= ? AND m.quality >= 7 ORDER BY m.quality DESC, m.said_at DESC LIMIT 3`, [sinceSql]) || []);
+ hot = (await db.all(`SELECT s.title, s.quotes_json, s.said_at, t.title AS topic, t.slug, u.username, u.display_name FROM rundown_segments s
+ JOIN users u ON u.id = s.user_id LEFT JOIN rundown_topics t ON t.id = s.topic_id
+ WHERE s.status = 'live' AND s.said_at >= ? AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 ORDER BY s.score DESC, s.said_at DESC LIMIT 3`, [sinceSql]) || [])
+ .map((r) => { let q = null; try { q = JSON.parse(r.quotes_json || '[]')[0] || null; } catch { q = null; } return { text: q ? q.text : r.title, quoted: !!q, topic: r.topic || null, slug: r.slug || null, said_at: r.said_at, username: r.username, display_name: r.display_name }; });
} catch { hot = []; }
} catch { hot = []; }
res.set('Cache-Control', uid ? 'private, max-age=30' : 'public, max-age=60');
res.set('Cache-Control', uid ? 'private, max-age=30' : 'public, max-age=60');
res.json({ since, signed_in: !!uid, liveNow, streamed, stats, hot });
res.json({ since, signed_in: !!uid, liveNow, streamed, stats, hot });
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
*
*
* Candidates are streamers who went live in the last CANDIDATE_DAYS days (not banned). Each one
* Candidates are streamers who went live in the last CANDIDATE_DAYS days (not banned). Each one
* gets real stats — sessions, hours, peak/avg viewers, chat lines in their streams, new followers,
* gets real stats — sessions, hours, peak/avg viewers, chat lines in their streams, new followers,
- * Arena mic moments, language, what they stream (AI overview) — and the shared LLM picks who gets
+ * Rundown takes, language, what they stream (AI overview) — and the shared LLM picks who gets
* the red carpet today and writes a one-line reason. Recent stars are excluded so it rotates
* the red carpet today and writes a one-line reason. Recent stars are excluded so it rotates
* instead of parking on one person. With AI off (or a bad answer) a plain score picks instead.
* instead of parking on one person. With AI off (or a bad answer) a plain score picks instead.
*
*
@@ -41,7 +41,7 @@ async function candidates() {
@@ -41,7 +41,7 @@ async function candidates() {
MAX(s.started_at) AS last_live_at,
MAX(s.started_at) AS last_live_at,
(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers,
(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id AND f.created_at >= datetime('now', ?)) AS new_followers,
(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,
(SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,
- (SELECT COUNT(*) FROM arena_mic_moments m WHERE m.user_id = u.id AND m.said_at >= datetime('now', ?)) AS mic_moments,
+ (SELECT COUNT(*) FROM rundown_segments r WHERE r.user_id = u.id AND r.status = 'live' AND r.said_at >= datetime('now', ?)) AS takes,
(SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview,
(SELECT COALESCE(so.overview_short, so.overview) FROM streamer_overviews so WHERE so.user_id = u.id) AS overview,
(SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS category
(SELECT ch.ai_category FROM channels ch WHERE ch.user_id = u.id) AS category
FROM users u JOIN streams s ON s.user_id = u.id
FROM users u JOIN streams s ON s.user_id = u.id
@@ -57,7 +57,7 @@ async function candidates() {
@@ -57,7 +57,7 @@ async function candidates() {
let i18n = null; try { i18n = require('../i18n/translate'); } catch { i18n = null; }
let i18n = null; try { i18n = require('../i18n/translate'); } catch { i18n = null; }
for (const r of rows) {
for (const r of rows) {
r.language = i18n ? (() => { try { return i18n.channelLanguage(r.id); } catch { return 'en'; } })() : 'en';
r.language = i18n ? (() => { try { return i18n.channelLanguage(r.id); } catch { return 'en'; } })() : 'en';
- r.score = Number(r.hours || 0) * 1.0 + Number(r.peak_viewers || 0) * 2 + Number(r.chat_lines || 0) / 40 + Number(r.new_followers || 0) * 3 + Number(r.mic_moments || 0) * 1.5 + Math.min(Number(r.sessions || 0), 10) * 0.5;
+ r.score = Number(r.hours || 0) * 1.0 + Number(r.peak_viewers || 0) * 2 + Number(r.chat_lines || 0) / 40 + Number(r.new_followers || 0) * 3 + Number(r.takes || 0) * 1.5 + Math.min(Number(r.sessions || 0), 10) * 0.5;
}
}
return rows;
return rows;
}
}
@@ -73,7 +73,7 @@ async function aiPick(cands, exclude, previous) {
@@ -73,7 +73,7 @@ async function aiPick(cands, exclude, previous) {
if (!llm || !llm.isEnabled() || !llm.withinBudget()) return null;
if (!llm || !llm.isEnabled() || !llm.withinBudget()) return null;
const list = cands.filter(c => !exclude.has(c.username.toLowerCase())).slice(0, 25).map(c => ({
const list = cands.filter(c => !exclude.has(c.username.toLowerCase())).slice(0, 25).map(c => ({
username: c.username, name: c.display_name || c.username, language: c.language, category: c.category || null,
username: c.username, name: c.display_name || c.username, language: c.language, category: c.category || null,
- last_14d: { sessions: c.sessions, hours: c.hours, peak_viewers: c.peak_viewers, avg_peak_viewers: c.avg_peak, chat_lines: c.chat_lines, new_followers: c.new_followers, arena_mic_moments: c.mic_moments },
+ last_14d: { sessions: c.sessions, hours: c.hours, peak_viewers: c.peak_viewers, avg_peak_viewers: c.avg_peak, chat_lines: c.chat_lines, new_followers: c.new_followers, rundown_takes: c.takes },
followers: c.followers, last_live: c.last_live_at,
followers: c.followers, last_live: c.last_live_at,
about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,
about: String(c.overview || c.bio || '').replace(/\s+/g, ' ').slice(0, 220) || null,
}));
}));
@@ -442,7 +442,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -442,7 +442,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
chat_messages: Number(chatStats.messages) || 0, chatters: Number(chatStats.chatters) || 0,
chat_messages: Number(chatStats.messages) || 0, chatters: Number(chatStats.chatters) || 0,
peak_viewers: Number(stream.peak_viewers) || 0, avg_viewers: avg.k ? Number(avg.a) : null,
peak_viewers: Number(stream.peak_viewers) || 0, avg_viewers: avg.k ? Number(avg.a) : null,
sound_commands: Number(chatStats.sounds) || 0,
sound_commands: Number(chatStats.sounds) || 0,
- mic_moments: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id])).n, 0),
+ rundown_takes: await safe(async () => Number((await db.get("SELECT COUNT(*) AS n FROM rundown_segments WHERE stream_id = ? AND status = 'live'", [stream.id])).n), 0),
follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),
follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),
tips: await safe(async () => await db.get('SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM channel_tips WHERE stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?)', [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),
tips: await safe(async () => await db.get('SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM channel_tips WHERE stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?)', [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),
top_chatters: topChatters,
top_chatters: topChatters,
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* recap.js — the after-show report. Every stream that ran long enough gets a recap page
* recap.js — the after-show report. Every stream that ran long enough gets a recap page
* (/recap/<streamId>) a few minutes after it ends: the viewer curve, chat numbers and top
* (/recap/<streamId>) a few minutes after it ends: the viewer curve, chat numbers and top
- * chatters, the best Arena mic lines, clips, tips and follows gained, the VOD — and an AI
+ * chatters, the stream's best Rundown takes, clips, tips and follows gained, the VOD — and an AI
* headline + summary written from all of it (template copy when AI is off).
* headline + summary written from all of it (template copy when AI is off).
*
*
* buildRecap(streamId) gather + (optionally) AI → { …recap } and store it
* buildRecap(streamId) gather + (optionally) AI → { …recap } and store it
@@ -49,8 +49,12 @@ async function gather(streamId) {
@@ -49,8 +49,12 @@ async function gather(streamId) {
.slice(0, 5);
.slice(0, 5);
const busiest = curve.length ? curve.reduce((best, p) => (Number(p.c) > Number(best.c) ? p : best), curve[0]) : null;
const busiest = curve.length ? curve.reduce((best, p) => (Number(p.c) > Number(best.c) ? p : best), curve[0]) : null;
- // Arena mic lines said on this stream.
- const micLines = await safe(async () => await db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);
+ // The Rundown's best takes on this stream (server/rundown), each with its quote when it has one.
+ const takes = await safe(async () => (await db.all(`SELECT s.title, s.quotes_json, s.start_sec, s.vod_id, t.title AS topic, t.slug FROM rundown_segments s
+ LEFT JOIN rundown_topics t ON t.id = s.topic_id WHERE s.stream_id = ? AND s.status = 'live' ORDER BY s.score DESC, s.start_sec ASC LIMIT 4`, [streamId])).map((s) => {
+ let q = null; try { q = JSON.parse(s.quotes_json || '[]')[0] || null; } catch { q = null; }
+ return { title: s.title, text: q ? q.text : null, en: q ? q.en || null : null, sec: q ? Number(q.sec) || 0 : Number(s.start_sec) || 0, vod_id: s.vod_id || null, topic: s.topic || null, slug: s.slug || null };
+ }), []);
// Money + love.
// Money + love.
// Tips from channel_tips: on-site Vibes, PowerChat and OpenVibe.Tips (a hidden amount counts as a tip, adds nothing).
// Tips from channel_tips: on-site Vibes, PowerChat and OpenVibe.Tips (a hidden amount counts as a tip, adds nothing).
@@ -90,7 +94,8 @@ async function gather(streamId) {
@@ -90,7 +94,8 @@ async function gather(streamId) {
streamer: { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, profile_color: user.profile_color || null },
streamer: { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, profile_color: user.profile_color || null },
viewers: { avg: viewersAvg, peak_at: peakAt ? peakAt.t : null, curve: curve.map(p => [p.t, Number(p.v) || 0, Number(p.c) || 0]) },
viewers: { avg: viewersAvg, peak_at: peakAt ? peakAt.t : null, curve: curve.map(p => [p.t, Number(p.v) || 0, Number(p.c) || 0]) },
chat: { messages: Number(chat.messages) || 0, chatters: Number(chat.chatters) || 0, top: topChatters.map(r => ({ username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url || null, profile_color: r.profile_color || null, n: Number(r.count) })), busiest_at: busiest && Number(busiest.c) > 0 ? busiest.t : null, busiest_n: busiest ? Number(busiest.c) : 0, sounds: Number(chat.sounds) || 0 },
chat: { messages: Number(chat.messages) || 0, chatters: Number(chat.chatters) || 0, top: topChatters.map(r => ({ username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url || null, profile_color: r.profile_color || null, n: Number(r.count) })), busiest_at: busiest && Number(busiest.c) > 0 ? busiest.t : null, busiest_n: busiest ? Number(busiest.c) : 0, sounds: Number(chat.sounds) || 0 },
- mic: micLines.map(m => ({ text: m.text, quality: m.quality, kind: m.kind, aimed_at: m.aimed_at || null, sec: m.sec, vod_id: m.vod_id || null })),
+ takes,
+ mic: [], // the Arena's mic lines are gone; an open tab of the previous release still reads mic.length
love: { tips: Number(tips.n) || 0, tips_total: Number(tips.total) || 0, top_tipper: topTipper ? { username: topTipper.username, display_name: topTipper.display_name || topTipper.username, total: Number(topTipper.total) } : null, follows, followers_now: followersNow },
love: { tips: Number(tips.n) || 0, tips_total: Number(tips.total) || 0, top_tipper: topTipper ? { username: topTipper.username, display_name: topTipper.display_name || topTipper.username, total: Number(topTipper.total) } : null, follows, followers_now: followersNow },
speech: { lines: speech.length, sample: speechSample },
speech: { lines: speech.length, sample: speechSample },
vod, clips,
vod, clips,
@@ -109,7 +114,7 @@ function templateWriteup(g) {
@@ -109,7 +114,7 @@ function templateWriteup(g) {
headline: `${name} went ${fmtDur(s.duration_seconds)} on "${String(s.title).slice(0, 40)}"`,
headline: `${name} went ${fmtDur(s.duration_seconds)} on "${String(s.title).slice(0, 40)}"`,
summary: bits.join(' ').slice(0, 420),
summary: bits.join(' ').slice(0, 420),
moment: g.clips[0] ? `Clip: ${g.clips[0].title}` : (g.chat.busiest_n >= 10 ? `Chat hit ${g.chat.busiest_n} lines in five minutes.` : ''),
moment: g.clips[0] ? `Clip: ${g.clips[0].title}` : (g.chat.busiest_n >= 10 ? `Chat hit ${g.chat.busiest_n} lines in five minutes.` : ''),
- tags: [s.category || 'stream', g.chat.messages >= 100 ? 'chatty' : 'chill', g.mic.length ? 'mic on fire' : 'good vibes'],
+ tags: [s.category || 'stream', g.chat.messages >= 100 ? 'chatty' : 'chill', g.takes.length ? 'on the Rundown' : 'good vibes'],
grade,
grade,
};
};
}
}
@@ -120,7 +125,7 @@ async function aiWriteup(g) {
@@ -120,7 +125,7 @@ async function aiWriteup(g) {
const facts = {
const facts = {
streamer: g.streamer.display_name, title: g.stream.title, category: g.stream.category, duration: fmtDur(g.stream.duration_seconds),
streamer: g.streamer.display_name, title: g.stream.title, category: g.stream.category, duration: fmtDur(g.stream.duration_seconds),
viewers: { peak: g.stream.peak_viewers, avg: g.viewers.avg }, chat: { messages: g.chat.messages, chatters: g.chat.chatters, top: g.chat.top.map(t => `${t.display_name} (${t.n})`), sound_commands: g.chat.sounds },
viewers: { peak: g.stream.peak_viewers, avg: g.viewers.avg }, chat: { messages: g.chat.messages, chatters: g.chat.chatters, top: g.chat.top.map(t => `${t.display_name} (${t.n})`), sound_commands: g.chat.sounds },
- mic_lines: g.mic.map(m => m.text), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,
+ best_lines: g.takes.map(t => t.text || t.title), clips: g.clips.map(c => c.title), tips: g.love.tips_total ? `${g.love.tips} tips, ${g.love.tips_total} total` : 'none', new_follows: g.love.follows,
what_was_said: g.speech.sample || '(no transcript)',
what_was_said: g.speech.sample || '(no transcript)',
};
};
// The prompt lives in OpenVibe.AI (workflow live.stream.recap).
// The prompt lives in OpenVibe.AI (workflow live.stream.recap).
@@ -61,7 +61,7 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
@@ -61,7 +61,7 @@ const last = (wf) => runs.filter((r) => r.workflow === wf).pop();
// The star: only a real, eligible candidate.
// The star: only a real, eligible candidate.
const star = require('../server/home/star-job');
const star = require('../server/home/star-job');
- const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, mic_moments: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },
+ const cands = [{ id: 1, username: 'ann', display_name: 'Ann', sessions: 3, hours: 5, peak_viewers: 2, avg_peak: 1, chat_lines: 10, new_followers: 1, takes: 0, followers: 4, last_live_at: '2026-09-27', overview: 'codes rust', language: 'en', category: 'desktop' },
{ id: 2, username: 'bob', display_name: 'Bob', sessions: 1, hours: 1 }];
{ id: 2, username: 'bob', display_name: 'Bob', sessions: 1, hours: 1 }];
answers['live.home.star'] = { username: 'ANN', headline: 'Rust all night', reason: 'You showed up five hours this week.' };
answers['live.home.star'] = { username: 'ANN', headline: 'Rust all night', reason: 'You showed up five hours this week.' };
const pick = await star._aiPick(cands, new Set(['bob']), 'bob');
const pick = await star._aiPick(cands, new Set(['bob']), 'bob');
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * The home digest ("Lately on OpenVibe") and the star pick read The Rundown, not the Arena's mic moments
+ * (server/home/routes.js, server/home/star-job.js):
+ * the digest counts the live takes since the window opened, and its hot lines are the best takes (score 4 and up):
+ * the quote when there is one, else the headline, linked by topic; never a hidden take or a banned streamer
+ * the star a candidate's takes over the last two weeks count toward the pick
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const db = require('../server/db/database');
+
+(async () => {
+await db.initDb();
+require('../server/chat/chat-reads').windowStatsPeek = async () => ({ messages: 0 });
+require('../server/chat/chat-reads').windowStats = async () => ({ messages: 0 });
+
+const ago = (min) => new Date(Date.now() - min * 60000).toISOString().replace('T', ' ').slice(0, 19);
+const mk = async (username, extra = {}) => {
+ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') });
+ const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id;
+ if (extra.banned) await db.run('UPDATE users SET is_banned = 1 WHERE id = ?', [id]);
+ return id;
+};
+const stream = async (uid) => (await db.run('INSERT INTO streams (user_id, title, is_live, started_at, ended_at, duration_seconds) VALUES (?, ?, 0, ?, ?, 3600) RETURNING id',
+ [uid, 'Night', ago(180), ago(120)])).lastInsertRowid;
+const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('league-autofill', 'League autofill', 'games', ?, ?) RETURNING id", [ago(150), ago(150)])).lastInsertRowid;
+const take = async (uid, sid, { title, score, quote = null, status = 'live', withTopic = true }) => db.run(
+ 'INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, said_at, status) VALUES (?, ?, ?, 10, 60, ?, ?, ?, ?, ?, ?)',
+ [sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);
+
+const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });
+const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban);
+await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });
+await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });
+await take(bob, sb, { title: 'A quiet stretch', score: 3 });
+await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });
+await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });
+
+const app = express();
+app.use('/api/home', require('../server/home/routes'));
+const server = http.createServer(app);
+await new Promise((res) => server.listen(0, '127.0.0.1', res));
+try {
+ const d = await (await fetch(`http://127.0.0.1:${server.address().port}/api/home/digest`)).json();
+ assert.strictEqual(d.stats.takes, 4, 'every live take since the window opened (the hidden one is not)');
+ assert.ok(!('mic_moments' in d.stats), 'the Arena\'s mic moments are gone');
+ assert.deepStrictEqual(d.hot.map((h) => [h.text, h.quoted, h.slug, h.username]), [
+ ['autofill put me on support again chat', true, 'league-autofill', 'annstreams'],
+ ['Bob on his new keyboard', false, null, 'bobstreams'],
+ ], 'the best takes: the quote, else the headline; never hidden, a 3, or a banned streamer');
+ console.log('✅ the digest reads The Rundown');
+} finally {
+ server.close();
+}
+
+const cands = await require('../server/home/star-job').candidates();
+const byName = Object.fromEntries(cands.map((c) => [c.username, c]));
+assert.strictEqual(Number(byName.annstreams.takes), 1, 'a live take counts toward the star pick (a hidden one does not)');
+assert.strictEqual(Number(byName.bobstreams.takes), 2);
+assert.ok(!byName.bannedone, 'a banned streamer is no candidate');
+console.log('✅ the star pick counts Rundown takes');
+
+console.log('home digest + star: ok');
+process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -17,7 +17,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -17,7 +17,11 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
chatReads.streamStats = async () => ({ messages: 14, chatters: 3, sounds: 0 });
chatReads.streamStats = async () => ({ messages: 14, chatters: 3, sounds: 0 });
chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];
chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];
await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);
await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);
- await db.run('INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)', [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
+ // The Rundown's takes on the stream: the best quoted one leads; a hidden one is never shown.
+ const topic = (await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES ('soldering-irons', 'Soldering irons', 'tech', ?, ?) RETURNING id", [ts(40), ts(40)])).lastInsertRowid;
+ await db.run('INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, score, quotes_json, vod_id, said_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
+ [sid, host, topic, 1190, 1260, 'The iron with rizz', 'hostess rated their soldering iron.', 4, JSON.stringify([{ text: 'my soldering iron has more rizz than your whole setup', sec: 1200, en: null }]), 77, ts(40)]);
+ await db.run("INSERT INTO rundown_segments (stream_id, user_id, start_sec, end_sec, title, what, score, said_at, status) VALUES (?,?,?,?,?,?,?,?,'hidden')", [sid, host, 10, 20, 'Taken down', 'x', 5, ts(60)]);
await require('../server/monetization/channel-tips').record({ toUserId: host, fromUserId: fan, streamId: sid, amount: 5, source: 'vibes', ref: 'live-txn:1' });
await require('../server/monetization/channel-tips').record({ toUserId: host, fromUserId: fan, streamId: sid, amount: 5, source: 'vibes', ref: 'live-txn:1' });
await require('../server/monetization/channel-tips').record({ toUserId: host, username: 'pcfan', streamId: sid, amount: 3, source: 'powerchat', ref: 'powerchat:evt_1' });
await require('../server/monetization/channel-tips').record({ toUserId: host, username: 'pcfan', streamId: sid, amount: 3, source: 'powerchat', ref: 'powerchat:evt_1' });
await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
@@ -34,7 +38,9 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -34,7 +38,9 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
assert.strictEqual(r.love.follows, 1);
assert.strictEqual(r.love.follows, 1);
assert.strictEqual(r.love.tips, 2); assert.strictEqual(r.love.tips_total, 8, 'on-site and PowerChat tips both count');
assert.strictEqual(r.love.tips, 2); assert.strictEqual(r.love.tips_total, 8, 'on-site and PowerChat tips both count');
assert.deepStrictEqual(r.love.top_tipper, { username: 'fan1', display_name: 'Fan1', total: 5 });
assert.deepStrictEqual(r.love.top_tipper, { username: 'fan1', display_name: 'Fan1', total: 5 });
- assert.strictEqual(r.mic.length, 1);
+ assert.deepStrictEqual(r.takes, [{ title: 'The iron with rizz', text: 'my soldering iron has more rizz than your whole setup', en: null, sec: 1200, vod_id: 77, topic: 'Soldering irons', slug: 'soldering-irons' }], 'the Rundown\'s takes, never a hidden one');
+ assert.deepStrictEqual(r.mic, [], 'the Arena\'s mic lines are gone (kept empty for the previous release\'s open tabs)');
+ assert.ok(r.write.tags.includes('on the Rundown'));
assert.strictEqual(r.viewers.curve.length, 12);
assert.strictEqual(r.viewers.curve.length, 12);
assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');
assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');
assert.ok(['S', 'A', 'B', 'C'].includes(r.write.grade));
assert.ok(['S', 'A', 'B', 'C'].includes(r.write.grade));