openvibers/openvibe.live Commits

The Pair on the home hero, and the mark's wait instead of spinners (#790)

OP OpenVibers committed 2 days ago · 746043bfc7 · 34 files, +67 −261

The home hero drew its own retired ring-and-V SVG; it now uses openvibe-shared/ov-mark (the Pair, its intro and states) and the old mark's CSS and route-wake code are gone. The share fallback is the current live card, and generic loading rings, dots and spinners across the player, chat, broadcast, recap and pastes are the mark's busy state.

Changes

ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex c8cda375..0dc7af65 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -26,23 +26,6 @@
"*::after",
"*::before",
".badge",
-- ".brand-mark",
-- ".brand-mark .ov-comet",
-- ".brand-mark .ov-dot",
-- ".brand-mark .ov-glow",
-- ".brand-mark .ov-spark",
-- ".brand-mark .ov-v",
-- ".brand-mark svg",
-- ".brand-mark--hero",
-- ".brand-mark--hero svg",
-- ".brand-mark.is-awake .ov-comet",
-- ".brand-mark.is-awake .ov-dot",
-- ".brand-mark.is-awake .ov-glow",
-- ".brand-mark.is-awake .ov-spark",
-- ".brand-mark.is-awake .ov-v",
-- ".brand-mark.is-awake svg",
-- ".brand-mark.is-settling svg",
-- ".brand-mark.is-spin svg",
".btn",
".btn-danger",
".btn-follow",
@@@ -412,6 +395,7 @@
"input",
"select",
"select.form-input",
-- "textarea"
++ "textarea",
++ ".hero h1 .ov-mark"
]
}
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex a8766b31..50a93126 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -779,26 +779,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
flex-shrink: 0; align-self: center;
}
--.stream-switch-spinner::before,
--.stream-switch-spinner::after {
-- content: '';
-- position: absolute;
-- inset: 12%;
-- border-radius: 50%;
--}
--
--.stream-switch-spinner::before {
-- inset: 18%;
-- border: 2px solid rgba(255,255,255,0.9);
-- animation: spin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; /* "spin" breathes now (style.css): nothing spins */
--}
--
--.stream-switch-spinner::after {
-- inset: 34%;
-- background: radial-gradient(circle at 32% 30%, rgba(255,255,255,0.9), color-mix(in srgb, var(--accent) 86%, white 10%));
-- box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 44%, transparent);
-- animation: playerLoaderPulse 1.8s var(--ov-ease-in-out, ease-in-out) infinite;
--}
.ch-cumulative-viewers {
display: flex; align-items: center; gap: 10px;
flex-wrap: wrap; margin-top: 4px;
ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex 0a47a03e..338ec05b 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -15,7 +15,6 @@
.pc-fade-in { animation: pcFadeIn var(--ov-dur-2, .22s) var(--ov-ease-out, ease-out); }
.pc-card-in { animation: pcCardIn var(--ov-dur-3, .4s) var(--ov-ease-spring, cubic-bezier(.34,1.4,.64,1)); }
#dash-powerchat-card.pc-busy { border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 6px 24px color-mix(in srgb, var(--accent) 12%, transparent); }
--.pc-spinner { width: 15px; height: 15px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--accent); display: inline-block; animation: pcSpin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }
.pc-ellipsis span { animation: pcBlink 1.2s infinite both; }
.pc-ellipsis span:nth-child(2) { animation-delay: .2s; }
.pc-ellipsis span:nth-child(3) { animation-delay: .4s; }
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex c97cd372..dc6e1fb0 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -547,7 +547,6 @@ html[data-ov-motion=off] .cta-hero-grad{-webkit-text-fill-color: var(--accent);c
.hero-tip { max-width: calc(100vw - 24px); }
.hero-stat--clickable:hover { transform: none; box-shadow: none; }
}
--.brand-mark--hero .ov-ring { stroke-width: 3.6; } .brand-mark--hero .ov-comet { stroke-width: 3.6; } .brand-mark--hero .ov-v { stroke-width: 4.2; }
.hero-stats { min-height: 246px; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
ddiff --git a/public/css/media-player.css b/public/css/media-player.css
iindex 41bc184f..4d3fc786 100644
---- a/public/css/media-player.css
++++ b/public/css/media-player.css
@@@ -75,15 +75,6 @@ code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 6px;
.mp-error-overlay i { font-size: 48px; color: var(--mp-danger); }
--.mp-spinner {
-- width: 40px; height: 40px;
-- border: 3px solid var(--mp-accent);
-- border-radius: 50%;
-- animation: mp-pulse 1.4s var(--ov-ease-out, ease-out) infinite; /* a ring that pulses out: nothing spins */
--}
--@keyframes mp-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mp-accent) 55%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }
--html[data-ov-motion=off] .mp-spinner{opacity: .7} /* a still ring while it loads */
--
/* ── Custom player controls ──────────────────────────────── */
.mp-controls {
position: absolute;
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 5a1f1051..0a446a4b 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -4420,18 +4420,6 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
.stream-switch-overlay.visible {
display: flex; animation: fadeIn var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);
}
--.stream-switch-spinner {
-- position: relative;
-- width: 58px;
-- height: 58px;
-- border-radius: 50%;
-- background: linear-gradient(140deg, color-mix(in srgb, var(--accent) 82%, white 8%), color-mix(in srgb, var(--text-primary, #fff) 8%, transparent));
-- box-shadow:
-- 0 0 0 1px rgba(255,255,255,0.1),
-- 0 18px 32px rgba(0,0,0,0.3),
-- inset 0 0 26px rgba(255,255,255,0.08);
-- animation: playerLoaderMorph 5.8s var(--ov-ease-in-out, ease-in-out) infinite alternate;
--}
/* "spin" is kept as a name for older rules, but it breathes: nothing spins (design lint, plan L45). */
@keyframes spin { 50% { opacity: 0.45; transform: scale(0.85); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@@@ -9489,76 +9477,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
reason to stop motion (the owner, 2026-11-10). */
}
--/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is
-- drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */
--.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }
--/* Idle is the default state. Every loop below is declared as before but held at its 0% frame —
-- each one's 0% is a sensible resting pose — and only runs while .is-awake is on the mark, which
-- app.js adds for five seconds after a route change. Six looping animations on an element that is
-- on screen the entire session is a composited repaint per frame for no reason while someone is
-- reading. */
--.brand-mark svg { overflow: visible; transform-origin: 50% 50%; animation: ovFloat 5s var(--ov-ease-in-out, ease-in-out) infinite; }
--.brand-mark svg,
--.brand-mark .ov-glow,
--.brand-mark .ov-comet,
--.brand-mark .ov-v,
--.brand-mark .ov-dot,
--.brand-mark .ov-spark { animation-play-state: paused; }
--.brand-mark.is-awake svg,
--.brand-mark.is-awake .ov-glow,
--.brand-mark.is-awake .ov-comet,
--.brand-mark.is-awake .ov-v,
--.brand-mark.is-awake .ov-dot,
--.brand-mark.is-awake .ov-spark { animation-play-state: running; }
--/* Hovering is a reason to come alive too, without waiting for a navigation. */
--.nav-brand:hover .brand-mark svg,
--.nav-brand:hover .brand-mark .ov-glow,
--.nav-brand:hover .brand-mark .ov-comet,
--.nav-brand:hover .brand-mark .ov-dot { animation-play-state: running; }
--.ov-glow { fill: currentColor; opacity: 0.1; transform-box: fill-box; transform-origin: center; animation: ovGlow 3.4s var(--ov-ease-in-out, ease-in-out) infinite; }
--.ov-ring { fill: none; stroke: currentColor; stroke-width: 4; opacity: 0.28; }
--.ov-comet { fill: none; stroke-width: 4; stroke-linecap: round; stroke-dasharray: 34 79; transform-box: fill-box; transform-origin: center; animation: ovComet 3.6s linear infinite; }
--.ov-v { fill: none; stroke-width: 4.6; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 46; stroke-dashoffset: 0; animation: ovDraw 1.1s var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }
--.ov-dot { fill: #fff; transform-box: fill-box; transform-origin: center; animation: ovDot 2.4s var(--ov-ease-in-out, ease-in-out) infinite; }
--.ov-orbit { fill: #fff; filter: drop-shadow(0 0 3px currentColor); }
--.ov-orbit--tail { opacity: 0.5; }
--.nav-brand:hover .brand-mark svg, .hero h1:hover .brand-mark--hero svg { animation: ovSpin 0.9s var(--ov-ease-spring, cubic-bezier(.2,1.5,.3,1)) 1; }
--.nav-brand:hover .ov-comet, .hero h1:hover .ov-comet { animation-duration: 0; }
--.nav-brand:hover .ov-glow, .hero h1:hover .ov-glow { opacity: 0.28; }
--.nav-brand:hover .ov-v, .hero h1:hover .ov-v { animation: ovDraw var(--ov-dur-4, 0.7s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }
--@keyframes ovFloat { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-1px) rotate(2deg); } }
--@keyframes ovGlow { 0%, 100% { opacity: 0.08; transform: scale(0.92); } 50% { opacity: 0.2; transform: scale(1.06); } }
--@keyframes ovComet { 50% { opacity: 0.35; } }
--@keyframes ovDraw { from { stroke-dashoffset: 46; } to { stroke-dashoffset: 0; } }
--@keyframes ovDot { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.5); opacity: 1; } }
--@keyframes ovSpin { 40% { transform: scale(1.18); } }
--/* One spring as the route lands: it grows past its size and settles (it no longer turns). */
--.brand-mark.is-spin svg { animation: ovSpinIn 1.05s var(--ov-ease-spring, cubic-bezier(.16, 1.16, .3, 1)) 1 both; animation-play-state: running; }
--@keyframes ovSpinIn {
-- 0% { transform: scale(0.86); }
-- 45% { transform: scale(1.2); }
-- 72% { transform: scale(0.97); }
-- 100% { transform: scale(1); }
--}
--/* And a short overshoot as it comes to rest, so going still reads as deliberate. */
--.brand-mark.is-settling svg { animation: ovSettle var(--ov-dur-4, 0.68s) var(--ov-ease-spring, cubic-bezier(.26, 1.32, .42, 1)) 1 both; animation-play-state: running; }
--@keyframes ovSettle {
-- 0% { transform: rotate(5deg) scale(1.07); }
-- 55% { transform: rotate(-2.5deg) scale(0.982); }
-- 100% { transform: none; }
--}
--/* Hero: bigger, plus sparks off the V */
--.brand-mark--hero { width: 1.05em; height: 1.05em; vertical-align: -0.16em; color: var(--accent); margin-right: 0.08em; }
--.brand-mark--hero svg { width: 100%; height: 100%; }
--.ov-spark { fill: #fff; opacity: 0; transform-box: fill-box; transform-origin: center; animation: ovSpark 3.4s var(--ov-ease-out, ease-out) infinite; }
--.ov-spark--2 { animation-delay: 1.1s; animation-name: ovSpark2; } .ov-spark--3 { animation-delay: 2.3s; animation-name: ovSpark3; }
--@keyframes ovSpark { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(12px,-12px) scale(1.4); } 100% { opacity: 0; } }
--@keyframes ovSpark2 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(-13px,-8px) scale(1.3); } 100% { opacity: 0; } }
--@keyframes ovSpark3 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(3px,16px) scale(1.2); } 100% { opacity: 0; } }
--@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } .brand-mark--hero svg { width: 100%; height: 100%; } }
--.rs-lite .ov-orbit, .rs-lite .ov-spark { filter: none; }
--html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: none}html[data-ov-motion=off] .ov-comet{stroke-dasharray: none;opacity: 0.9}
--@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } }
++.hero h1 .ov-mark { width: 1.05em; height: 1.05em; vertical-align: -0.14em; margin-right: 0.08em; }
.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }
/* ── Hero stat board: headline strip + expandable full board ──────────────────
@@@ -9753,7 +9672,7 @@ html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: n
.nav-brand { min-width: 0; overflow: hidden; }
@media (max-width: 420px) {
.navbar { padding: 0 9px; gap: 8px; }
-- .nav-brand .brand-text, .nav-brand > span:not(.brand-mark) { display: none; }
++ .nav-brand .brand-text, .nav-brand > span:not(.ov-mark) { display: none; }
.nav-right { gap: 6px; min-width: 0; }
.nav-coins, .nav-balance { max-width: 88px; overflow: hidden; }
}
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 7153dbd4..8ab88d4d 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -126,7 +126,7 @@
<!-- RTMP Feed Status Indicator -->
<div id="bc-rtmp-status" class="bc-rtmp-status waiting">
<div class="bc-rtmp-status-icon">
-- <i class="fa-solid fa-circle-notch fa-spin" id="bc-rtmp-status-spinner"></i>
++ <span class="ov-mark" data-state="busy" data-size="18" id="bc-rtmp-status-spinner" aria-hidden="true"></span>
<i class="fa-solid fa-check-circle" id="bc-rtmp-status-ok" style="display:none"></i>
</div>
<div class="bc-rtmp-status-text">
@@@ -787,7 +787,7 @@ WantedBy=multi-user.target</code>
<span>No media playing</span>
</div>
<div class="bc-media-pip-loading" id="bc-media-pip-loading" style="display:none">
-- <div class="spinner-small"></div>
++ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span>
<span>Loading…</span>
</div>
<div id="bc-media-pip-host"></div>
@@@ -1206,7 +1206,7 @@ WantedBy=multi-user.target</code>
</div>
<div class="bc-camera-sheet-list" id="bc-camera-sheet-list">
<div class="bc-camera-sheet-empty">
-- <i class="fa-solid fa-spinner fa-spin"></i>
++ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>
<span>Loading cameras…</span>
</div>
</div>
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 6554e847..0b6348fe 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -18,7 +18,7 @@
</div>
<!-- Loading overlay shown during stream switching -->
<div class="stream-switch-overlay" id="stream-switch-overlay">
-- <div class="stream-switch-spinner"></div>
++ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span>
<p>Switching stream…</p>
</div>
<canvas id="video-canvas"></canvas>
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 04c38e64..83cca4a9 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -4,21 +4,7 @@
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
<div class="hero-scrim" aria-hidden="true"></div>
<div class="hero-content">
-- <h1><span class="brand-mark brand-mark--hero" aria-hidden="true"><svg viewBox="0 0 48 48">
-- <defs>
-- <linearGradient id="ovh-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
-- <linearGradient id="ovh-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
-- <path id="ovh-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>
-- </defs>
-- <circle class="ov-glow" cx="24" cy="24" r="21"/>
-- <circle class="ov-ring" cx="24" cy="24" r="18"/>
-- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovh-g)"/>
-- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovh-v)"/>
-- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>
-- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovh-ring"/></animateMotion></circle>
-- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovh-ring"/></animateMotion></circle>
-- <circle class="ov-spark" cx="24" cy="24" r="1.3"/><circle class="ov-spark ov-spark--2" cx="24" cy="24" r="1"/><circle class="ov-spark ov-spark--3" cx="24" cy="24" r="1.1"/>
--</svg></span> OpenVibe.Live</h1>
++ <h1><span class="ov-mark hero-mark" aria-hidden="true"></span> OpenVibe.Live</h1>
<!-- The first word and quip ship in the HTML so the hero is complete at first paint; the
rotators take over once the page's data arrives. No aria-live: a slogan that changes
every few seconds would be read out to screen-reader users over and over. -->
@@@ -385,7 +371,7 @@
</div>
<div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
<div id="home-changelog" class="home-changelog">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</div>
ddiff --git a/public/fragments/rundown.html b/public/fragments/rundown.html
iindex 30955a3c..651e7bfe 100644
---- a/public/fragments/rundown.html
++++ b/public/fragments/rundown.html
@@@ -1,5 +1,5 @@
<div class="content-container rundown-container">
<div id="rundown-root">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
ddiff --git a/public/fragments/updates.html b/public/fragments/updates.html
iindex dae35403..2789920c 100644
---- a/public/fragments/updates.html
++++ b/public/fragments/updates.html
@@@ -4,6 +4,6 @@
<p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p>
</div>
<div id="updates-list" class="updates-list">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
ddiff --git a/public/index.html b/public/index.html
iindex 6652a3b7..4b45e8f6 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -165,7 +165,7 @@
<section id="page-stream" class="page">
<div class="container">
<div class="empty-state">
-- <i class="fa-solid fa-spinner fa-spin fa-3x"></i>
++ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>
<p>Redirecting to channel...</p>
</div>
</div>
@@@ -226,7 +226,7 @@
<button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>
</div>
<div id="paste-viewer-content">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
</section>
@@@ -234,7 +234,7 @@
<!-- Documentation Page -->
<section id="page-recap" class="page">
<div class="content-container recap-container">
-- <div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
++ <div id="recap-root"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
</div>
</section>
@@@ -401,6 +401,7 @@
adds the requested route's scripts at the end of this document, and js/ov-loader.js loads the
rest when someone navigates. The asset manifest below carries content hashes and the registry. -->
<!--ov:asset-manifest-->
++<script src="/shared/ov-mark.js" defer></script>
<script src="/shared/web-runtime.js" defer></script>
<script src="/js/ov-loader.js" defer></script>
<script src="/shared/navbar.js" defer></script>
ddiff --git a/public/js/ai-viewers.js b/public/js/ai-viewers.js
iindex 04c3a1b2..15582733 100644
---- a/public/js/ai-viewers.js
++++ b/public/js/ai-viewers.js
@@@ -264,7 +264,7 @@
window.previewAiViewers = async function previewAiViewers(btn) {
const box = $('aiv-preview'); if (btn) btn.disabled = true;
-- if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }
++ if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Asking the director… (this is a dry run — nothing is posted)</span>'; }
try {
const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });
if (!box) return;
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 482190a7..ce6b7afa 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2647,7 +2647,7 @@ async function _aiTlToggleTranscript(btn) {
box.hidden = false; btn.classList.add('open');
if (!box.dataset.built) {
box.dataset.built = '1';
-- box.innerHTML = '<div class="loading" style="padding:8px"><i class="fa-solid fa-spinner fa-spin"></i> Loading transcript…</div>';
++ box.innerHTML = '<div class="loading" style="padding:8px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading transcript…</div>';
try {
const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);
const segs = data.segments || [];
@@@ -2834,7 +2834,7 @@ async function loadChannelAiTimeline(username) {
const summary = `<div class="ai-tl-summary">${data.sessionCount || sessions.length} session${(data.sessionCount || sessions.length) === 1 ? '' : 's'} · ${data.momentCount || 0} AI moment${(data.momentCount || 0) === 1 ? '' : 's'} tracked</div>`;
streamerInner = streamerOvCard + summary + _aiTlBuildMonthBar()
+ `<div class="ai-tl-track" id="ai-tl-track">${sessions.map(_aiTimelineSessionHTML).join('')}</div>`
-- + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<i class="fa-solid fa-spinner fa-spin"></i> Loading more…' : ''}</div>`;
++ + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading more…' : ''}</div>`;
}
// ── As a chatter (inner content) ──
@@@ -3592,7 +3592,7 @@ async function submitMediaRequest(e) {
const val = input && input.value.trim();
if (!val) return false;
if (status) { status.className = 'ch-media-status'; status.textContent = ''; }
-- if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><i class="fa-solid fa-spinner fa-spin"></i> Checking that link…</div>';
++ if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking that link…</div>';
try {
const q = await api('/media/quote', { method: 'POST', body: { username: currentChannelUsername, input: val } });
_mediaQuote = { ...q, input: val };
ddiff --git a/public/js/app-chatpage.js b/public/js/app-chatpage.js
iindex dd69c14b..e3ed8833 100644
---- a/public/js/app-chatpage.js
++++ b/public/js/app-chatpage.js
@@@ -81,7 +81,7 @@ function _gaiTlOnScroll(el) {
async function _gaiTlInit() {
_gaiTl.oldestTs = 0; _gaiTl.done = false; _gaiTl.loading = false; _gaiTl.lastDay = null;
const el = document.getElementById('global-ai-timeline');
-- if (el) { el.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>'; el.scrollTop = 0; }
++ if (el) { el.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>'; el.scrollTop = 0; }
await _gaiTlLoad(true);
}
async function _gaiTlLoad(reset) {
ddiff --git a/public/js/app.js b/public/js/app.js
iindex d3a5e74a..b2a489dc 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1360,57 +1360,6 @@ function showNotFound({ title = 'Page not found', message = 'Nothing lives at th
Route-change motion: the brand mark, and the page itself.
═══════════════════════════════════════════════════════════════════════════ */
--/** How long the mark stays animated after a route change before settling. */
--const BRAND_AWAKE_MS = 5000;
--let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;
--
--/**
-- * Wake the OV mark for a moment, then let it go still: a route change spins it once and keeps it
-- * lively for five seconds (looping forever repainted every frame while people read). CSS loops stop
-- * via animation-play-state on their 0% rest pose; SMIL needs the SVG's pauseAnimations().
-- */
--function ovWakeBrandMark() {
-- let marks;
-- try { marks = document.querySelectorAll('.brand-mark'); } catch { return; }
-- if (!marks || !marks.length) return;
-- let reduce = false;
-- try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }
-- if (reduce) {
-- // CSS stops the loops under reduced motion but cannot reach SMIL — the gradient sweep on the
-- // V would otherwise run for the whole session. Hold everything still, once.
-- if (!_brandEverWoken) marks.forEach(m => { try { const svg = m.querySelector('svg'); svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ } });
-- _brandEverWoken = true;
-- return;
-- }
--
-- _brandEverWoken = true;
-- clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);
-- marks.forEach((m) => {
-- m.classList.remove('is-settling', 'is-spin');
-- m.classList.add('is-awake');
-- void m.offsetWidth; // restart the spin on a repeat navigation
-- m.classList.add('is-spin');
-- const svg = m.querySelector('svg');
-- try { svg && svg.unpauseAnimations && svg.unpauseAnimations(); } catch { /* no SMIL here */ }
-- });
--
-- // The spin owns the svg's animation slot while it runs, so the ambient float cannot start
-- // until it is off. Hand over as soon as the spin finishes rather than at the end of the window.
-- _brandSpinTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-spin')), 1080);
--
-- _brandIdleTimer = setTimeout(() => {
-- marks.forEach((m) => {
-- m.classList.remove('is-awake', 'is-spin');
-- m.classList.add('is-settling');
-- const svg = m.querySelector('svg');
-- // Pause in place rather than rewinding: a jump back to t=0 would throw the orbit dot
-- // across the ring at the exact moment the mark is supposed to be coming to rest.
-- try { svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ }
-- });
-- _brandSettleTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-settling')), 720);
-- }, BRAND_AWAKE_MS);
--}
--
const _PAGE_LABELS = { broadcast: 'Go Live', recap: 'recap' };
function _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
@@@ -1423,11 +1372,7 @@ function showPage(page) {
// change only — re-rendering the page you are already on should not restart either.
// A page move: the page stays hidden until its route's styles and code are in (never unstyled), with a progress
// bar, a "Loading …" line if it takes a moment, and a fade-in (openvibe-shared web-runtime enter()).
-- if (changed) { ovWakeBrandMark(); if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }
-- // A fresh page load is not a "change" — currentPage is already the page being shown — so the
-- // mark would never take its first sleep and the SMIL orbits would loop for the whole session.
-- // Wake it once on arrival; the same timer puts it to sleep five seconds later.
-- else if (!_brandEverWoken) ovWakeBrandMark();
++ if (changed) { if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }
// One event for modules that react to the visible page (voice mini bar, floating chat…), instead
// of each attaching MutationObservers to every section's class attribute.
try { document.dispatchEvent(new CustomEvent('ov:page', { detail: { page, changed } })); } catch { /* */ }
@@@ -2434,7 +2379,7 @@ async function uploadGoalMedia(input) {
const file = input.files && input.files[0];
if (!file) return;
const preview = document.getElementById('goal-media-preview');
-- if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Uploading…</div>';
++ if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…</div>';
try {
const fd = new FormData(); fd.append('file', file);
const token = localStorage.getItem('token');
@@@ -2530,7 +2475,7 @@ async function loadUpdatesPage() {
const container = document.getElementById('updates-list');
if (!container) return;
if (container.dataset.ovShippedMounted) return; // already showing; "Load more" keeps its place
-- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
const shipped = await loadShipped();
if (!shipped) {
container.innerHTML = '<p style="opacity:0.6;text-align:center;padding:32px 0;">The update log could not be loaded. <a href="https://openvibe.network/updates?site=live">See it on openvibe.network</a>.</p>';
ddiff --git a/public/js/broadcast-devices.js b/public/js/broadcast-devices.js
iindex a5eb8258..a4799680 100644
---- a/public/js/broadcast-devices.js
++++ b/public/js/broadcast-devices.js
@@@ -483,7 +483,7 @@ async function requestMediaPermissions() {
// Immediate visual feedback — if user doesn't see spinner, the function isn't reached
if (btn) {
btn.disabled = true;
-- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Requesting access...';
++ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Requesting access...';
}
if (dbg) { dbg.style.display = ''; dbg.textContent = 'Requesting permissions...'; }
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 74e06908..05765b2e 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -3575,7 +3575,7 @@ function _wsRenderRestreamConnect(platform) {
// Status not loaded yet — show a light placeholder
if (!st) {
box.innerHTML = `<div style="background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:0 0 12px">
-- <p class="muted" style="font-size:0.78rem;margin:0"><i class="fa-solid fa-spinner fa-spin"></i> Checking ${esc(plat.name)} connection…</p></div>`;
++ <p class="muted" style="font-size:0.78rem;margin:0"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking ${esc(plat.name)} connection…</p></div>`;
return;
}
@@@ -3636,7 +3636,7 @@ function _wsConnectPlatform(platform) {
const box = document.getElementById('ws-rs-connect');
if (box && !document.getElementById('ws-rs-connecting')) {
box.insertAdjacentHTML('afterbegin',
-- `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Waiting for authorization…</div>`);
++ `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for authorization…</div>`);
}
let finished = false;
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 800b5ed0..a735e23b 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -272,7 +272,7 @@ function updateRsRestreamSlotUI() {
html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> Live on RobotStreamer</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;
} else if (isStarting) {
-- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Connecting…</span>`;
++ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Connecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else if (hasFailed) {
html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> RobotStreamer failed</span>`;
@@@ -281,7 +281,7 @@ function updateRsRestreamSlotUI() {
// Slot assigned but not started yet (could be reconnecting)
const isReconnecting = !!(slotSs._rsReconnectTimer);
if (isReconnecting) {
-- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Reconnecting…</span>`;
++ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Reconnecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else {
html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> RobotStreamer ready</span>`;
@@@ -3196,6 +3196,8 @@ function applyManualGain(streamId) {
/* ── Multi-Platform Restream Destinations ──────────────────── */
++const MARK_BUSY_16 = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';
++const statusIcon = (icon) => icon === 'busy' ? MARK_BUSY_16 : `<i class="${icon}"></i>`;
const RESTREAM_PLATFORM_META = {
youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0000' },
twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff' },
@@@ -3356,8 +3358,8 @@ function rsDetailLine(rs) {
function rsBadgeFor(rs, clientFallback) {
if (rs) {
if (rs.state === 'live') return { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
-- if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Starting…', active: true };
-- if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Reconnecting…', active: true };
++ if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Starting…', active: true };
++ if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Reconnecting…', active: true };
if (rs.state === 'failed') return { cls: 'bc-restream-status-failed', icon: 'fa-solid fa-circle-xmark', text: 'Failed', active: false };
}
return clientFallback;
@@@ -3419,7 +3421,7 @@ function updateRestreamControlPanel() {
badgeText = 'Live';
} else if (statusLabel === 'starting') {
badgeClass = 'bc-restream-status-starting';
-- badgeIcon = 'fa-solid fa-spinner fa-spin';
++ badgeIcon = 'busy';
badgeText = 'Starting…';
} else if (statusLabel === 'error') {
badgeClass = 'bc-restream-status-error';
@@@ -3445,7 +3447,7 @@ function updateRestreamControlPanel() {
html += `<div class="bc-restream-row" data-dest="${dest.id}">
<span class="bc-restream-platform-icon" style="color:${meta.color}"><i class="${meta.icon}"></i></span>
<span class="bc-restream-name">${esc(dest.name || meta.name)}${transport}</span>
-- <span class="bc-restream-status-badge ${badgeClass}"><i class="${badgeIcon}"></i> ${badgeText}</span>
++ <span class="bc-restream-status-badge ${badgeClass}">${statusIcon(badgeIcon)} ${badgeText}</span>
<div class="bc-restream-actions">
${isActive
? `<button class="bc-ctrl-btn-sm" onclick="stopRestreamDest(${dest.id})" title="Stop pushing to this destination"><i class="fa-solid fa-stop"></i> Stop</button>`
@@@ -3467,14 +3469,14 @@ function updateRestreamControlPanel() {
let fallback = { cls: 'bc-restream-status-idle', icon: 'fa-solid fa-circle', text: 'Idle', active: false };
if (isRsLive) fallback = { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
-- else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };
++ else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'busy', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };
const b = rsBadgeFor(rsSrv, fallback);
const robot = broadcastState.robotStreamer?.robotId || rsSrv?.robot_id;
html += `<div class="bc-restream-row" data-dest="rs">
<span class="bc-restream-platform-icon" style="color:#4a9eff"><i class="fa-solid fa-robot"></i></span>
<span class="bc-restream-name">RobotStreamer${robot ? ` <span class="bc-restream-transport" title="Robot id">#${esc(String(robot))}</span>` : ''}</span>
-- <span class="bc-restream-status-badge ${b.cls}"><i class="${b.icon}"></i> ${b.text}</span>
++ <span class="bc-restream-status-badge ${b.cls}">${statusIcon(b.icon)} ${b.text}</span>
<div class="bc-restream-actions">
${b.active
? `<button class="bc-ctrl-btn-sm" onclick="stopRsRestream()" title="Take the robot offline"><i class="fa-solid fa-stop"></i> Stop</button>`
@@@ -4908,7 +4910,7 @@ async function openCameraSwitcher() {
sheet.style.display = 'flex';
_bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));
_bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);
-- list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';
++ list.innerHTML = '<div class="bc-camera-sheet-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span><span>Loading cameras…</span></div>';
try {
const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: true });
@@@ -5920,7 +5922,7 @@ async function createBroadcastClip() {
// does, from what was said and shown: server/ai/clip-title.js).
const typed = document.getElementById('bc-clip-title')?.value?.trim() || '';
const btn = document.getElementById('bc-clip-create-btn');
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }
++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating…'; }
try {
const res = await fetch('/api/vods/clips', {
ddiff --git a/public/js/chat-shared.js b/public/js/chat-shared.js
iindex d169bd5d..99df3424 100644
---- a/public/js/chat-shared.js
++++ b/public/js/chat-shared.js
@@@ -235,7 +235,7 @@
</div>
<div class="link-preview-body">
<div class="link-preview-loading">
-- <i class="fa-solid fa-spinner fa-spin"></i> Loading preview...
++ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading preview...
</div>
<iframe class="link-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms" src="${esc(url)}" onload="this.previousElementSibling.style.display='none'" onerror="this.previousElementSibling.innerHTML='<i class=\\'fa-solid fa-triangle-exclamation\\'></i> Could not load preview'"></iframe>
</div>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex fd687e28..18535de4 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -1125,7 +1125,7 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {
_swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(_historyPieces(cached, 50)[0])));
messages.dataset.room = room;
} else if (clear || roomChanged) {
-- messages.innerHTML = '<div class="chat-loading-history"><i class="fa-solid fa-spinner fa-spin"></i> Loading history…</div>';
++ messages.innerHTML = '<div class="chat-loading-history"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading history…</div>';
messages.dataset.room = room;
}
}
@@@ -1798,7 +1798,7 @@ async function _translateForMe(el, text, btn) {
if (existing) { existing.hidden = !existing.hidden; return; }
const hit = _trCache.get(to + '\u0001' + text);
if (hit) { _trShow(el, hit, to); return; }
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i>'; }
++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>'; }
try {
const d = await api('/i18n/translate', { method: 'POST', body: { text, to } });
if (d && d.same) { toast(`Already in ${_LANG_LABEL[to] || to}`, 'info'); return; }
@@@ -4417,7 +4417,7 @@ async function ctxAdminTools(username, userId, anonId) {
<button class="modal-close-btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="admin-tools-body">
-- <div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading IP intelligence...</div>
++ <div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading IP intelligence...</div>
</div>
</div>
`;
@@@ -4536,7 +4536,7 @@ function renderAdminToolsUser(container, data, username) {
<div class="at-section" id="at-tts-voice" data-kind="user" data-id="${esc(user.username)}">
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
-- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>
++ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div>
</div>
`;
@@@ -4602,7 +4602,7 @@ function renderAdminToolsAnon(container, data, username) {
html += `
<div class="at-section" id="at-tts-voice" data-kind="anon" data-id="${esc(anon_id || username)}">
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
-- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>
++ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div>
</div>`;
container.innerHTML = html;
@@@ -4660,7 +4660,7 @@ function renderAdminTtsVoice(bodyEl, kind, id, d) {
if (pr.speed != null) { $('#atv-speed').value = pr.speed; $('#atv-speed-n').textContent = pr.speed; }
}));
$('#atv-preview').addEventListener('click', async () => {
-- const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Playing…';
++ const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Playing…';
try {
const r = await api('/mod/tts-voice/preview', { method: 'POST', body: getParams() });
if (r.url || r.audio) { const a = new Audio(r.url || `data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex d346a95e..0381ade2 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -1186,7 +1186,7 @@ async function createDashToken() {
const btn = document.getElementById('token-create-btn');
btn.disabled = true;
-- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating...';
++ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating...';
try {
const data = await api('/auth/tokens', {
ddiff --git a/public/js/emotes.js b/public/js/emotes.js
iindex d5a077d1..41d42cc4 100644
---- a/public/js/emotes.js
++++ b/public/js/emotes.js
@@@ -479,7 +479,7 @@ async function searchFFZEmotes(query) {
if (!query || query.length < 2) return;
const { grid } = _getPickerEls(_emotePickerTarget);
if (!grid) return;
-- grid.innerHTML = '<div class="emote-picker-empty"><i class="fa-solid fa-spinner fa-spin"></i> Searching FFZ...</div>';
++ grid.innerHTML = '<div class="emote-picker-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Searching FFZ...</div>';
try {
const data = await api(`/emotes/search?q=${encodeURIComponent(query)}`);
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 2944ea22..c084a39b 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -250,7 +250,7 @@
const oauthBtn = f.querySelector('#ovg-oauth-go');
if (oauthBtn) oauthBtn.onclick = () => {
oauthBtn.disabled = true;
-- oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';
++ oauthBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for ' + esc(name) + '…';
connectPlatform(plat, (ok, needsKey) => {
if (ok && !needsKey) return rerender(); // done — back to the list
if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting
ddiff --git a/public/js/messenger.js b/public/js/messenger.js
iindex 94e96f4d..5752409f 100644
---- a/public/js/messenger.js
++++ b/public/js/messenger.js
@@@ -757,7 +757,7 @@
<div class="msg-settings-section">
<div class="msg-settings-section-title"><i class="fa-solid fa-shield-halved"></i> Blocked Users</div>
-- <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><i class="fa-solid fa-spinner fa-spin"></i></div></div>
++ <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span></div></div>
</div>
<div class="msg-settings-footer">
ddiff --git a/public/js/ov-guide.js b/public/js/ov-guide.js
iindex 9cd4d05b..87442c2b 100644
---- a/public/js/ov-guide.js
++++ b/public/js/ov-guide.js
@@@ -88,7 +88,7 @@
$('#ovg-kicker').textContent = step.kicker || S.journey.kicker || '';
$('#ovg-title').textContent = typeof step.title === 'function' ? step.title(S.ctx) : (step.heading || step.title);
renderSteps();
-- panel.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ panel.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
let html = '';
try { html = await step.render(S.ctx); } catch (e) { html = `<div class="ovg-error"><i class="fa-solid fa-triangle-exclamation"></i> ${cfg.esc((e && e.message) || 'Something went wrong')}</div>`; }
if (S.step !== step) return; // user moved on while we loaded
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex a86b203c..1479d501 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -226,7 +226,7 @@ function togglePasteMarkdown() {
async function loadPasteViewer(slug) {
const container = document.getElementById('paste-viewer-content');
if (!container) return;
-- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
try {
const data = await api(`/pastes/${slug}`);
@@@ -1149,7 +1149,7 @@ function _setScreenshotCapturing(on) {
el = document.createElement('div');
el.id = 'screenshot-capturing';
el.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;padding:32px;color:var(--text-secondary);font-size:0.95rem';
-- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Capturing screenshot…';
++ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Capturing screenshot…';
area.prepend(el);
}
} else {
@@@ -1421,7 +1421,7 @@ async function submitScreenshot() {
formData.append('user_agent', navigator.userAgent);
const submitBtn = document.getElementById('screenshot-submit-btn');
-- if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading…'; }
++ if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…'; }
try {
const token = localStorage.getItem('token');
ddiff --git a/public/js/powerchat.js b/public/js/powerchat.js
iindex 53d77856..9b034e61 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -111,7 +111,7 @@
if (s) s.classList.add('pc-status-block');
_swap(s, `
<div class="pc-connecting-box">
-- <div class="pc-connecting-head"><span class="pc-spinner"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div>
++ <div class="pc-connecting-head"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div>
<div class="pc-progress"><span class="pc-progress-bar"></span></div>
<div class="pc-manual">
<span>Popup didn't open?</span>
@@@ -233,7 +233,7 @@
window.powerchatDisconnect = async function powerchatDisconnect(btn) {
if (!confirm('Disconnect PowerChat? Tips will stop flowing into OpenVibe.Live until you reconnect.')) return;
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Disconnecting…'; }
++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Disconnecting…'; }
const card = $('dash-powerchat-card'); if (card) card.classList.add('pc-busy');
try { await api('/powerchat/oauth/connection', { method: 'DELETE' }); toast('PowerChat disconnected', 'success'); }
catch (e) { toast(e.message || 'Failed to disconnect', 'error'); }
@@@ -242,7 +242,7 @@
// Local pipeline test — verifies the streamer's alert sound + chat celebration.
window.powerchatTestTip = async function powerchatTestTip(btn) {
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…'; }
try {
const r = await api('/powerchat/test-tip', { method: 'POST', body: { amount: 5 } });
toast(r && r.powerchat
@@@ -278,7 +278,7 @@
const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);
buttons.forEach(b => { b.disabled = true; });
const orig = btn ? btn.innerHTML : '';
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';
++ if (btn) btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…';
try {
const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });
toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');
ddiff --git a/public/js/recap.js b/public/js/recap.js
iindex d8d83b5b..4675d724 100644
---- a/public/js/recap.js
++++ b/public/js/recap.js
@@@ -89,14 +89,14 @@
${more}
</article>`;
const regen = root.querySelector('#recap-regen');
-- if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
++ if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
}
window.loadRecapPage = async function (id) {
const root = document.getElementById('recap-root');
if (!root) return;
-- root.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ root.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
let data = null;
try { data = await api(`/recap/${encodeURIComponent(id)}`); } catch (e) {
const live = e && /live/i.test(e.message || '');
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 8fca34bc..f23b007b 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
const el = document.createElement('div');
el.id = 'reconnecting-indicator';
el.style.cssText = 'position:absolute;top:12px;right:12px;z-index:25;background:rgba(0,0,0,0.7);color:#fbbf24;padding:6px 14px;border-radius:8px;font-size:0.85rem;display:flex;align-items:center;gap:8px;pointer-events:none;';
-- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:0.9rem"></i> The streamer is reconnecting…';
++ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> The streamer is reconnecting…';
container.appendChild(el);
}
@@@ -3142,7 +3142,7 @@ function setClipButtonBusy(isBusy) {
btn.disabled = !!isBusy;
btn.classList.toggle('is-busy', !!isBusy);
btn.innerHTML = isBusy
-- ? '<i class="fa-solid fa-spinner fa-spin"></i>'
++ ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>'
: '<i class="fa-solid fa-scissors"></i>';
}
@@@ -3528,7 +3528,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
if (saveBtn.disabled) return;
const title = titleInput.value.trim();
saveBtn.disabled = true;
-- saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
++ saveBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Saving...';
try {
const token = localStorage.getItem('token');
const resp = await fetch(`/api/clips/${clipId}/trim`, {
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex 14d481c7..3ddf7119 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -9,7 +9,7 @@ function openvibeSubscribeModal(username) {
<h3><i class="fa-solid fa-star"></i> Subscribe to ${username ? username : 'this channel'}</h3>
<p class="muted" id="sub-price-line" style="margin-bottom:16px">Monthly channel subscription.</p>
<div id="subscribe-options" data-streamer="${username || ''}">
-- <p class="muted" style="font-size:0.85rem;text-align:center"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</p>
++ <p class="muted" style="font-size:0.85rem;text-align:center"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</p>
</div>`;
}
ddiff --git a/public/kiosk.html b/public/kiosk.html
iindex 766baeec..9212794c 100644
---- a/public/kiosk.html
++++ b/public/kiosk.html
@@@ -942,7 +942,7 @@ button{font:inherit}
const kind=el.dataset.kind, username=el.dataset.username, label=el.dataset.label;
const menu=document.createElement("div");
menu.className="chat-context-menu";
-- menu.innerHTML=`<div class="ctx-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>`;
++ menu.innerHTML=`<div class="ctx-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</div>`;
document.body.appendChild(menu); _kioskMenu=menu;
_posMenu(menu,x,y);
menu.addEventListener("click",e=>e.stopPropagation());
@@@ -1204,7 +1204,7 @@ button{font:inherit}
if(looksLikeDomain(q)){
const key=q.toLowerCase(); let res=resolved.get(key);
if(!res){
-- goBtn.innerHTML='<i class="fa-solid fa-spinner fa-spin"></i>';
++ goBtn.innerHTML='<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';
try{ const r=await fetch(`/api/kiosk/site?url=${encodeURIComponent(q)}`); const d=await r.json(); res=d.reachable?{reachable:true,url:d.url,host:d.host,title:d.title}:{reachable:false}; resolved.set(key,res); }
catch{ res={reachable:true,url:fullUrl(q),host:hostOf(q)}; }
goBtn.innerHTML='<i class="fa-solid fa-arrow-right"></i>';
ddiff --git a/public/media-player.html b/public/media-player.html
iindex 765ae5c8..42221a40 100644
---- a/public/media-player.html
++++ b/public/media-player.html
@@@ -48,7 +48,7 @@
</div>
<div id="mp-player-host" class="mp-player-host" hidden>
<div id="mp-loading-overlay" class="mp-loading-overlay" hidden>
-- <div class="mp-spinner"></div>
++ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>
<p id="mp-loading-text">Loading media…</p>
</div>
<div id="mp-error-overlay" class="mp-error-overlay" hidden>
ddiff --git a/public/og-image.png b/public/og-image.png
iindex b3b5a1f6..1993c152 100644
BBinary files a/public/og-image.png and b/public/og-image.png differ