openvibers/openvibe.live Commits

Theme-aware section titles, an accessibility pass over all 34 palettes, and a shorter offline channel

OP OpenVibers committed 16 Sept 2026 · a8dcd785db · 14 files, +645 −147

Section headings carried fixed hues (#ef4444, #a78bfa, …) that ignored the reader's theme: on a
warm palette they read as imported colour, and across the 34 built-in themes 126 of 340
title×theme pairs fell below WCAG AA, the worst at 1.35:1. Each hue now mixes 45% into the
active --accent and 42% of that into --text-primary, so sections stay distinguishable from one
another while belonging to the page. Worst case is now 4.86:1, measured across all 34.

The palettes themselves were audited at the same time. --text-muted failed AA against --bg-card
on 33 of 34 themes (as low as 2.03:1) — it carries timestamps, view counts and "streamed 2h ago"
everywhere — and --accent failed on ten light themes (as low as 2.61:1), which also meant white
text on accent-filled buttons failed. Both are fixed by moving each value along its own palette's
axis (toward that theme's --text-primary / --accent-dark) rather than to grey, so Gruvbox still
reads as Gruvbox. Worst body contrast across all themes: 2.03 → 4.32.

Offline channels: the "while away" discovery board was 1101px of other people's content standing
between a phone visitor and the channel's own About tabs. It is now one 57px row that says how
many things happened this week, and opens on demand with a measured height animation.

Also:
- One go-live button instead of three. The tour's standalone "Go live" sat directly above the
  quest slot, which is itself a go-live button that knows where the reader is in their setup.
  Removed; the quest becomes a "Start streaming" launch button once the list is finished, and the
  home page's "Be the first to go live" is now a real button. All three route through one
  startGoLiveJourney() that remembers intent across sign-in.
- Six more setup tasks (moderator, media requests, chat rules, viewer controls, AI chat regular,
  paste), each backed by a real table so none can be permanently unreachable, and the hub now
  falls back to navigating when a task has no guided journey.
- Density: the auto-clamp measured the result of its own previous clamp, so a narrow grid
  oscillated between one and two columns. It now measures with its own override removed.
  Recently-online cards get a 188px floor: one per row on a small phone, two on a large one.
- Load more staggers the new cards in instead of splicing eight rows into one frame.
- Thumbnail view counts: frosted pill, tabular figures, compact counts (12.4K), matched to the
  duration badge that sits beside them.
- Usernames on the home activity and leaderboard cards open the chat user menu.
- Unread chat badges can no longer render "0" — visibility is derived from the count in one
  place, with an observer as a backstop.
- Recent Changes showed ~2 commits under a 70px fade: 220→320px box, 40px fade, 96→82px of tail.
- Startup changelog crashed on `execSync is not defined` after the /api/updates rework; converted
  to the async execFile already imported.

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

Changes

ddiff --git a/public/css/context-cards.css b/public/css/context-cards.css
iindex 20c16603..af293ee4 100644
---- a/public/css/context-cards.css
++++ b/public/css/context-cards.css
@@@ -106,3 +106,66 @@
.dsc--noleft .dsc-sec--side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); align-items: start; }
@media (max-width: 980px) { .dsc { grid-template-columns: 1fr; grid-template-areas: "head" "live" "clips" "side" "people"; } .dsc--noleft { grid-template-areas: "head" "side" "people"; } .dsc--noleft .dsc-sec--side { grid-template-columns: 1fr; } }
@media (max-width: 640px) { .dsc-live { grid-template-columns: 1fr 1fr; } .dsc-clips { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); } }
++
++/* ═══════════════════════════════════════════════════════════════
++ The "while away" board, collapsed.
++
++ On a phone this board was six sections of other people's content standing between a visitor and
++ the About panels / tabs of the channel they actually opened. It now starts as one row and opens
++ on demand. The count is the hook — it says how much has happened, which is the only reason to
++ look — and the row stays a real, obvious control rather than a heading that happens to be
++ clickable.
++ ═══════════════════════════════════════════════════════════════ */
++.dsc-wrap { width: 100%; margin: 14px 0 0; }
++.dsc-toggle {
++ position: relative; overflow: hidden; isolation: isolate;
++ display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 11px;
++ width: 100%; padding: 10px 13px; cursor: pointer; text-align: left; font: inherit;
++ color: var(--text-primary); border-radius: 14px;
++ border: 1px solid var(--border);
++ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--bg-card)), var(--bg-secondary));
++ transition: border-color .22s, box-shadow .22s, transform .18s;
++}
++.dsc-toggle:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); box-shadow: 0 8px 22px rgba(0,0,0,0.26); }
++.dsc-toggle:active { transform: scale(0.995); }
++.dsc-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++.dsc-toggle-ico {
++ width: 32px; height: 32px; flex: none; border-radius: 10px; display: grid; place-items: center;
++ font-size: 0.8rem; color: var(--accent);
++ background: color-mix(in srgb, var(--accent) 15%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
++}
++.dsc-toggle-text { min-width: 0; display: grid; gap: 1px; }
++.dsc-toggle-text b { font-size: 0.88rem; font-weight: 850; letter-spacing: -0.01em; }
++/* One line, clipped — the sub-line is context, never a reason for the row to grow taller. */
++.dsc-toggle-text small { font-size: 0.72rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.dsc-toggle-count {
++ flex: none; min-width: 26px; padding: 3px 8px; border-radius: 999px;
++ font-size: 0.76rem; font-weight: 900; text-align: center; color: #fff;
++ background: var(--accent);
++ box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent);
++}
++/* A single slow pulse ring on the count, only while the board is closed and only on screens that
++ want motion — once it is open the count has done its job. */
++.dsc-wrap:not(.is-open) .dsc-toggle-count { animation: dscPulse 2.8s ease-out infinite; }
++@keyframes dscPulse {
++ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
++ 70% { box-shadow: 0 0 0 9px transparent; }
++ 100% { box-shadow: 0 0 0 0 transparent; }
++}
++.dsc-toggle-chev { flex: none; color: var(--text-muted); transition: transform .3s cubic-bezier(.22,1,.36,1), color .2s; }
++.dsc-wrap.is-open .dsc-toggle-chev { transform: rotate(180deg); color: var(--accent); }
++.dsc-toggle:hover .dsc-toggle-chev { color: var(--accent); }
++/* The board no longer carries its own top margin — the wrapper owns the spacing now. */
++.dsc-wrap .dsc { margin-top: 10px; }
++
++@media (max-width: 560px) {
++ .dsc-toggle { gap: 9px; padding: 9px 11px; }
++ .dsc-toggle-ico { width: 29px; height: 29px; font-size: 0.74rem; }
++ .dsc-toggle-text b { font-size: 0.83rem; }
++ .dsc-toggle-text small { font-size: 0.68rem; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .dsc-toggle-count, .dsc-toggle-chev, .dsc-toggle { animation: none !important; transition: none !important; }
++}
++.rs-lite .dsc-toggle-count { animation: none !important; }
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 10d4d9b6..7da42c31 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -364,3 +364,90 @@
.ovg-quest-liquid { transition: none; }
.ovg-join-perks li { animation: none !important; }
}
++
++/* ═══════════════════════════════════════════════════════════════
++ The launch button — what the quest slot becomes once the setup list is finished, and what the
++ home page's empty "nobody is live" state offers. Both share one shape and one entry point
++ (startGoLiveJourney), so there is a single answer to "take me to streaming" everywhere.
++
++ The quest sells progress, so it fills with water. This sells the act, so it leans on a slow
++ aura and a sheen that crosses on hover — motion that reads as "ready", not "incomplete".
++ All of it is transform/opacity only and stops dead under reduced motion or rs-lite.
++ ═══════════════════════════════════════════════════════════════ */
++.ovg-launch, .ovg-firstlive {
++ position: relative; overflow: hidden; isolation: isolate; cursor: pointer;
++ font: inherit; color: var(--text-primary); text-align: left;
++ border-radius: 16px;
++ border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
++ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 12%, var(--bg-card)), var(--bg-secondary));
++ box-shadow: 0 10px 26px rgba(0,0,0,0.30), inset 0 1px 0 rgba(255,255,255,0.06);
++ transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s;
++}
++.ovg-launch {
++ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px;
++ width: min(560px, 100%); padding: 12px 15px;
++}
++.ovg-launch:hover, .ovg-firstlive:hover {
++ transform: translateY(-2px);
++ border-color: color-mix(in srgb, var(--accent) 80%, transparent);
++ box-shadow: 0 16px 38px rgba(0,0,0,0.42), 0 0 30px color-mix(in srgb, var(--accent) 26%, transparent);
++}
++.ovg-launch:active, .ovg-firstlive:active { transform: translateY(0) scale(0.99); }
++.ovg-launch:focus-visible, .ovg-firstlive:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
++
++/* A slow breathing glow behind the icon — the "on air" tell. */
++.ovg-launch-aura, .ovg-firstlive-ring {
++ position: absolute; z-index: 0; pointer-events: none; border-radius: 50%;
++ background: radial-gradient(circle, color-mix(in srgb, var(--accent) 34%, transparent), transparent 70%);
++}
++.ovg-launch-aura { left: -10px; top: 50%; width: 120px; height: 120px; transform: translateY(-50%); }
++.ovg-firstlive-ring { inset: -40% -10%; border-radius: 999px; }
++.fx-live .ovg-launch-aura, .fx-live .ovg-firstlive-ring { animation: ovgAura 3.6s ease-in-out infinite; }
++@keyframes ovgAura { 0%, 100% { opacity: 0.45; transform: translateY(-50%) scale(1); } 50% { opacity: 0.9; transform: translateY(-50%) scale(1.12); } }
++.ovg-firstlive-ring { transform: none; }
++@keyframes ovgAuraFlat { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.75; } }
++.fx-live .ovg-firstlive-ring { animation: ovgAuraFlat 3.6s ease-in-out infinite; }
++
++/* The sheen crosses once on hover rather than looping — a loop on a primary button is noise. */
++.ovg-launch-sheen, .ovg-firstlive-sheen {
++ position: absolute; inset: 0; z-index: 1; pointer-events: none; opacity: 0;
++ background: linear-gradient(105deg, transparent 38%, rgba(255,255,255,0.16) 50%, transparent 62%);
++ transform: translateX(-60%);
++}
++.ovg-launch:hover .ovg-launch-sheen, .ovg-firstlive:hover .ovg-firstlive-sheen {
++ opacity: 1; animation: ovgSheen 0.85s ease-out;
++}
++@keyframes ovgSheen { from { transform: translateX(-60%); } to { transform: translateX(60%); } }
++
++.ovg-launch-ico {
++ position: relative; z-index: 2; width: 38px; height: 38px; border-radius: 12px;
++ display: grid; place-items: center; font-size: 1rem; color: var(--accent);
++ background: color-mix(in srgb, var(--accent) 18%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
++}
++.ovg-launch-text { position: relative; z-index: 2; min-width: 0; display: grid; gap: 2px; }
++.ovg-launch-text b { font-size: 0.95rem; font-weight: 800; letter-spacing: -0.01em; }
++.ovg-launch-text small { font-size: 0.74rem; color: var(--text-muted); }
++.ovg-launch-go { position: relative; z-index: 2; color: var(--accent); transition: transform 0.2s; }
++.ovg-launch:hover .ovg-launch-go { transform: translateX(3px); }
++
++/* The home page's empty state is a single centred pill — there is no progress to explain there. */
++.ovg-firstlive {
++ display: inline-flex; align-items: center; gap: 10px;
++ margin-top: 6px; padding: 11px 20px; font-size: 0.92rem; font-weight: 800;
++}
++.ovg-firstlive > i, .ovg-firstlive > span { position: relative; z-index: 2; }
++.ovg-firstlive > i:first-of-type { color: var(--accent); }
++.ovg-firstlive-go { color: var(--accent); transition: transform 0.2s; }
++.ovg-firstlive:hover .ovg-firstlive-go { transform: translateX(3px); }
++
++@media (max-width: 560px) {
++ .ovg-launch { grid-template-columns: auto 1fr auto; gap: 10px; padding: 11px 12px; }
++ .ovg-launch-ico { width: 34px; height: 34px; }
++ .ovg-launch-text small { font-size: 0.7rem; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .ovg-launch-aura, .ovg-firstlive-ring, .ovg-launch-sheen, .ovg-firstlive-sheen { animation: none !important; }
++ .ovg-launch, .ovg-firstlive { transition: none; }
++}
++.rs-lite .ovg-launch-aura, .rs-lite .ovg-firstlive-ring { animation: none !important; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 13f846db..99eb4eba 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -1860,12 +1860,34 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.stream-card-thumb .protocol-badge {
position: absolute; bottom: 8px; left: 8px;
}
--.stream-card-viewers {
-- position: absolute; bottom: 8px; right: 8px;
-- background: rgba(0,0,0,0.7); color: #fff;
-- padding: 2px 8px; border-radius: var(--radius-sm);
-- font-size: 0.75rem;
-- display: flex; align-items: center; gap: 4px;
++/* The view count and the duration sit on the same thumbnail and used to be two different
++ objects: one a translucent grey box, the other a near-opaque black one, both with square-ish
++ corners and neither aligned with the other. They are one component now — a frosted pill with a
++ hairline, tabular figures so the width does not jitter as counts change, and a dimmed icon so
++ the number is what the eye lands on. */
++.stream-card-viewers,
++.stream-card-duration {
++ position: absolute; bottom: 7px;
++ display: inline-flex; align-items: center; gap: 5px;
++ padding: 3px 8px; border-radius: 999px;
++ font-size: 0.72rem; font-weight: 700; line-height: 1.25;
++ color: #fff;
++ font-variant-numeric: tabular-nums;
++ background: rgba(12, 12, 16, 0.62);
++ border: 1px solid rgba(255, 255, 255, 0.14);
++ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
++ -webkit-backdrop-filter: blur(9px) saturate(1.3);
++ backdrop-filter: blur(9px) saturate(1.3);
++ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
++}
++.stream-card-viewers { right: 7px; }
++.stream-card-viewers i { font-size: 0.68em; opacity: 0.75; }
++.stream-card-viewers b { font-weight: 800; letter-spacing: 0.01em; }
++/* A thumbnail the reader is hovering can afford a little more contrast. */
++.stream-card:hover .stream-card-viewers,
++.stream-card:hover .stream-card-duration { background: rgba(12, 12, 16, 0.78); border-color: rgba(255, 255, 255, 0.22); }
++@supports not (backdrop-filter: blur(4px)) {
++ .stream-card-viewers, .stream-card-duration { background: rgba(12, 12, 16, 0.82); }
}
.stream-card-nsfw {
position: absolute; top: 8px; right: 8px;
@@@ -2384,12 +2406,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
/* ═══════════════════════════════════════════════════════════════
STREAM CARD EXTRAS (duration, ago badge)
═══════════════════════════════════════════════════════════════ */
--.stream-card-duration {
-- position: absolute; bottom: 8px; left: 8px;
-- background: rgba(0,0,0,0.8); color: #fff;
-- padding: 2px 6px; border-radius: var(--radius-sm);
-- font-size: 0.7rem; font-weight: 600;
--}
++/* Shape and colour come from the shared rule alongside .stream-card-viewers; only the side differs. */
++.stream-card-duration { left: 7px; }
.stream-card-ago {
margin-left: auto;
font-size: 0.75rem;
@@@ -9033,28 +9051,36 @@ body.theme-light .user-dropdown {
HOME CHANGELOG
═══════════════════════════════════════════════════════════════ */
/* Collapsible wrapper */
++/* Collapsed, this box was 220px tall with a 70px fade over it — a third of the visible area
++ washed out, which left roughly two commits legible and a lot of gradient. It is now taller and
++ the fade is shorter, so the same box shows about twice as many entries. The tail is tightened
++ too: 24px of margin under a card that is already the last thing on the page, above a 32px page
++ padding and the footer's own margin, added up to ~96px of nothing. */
.home-changelog-wrapper {
position: relative;
-- margin-bottom: 24px;
++ margin-bottom: 10px;
border-radius: 12px;
overflow: hidden;
transition: max-height 0.35s ease;
}
.home-changelog-wrapper.collapsed {
-- max-height: 220px;
++ max-height: 360px;
}
.home-changelog-wrapper.expanded {
max-height: 2000px;
}
--/* Gradient fade overlay when collapsed */
++/* Gradient fade overlay when collapsed. Held to the last 40px and kept transparent for the first
++ half of that, so it reads as "there is more" rather than as an empty band. --bg was never a
++ defined token here, so every theme fell through to the hard-coded #0e0e12; --bg-primary is the
++ variable the themes actually set. */
.home-changelog-fade {
display: none;
position: absolute;
bottom: 0;
left: 0;
right: 0;
-- height: 70px;
-- background: linear-gradient(to bottom, transparent, var(--bg, #0e0e12) 90%);
++ height: 40px;
++ background: linear-gradient(to bottom, transparent, var(--bg-primary, #0e0e12) 92%);
pointer-events: none;
border-radius: 0 0 12px 12px;
}
@@@ -9066,9 +9092,13 @@ body.theme-light .user-dropdown {
background: var(--bg-secondary, rgba(255,255,255,0.03));
border: 1px solid var(--border);
border-radius: 12px;
-- padding: 16px 20px;
++ padding: 14px 16px 10px;
overflow: hidden;
}
++@media (max-width: 560px) {
++ .home-changelog-wrapper.collapsed { max-height: 320px; }
++ .home-changelog { padding: 12px 13px 8px; }
++}
/* ═══════════════════════════════════════════════════════════════
UPDATES / CHANGELOG
@@@ -14899,7 +14929,14 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
Falls back to the theme accent when --sc is not set, so headers on other pages are unchanged
apart from the new shape. */
.section-header {
++ /* Each section carries a hue so they can be told apart while scrolling. Those hues were used
++ raw, which meant a fixed blue/purple/pink/teal sitting on top of whatever palette the
++ reader had chosen — on a warm theme they read as foreign. --sc-on blends the section hue
++ into the active accent so it stays a *variant of the theme* rather than an imported colour,
++ and the title is mixed toward --text-primary so it is legible on any background the theme
++ provides. Sections still differ from one another; they just belong to the page now. */
--sc: var(--accent);
++ --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
display: flex; align-items: center; gap: 11px;
margin: 34px 0 14px; justify-content: flex-start; flex-wrap: nowrap;
position: relative;
@@@ -14907,17 +14944,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.section-header h2 {
display: flex; align-items: center; gap: 10px; flex: none;
font-size: clamp(1.05rem, 2.6vw, 1.32rem); font-weight: 850; letter-spacing: -0.02em;
-- color: color-mix(in srgb, var(--sc) 58%, var(--text-primary));
++ color: color-mix(in srgb, var(--sc-on) 42%, var(--text-primary));
margin: 0; white-space: nowrap;
}
/* The icon gets a tile so the colour reads as a label, not as decoration on a letter. */
.section-header h2 > i:first-child {
width: 30px; height: 30px; flex: none; border-radius: 10px;
display: grid; place-items: center; font-size: 0.82rem;
-- color: var(--sc);
-- background: color-mix(in srgb, var(--sc) 15%, transparent);
-- border: 1px solid color-mix(in srgb, var(--sc) 34%, transparent);
-- box-shadow: 0 0 16px color-mix(in srgb, var(--sc) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);
++ color: var(--sc-on);
++ background: color-mix(in srgb, var(--sc-on) 15%, transparent);
++ border: 1px solid color-mix(in srgb, var(--sc-on) 34%, transparent);
++ box-shadow: 0 0 16px color-mix(in srgb, var(--sc-on) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.06);
}
/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. */
.section-header h2 > i.live-dot {
@@@ -14926,22 +14963,22 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
}
.section-kicker {
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase;
-- color: color-mix(in srgb, var(--sc) 62%, var(--text-muted));
++ color: color-mix(in srgb, var(--sc-on) 62%, var(--text-muted));
padding: 3px 8px; border-radius: 999px; align-self: center;
-- background: color-mix(in srgb, var(--sc) 10%, transparent);
-- border: 1px solid color-mix(in srgb, var(--sc) 22%, transparent);
++ background: color-mix(in srgb, var(--sc-on) 10%, transparent);
++ border: 1px solid color-mix(in srgb, var(--sc-on) 22%, transparent);
white-space: nowrap;
}
/* A rule that carries the section colour out across the page and fades. */
.section-header::after {
content: ''; flex: 1 1 auto; height: 1px; min-width: 18px; align-self: center;
-- background: linear-gradient(90deg, color-mix(in srgb, var(--sc) 46%, transparent), transparent 88%);
++ background: linear-gradient(90deg, color-mix(in srgb, var(--sc-on) 46%, transparent), transparent 88%);
}
/* Links like "View All" belong at the far right; a count badge belongs against the title it
counts, not marooned on the other side of the page. */
.section-header > :not(h2) { flex: none; order: 3; margin-left: 10px; min-width: 0; }
.section-header > .badge { order: 1; margin-left: 0; }
--.section-header .badge { background: var(--sc); }
++.section-header .badge { background: var(--sc-on); }
/* Some headers carry a sentence of their own (the star spotlight does). Let it take the slack
the rule would have used, and clip rather than overflow the row. */
.section-header > span:not(.badge), .section-header > p, .section-header > .muted {
@@@ -15256,20 +15293,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
fades. Spacing is tightened at the same time; the old margins left a dead band above each one. */
.cta-section-label {
--sc: var(--accent);
++ --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
display: flex; align-items: center; gap: 10px; width: 100%;
margin: 26px 0 12px; font-size: 0.8rem; letter-spacing: 0.05em;
-- color: color-mix(in srgb, var(--sc) 62%, var(--text-primary)) !important;
++ color: color-mix(in srgb, var(--sc-on) 42%, var(--text-primary)) !important;
}
.cta-section-label > i:first-child {
width: 28px; height: 28px; flex: none; border-radius: 50%;
display: grid; place-items: center; line-height: 1; font-size: 0.76rem;
-- color: var(--sc);
-- background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--sc) 24%, transparent), color-mix(in srgb, var(--sc) 10%, transparent));
-- border: 1px solid color-mix(in srgb, var(--sc) 30%, transparent);
++ color: var(--sc-on);
++ background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--sc-on) 24%, transparent), color-mix(in srgb, var(--sc-on) 10%, transparent));
++ border: 1px solid color-mix(in srgb, var(--sc-on) 30%, transparent);
}
.cta-section-label::after {
content: ''; flex: 1 1 auto; height: 1px; min-width: 16px;
-- background: linear-gradient(90deg, color-mix(in srgb, var(--sc) 42%, transparent), transparent 88%);
++ background: linear-gradient(90deg, color-mix(in srgb, var(--sc-on) 42%, transparent), transparent 88%);
}
.home-cta-features { margin: 2px 0 6px; gap: 12px; }
.cta-lede { margin: 0 0 12px; }
@@@ -15300,3 +15338,34 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* The density control sits at the end of a section header, after the "View all" link. */
.section-header .ovd { order: 4; margin-left: 8px; }
@media (max-width: 420px) { .section-header .ovd { display: none; } }
++
++/* ═══════════════════════════════════════════════════════════════
++ .ov-user — a username outside chat that opens the same menu chat names do.
++ Bold like the surrounding sentence so the copy still reads naturally, but with an underline on
++ hover and a real focus ring, because it is a control and needs to look like one.
++ ═══════════════════════════════════════════════════════════════ */
++.ov-user {
++ font-weight: 800; color: var(--text-primary); cursor: pointer;
++ border-radius: 4px; text-decoration: none;
++ text-underline-offset: 2px; transition: color .15s;
++}
++.ov-user:hover { color: var(--accent); text-decoration: underline; text-decoration-thickness: 1.5px; }
++.ov-user:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++
++
++/* ═══════════════════════════════════════════════════════════════
++ .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
++ in a half-second queue). `both` keeps the pre-delay state hidden; ovPutCards() strips the class
++ on a timer afterwards, so an element whose animation never ran is never left invisible.
++ ═══════════════════════════════════════════════════════════════ */
++@keyframes ovEnter {
++ from { opacity: 0; transform: translateY(15px) scale(0.985); }
++ to { opacity: 1; transform: none; }
++}
++.ov-enter {
++ animation: ovEnter 0.46s cubic-bezier(.22, 1, .36, 1) both;
++ animation-delay: calc(var(--ov-enter-i, 0) * 45ms);
++ will-change: transform, opacity;
++}
++@media (prefers-reduced-motion: reduce) { .ov-enter { animation: none !important; } }
ddiff --git a/public/index.html b/public/index.html
iindex bb3e1ba2..332a46a6 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,9 +88,9 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=196">
++ <link rel="stylesheet" href="/css/style.css?v=200">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
-- <link rel="stylesheet" href="/css/home-tour.css?v=8">
++ <link rel="stylesheet" href="/css/home-tour.css?v=9">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@@ -99,7 +99,7 @@
<link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
-- <link rel="stylesheet" href="/css/context-cards.css?v=3" media="print" onload="this.media='all';this.onload=null">
++ <link rel="stylesheet" href="/css/context-cards.css?v=4" media="print" onload="this.media='all';this.onload=null">
<noscript>
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
@@@ -107,7 +107,7 @@
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/recap.css?v=1">
-- <link rel="stylesheet" href="/css/context-cards.css?v=3">
++ <link rel="stylesheet" href="/css/context-cards.css?v=4">
</noscript>
<!-- JSON-LD Structured Data -->
@@@ -329,7 +329,16 @@
<div class="empty-state" id="no-live-streams" style="display:none">
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<p>No one is streaming right now</p>
-- <p class="muted">Be the first to go live!</p>
++ <!-- This was a line of grey text telling people to go live with no way to do it.
++ It is the same button as the one under the tour, routed through the same
++ entry point, so signing in lands you back in the go-live journey. -->
++ <button type="button" class="ovg-firstlive" onclick="if (window.startGoLiveJourney) startGoLiveJourney(); else location.href='/broadcast';">
++ <span class="ovg-firstlive-ring" aria-hidden="true"></span>
++ <span class="ovg-firstlive-sheen" aria-hidden="true"></span>
++ <i class="fa-solid fa-tower-broadcast"></i>
++ <span>Be the first to go live</span>
++ <i class="fa-solid fa-arrow-right ovg-firstlive-go"></i>
++ </button>
</div>
</div>
@@@ -729,7 +738,7 @@
</div>
<!-- OpenVibe.Live Changelog -->
-- <div class="section-header" style="--sc:#94a3b8" style="margin-top:32px;">
++ <div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
<h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
<div style="display:flex;gap:8px;align-items:center;">
<button id="home-changelog-toggle" class="badge" onclick="toggleHomeChangelog()" style="cursor:pointer;border:none;background:var(--surface);padding:4px 10px;border-radius:6px;color:var(--text);font-size:0.78rem;">Show All</button>
@@@ -5085,14 +5094,14 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
--<script src="/js/ov-density.js?v=1" defer></script>
--<script src="/js/app.js?v=169" defer></script>
++<script src="/js/ov-density.js?v=3" defer></script>
++<script src="/js/app.js?v=172" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
--<script src="/js/chat.js?v=82" defer></script>
++<script src="/js/chat.js?v=83" defer></script>
<script src="/js/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" defer></script>
@@@ -5151,7 +5160,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/broadcast-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.js?v=2" defer></script>
<script src="/js/broadcast-workspace.js?v=16" defer></script>
--<script src="/js/broadcast.js?v=29" defer></script>
++<script src="/js/broadcast.js?v=30" defer></script>
<script src="/js/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" defer></script>
@@@ -5160,7 +5169,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/ai-viewers.js?v=3" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/recap.js?v=1" defer></script>
--<script src="/js/context-cards.js?v=3" defer></script>
++<script src="/js/context-cards.js?v=4" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
@@@ -5170,9 +5179,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
--<script src="/js/home-tour.js?v=8" defer></script>
++<script src="/js/home-tour.js?v=9" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
--<script src="/js/guides-live.js?v=6" defer></script>
++<script src="/js/guides-live.js?v=7" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js?v=1" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 41b1e5c3..83020380 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -919,6 +919,83 @@ function isModifiedLinkClick(event) {
return !!(event && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey));
}
++/**
++ * A username outside chat that behaves like a username inside chat.
++ *
++ * Names on the home page's activity and leaderboard cards were either plain bold text or a bare
++ * link to the channel. In chat the same name opens a menu — message them, view the channel, mod
++ * actions if you have them — and people reasonably expect that everywhere a name appears. This
++ * emits the same data-* contract showChatContextMenu() reads, so one menu serves both.
++ *
++ * `u` may be an object ({username, display_name, user_id}) or a bare username string.
++ */
++function ovUserTag(u, label) {
++ const core = typeof u === 'string' ? u : (u && (u.username || u.core_username)) || '';
++ const name = label || (typeof u === 'string' ? u : (u && (u.display_name || u.username))) || core;
++ if (!core) return esc(name || '');
++ const id = (typeof u === 'object' && u && (u.user_id ?? u.id)) ?? '';
++ return `<span class="ov-user" role="button" tabindex="0" title="${esc(name)} — open menu"`
++ + ` data-username="${esc(name)}" data-core-username="${esc(core)}" data-user-id="${esc(String(id))}"`
++ + ` onclick="ovUserMenu(event)" oncontextmenu="ovUserMenu(event)"`
++ + ` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ovUserMenu(event);}">${esc(name)}</span>`;
++}
++
++/** Open the chat user menu; if chat has not loaded, fall back to the channel. */
++function ovUserMenu(event) {
++ if (typeof showChatContextMenu === 'function') return showChatContextMenu(event);
++ const el = event.currentTarget;
++ const core = el && el.dataset && el.dataset.coreUsername;
++ if (!core) return;
++ event.preventDefault();
++ navigate(`/@${core}`);
++}
++
++/**
++ * Insert cards into a rail, and let appended ones arrive rather than appear.
++ *
++ * "Load more" used to splice eight cards into the DOM in one frame: the list jumped and there was
++ * no visual link between pressing the button and the new rows. Only the new children are
++ * staggered — re-animating what was already on screen would read as a full page repaint. The
++ * class is stripped afterwards so it cannot affect a later re-render, and that cleanup runs on a
++ * timer rather than on animationend, so an element whose animation never started (off-screen,
++ * display:none) is still returned to normal.
++ */
++function ovPutCards(container, html, append) {
++ if (!container) return;
++ if (!append) { container.innerHTML = html; return; }
++ const first = container.children.length;
++ container.insertAdjacentHTML('beforeend', html);
++ let reduce = false;
++ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
++ if (reduce) return;
++ const added = Array.prototype.slice.call(container.children, first);
++ added.forEach((el, i) => {
++ el.classList.add('ov-enter');
++ el.style.setProperty('--ov-enter-i', String(Math.min(i, 11)));
++ });
++ setTimeout(() => added.forEach(el => {
++ el.classList.remove('ov-enter');
++ el.style.removeProperty('--ov-enter-i');
++ }), 1400);
++}
++
++/**
++ * The view count on a thumbnail.
++ *
++ * Was a bare eye glyph and a raw integer on a flat black box. Now a frosted pill with tabular
++ * figures and a compact count, so 12400 reads as 12.4K instead of widening the badge past the
++ * corner of the card.
++ */
++function ovViewsBadge(n) {
++ const v = Math.max(0, Number(n) || 0);
++ // Thresholds are set just below the round number, not at it: 999,999 rounds to 1000 in the
++ // K branch and would render "1000K".
++ const label = v >= 999500 ? (v / 1000000).toFixed(v >= 9999500 ? 0 : 1).replace(/\.0$/, '') + 'M'
++ : v >= 999.5 ? (v / 1000).toFixed(v >= 9999.5 ? 0 : 1).replace(/\.0$/, '') + 'K'
++ : String(v);
++ return `<span class="stream-card-viewers" title="${v.toLocaleString()} view${v === 1 ? '' : 's'}"><i class="fa-solid fa-eye"></i><b>${label}</b></span>`;
++}
++
function handleLinkClick(event, urlPath, replace = false) {
if (isModifiedLinkClick(event)) return true;
event?.preventDefault?.();
@@@ -2374,15 +2451,15 @@ async function loadHomePulse() {
// Latest activity card (tip + follow together).
const act = [];
-- if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> <b>${esc(p.latestTip.from_display || p.latestTip.from_username || 'Someone')}</b> tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to <b>${esc(p.latestTip.to_display || p.latestTip.to_username)}</b> <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);
-- if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> <b>${esc(p.newestFollow.follower_display || p.newestFollow.follower_username)}</b> followed <b>${esc(p.newestFollow.streamer_display || p.newestFollow.streamer_username)}</b> <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);
++ if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> ${ovUserTag({ username: p.latestTip.from_username, display_name: p.latestTip.from_display }, p.latestTip.from_display || p.latestTip.from_username || 'Someone')} tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to ${ovUserTag({ username: p.latestTip.to_username, display_name: p.latestTip.to_display })} <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);
++ if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> ${ovUserTag({ username: p.newestFollow.follower_username, display_name: p.newestFollow.follower_display })} followed ${ovUserTag({ username: p.newestFollow.streamer_username, display_name: p.newestFollow.streamer_display })} <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);
if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);
// Weekly leader teasers.
const board = (title, icon, rows, unit) => rows && rows.length ? `
<div class="pulse-card">
<div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>
-- ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> <a href="/@${esc(r.username)}" onclick="return handleLinkClick(event, '/@${esc(r.username)}')">${esc(r.display_name || r.username)}</a> <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}
++ ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> ${ovUserTag(r)} <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}
</div>` : '';
const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');
const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');
@@@ -2462,8 +2539,11 @@ async function loadHomeRecentOnline(page, append) {
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
if (window.OVDensity) {
++ // Recently-online cards are wider than a thumbnail — avatar, name and last-seen line
++ // all sit on one row — so they need more room before two columns stop being cramped.
++ // 188px puts a small phone on one card per row and a large one on two.
OVDensity.attach(document.getElementById('stream-grid-recent'),
-- { key: 'recent', header: document.getElementById('stream-grid-recent')?.previousElementSibling });
++ { key: 'recent', minCard: 188, header: document.getElementById('stream-grid-recent')?.previousElementSibling });
}
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
} catch { /* silent */ }
@@@ -2476,7 +2556,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
return;
}
-- const _put = (html) => { if (append) container.insertAdjacentHTML('beforeend', html); else container.innerHTML = html; };
++ const _put = (html) => ovPutCards(container, html, append);
_put(streamers.map(s => {
const msList = (s.managed_streams || []);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
@@@ -2532,7 +2612,7 @@ async function loadHomeRecentVods(page, append) {
if (!header || !grid) return;
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
header.style.display = '';
-- const _put = (html) => { if (append) grid.insertAdjacentHTML('beforeend', html); else grid.innerHTML = html; };
++ const _put = (html) => ovPutCards(grid, html, append);
_put(vods.map(v => {
const href = `/vod/${v.id}`;
return `
@@@ -2540,7 +2620,7 @@ async function loadHomeRecentVods(page, append) {
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${v.duration_seconds ? `<span class="stream-card-duration">${formatDuration(v.duration_seconds)}</span>` : ''}
-- <span class="stream-card-viewers"><i class="fa-solid fa-eye"></i> ${v.view_count || 0}</span>
++ ${ovViewsBadge(v.view_count)}
</div>
<div class="stream-card-info">
<div class="stream-card-title">${esc(v.title || 'VOD')}</div>
@@@ -2571,12 +2651,12 @@ async function loadHomeClips(page, append) {
const grid = document.getElementById('home-clips-grid');
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
-- const _put = (html) => { if (append) grid.insertAdjacentHTML('beforeend', html); else grid.innerHTML = html; };
++ const _put = (html) => ovPutCards(grid, html, append);
_put(clips.map(c => `
<a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
-- <span class="stream-card-viewers"><i class="fa-solid fa-eye"></i> ${c.view_count || 0}</span>
++ ${ovViewsBadge(c.view_count)}
${c.duration_seconds ? `<span class="stream-card-duration">${formatDuration(c.duration_seconds)}</span>` : ''}
</div>
<div class="stream-card-info">
@@@ -2607,7 +2687,7 @@ async function loadHomePastes(page, append) {
const list = document.getElementById('home-pastes-list');
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
-- const _put = (html) => { if (append) list.insertAdjacentHTML('beforeend', html); else list.innerHTML = html; };
++ const _put = (html) => ovPutCards(list, html, append);
_put(pastes.map(p => {
const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');
const preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';
@@@ -3317,7 +3397,7 @@ function _renderClipsTakenGrid(username, data) {
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
-- <span class="stream-card-viewers"><i class="fa-solid fa-eye"></i> ${cl.view_count || 0}</span>
++ ${ovViewsBadge(cl.view_count)}
<span class="stream-card-duration">${formatDuration(cl.duration_seconds)}</span>
</div>
<div class="stream-card-info">
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 6cbcb991..e5846927 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -4544,8 +4544,11 @@ function toggleBroadcastMobileChat() {
if (_bcMobileChatOpen) {
const msgs = document.getElementById('bc-chat-messages');
if (msgs) msgs.scrollTop = msgs.scrollHeight;
++ // setUnreadBadge lives in chat.js and is the single decision point for badge visibility;
++ // fall back to the manual reset if chat has not loaded on this page.
const badge = document.getElementById('bc-mobile-chat-badge');
-- if (badge) { badge.style.display = 'none'; badge.textContent = '0'; }
++ if (typeof setUnreadBadge === 'function') setUnreadBadge(badge, 0);
++ else if (badge) { badge.style.display = 'none'; badge.textContent = '0'; }
}
}
/** Show/hide the broadcast mobile chat FAB based on screen size and live state */
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 0b12c2a1..5fb38ca0 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -6489,20 +6489,57 @@ function toggleMobileChat() {
if (_mobileChatOpen) {
_mobileChatUnread = 0;
-- const badge = document.getElementById('mobile-chat-badge');
-- if (badge) { badge.style.display = 'none'; badge.textContent = '0'; }
++ setUnreadBadge(document.getElementById('mobile-chat-badge'), 0);
scrollChat();
}
}
++/**
++ * One place that decides whether an unread badge is on screen.
++ *
++ * Three call sites each set .textContent and .style.display by hand, and a badge reading "0" was
++ * reaching the screen — a count of nothing, drawn in alert red, is worse than no badge at all.
++ * Visibility is now a function of the number, and setUnreadBadge is the only way to change it.
++ * The observer is the backstop: if anything else ever clears the inline display while the count
++ * is zero, it gets hidden again on the next tick.
++ */
++function setUnreadBadge(el, n) {
++ if (!el) return;
++ const count = Math.max(0, Number(n) || 0);
++ el.textContent = count > 99 ? '99+' : String(count);
++ el.style.display = count > 0 ? '' : 'none';
++ el.hidden = count === 0;
++}
++
++(function guardUnreadBadges() {
++ const IDS = ['mobile-chat-badge', 'bc-mobile-chat-badge', 'fcw-unread-badge'];
++ const isZero = (el) => {
++ const t = (el.textContent || '').trim();
++ return t === '' || t === '0';
++ };
++ const sweep = () => {
++ for (const id of IDS) {
++ const el = document.getElementById(id);
++ if (el && isZero(el) && el.style.display !== 'none') { el.style.display = 'none'; el.hidden = true; }
++ }
++ };
++ const start = () => {
++ sweep();
++ for (const id of IDS) {
++ const el = document.getElementById(id);
++ if (!el) continue;
++ try {
++ new MutationObserver(sweep).observe(el, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['style'] });
++ } catch { /* */ }
++ }
++ };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
++})();
++
function incrementMobileChatUnread() {
if (!isMobileChatLayout() || _mobileChatOpen) return;
_mobileChatUnread++;
-- const badge = document.getElementById('mobile-chat-badge');
-- if (badge) {
-- badge.textContent = _mobileChatUnread > 99 ? '99+' : _mobileChatUnread;
-- badge.style.display = '';
-- }
++ setUnreadBadge(document.getElementById('mobile-chat-badge'), _mobileChatUnread);
}
// Hook into addChatMessage to count unread on mobile
@@@ -6667,8 +6704,7 @@ function fcwToggle() {
if (_fcwOpen) {
// Clear unread
_fcwUnread = 0;
-- const badge = document.getElementById('fcw-unread-badge');
-- if (badge) badge.style.display = 'none';
++ setUnreadBadge(document.getElementById('fcw-unread-badge'), 0);
// Ensure chat is connected (global)
if (!chatWs || chatWs.readyState !== WebSocket.OPEN) {
@@@ -6795,11 +6831,7 @@ function _fcwAddMessage(msg) {
const isOnChatPage = chatPage && chatPage.classList.contains('active');
if (!isOnChatPage) {
_fcwUnread++;
-- const badge = document.getElementById('fcw-unread-badge');
-- if (badge) {
-- badge.textContent = _fcwUnread > 99 ? '99+' : String(_fcwUnread);
-- badge.style.display = '';
-- }
++ setUnreadBadge(document.getElementById('fcw-unread-badge'), _fcwUnread);
}
}
}
ddiff --git a/public/js/context-cards.js b/public/js/context-cards.js
iindex 97e55bd2..8527e8b9 100644
---- a/public/js/context-cards.js
++++ b/public/js/context-cards.js
@@@ -165,6 +165,72 @@
}
if (!sections.length) { host.innerHTML = ''; return; }
const noLeft = !(d.live || []).length && !(d.clips || []).length;
-- host.innerHTML = `<div class="dsc${noLeft ? ' dsc--noleft' : ''}"><div class="dsc-head"><span class="dsc-title"><i class="fa-solid fa-compass"></i> While ${esc(name)} is away</span><span class="dsc-sub">the rest of OpenVibe is right here — follow ${esc(name)} to get pinged when they're back</span></div>${sections.join('')}</div>`;
++
++ // This board is a whole second page of content sitting under an offline channel. Someone
++ // who came for this streamer should meet a single line about it, not scroll past six
++ // sections of other people's clips to reach the About panels. So it starts closed, and
++ // the toggle carries a count — "17 things happened" is a reason to open it; "Discover" is
++ // not. Opening is remembered per channel, closing forgets it again.
++ const events = (d.live || []).length + (d.clips || []).length + (d.recaps || []).length;
++ const KEY = 'ov_dsc_open_v1';
++ const openSet = (() => { try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } catch { return new Set(); } })();
++ const startOpen = openSet.has(username || '');
++
++ host.innerHTML = `
++ <div class="dsc-wrap${startOpen ? ' is-open' : ''}">
++ <button type="button" class="dsc-toggle" aria-expanded="${startOpen}" aria-controls="dsc-body">
++ <span class="dsc-toggle-ico" aria-hidden="true"><i class="fa-solid fa-compass"></i></span>
++ <span class="dsc-toggle-text">
++ <b>While ${esc(name)} is away</b>
++ <small>${events ? `${events} thing${events === 1 ? '' : 's'} happened on OpenVibe this week` : 'the rest of OpenVibe is right here'} · follow ${esc(name)} to get pinged when they're back</small>
++ </span>
++ ${events ? `<span class="dsc-toggle-count" aria-hidden="true">${events}</span>` : ''}
++ <span class="dsc-toggle-chev" aria-hidden="true"><i class="fa-solid fa-chevron-down"></i></span>
++ </button>
++ <div class="dsc-body" id="dsc-body"${startOpen ? '' : ' hidden'}>
++ <div class="dsc${noLeft ? ' dsc--noleft' : ''}">${sections.join('')}</div>
++ </div>
++ </div>`;
++
++ const wrap = host.querySelector('.dsc-wrap');
++ const btn = host.querySelector('.dsc-toggle');
++ const body = host.querySelector('.dsc-body');
++ let busy = false;
++ btn.addEventListener('click', () => {
++ if (busy) return;
++ const opening = body.hidden;
++ busy = true;
++ btn.setAttribute('aria-expanded', String(opening));
++ wrap.classList.toggle('is-open', opening);
++ try {
++ if (opening) openSet.add(username || ''); else openSet.delete(username || '');
++ localStorage.setItem(KEY, JSON.stringify([...openSet]));
++ } catch { /* */ }
++
++ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
++ if (reduce) { body.hidden = !opening; busy = false; return; }
++
++ // Animate the real height rather than a max-height guess: the board's height varies by
++ // an order of magnitude depending on how much happened, and a fixed max-height either
++ // clips it or spends most of the transition animating empty space.
++ if (opening) {
++ body.hidden = false;
++ const h = body.scrollHeight;
++ body.style.height = '0px'; body.style.overflow = 'hidden';
++ requestAnimationFrame(() => {
++ body.style.transition = 'height .42s cubic-bezier(.22,1,.36,1), opacity .3s ease';
++ body.style.opacity = '0';
++ requestAnimationFrame(() => { body.style.height = h + 'px'; body.style.opacity = '1'; });
++ });
++ setTimeout(() => { body.style.cssText = ''; busy = false; }, 460);
++ } else {
++ body.style.height = body.scrollHeight + 'px'; body.style.overflow = 'hidden';
++ requestAnimationFrame(() => {
++ body.style.transition = 'height .34s cubic-bezier(.4,0,.2,1), opacity .24s ease';
++ requestAnimationFrame(() => { body.style.height = '0px'; body.style.opacity = '0'; });
++ });
++ setTimeout(() => { body.hidden = true; body.style.cssText = ''; busy = false; }, 370);
++ }
++ });
};
})();
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex ae963860..08c55f6f 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -21,7 +21,7 @@
const CATS = [['irl', 'IRL', 'fa-person-walking'], ['outdoors', 'Outdoors', 'fa-mountain-sun'], ['travel', 'Travel', 'fa-plane'], ['building', 'Building / Craft', 'fa-hammer'], ['music', 'Music', 'fa-music'], ['gaming', 'Gaming', 'fa-gamepad'], ['robot', 'Robot', 'fa-robot'], ['desktop', 'Desktop', 'fa-desktop'], ['other', 'Other', 'fa-sparkles']];
const PLAT = { twitch: ['Twitch', 'fa-brands fa-twitch', '#a970ff'], youtube: ['YouTube', 'fa-brands fa-youtube', '#ff4b4b'], kick: ['Kick', 'fa-solid fa-bolt', '#53fc18'], custom: ['Any RTMP', 'fa-solid fa-tower-broadcast', '#e5e7eb'] };
const TASK_JOURNEY = { slot: ['golive', 'stream'], method: ['golive', 'method'], restream: ['golive', 'restream'], golive: ['golive', 'golive'], profile: ['profile'], offline: ['offline'], emote: ['emote'], sound: ['sound'], goal: ['goal'], powerchat: ['powerchat'], panels: ['panels'], share: ['share'] };
-- const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes' };
++ const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };
// ── Shared stream state (slot / endpoint / restreams) ─────
const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };
@@@ -273,10 +273,10 @@
const pct = Math.round((p.done / p.total) * 100);
const groups = {}; p.tasks.forEach(t => { (groups[t.group] = groups[t.group] || []).push(t); });
const next = p.next;
-- return `<div class="ovg-hub-top"><div class="ovg-ring" style="--pct:${pct}"><span>${p.done}<small>/${p.total}</small></span></div><div><div class="ovg-hub-title">${pct === 100 ? 'Everything is set up. Go be great.' : next ? `Next up: ${esc(next.title)}` : 'Nice work'}</div><div class="ovg-hub-sub">${pct === 100 ? 'Come back any time something new ships.' : next ? esc(next.why) : ''}</div>${next ? `<button type="button" class="btn btn-primary" data-task="${esc(next.id)}"><i class="fa-solid ${TASK_ICON[next.id] || 'fa-arrow-right'}"></i> Do it now</button>` : ''}</div></div>
-- ${Object.entries(groups).map(([g, tasks]) => `<div class="ovg-hub-group"><h4>${esc(g)}</h4><ul class="ovg-tasks">${tasks.map(t => `<li class="${t.done ? 'done' : ''} ${ctx.focus === t.id ? 'focus' : ''}"><span class="ovg-task-ico"><i class="fa-solid ${t.done ? 'fa-check' : (TASK_ICON[t.id] || 'fa-circle')}"></i></span><span class="ovg-task-body"><b>${esc(t.title)}${t.done && t.count > 1 ? ` <small>×${t.count}</small>` : ''}</b><small>${esc(t.why)}</small></span><button type="button" class="btn btn-sm ${t.done ? 'btn-outline' : 'btn-primary'}" data-task="${esc(t.id)}">${t.done ? 'Revisit' : 'Set up'}</button></li>`).join('')}</ul></div>`).join('')}`;
++ return `<div class="ovg-hub-top"><div class="ovg-ring" style="--pct:${pct}"><span>${p.done}<small>/${p.total}</small></span></div><div><div class="ovg-hub-title">${pct === 100 ? 'Everything is set up. Go be great.' : next ? `Next up: ${esc(next.title)}` : 'Nice work'}</div><div class="ovg-hub-sub">${pct === 100 ? 'Come back any time something new ships.' : next ? esc(next.why) : ''}</div>${next ? `<button type="button" class="btn btn-primary" data-task="${esc(next.id)}" data-go="${esc(next.go || '')}"><i class="fa-solid ${TASK_ICON[next.id] || 'fa-arrow-right'}"></i> Do it now</button>` : ''}</div></div>
++ ${Object.entries(groups).map(([g, tasks]) => `<div class="ovg-hub-group"><h4>${esc(g)}</h4><ul class="ovg-tasks">${tasks.map(t => `<li class="${t.done ? 'done' : ''} ${ctx.focus === t.id ? 'focus' : ''}"><span class="ovg-task-ico"><i class="fa-solid ${t.done ? 'fa-check' : (TASK_ICON[t.id] || 'fa-circle')}"></i></span><span class="ovg-task-body"><b>${esc(t.title)}${t.done && t.count > 1 ? ` <small>×${t.count}</small>` : ''}</b><small>${esc(t.why)}</small></span><button type="button" class="btn btn-sm ${t.done ? 'btn-outline' : 'btn-primary'}" data-task="${esc(t.id)}" data-go="${esc(t.go || '')}">${t.done ? 'Revisit' : 'Set up'}</button></li>`).join('')}</ul></div>`).join('')}`;
},
-- mount: (el) => { el.querySelectorAll('[data-task]').forEach(b => b.addEventListener('click', () => { const [j, step] = TASK_JOURNEY[b.dataset.task] || []; if (!j) return; const needsBroadcast = j === 'golive' && !onBroadcast(); if (needsBroadcast) { G.close(false); G.cfg.navigate(`/broadcast?guide=golive${step ? ':' + step : ''}`); } else G.open(j, { step }); })); },
++ mount: (el) => { el.querySelectorAll('[data-task]').forEach(b => b.addEventListener('click', () => { const [j, step] = TASK_JOURNEY[b.dataset.task] || []; if (!j) { const go = b.dataset.go; if (go) { G.close(false); G.cfg.navigate(go); } return; } const needsBroadcast = j === 'golive' && !onBroadcast(); if (needsBroadcast) { G.close(false); G.cfg.navigate(`/broadcast?guide=golive${step ? ':' + step : ''}`); } else G.open(j, { step }); })); },
footer: () => `<span class="ovg-foot-note">Everything here is optional — do it in any order.</span><button type="button" class="btn btn-outline" data-act="next">Close</button>`,
next: 'close',
}],
@@@ -343,6 +343,10 @@
const gained = Number.isFinite(prev) && p.done > prev ? p.done - prev : 0;
const startPct = gained ? Math.round((prev / p.total) * 100) : pct;
++ // Everything ticked? The quest has nothing left to nag about, so it becomes the one thing
++ // they actually came for. Same slot, same width — a different job.
++ if (pct >= 100 && !gained) { renderLaunch(el); return; }
++
el.innerHTML = `
<button type="button" class="ovg-quest${pct >= 100 ? ' is-done' : ''}" style="--q:${tone.c};--pct:${startPct}"
aria-label="${next ? `Streamer setup, ${p.done} of ${p.total} done. Next up: ${esc(next.title)}` : 'Streamer setup complete'}">
@@@ -371,6 +375,42 @@
try { localStorage.setItem(SEEN_KEY, String(p.done)); } catch { /* */ }
}
++ /**
++ * The single button a set-up streamer sees: start streaming.
++ *
++ * This is also what the home page's empty "nobody is live" state and the offline-channel CTA
++ * point at, so there is one code path for "take me to my stream" rather than three buttons
++ * that each guess at the right destination.
++ */
++ function renderLaunch(el) {
++ el.innerHTML = `
++ <button type="button" class="ovg-launch" aria-label="Start streaming">
++ <span class="ovg-launch-aura" aria-hidden="true"></span>
++ <span class="ovg-launch-sheen" aria-hidden="true"></span>
++ <span class="ovg-launch-ico"><i class="fa-solid fa-tower-broadcast"></i></span>
++ <span class="ovg-launch-text"><b>Start streaming</b><small>Your setup is done — the desk is one tap away</small></span>
++ <span class="ovg-launch-go" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></span>
++ </button>`;
++ el.firstElementChild.addEventListener('click', () => window.startGoLiveJourney());
++ }
++
++ /**
++ * "Take me to streaming", wherever it is clicked from.
++ *
++ * Signed out, it remembers the intent and sends them to sign-in, so they land back in the
++ * go-live journey instead of on a cold home page. Signed in with nothing set up, it opens the
++ * guided journey. Signed in and set up, it just goes to the desk.
++ */
++ window.startGoLiveJourney = async function () {
++ if (!me()) {
++ try { localStorage.setItem('ovg:pending', JSON.stringify({ id: 'golive', opts: { step: 'stream' }, when: Date.now() })); } catch { /* */ }
++ location.href = G.cfg.loginHref || '/api/auth/sso/login';
++ return;
++ }
++ await loadSlots();
++ G.cfg.navigate(D.slots.length ? '/broadcast' : '/broadcast?guide=golive:stream');
++ };
++
function renderJoin(el) {
// Six perks with a sentence each is a tall block to put in the middle of a front page, and
// most of that height is explanation nobody needs before they are interested. Collapsed,
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex d6dcc3a1..80b650d5 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -29,7 +29,6 @@
['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
];
-- const go = (href) => `href="${href}" onclick="return handleLinkClick(event, '${href}')"`;
function mount() {
const el = document.getElementById('home-tour-mount');
@@@ -65,9 +64,9 @@
</div>
</div>
<div class="tour-features">${FEATURES.map(([i, t], k) => `<span class="tour-feat" style="--i:${k}"><i class="${i.startsWith('fa-brands') ? i : 'fa-solid ' + i}"></i> ${t}</span>`).join('')}</div>
-- <div class="tour-actions">
-- <a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
-- </div>
++ <!-- A "Go live" button used to sit here, directly above the quest slot, which is
++ itself a go-live button that knows where the reader actually is in their setup.
++ Two buttons, one of them uninformed. The slot below is the only one now. -->
<!-- Filled by setupNextUp() in guides-live.js: the progress quest for signed-in
streamers, the join panel for everyone else. The link below is the no-JS
fallback and what a crawler sees. -->
ddiff --git a/public/js/ov-density.js b/public/js/ov-density.js
iindex 98eda95e..b53a0bce 100644
---- a/public/js/ov-density.js
++++ b/public/js/ov-density.js
@@@ -34,8 +34,8 @@
.ovd {
display: inline-flex; align-items: center; gap: 3px; padding: 3px;
border-radius: 10px; flex: none; order: 2; margin-left: auto;
-- background: color-mix(in srgb, var(--sc, var(--accent)) 8%, transparent);
-- border: 1px solid color-mix(in srgb, var(--sc, var(--accent)) 20%, transparent);
++ background: color-mix(in srgb, var(--sc-on, var(--accent)) 8%, transparent);
++ border: 1px solid color-mix(in srgb, var(--sc-on, var(--accent)) 20%, transparent);
}
.ovd-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 2px;
@@@ -43,14 +43,14 @@
background: none; color: var(--text-muted); font: inherit; line-height: 1;
transition: background .16s, color .16s, transform .16s;
}
--.ovd-btn:hover { color: var(--text-primary); background: color-mix(in srgb, var(--sc, var(--accent)) 16%, transparent); }
++.ovd-btn:hover { color: var(--text-primary); background: color-mix(in srgb, var(--sc-on, var(--accent)) 16%, transparent); }
.ovd-btn:active { transform: scale(.93); }
.ovd-btn[aria-pressed="true"] {
-- color: color-mix(in srgb, var(--sc, var(--accent)) 92%, #fff);
-- background: color-mix(in srgb, var(--sc, var(--accent)) 24%, transparent);
-- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sc, var(--accent)) 42%, transparent);
++ color: color-mix(in srgb, var(--sc-on, var(--accent)) 92%, #fff);
++ background: color-mix(in srgb, var(--sc-on, var(--accent)) 24%, transparent);
++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sc-on, var(--accent)) 42%, transparent);
}
--.ovd-btn:focus-visible { outline: 2px solid var(--sc, var(--accent)); outline-offset: 2px; }
++.ovd-btn:focus-visible { outline: 2px solid var(--sc-on, var(--accent)); outline-offset: 2px; }
/* Each button draws its own count as bars, so it reads at a glance without a legend. */
.ovd-bar { display: block; width: 2.5px; border-radius: 1px; background: currentColor; height: 11px; }
.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }
@@@ -66,15 +66,23 @@
document.head.appendChild(st);
}
-- /** How many columns this container can hold without cards becoming unreadable. */
++ /**
++ * How many columns this container can hold without cards becoming unreadable.
++ *
++ * Sections can raise the floor: a "Recently online" card carries an avatar, a name and a
++ * "streamed 2h ago" line side by side, so it needs more width than a VOD thumbnail before it
++ * stops looking cramped. attach() stores that per grid.
++ */
function maxColumns(el) {
const w = el.getBoundingClientRect().width || el.clientWidth || 0;
if (!w) return 1;
-- return Math.max(1, Math.min(HARD_MAX, Math.floor(w / MIN_CARD)));
++ const min = Number(el.dataset.ovdMin) || MIN_CARD;
++ return Math.max(1, Math.min(HARD_MAX, Math.floor(w / min)));
}
-- function attach(grid, { key, header } = {}) {
++ function attach(grid, { key, header, minCard } = {}) {
if (!grid || !key) return;
++ if (minCard) grid.dataset.ovdMin = String(minCard);
if (grid.dataset.ovdBound) { apply(grid, key); return; }
grid.dataset.ovdBound = '1';
ensureCss();
@@@ -94,14 +102,34 @@
const max = maxColumns(grid);
const store = readStore();
const wanted = Number(store[key]) || 0;
++
++ // Drop an override this function applied on a previous pass before measuring. The
++ // stylesheet's natural column count has to be read with our own clamp *off*: reading
++ // it with the clamp on returns the clamp, the comparison below then goes false, the
++ // clamp is removed, the next pass re-applies it — the grid flickers between one and
++ // two columns forever. A preference-driven override is not ours to remove.
++ if (grid.dataset.ovdAuto === '1') {
++ delete grid.dataset.ovdAuto;
++ delete grid.dataset.ovdCols;
++ grid.style.removeProperty('--ovd-cols');
++ }
++ const natural = autoColumns(grid, HARD_MAX);
++
// Honour the preference where possible, clamp where not, and keep it either way.
-- const active = wanted ? Math.min(wanted, max) : 0;
++ let active = wanted ? Math.min(wanted, max) : 0;
++ let auto = false;
++ // With no preference the stylesheet decides — but it picked its column count from a
++ // media query, not from this container's actual width. Where that lands above what
++ // fits legibly, override it down; otherwise leave the CSS alone.
++ if (!active && natural > max) { active = max; auto = true; }
if (active) {
grid.dataset.ovdCols = String(active);
++ if (auto) grid.dataset.ovdAuto = '1';
grid.style.setProperty('--ovd-cols', String(active));
} else {
delete grid.dataset.ovdCols;
++ delete grid.dataset.ovdAuto;
grid.style.removeProperty('--ovd-cols');
}
if (!ctrl) return;
@@@ -135,7 +163,7 @@
ctrl.appendChild(b);
}
}
-- const effective = active || autoColumns(grid, max);
++ const effective = active || Math.min(natural, max);
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));
}
ddiff --git a/server/index.js b/server/index.js
iindex 73dd34d8..9c0b0c41 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1276,18 +1276,23 @@ async function start() {
const _changelogMaxBroadcasts = 3;
const _changelogDelays = [5000, 15000, 30000];
-- function broadcastChangelog() {
++ /** Promise wrapper around execFile — git, argv-style, never through a shell. */
++ function _git(args, timeout) {
++ return new Promise((resolve) => {
++ execFile('git', args, { cwd: REPO_DIR, encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 },
++ (err, stdout) => resolve(err ? '' : String(stdout || '')));
++ });
++ }
++
++ // These two git calls used to be execSync. At boot that blocks the event loop while the
++ // server is meant to be accepting its first requests, and the whole reason this runs on a
++ // 5/15/30s delay is to stay out of the way of startup. execFile keeps it off the loop.
++ async function broadcastChangelog() {
try {
-- // Get git describe for a human-readable version tag
-- let versionTag = '';
-- try {
-- versionTag = execSync('git describe --always --tags', { cwd: REPO_DIR, encoding: 'utf8', timeout: 3000 }).trim();
-- } catch { versionTag = ''; }
--
-- const raw = execSync(
-- `git --no-pager log --pretty=format:'%H||%h||%s||%an||%aI' -10`,
-- { cwd: REPO_DIR, encoding: 'utf8', timeout: 5000 }
-- );
++ const versionTag = (await _git(['describe', '--always', '--tags'], 3000)).trim();
++
++ const raw = await _git(['--no-pager', 'log', '--pretty=format:%H||%h||%s||%an||%aI', '-10'], 5000);
++ if (!raw.trim()) return;
const commits = raw.trim().split('\n').filter(Boolean).map(line => {
const [hash, short, subject, author, date] = line.split('||');
return { hash, short, subject, author, date };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 1c0b408b..aac76901 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1484,6 +1484,15 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
let panels = 0; try { const p = channel.panels ? JSON.parse(channel.panels) : []; panels = Array.isArray(p) ? p.length : 0; } catch { panels = 0; }
const offline = !!(channel.offline_screen_type && channel.offline_screen_type !== 'none');
const methodSet = slots.some(sl => !!sl.streaming_method);
++ // Second wave of tasks. Each one is a real feature with a real table behind it, so nothing on
++ // the list can be permanently unreachable — a task that can never be ticked would park every
++ // streamer below 100% forever.
++ const mods = safe(() => channel.id ? db.get('SELECT COUNT(*) AS n FROM channel_moderators WHERE channel_id = ?', [channel.id]).n : 0, 0);
++ const controls = safe(() => db.get('SELECT COUNT(*) AS n FROM control_configs WHERE user_id = ?', [uid]).n, 0);
++ const aibot = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_ai_bots WHERE channel_user_id = ?', [uid]).n, 0);
++ const pastes = safe(() => db.get('SELECT COUNT(*) AS n FROM pastes WHERE user_id = ?', [uid]).n, 0);
++ const requests = safe(() => !!db.get('SELECT 1 FROM media_request_settings WHERE user_id = ? LIMIT 1', [uid]), false);
++ const modRules = safe(() => channel.id ? !!db.get('SELECT 1 FROM channel_moderation_settings WHERE channel_id = ? LIMIT 1', [channel.id]) : false, false);
const tasks = [
{ id: 'slot', group: 'Stream', title: 'Create your stream slot', why: 'Your show gets its own key, settings, VODs and restreams.', done: slots.length > 0, count: slots.length },
{ id: 'method', group: 'Stream', title: 'Choose how you stream', why: 'Browser, OBS over RTMP, or OBS over WHIP.', done: methodSet },
@@@ -1497,6 +1506,14 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
{ id: 'powerchat', group: 'Community & money', title: 'Connect PowerChat for real tips', why: 'Card and crypto tips with on-stream alerts.', done: powerchat },
{ id: 'panels', group: 'Community & money', title: 'Fill in your About panels', why: 'Links, schedule, rules — the stuff under the player.', done: panels > 0, count: panels },
{ id: 'share', group: 'Grow', title: 'Get your first follower', why: 'Share your channel link; followers get pinged when you go live.', done: followers > 0, count: followers },
++ // `go` is where the task lives when it has no guided journey of its own — the hub and the
++ // home quest fall back to navigating there rather than silently doing nothing on click.
++ { id: 'mods', group: 'Community & money', title: 'Add a moderator', why: 'Someone you trust watching chat while you stream.', done: mods > 0, count: mods, go: '/dashboard' },
++ { id: 'requests', group: 'Community & money', title: 'Open media requests', why: 'Viewers spend OpenCoins to queue clips and tracks on your stream.', done: requests, go: '/dashboard' },
++ { id: 'moderation', group: 'Community & money', title: 'Set your chat rules', why: 'Slow mode, followers-only and emote-only, tuned how you want them.', done: modRules, go: '/dashboard' },
++ { id: 'controls', group: 'Build & mod', title: 'Build viewer controls', why: 'Let chat drive a robot, trigger hardware or press your buttons.', done: controls > 0, count: controls, go: '/dashboard' },
++ { id: 'aibot', group: 'Build & mod', title: 'Add an AI chat regular', why: 'Keeps an empty chat warm and answers the usual questions.', done: aibot > 0, count: aibot, go: '/dashboard' },
++ { id: 'paste', group: 'Build & mod', title: 'Publish a paste', why: 'Share code, configs or notes from your stream — they get their own page.', done: pastes > 0, count: pastes, go: '/pastes' },
];
const done = tasks.filter(t => t.done).length;
const next = tasks.find(t => !t.done) || null;
ddiff --git a/vendor/openvibe-shared/builtin-themes.js b/vendor/openvibe-shared/builtin-themes.js
iindex 073ddaeb..9c15534d 100644
---- a/vendor/openvibe-shared/builtin-themes.js
++++ b/vendor/openvibe-shared/builtin-themes.js
@@@ -16,7 +16,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#12121a',
'--text-primary': '#e8e6e3',
'--text-secondary': '#9a9a9a',
-- '--text-muted': '#666',
++ '--text-muted': '#838282',
'--accent': '#8b5cf6',
'--accent-light': '#a78bfa',
'--accent-dark': '#6d28d9',
@@@ -46,7 +46,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d1220',
'--text-primary': '#e2e8f0',
'--text-secondary': '#94a3b8',
-- '--text-muted': '#64748b',
++ '--text-muted': '#78879b',
'--accent': '#6366f1',
'--accent-light': '#818cf8',
'--accent-dark': '#4f46e5',
@@@ -69,7 +69,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d160d',
'--text-primary': '#d4e8d4',
'--text-secondary': '#8aaa8a',
-- '--text-muted': '#5a7a5a',
++ '--text-muted': '#708e70',
'--accent': '#4ade80',
'--accent-light': '#86efac',
'--accent-dark': '#22c55e',
@@@ -92,7 +92,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d0b14',
'--text-primary': '#f0e6ff',
'--text-secondary': '#b4a0d0',
-- '--text-muted': '#6a5a80',
++ '--text-muted': '#87799c',
'--accent': '#ff006e',
'--accent-light': '#ff4d94',
'--accent-dark': '#c9005a',
@@@ -115,7 +115,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#21222c',
'--text-primary': '#f8f8f2',
'--text-secondary': '#bfbfb2',
-- '--text-muted': '#6272a4',
++ '--text-muted': '#8995b8',
'--accent': '#bd93f9',
'--accent-light': '#d4b8ff',
'--accent-dark': '#9b6ddb',
@@@ -143,7 +143,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#22231e',
'--text-primary': '#f8f8f2',
'--text-secondary': '#cfcfc2',
-- '--text-muted': '#75715e',
++ '--text-muted': '#979484',
'--accent': '#a6e22e',
'--accent-light': '#c4ff50',
'--accent-dark': '#7dac1c',
@@@ -169,7 +169,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0c1418',
'--text-primary': '#d0e8ef',
'--text-secondary': '#7aa8ba',
-- '--text-muted': '#4a7080',
++ '--text-muted': '#658896',
'--accent': '#00bcd4',
'--accent-light': '#4dd0e1',
'--accent-dark': '#0097a7',
@@@ -192,7 +192,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#140c0c',
'--text-primary': '#ffe8e0',
'--text-secondary': '#c49080',
-- '--text-muted': '#7a5a50',
++ '--text-muted': '#977970',
'--accent': '#ff6b35',
'--accent-light': '#ff8f5e',
'--accent-dark': '#d04a1a',
@@@ -215,7 +215,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0e1422',
'--text-primary': '#e0eaf5',
'--text-secondary': '#8aa0c0',
-- '--text-muted': '#5a708a',
++ '--text-muted': '#72869d',
'--accent': '#38bdf8',
'--accent-light': '#7dd3fc',
'--accent-dark': '#0284c7',
@@@ -238,7 +238,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#140a0a',
'--text-primary': '#f0dada',
'--text-secondary': '#b0808a',
-- '--text-muted': '#6a4a50',
++ '--text-muted': '#95787c',
'--accent': '#ef4444',
'--accent-light': '#f87171',
'--accent-dark': '#b91c1c',
@@@ -261,7 +261,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#100c16',
'--text-primary': '#f0e0ff',
'--text-secondary': '#c090e0',
-- '--text-muted': '#7050a0',
++ '--text-muted': '#8f73b7',
'--accent': '#e040fb',
'--accent-light': '#ea80fc',
'--accent-dark': '#aa00d4',
@@@ -286,7 +286,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#12141c',
'--text-primary': '#e4e6ea',
'--text-secondary': '#a0a4b0',
-- '--text-muted': '#606470',
++ '--text-muted': '#828690',
'--accent': '#a78bfa',
'--accent-light': '#c4b5fd',
'--accent-dark': '#7c3aed',
@@@ -309,7 +309,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#030803',
'--text-primary': '#00ff41',
'--text-secondary': '#00b030',
-- '--text-muted': '#005a18',
++ '--text-muted': '#008f25',
'--accent': '#00ff41',
'--accent-light': '#66ff8a',
'--accent-dark': '#00aa2a',
@@@ -332,7 +332,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#2a303c',
'--text-primary': '#eceff4',
'--text-secondary': '#d8dee9',
-- '--text-muted': '#7b88a1',
++ '--text-muted': '#9ba5b8',
'--accent': '#88c0d0',
'--accent-light': '#8fbcbb',
'--accent-dark': '#5e81ac',
@@@ -359,7 +359,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#242424',
'--text-primary': '#ebdbb2',
'--text-secondary': '#d5c4a1',
-- '--text-muted': '#928374',
++ '--text-muted': '#a69682',
'--accent': '#fabd2f',
'--accent-light': '#fce566',
'--accent-dark': '#d79921',
@@@ -386,7 +386,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#050505',
'--text-primary': '#cccccc',
'--text-secondary': '#888888',
-- '--text-muted': '#444444',
++ '--text-muted': '#7a7a7a',
'--accent': '#ffffff',
'--accent-light': '#ffffff',
'--accent-dark': '#aaaaaa',
@@@ -409,7 +409,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#13100b',
'--text-primary': '#e8ddd0',
'--text-secondary': '#b49a80',
-- '--text-muted': '#7a6a54',
++ '--text-muted': '#8e7f6a',
'--accent': '#cd7f32',
'--accent-light': '#daa060',
'--accent-dark': '#a06020',
@@@ -432,7 +432,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#100c16',
'--text-primary': '#f0e0f0',
'--text-secondary': '#c0a0c0',
-- '--text-muted': '#7a5a7a',
++ '--text-muted': '#927592',
'--accent': '#f472b6',
'--accent-light': '#f9a8d4',
'--accent-dark': '#db2777',
@@@ -455,7 +455,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#060400',
'--text-primary': '#ffb000',
'--text-secondary': '#cc8d00',
-- '--text-muted': '#664700',
++ '--text-muted': '#a37100',
'--accent': '#ffb000',
'--accent-light': '#ffd050',
'--accent-dark': '#cc8d00',
@@@ -478,8 +478,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f5f7',
'--text-primary': '#1a1a2e',
'--text-secondary': '#4a4a6a',
-- '--text-muted': '#9a9ab0',
-- '--accent': '#8b5cf6',
++ '--text-muted': '#747489',
++ '--accent': '#8756f3',
'--accent-light': '#a78bfa',
'--accent-dark': '#6d28d9',
'--border': '#e0e0e6',
@@@ -503,7 +503,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f2ed',
'--text-primary': '#2c2416',
'--text-secondary': '#5a4e3e',
-- '--text-muted': '#9a8e7e',
++ '--text-muted': '#7b7061',
'--accent': '#8b6914',
'--accent-light': '#b08a30',
'--accent-dark': '#6a5010',
@@@ -528,8 +528,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#edf2fa',
'--text-primary': '#1a2040',
'--text-secondary': '#4a5070',
-- '--text-muted': '#8a90aa',
-- '--accent': '#3b82f6',
++ '--text-muted': '#6b718c',
++ '--accent': '#2867ed',
'--accent-light': '#60a5fa',
'--accent-dark': '#2563eb',
'--border': '#d0d8ea',
@@@ -553,8 +553,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#edf5ea',
'--text-primary': '#1a2e16',
'--text-secondary': '#3a5a32',
-- '--text-muted': '#7a9a6e',
-- '--accent': '#16a34a',
++ '--text-muted': '#5d7a54',
++ '--accent': '#15823e',
'--accent-light': '#22c55e',
'--accent-dark': '#15803d',
'--border': '#c8dcc0',
@@@ -578,8 +578,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#fdf0ea',
'--text-primary': '#2e1a14',
'--text-secondary': '#6a4438',
-- '--text-muted': '#aa8070',
-- '--accent': '#f97316',
++ '--text-muted': '#8f6a5c',
++ '--accent': '#c04808',
'--accent-light': '#fb923c',
'--accent-dark': '#ea580c',
'--border': '#f0d0c0',
@@@ -603,8 +603,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f4f0fa',
'--text-primary': '#1e1430',
'--text-secondary': '#4a3a60',
-- '--text-muted': '#9080aa',
-- '--accent': '#8b5cf6',
++ '--text-muted': '#796a92',
++ '--accent': '#844cf2',
'--accent-light': '#a78bfa',
'--accent-dark': '#7c3aed',
'--border': '#ddd0ee',
@@@ -628,8 +628,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5ecc4',
'--text-primary': '#3c3836',
'--text-secondary': '#504945',
-- '--text-muted': '#928374',
-- '--accent': '#d65d0e',
++ '--text-muted': '#766b60',
++ '--accent': '#be4707',
'--accent-light': '#fe8019',
'--accent-dark': '#af3a03',
'--border': '#d5c4a1',
@@@ -657,8 +657,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f8fafc',
'--text-primary': '#0f172a',
'--text-secondary': '#334155',
-- '--text-muted': '#94a3b8',
-- '--accent': '#0ea5e9',
++ '--text-muted': '#69768b',
++ '--accent': '#027cbb',
'--accent-light': '#38bdf8',
'--accent-dark': '#0284c7',
'--border': '#e2e8f0',
@@@ -682,8 +682,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f0e8',
'--text-primary': '#2e2418',
'--text-secondary': '#5a4e3e',
-- '--text-muted': '#9a8e7e',
-- '--accent': '#b8860b',
++ '--text-muted': '#7a6e5f',
++ '--accent': '#8c6707',
'--accent-light': '#daa520',
'--accent-dark': '#8b6508',
'--border': '#ddd0be',
@@@ -707,8 +707,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#fdeef2',
'--text-primary': '#2e1420',
'--text-secondary': '#6a3850',
-- '--text-muted': '#aa7090',
-- '--accent': '#e11d48',
++ '--text-muted': '#96617e',
++ '--accent': '#de1c47',
'--accent-light': '#f43f5e',
'--accent-dark': '#be123c',
'--border': '#f0c8d4',
@@@ -732,7 +732,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#01303d',
'--text-primary': '#fdf6e3',
'--text-secondary': '#93a1a1',
-- '--text-muted': '#586e75',
++ '--text-muted': '#909c9a',
'--accent': '#b58900',
'--accent-light': '#d4a017',
'--accent-dark': '#8a6a00',
@@@ -759,8 +759,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5eedb',
'--text-primary': '#002b36',
'--text-secondary': '#586e75',
-- '--text-muted': '#93a1a1',
-- '--accent': '#b58900',
++ '--text-muted': '#5b7478',
++ '--accent': '#8d6c00',
'--accent-light': '#d4a017',
'--accent-dark': '#8a6a00',
'--border': '#d6ceb5',
@@@ -788,7 +788,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#1a1a2c',
'--text-primary': '#cdd6f4',
'--text-secondary': '#bac2de',
-- '--text-muted': '#6c7086',
++ '--text-muted': '#81869e',
'--accent': '#cba6f7',
'--accent-light': '#dcc0ff',
'--accent-dark': '#b07de0',
@@@ -816,7 +816,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#e8ebf0',
'--text-primary': '#4c4f69',
'--text-secondary': '#5c5f77',
-- '--text-muted': '#9ca0b0',
++ '--text-muted': '#696c83',
'--accent': '#8839ef',
'--accent-light': '#a05cff',
'--accent-dark': '#7028d4',