openvibers/openvibe.live Commits
Theme-aware section titles, an accessibility pass over all 34 palettes, and a shorter offline channel
Changes
@@@ -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; }@@@ -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; }@@@ -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 srgFalls back to the theme accent when --sc is not set, so headers on other pages are unchangedapart 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 itcounts, 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 slackthe 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 srgfades. 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; } }@@@ -88,9 +88,9 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat 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>@@@ -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">@@@ -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 */@@@ -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);}}}@@@ -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);++ }++ });};})();@@@ -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,@@@ -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-instreamers, the join panel for everyone else. The link below is the no-JSfallback and what a crawler sees. -->@@@ -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));}@@@ -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 };@@@ -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;@@@ -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',
@@ -106,3 +106,66 @@
@@ -106,3 +106,66 @@
.dsc--noleft .dsc-sec--side { grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); align-items: start; }
.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: 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)); } }
@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; }
@@ -364,3 +364,90 @@
@@ -364,3 +364,90 @@
.ovg-quest-liquid { transition: none; }
.ovg-quest-liquid { transition: none; }
.ovg-join-perks li { animation: none !important; }
.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; }
@@ -1860,12 +1860,34 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1860,12 +1860,34 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.stream-card-thumb .protocol-badge {
.stream-card-thumb .protocol-badge {
position: absolute; bottom: 8px; left: 8px;
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 {
.stream-card-nsfw {
position: absolute; top: 8px; right: 8px;
position: absolute; top: 8px; right: 8px;
@@ -2384,12 +2406,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2384,12 +2406,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
STREAM CARD EXTRAS (duration, ago badge)
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 {
.stream-card-ago {
margin-left: auto;
margin-left: auto;
font-size: 0.75rem;
font-size: 0.75rem;
@@ -9033,28 +9051,36 @@ body.theme-light .user-dropdown {
@@ -9033,28 +9051,36 @@ body.theme-light .user-dropdown {
HOME CHANGELOG
HOME CHANGELOG
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
/* Collapsible wrapper */
/* 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 {
.home-changelog-wrapper {
position: relative;
position: relative;
- margin-bottom: 24px;
+ margin-bottom: 10px;
border-radius: 12px;
border-radius: 12px;
overflow: hidden;
overflow: hidden;
transition: max-height 0.35s ease;
transition: max-height 0.35s ease;
}
}
.home-changelog-wrapper.collapsed {
.home-changelog-wrapper.collapsed {
- max-height: 220px;
+ max-height: 360px;
}
}
.home-changelog-wrapper.expanded {
.home-changelog-wrapper.expanded {
max-height: 2000px;
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 {
.home-changelog-fade {
display: none;
display: none;
position: absolute;
position: absolute;
bottom: 0;
bottom: 0;
left: 0;
left: 0;
right: 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;
pointer-events: none;
border-radius: 0 0 12px 12px;
border-radius: 0 0 12px 12px;
}
}
@@ -9066,9 +9092,13 @@ body.theme-light .user-dropdown {
@@ -9066,9 +9092,13 @@ body.theme-light .user-dropdown {
background: var(--bg-secondary, rgba(255,255,255,0.03));
background: var(--bg-secondary, rgba(255,255,255,0.03));
border: 1px solid var(--border);
border: 1px solid var(--border);
border-radius: 12px;
border-radius: 12px;
- padding: 16px 20px;
+ padding: 14px 16px 10px;
overflow: hidden;
overflow: hidden;
}
}
+@media (max-width: 560px) {
+ .home-changelog-wrapper.collapsed { max-height: 320px; }
+ .home-changelog { padding: 12px 13px 8px; }
+}
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
UPDATES / CHANGELOG
UPDATES / CHANGELOG
@@ -14899,7 +14929,14 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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
Falls back to the theme accent when --sc is not set, so headers on other pages are unchanged
apart from the new shape. */
apart from the new shape. */
.section-header {
.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: var(--accent);
+ --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
display: flex; align-items: center; gap: 11px;
display: flex; align-items: center; gap: 11px;
margin: 34px 0 14px; justify-content: flex-start; flex-wrap: nowrap;
margin: 34px 0 14px; justify-content: flex-start; flex-wrap: nowrap;
position: relative;
position: relative;
@@ -14907,17 +14944,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -14907,17 +14944,17 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.section-header h2 {
.section-header h2 {
display: flex; align-items: center; gap: 10px; flex: none;
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;
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;
margin: 0; white-space: nowrap;
}
}
/* The icon gets a tile so the colour reads as a label, not as decoration on a letter. */
/* The icon gets a tile so the colour reads as a label, not as decoration on a letter. */
.section-header h2 > i:first-child {
.section-header h2 > i:first-child {
width: 30px; height: 30px; flex: none; border-radius: 10px;
width: 30px; height: 30px; flex: none; border-radius: 10px;
display: grid; place-items: center; font-size: 0.82rem;
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. */
/* The live dot keeps its pulse and stays a dot — a tile around it would hide the signal. */
.section-header h2 > i.live-dot {
.section-header h2 > i.live-dot {
@@ -14926,22 +14963,22 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -14926,22 +14963,22 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
}
}
.section-kicker {
.section-kicker {
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase;
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;
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;
white-space: nowrap;
}
}
/* A rule that carries the section colour out across the page and fades. */
/* A rule that carries the section colour out across the page and fades. */
.section-header::after {
.section-header::after {
content: ''; flex: 1 1 auto; height: 1px; min-width: 18px; align-self: center;
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
/* 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. */
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 > :not(h2) { flex: none; order: 3; margin-left: 10px; min-width: 0; }
.section-header > .badge { order: 1; margin-left: 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
/* 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. */
the rule would have used, and clip rather than overflow the row. */
.section-header > span:not(.badge), .section-header > p, .section-header > .muted {
.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
@@ -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. */
fades. Spacing is tightened at the same time; the old margins left a dead band above each one. */
.cta-section-label {
.cta-section-label {
--sc: var(--accent);
--sc: var(--accent);
+ --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
display: flex; align-items: center; gap: 10px; width: 100%;
display: flex; align-items: center; gap: 10px; width: 100%;
margin: 26px 0 12px; font-size: 0.8rem; letter-spacing: 0.05em;
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 {
.cta-section-label > i:first-child {
width: 28px; height: 28px; flex: none; border-radius: 50%;
width: 28px; height: 28px; flex: none; border-radius: 50%;
display: grid; place-items: center; line-height: 1; font-size: 0.76rem;
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 {
.cta-section-label::after {
content: ''; flex: 1 1 auto; height: 1px; min-width: 16px;
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; }
.home-cta-features { margin: 2px 0 6px; gap: 12px; }
.cta-lede { margin: 0 0 12px; }
.cta-lede { margin: 0 0 12px; }
@@ -15300,3 +15338,34 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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. */
/* The density control sits at the end of a section header, after the "View all" link. */
.section-header .ovd { order: 4; margin-left: 8px; }
.section-header .ovd { order: 4; margin-left: 8px; }
@media (max-width: 420px) { .section-header .ovd { display: none; } }
@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; } }
@@ -88,9 +88,9 @@
@@ -88,9 +88,9 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
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. -->
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="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-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="/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="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">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@ -99,7 +99,7 @@
@@ -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/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/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/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>
<noscript>
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
@@ -107,7 +107,7 @@
@@ -107,7 +107,7 @@
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/ov-guide.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/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>
</noscript>
<!-- JSON-LD Structured Data -->
<!-- JSON-LD Structured Data -->
@@ -329,7 +329,16 @@
@@ -329,7 +329,16 @@
<div class="empty-state" id="no-live-streams" style="display:none">
<div class="empty-state" id="no-live-streams" style="display:none">
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<i class="fa-solid fa-tower-broadcast fa-3x"></i>
<p>No one is streaming right now</p>
<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>
</div>
</div>
@@ -729,7 +738,7 @@
@@ -729,7 +738,7 @@
</div>
</div>
<!-- OpenVibe.Live Changelog -->
<!-- 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>
<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;">
<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>
<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
@@ -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="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" 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-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-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" 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/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" 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/chat-autocomplete.js?v=1" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/channel-uploads.js?v=12" defer></script>
<script src="/js/controls.js?v=8" 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
@@ -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-devices.js?v=2" defer></script>
<script src="/js/broadcast-vods.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-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/call.js?v=3" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/voice-channels.js?v=2" defer></script>
<script src="/js/settings.js?v=12" 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
@@ -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/ai-viewers.js?v=3" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/recap.js?v=1" 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/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" 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
@@ -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="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.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-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/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) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js?v=1" defer></script>
<script src="/shared/theme-loader.js?v=1" defer></script>
@@ -919,6 +919,83 @@ function isModifiedLinkClick(event) {
@@ -919,6 +919,83 @@ function isModifiedLinkClick(event) {
return !!(event && (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey));
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) {
function handleLinkClick(event, urlPath, replace = false) {
if (isModifiedLinkClick(event)) return true;
if (isModifiedLinkClick(event)) return true;
event?.preventDefault?.();
event?.preventDefault?.();
@@ -2374,15 +2451,15 @@ async function loadHomePulse() {
@@ -2374,15 +2451,15 @@ async function loadHomePulse() {
// Latest activity card (tip + follow together).
// Latest activity card (tip + follow together).
const act = [];
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>`);
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.
// Weekly leader teasers.
const board = (title, icon, rows, unit) => rows && rows.length ? `
const board = (title, icon, rows, unit) => rows && rows.length ? `
<div class="pulse-card">
<div class="pulse-card">
<div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>
<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>` : '';
</div>` : '';
const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');
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');
const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');
@@ -2462,8 +2539,11 @@ async function loadHomeRecentOnline(page, append) {
@@ -2462,8 +2539,11 @@ async function loadHomeRecentOnline(page, append) {
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
if (window.OVDensity) {
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'),
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');
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
} catch { /* silent */ }
} catch { /* silent */ }
@@ -2476,7 +2556,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -2476,7 +2556,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
return;
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 => {
_put(streamers.map(s => {
const msList = (s.managed_streams || []);
const msList = (s.managed_streams || []);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
@@ -2532,7 +2612,7 @@ async function loadHomeRecentVods(page, append) {
@@ -2532,7 +2612,7 @@ async function loadHomeRecentVods(page, append) {
if (!header || !grid) return;
if (!header || !grid) return;
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
header.style.display = '';
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 => {
_put(vods.map(v => {
const href = `/vod/${v.id}`;
const href = `/vod/${v.id}`;
return `
return `
@@ -2540,7 +2620,7 @@ async function loadHomeRecentVods(page, append) {
@@ -2540,7 +2620,7 @@ async function loadHomeRecentVods(page, append) {
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${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>` : ''}
${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>
<div class="stream-card-info">
<div class="stream-card-info">
<div class="stream-card-title">${esc(v.title || 'VOD')}</div>
<div class="stream-card-title">${esc(v.title || 'VOD')}</div>
@@ -2571,12 +2651,12 @@ async function loadHomeClips(page, append) {
@@ -2571,12 +2651,12 @@ async function loadHomeClips(page, append) {
const grid = document.getElementById('home-clips-grid');
const grid = document.getElementById('home-clips-grid');
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
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 => `
_put(clips.map(c => `
<a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
<a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${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>` : ''}
${c.duration_seconds ? `<span class="stream-card-duration">${formatDuration(c.duration_seconds)}</span>` : ''}
</div>
</div>
<div class="stream-card-info">
<div class="stream-card-info">
@@ -2607,7 +2687,7 @@ async function loadHomePastes(page, append) {
@@ -2607,7 +2687,7 @@ async function loadHomePastes(page, append) {
const list = document.getElementById('home-pastes-list');
const list = document.getElementById('home-pastes-list');
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
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 => {
_put(pastes.map(p => {
const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');
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') : '';
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) {
@@ -3317,7 +3397,7 @@ function _renderClipsTakenGrid(username, data) {
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
${_visBadge(cl.visibility, cl.is_public, canManage)}
${_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>
<span class="stream-card-duration">${formatDuration(cl.duration_seconds)}</span>
</div>
</div>
<div class="stream-card-info">
<div class="stream-card-info">
@@ -4544,8 +4544,11 @@ function toggleBroadcastMobileChat() {
@@ -4544,8 +4544,11 @@ function toggleBroadcastMobileChat() {
if (_bcMobileChatOpen) {
if (_bcMobileChatOpen) {
const msgs = document.getElementById('bc-chat-messages');
const msgs = document.getElementById('bc-chat-messages');
if (msgs) msgs.scrollTop = msgs.scrollHeight;
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');
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 */
/** Show/hide the broadcast mobile chat FAB based on screen size and live state */
@@ -6489,20 +6489,57 @@ function toggleMobileChat() {
@@ -6489,20 +6489,57 @@ function toggleMobileChat() {
if (_mobileChatOpen) {
if (_mobileChatOpen) {
_mobileChatUnread = 0;
_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();
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() {
function incrementMobileChatUnread() {
if (!isMobileChatLayout() || _mobileChatOpen) return;
if (!isMobileChatLayout() || _mobileChatOpen) return;
_mobileChatUnread++;
_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
// Hook into addChatMessage to count unread on mobile
@@ -6667,8 +6704,7 @@ function fcwToggle() {
@@ -6667,8 +6704,7 @@ function fcwToggle() {
if (_fcwOpen) {
if (_fcwOpen) {
// Clear unread
// Clear unread
_fcwUnread = 0;
_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)
// Ensure chat is connected (global)
if (!chatWs || chatWs.readyState !== WebSocket.OPEN) {
if (!chatWs || chatWs.readyState !== WebSocket.OPEN) {
@@ -6795,11 +6831,7 @@ function _fcwAddMessage(msg) {
@@ -6795,11 +6831,7 @@ function _fcwAddMessage(msg) {
const isOnChatPage = chatPage && chatPage.classList.contains('active');
const isOnChatPage = chatPage && chatPage.classList.contains('active');
if (!isOnChatPage) {
if (!isOnChatPage) {
_fcwUnread++;
_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);
}
}
}
}
}
}
@@ -165,6 +165,72 @@
@@ -165,6 +165,72 @@
}
}
if (!sections.length) { host.innerHTML = ''; return; }
if (!sections.length) { host.innerHTML = ''; return; }
const noLeft = !(d.live || []).length && !(d.clips || []).length;
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);
+ }
+ });
};
};
})();
})();
@@ -21,7 +21,7 @@
@@ -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 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 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_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) ─────
// ── 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 };
const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };
@@ -273,10 +273,10 @@
@@ -273,10 +273,10 @@
const pct = Math.round((p.done / p.total) * 100);
const pct = Math.round((p.done / p.total) * 100);
const groups = {}; p.tasks.forEach(t => { (groups[t.group] = groups[t.group] || []).push(t); });
const groups = {}; p.tasks.forEach(t => { (groups[t.group] = groups[t.group] || []).push(t); });
const next = p.next;
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>`,
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',
next: 'close',
}],
}],
@@ -343,6 +343,10 @@
@@ -343,6 +343,10 @@
const gained = Number.isFinite(prev) && p.done > prev ? p.done - prev : 0;
const gained = Number.isFinite(prev) && p.done > prev ? p.done - prev : 0;
const startPct = gained ? Math.round((prev / p.total) * 100) : pct;
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 = `
el.innerHTML = `
<button type="button" class="ovg-quest${pct >= 100 ? ' is-done' : ''}" style="--q:${tone.c};--pct:${startPct}"
<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'}">
aria-label="${next ? `Streamer setup, ${p.done} of ${p.total} done. Next up: ${esc(next.title)}` : 'Streamer setup complete'}">
@@ -371,6 +375,42 @@
@@ -371,6 +375,42 @@
try { localStorage.setItem(SEEN_KEY, String(p.done)); } catch { /* */ }
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) {
function renderJoin(el) {
// Six perks with a sentence each is a tall block to put in the middle of a front page, and
// 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,
// most of that height is explanation nobody needs before they are interested. Collapsed,
@@ -29,7 +29,6 @@
@@ -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-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'],
['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() {
function mount() {
const el = document.getElementById('home-tour-mount');
const el = document.getElementById('home-tour-mount');
@@ -65,9 +64,9 @@
@@ -65,9 +64,9 @@
</div>
</div>
</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-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
<!-- 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
streamers, the join panel for everyone else. The link below is the no-JS
fallback and what a crawler sees. -->
fallback and what a crawler sees. -->
@@ -34,8 +34,8 @@
@@ -34,8 +34,8 @@
.ovd {
.ovd {
display: inline-flex; align-items: center; gap: 3px; padding: 3px;
display: inline-flex; align-items: center; gap: 3px; padding: 3px;
border-radius: 10px; flex: none; order: 2; margin-left: auto;
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 {
.ovd-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 2px;
display: inline-flex; align-items: center; justify-content: center; gap: 2px;
@@ -43,14 +43,14 @@
@@ -43,14 +43,14 @@
background: none; color: var(--text-muted); font: inherit; line-height: 1;
background: none; color: var(--text-muted); font: inherit; line-height: 1;
transition: background .16s, color .16s, transform .16s;
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:active { transform: scale(.93); }
.ovd-btn[aria-pressed="true"] {
.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. */
/* 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-bar { display: block; width: 2.5px; border-radius: 1px; background: currentColor; height: 11px; }
.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }
.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }
@@ -66,15 +66,23 @@
@@ -66,15 +66,23 @@
document.head.appendChild(st);
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) {
function maxColumns(el) {
const w = el.getBoundingClientRect().width || el.clientWidth || 0;
const w = el.getBoundingClientRect().width || el.clientWidth || 0;
if (!w) return 1;
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 (!grid || !key) return;
+ if (minCard) grid.dataset.ovdMin = String(minCard);
if (grid.dataset.ovdBound) { apply(grid, key); return; }
if (grid.dataset.ovdBound) { apply(grid, key); return; }
grid.dataset.ovdBound = '1';
grid.dataset.ovdBound = '1';
ensureCss();
ensureCss();
@@ -94,14 +102,34 @@
@@ -94,14 +102,34 @@
const max = maxColumns(grid);
const max = maxColumns(grid);
const store = readStore();
const store = readStore();
const wanted = Number(store[key]) || 0;
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.
// 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) {
if (active) {
grid.dataset.ovdCols = String(active);
grid.dataset.ovdCols = String(active);
+ if (auto) grid.dataset.ovdAuto = '1';
grid.style.setProperty('--ovd-cols', String(active));
grid.style.setProperty('--ovd-cols', String(active));
} else {
} else {
delete grid.dataset.ovdCols;
delete grid.dataset.ovdCols;
+ delete grid.dataset.ovdAuto;
grid.style.removeProperty('--ovd-cols');
grid.style.removeProperty('--ovd-cols');
}
}
if (!ctrl) return;
if (!ctrl) return;
@@ -135,7 +163,7 @@
@@ -135,7 +163,7 @@
ctrl.appendChild(b);
ctrl.appendChild(b);
}
}
}
}
- const effective = active || autoColumns(grid, max);
+ const effective = active || Math.min(natural, max);
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));
b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));
}
}
@@ -1276,18 +1276,23 @@ async function start() {
@@ -1276,18 +1276,23 @@ async function start() {
const _changelogMaxBroadcasts = 3;
const _changelogMaxBroadcasts = 3;
const _changelogDelays = [5000, 15000, 30000];
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 {
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 commits = raw.trim().split('\n').filter(Boolean).map(line => {
const [hash, short, subject, author, date] = line.split('||');
const [hash, short, subject, author, date] = line.split('||');
return { hash, short, subject, author, date };
return { hash, short, subject, author, date };
@@ -1484,6 +1484,15 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
@@ -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; }
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 offline = !!(channel.offline_screen_type && channel.offline_screen_type !== 'none');
const methodSet = slots.some(sl => !!sl.streaming_method);
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 = [
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: '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 },
{ 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) => {
@@ -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: '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: '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 },
{ 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 done = tasks.filter(t => t.done).length;
const next = tasks.find(t => !t.done) || null;
const next = tasks.find(t => !t.done) || null;
@@ -16,7 +16,7 @@ const RAW_BUILTIN_THEMES = [
@@ -16,7 +16,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#12121a',
'--bg-input': '#12121a',
'--text-primary': '#e8e6e3',
'--text-primary': '#e8e6e3',
'--text-secondary': '#9a9a9a',
'--text-secondary': '#9a9a9a',
- '--text-muted': '#666',
+ '--text-muted': '#838282',
'--accent': '#8b5cf6',
'--accent': '#8b5cf6',
'--accent-light': '#a78bfa',
'--accent-light': '#a78bfa',
'--accent-dark': '#6d28d9',
'--accent-dark': '#6d28d9',
@@ -46,7 +46,7 @@ const RAW_BUILTIN_THEMES = [
@@ -46,7 +46,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d1220',
'--bg-input': '#0d1220',
'--text-primary': '#e2e8f0',
'--text-primary': '#e2e8f0',
'--text-secondary': '#94a3b8',
'--text-secondary': '#94a3b8',
- '--text-muted': '#64748b',
+ '--text-muted': '#78879b',
'--accent': '#6366f1',
'--accent': '#6366f1',
'--accent-light': '#818cf8',
'--accent-light': '#818cf8',
'--accent-dark': '#4f46e5',
'--accent-dark': '#4f46e5',
@@ -69,7 +69,7 @@ const RAW_BUILTIN_THEMES = [
@@ -69,7 +69,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d160d',
'--bg-input': '#0d160d',
'--text-primary': '#d4e8d4',
'--text-primary': '#d4e8d4',
'--text-secondary': '#8aaa8a',
'--text-secondary': '#8aaa8a',
- '--text-muted': '#5a7a5a',
+ '--text-muted': '#708e70',
'--accent': '#4ade80',
'--accent': '#4ade80',
'--accent-light': '#86efac',
'--accent-light': '#86efac',
'--accent-dark': '#22c55e',
'--accent-dark': '#22c55e',
@@ -92,7 +92,7 @@ const RAW_BUILTIN_THEMES = [
@@ -92,7 +92,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0d0b14',
'--bg-input': '#0d0b14',
'--text-primary': '#f0e6ff',
'--text-primary': '#f0e6ff',
'--text-secondary': '#b4a0d0',
'--text-secondary': '#b4a0d0',
- '--text-muted': '#6a5a80',
+ '--text-muted': '#87799c',
'--accent': '#ff006e',
'--accent': '#ff006e',
'--accent-light': '#ff4d94',
'--accent-light': '#ff4d94',
'--accent-dark': '#c9005a',
'--accent-dark': '#c9005a',
@@ -115,7 +115,7 @@ const RAW_BUILTIN_THEMES = [
@@ -115,7 +115,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#21222c',
'--bg-input': '#21222c',
'--text-primary': '#f8f8f2',
'--text-primary': '#f8f8f2',
'--text-secondary': '#bfbfb2',
'--text-secondary': '#bfbfb2',
- '--text-muted': '#6272a4',
+ '--text-muted': '#8995b8',
'--accent': '#bd93f9',
'--accent': '#bd93f9',
'--accent-light': '#d4b8ff',
'--accent-light': '#d4b8ff',
'--accent-dark': '#9b6ddb',
'--accent-dark': '#9b6ddb',
@@ -143,7 +143,7 @@ const RAW_BUILTIN_THEMES = [
@@ -143,7 +143,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#22231e',
'--bg-input': '#22231e',
'--text-primary': '#f8f8f2',
'--text-primary': '#f8f8f2',
'--text-secondary': '#cfcfc2',
'--text-secondary': '#cfcfc2',
- '--text-muted': '#75715e',
+ '--text-muted': '#979484',
'--accent': '#a6e22e',
'--accent': '#a6e22e',
'--accent-light': '#c4ff50',
'--accent-light': '#c4ff50',
'--accent-dark': '#7dac1c',
'--accent-dark': '#7dac1c',
@@ -169,7 +169,7 @@ const RAW_BUILTIN_THEMES = [
@@ -169,7 +169,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0c1418',
'--bg-input': '#0c1418',
'--text-primary': '#d0e8ef',
'--text-primary': '#d0e8ef',
'--text-secondary': '#7aa8ba',
'--text-secondary': '#7aa8ba',
- '--text-muted': '#4a7080',
+ '--text-muted': '#658896',
'--accent': '#00bcd4',
'--accent': '#00bcd4',
'--accent-light': '#4dd0e1',
'--accent-light': '#4dd0e1',
'--accent-dark': '#0097a7',
'--accent-dark': '#0097a7',
@@ -192,7 +192,7 @@ const RAW_BUILTIN_THEMES = [
@@ -192,7 +192,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#140c0c',
'--bg-input': '#140c0c',
'--text-primary': '#ffe8e0',
'--text-primary': '#ffe8e0',
'--text-secondary': '#c49080',
'--text-secondary': '#c49080',
- '--text-muted': '#7a5a50',
+ '--text-muted': '#977970',
'--accent': '#ff6b35',
'--accent': '#ff6b35',
'--accent-light': '#ff8f5e',
'--accent-light': '#ff8f5e',
'--accent-dark': '#d04a1a',
'--accent-dark': '#d04a1a',
@@ -215,7 +215,7 @@ const RAW_BUILTIN_THEMES = [
@@ -215,7 +215,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#0e1422',
'--bg-input': '#0e1422',
'--text-primary': '#e0eaf5',
'--text-primary': '#e0eaf5',
'--text-secondary': '#8aa0c0',
'--text-secondary': '#8aa0c0',
- '--text-muted': '#5a708a',
+ '--text-muted': '#72869d',
'--accent': '#38bdf8',
'--accent': '#38bdf8',
'--accent-light': '#7dd3fc',
'--accent-light': '#7dd3fc',
'--accent-dark': '#0284c7',
'--accent-dark': '#0284c7',
@@ -238,7 +238,7 @@ const RAW_BUILTIN_THEMES = [
@@ -238,7 +238,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#140a0a',
'--bg-input': '#140a0a',
'--text-primary': '#f0dada',
'--text-primary': '#f0dada',
'--text-secondary': '#b0808a',
'--text-secondary': '#b0808a',
- '--text-muted': '#6a4a50',
+ '--text-muted': '#95787c',
'--accent': '#ef4444',
'--accent': '#ef4444',
'--accent-light': '#f87171',
'--accent-light': '#f87171',
'--accent-dark': '#b91c1c',
'--accent-dark': '#b91c1c',
@@ -261,7 +261,7 @@ const RAW_BUILTIN_THEMES = [
@@ -261,7 +261,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#100c16',
'--bg-input': '#100c16',
'--text-primary': '#f0e0ff',
'--text-primary': '#f0e0ff',
'--text-secondary': '#c090e0',
'--text-secondary': '#c090e0',
- '--text-muted': '#7050a0',
+ '--text-muted': '#8f73b7',
'--accent': '#e040fb',
'--accent': '#e040fb',
'--accent-light': '#ea80fc',
'--accent-light': '#ea80fc',
'--accent-dark': '#aa00d4',
'--accent-dark': '#aa00d4',
@@ -286,7 +286,7 @@ const RAW_BUILTIN_THEMES = [
@@ -286,7 +286,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#12141c',
'--bg-input': '#12141c',
'--text-primary': '#e4e6ea',
'--text-primary': '#e4e6ea',
'--text-secondary': '#a0a4b0',
'--text-secondary': '#a0a4b0',
- '--text-muted': '#606470',
+ '--text-muted': '#828690',
'--accent': '#a78bfa',
'--accent': '#a78bfa',
'--accent-light': '#c4b5fd',
'--accent-light': '#c4b5fd',
'--accent-dark': '#7c3aed',
'--accent-dark': '#7c3aed',
@@ -309,7 +309,7 @@ const RAW_BUILTIN_THEMES = [
@@ -309,7 +309,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#030803',
'--bg-input': '#030803',
'--text-primary': '#00ff41',
'--text-primary': '#00ff41',
'--text-secondary': '#00b030',
'--text-secondary': '#00b030',
- '--text-muted': '#005a18',
+ '--text-muted': '#008f25',
'--accent': '#00ff41',
'--accent': '#00ff41',
'--accent-light': '#66ff8a',
'--accent-light': '#66ff8a',
'--accent-dark': '#00aa2a',
'--accent-dark': '#00aa2a',
@@ -332,7 +332,7 @@ const RAW_BUILTIN_THEMES = [
@@ -332,7 +332,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#2a303c',
'--bg-input': '#2a303c',
'--text-primary': '#eceff4',
'--text-primary': '#eceff4',
'--text-secondary': '#d8dee9',
'--text-secondary': '#d8dee9',
- '--text-muted': '#7b88a1',
+ '--text-muted': '#9ba5b8',
'--accent': '#88c0d0',
'--accent': '#88c0d0',
'--accent-light': '#8fbcbb',
'--accent-light': '#8fbcbb',
'--accent-dark': '#5e81ac',
'--accent-dark': '#5e81ac',
@@ -359,7 +359,7 @@ const RAW_BUILTIN_THEMES = [
@@ -359,7 +359,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#242424',
'--bg-input': '#242424',
'--text-primary': '#ebdbb2',
'--text-primary': '#ebdbb2',
'--text-secondary': '#d5c4a1',
'--text-secondary': '#d5c4a1',
- '--text-muted': '#928374',
+ '--text-muted': '#a69682',
'--accent': '#fabd2f',
'--accent': '#fabd2f',
'--accent-light': '#fce566',
'--accent-light': '#fce566',
'--accent-dark': '#d79921',
'--accent-dark': '#d79921',
@@ -386,7 +386,7 @@ const RAW_BUILTIN_THEMES = [
@@ -386,7 +386,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#050505',
'--bg-input': '#050505',
'--text-primary': '#cccccc',
'--text-primary': '#cccccc',
'--text-secondary': '#888888',
'--text-secondary': '#888888',
- '--text-muted': '#444444',
+ '--text-muted': '#7a7a7a',
'--accent': '#ffffff',
'--accent': '#ffffff',
'--accent-light': '#ffffff',
'--accent-light': '#ffffff',
'--accent-dark': '#aaaaaa',
'--accent-dark': '#aaaaaa',
@@ -409,7 +409,7 @@ const RAW_BUILTIN_THEMES = [
@@ -409,7 +409,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#13100b',
'--bg-input': '#13100b',
'--text-primary': '#e8ddd0',
'--text-primary': '#e8ddd0',
'--text-secondary': '#b49a80',
'--text-secondary': '#b49a80',
- '--text-muted': '#7a6a54',
+ '--text-muted': '#8e7f6a',
'--accent': '#cd7f32',
'--accent': '#cd7f32',
'--accent-light': '#daa060',
'--accent-light': '#daa060',
'--accent-dark': '#a06020',
'--accent-dark': '#a06020',
@@ -432,7 +432,7 @@ const RAW_BUILTIN_THEMES = [
@@ -432,7 +432,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#100c16',
'--bg-input': '#100c16',
'--text-primary': '#f0e0f0',
'--text-primary': '#f0e0f0',
'--text-secondary': '#c0a0c0',
'--text-secondary': '#c0a0c0',
- '--text-muted': '#7a5a7a',
+ '--text-muted': '#927592',
'--accent': '#f472b6',
'--accent': '#f472b6',
'--accent-light': '#f9a8d4',
'--accent-light': '#f9a8d4',
'--accent-dark': '#db2777',
'--accent-dark': '#db2777',
@@ -455,7 +455,7 @@ const RAW_BUILTIN_THEMES = [
@@ -455,7 +455,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#060400',
'--bg-input': '#060400',
'--text-primary': '#ffb000',
'--text-primary': '#ffb000',
'--text-secondary': '#cc8d00',
'--text-secondary': '#cc8d00',
- '--text-muted': '#664700',
+ '--text-muted': '#a37100',
'--accent': '#ffb000',
'--accent': '#ffb000',
'--accent-light': '#ffd050',
'--accent-light': '#ffd050',
'--accent-dark': '#cc8d00',
'--accent-dark': '#cc8d00',
@@ -478,8 +478,8 @@ const RAW_BUILTIN_THEMES = [
@@ -478,8 +478,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f5f7',
'--bg-input': '#f5f5f7',
'--text-primary': '#1a1a2e',
'--text-primary': '#1a1a2e',
'--text-secondary': '#4a4a6a',
'--text-secondary': '#4a4a6a',
- '--text-muted': '#9a9ab0',
- '--accent': '#8b5cf6',
+ '--text-muted': '#747489',
+ '--accent': '#8756f3',
'--accent-light': '#a78bfa',
'--accent-light': '#a78bfa',
'--accent-dark': '#6d28d9',
'--accent-dark': '#6d28d9',
'--border': '#e0e0e6',
'--border': '#e0e0e6',
@@ -503,7 +503,7 @@ const RAW_BUILTIN_THEMES = [
@@ -503,7 +503,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f2ed',
'--bg-input': '#f5f2ed',
'--text-primary': '#2c2416',
'--text-primary': '#2c2416',
'--text-secondary': '#5a4e3e',
'--text-secondary': '#5a4e3e',
- '--text-muted': '#9a8e7e',
+ '--text-muted': '#7b7061',
'--accent': '#8b6914',
'--accent': '#8b6914',
'--accent-light': '#b08a30',
'--accent-light': '#b08a30',
'--accent-dark': '#6a5010',
'--accent-dark': '#6a5010',
@@ -528,8 +528,8 @@ const RAW_BUILTIN_THEMES = [
@@ -528,8 +528,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#edf2fa',
'--bg-input': '#edf2fa',
'--text-primary': '#1a2040',
'--text-primary': '#1a2040',
'--text-secondary': '#4a5070',
'--text-secondary': '#4a5070',
- '--text-muted': '#8a90aa',
- '--accent': '#3b82f6',
+ '--text-muted': '#6b718c',
+ '--accent': '#2867ed',
'--accent-light': '#60a5fa',
'--accent-light': '#60a5fa',
'--accent-dark': '#2563eb',
'--accent-dark': '#2563eb',
'--border': '#d0d8ea',
'--border': '#d0d8ea',
@@ -553,8 +553,8 @@ const RAW_BUILTIN_THEMES = [
@@ -553,8 +553,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#edf5ea',
'--bg-input': '#edf5ea',
'--text-primary': '#1a2e16',
'--text-primary': '#1a2e16',
'--text-secondary': '#3a5a32',
'--text-secondary': '#3a5a32',
- '--text-muted': '#7a9a6e',
- '--accent': '#16a34a',
+ '--text-muted': '#5d7a54',
+ '--accent': '#15823e',
'--accent-light': '#22c55e',
'--accent-light': '#22c55e',
'--accent-dark': '#15803d',
'--accent-dark': '#15803d',
'--border': '#c8dcc0',
'--border': '#c8dcc0',
@@ -578,8 +578,8 @@ const RAW_BUILTIN_THEMES = [
@@ -578,8 +578,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#fdf0ea',
'--bg-input': '#fdf0ea',
'--text-primary': '#2e1a14',
'--text-primary': '#2e1a14',
'--text-secondary': '#6a4438',
'--text-secondary': '#6a4438',
- '--text-muted': '#aa8070',
- '--accent': '#f97316',
+ '--text-muted': '#8f6a5c',
+ '--accent': '#c04808',
'--accent-light': '#fb923c',
'--accent-light': '#fb923c',
'--accent-dark': '#ea580c',
'--accent-dark': '#ea580c',
'--border': '#f0d0c0',
'--border': '#f0d0c0',
@@ -603,8 +603,8 @@ const RAW_BUILTIN_THEMES = [
@@ -603,8 +603,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f4f0fa',
'--bg-input': '#f4f0fa',
'--text-primary': '#1e1430',
'--text-primary': '#1e1430',
'--text-secondary': '#4a3a60',
'--text-secondary': '#4a3a60',
- '--text-muted': '#9080aa',
- '--accent': '#8b5cf6',
+ '--text-muted': '#796a92',
+ '--accent': '#844cf2',
'--accent-light': '#a78bfa',
'--accent-light': '#a78bfa',
'--accent-dark': '#7c3aed',
'--accent-dark': '#7c3aed',
'--border': '#ddd0ee',
'--border': '#ddd0ee',
@@ -628,8 +628,8 @@ const RAW_BUILTIN_THEMES = [
@@ -628,8 +628,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5ecc4',
'--bg-input': '#f5ecc4',
'--text-primary': '#3c3836',
'--text-primary': '#3c3836',
'--text-secondary': '#504945',
'--text-secondary': '#504945',
- '--text-muted': '#928374',
- '--accent': '#d65d0e',
+ '--text-muted': '#766b60',
+ '--accent': '#be4707',
'--accent-light': '#fe8019',
'--accent-light': '#fe8019',
'--accent-dark': '#af3a03',
'--accent-dark': '#af3a03',
'--border': '#d5c4a1',
'--border': '#d5c4a1',
@@ -657,8 +657,8 @@ const RAW_BUILTIN_THEMES = [
@@ -657,8 +657,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f8fafc',
'--bg-input': '#f8fafc',
'--text-primary': '#0f172a',
'--text-primary': '#0f172a',
'--text-secondary': '#334155',
'--text-secondary': '#334155',
- '--text-muted': '#94a3b8',
- '--accent': '#0ea5e9',
+ '--text-muted': '#69768b',
+ '--accent': '#027cbb',
'--accent-light': '#38bdf8',
'--accent-light': '#38bdf8',
'--accent-dark': '#0284c7',
'--accent-dark': '#0284c7',
'--border': '#e2e8f0',
'--border': '#e2e8f0',
@@ -682,8 +682,8 @@ const RAW_BUILTIN_THEMES = [
@@ -682,8 +682,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5f0e8',
'--bg-input': '#f5f0e8',
'--text-primary': '#2e2418',
'--text-primary': '#2e2418',
'--text-secondary': '#5a4e3e',
'--text-secondary': '#5a4e3e',
- '--text-muted': '#9a8e7e',
- '--accent': '#b8860b',
+ '--text-muted': '#7a6e5f',
+ '--accent': '#8c6707',
'--accent-light': '#daa520',
'--accent-light': '#daa520',
'--accent-dark': '#8b6508',
'--accent-dark': '#8b6508',
'--border': '#ddd0be',
'--border': '#ddd0be',
@@ -707,8 +707,8 @@ const RAW_BUILTIN_THEMES = [
@@ -707,8 +707,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#fdeef2',
'--bg-input': '#fdeef2',
'--text-primary': '#2e1420',
'--text-primary': '#2e1420',
'--text-secondary': '#6a3850',
'--text-secondary': '#6a3850',
- '--text-muted': '#aa7090',
- '--accent': '#e11d48',
+ '--text-muted': '#96617e',
+ '--accent': '#de1c47',
'--accent-light': '#f43f5e',
'--accent-light': '#f43f5e',
'--accent-dark': '#be123c',
'--accent-dark': '#be123c',
'--border': '#f0c8d4',
'--border': '#f0c8d4',
@@ -732,7 +732,7 @@ const RAW_BUILTIN_THEMES = [
@@ -732,7 +732,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#01303d',
'--bg-input': '#01303d',
'--text-primary': '#fdf6e3',
'--text-primary': '#fdf6e3',
'--text-secondary': '#93a1a1',
'--text-secondary': '#93a1a1',
- '--text-muted': '#586e75',
+ '--text-muted': '#909c9a',
'--accent': '#b58900',
'--accent': '#b58900',
'--accent-light': '#d4a017',
'--accent-light': '#d4a017',
'--accent-dark': '#8a6a00',
'--accent-dark': '#8a6a00',
@@ -759,8 +759,8 @@ const RAW_BUILTIN_THEMES = [
@@ -759,8 +759,8 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#f5eedb',
'--bg-input': '#f5eedb',
'--text-primary': '#002b36',
'--text-primary': '#002b36',
'--text-secondary': '#586e75',
'--text-secondary': '#586e75',
- '--text-muted': '#93a1a1',
- '--accent': '#b58900',
+ '--text-muted': '#5b7478',
+ '--accent': '#8d6c00',
'--accent-light': '#d4a017',
'--accent-light': '#d4a017',
'--accent-dark': '#8a6a00',
'--accent-dark': '#8a6a00',
'--border': '#d6ceb5',
'--border': '#d6ceb5',
@@ -788,7 +788,7 @@ const RAW_BUILTIN_THEMES = [
@@ -788,7 +788,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#1a1a2c',
'--bg-input': '#1a1a2c',
'--text-primary': '#cdd6f4',
'--text-primary': '#cdd6f4',
'--text-secondary': '#bac2de',
'--text-secondary': '#bac2de',
- '--text-muted': '#6c7086',
+ '--text-muted': '#81869e',
'--accent': '#cba6f7',
'--accent': '#cba6f7',
'--accent-light': '#dcc0ff',
'--accent-light': '#dcc0ff',
'--accent-dark': '#b07de0',
'--accent-dark': '#b07de0',
@@ -816,7 +816,7 @@ const RAW_BUILTIN_THEMES = [
@@ -816,7 +816,7 @@ const RAW_BUILTIN_THEMES = [
'--bg-input': '#e8ebf0',
'--bg-input': '#e8ebf0',
'--text-primary': '#4c4f69',
'--text-primary': '#4c4f69',
'--text-secondary': '#5c5f77',
'--text-secondary': '#5c5f77',
- '--text-muted': '#9ca0b0',
+ '--text-muted': '#696c83',
'--accent': '#8839ef',
'--accent': '#8839ef',
'--accent-light': '#a05cff',
'--accent-light': '#a05cff',
'--accent-dark': '#7028d4',
'--accent-dark': '#7028d4',