openvibers/openvibe.live Commits

Touch tooltips, navbar alignment, and a card that could render as an empty outline

OP OpenVibers committed 16 Sept 2026 · 30caef4c09 · 4 files, +149 −8

The blank card in the About list: its entrance animation used fill-mode `both`, which holds the
from-state — opacity 0 — for the whole animation-delay. A subtree whose rendering is deferred or
skipped can sit in that state indefinitely, leaving an empty outline mid-list. The card's resting
style is already the animation's end state, so the fill mode was never needed; without it the
card is visible before the animation, during it and after. Same fix on the Arena moment rows, and
an explicit opacity floor on the card and its contents so nothing can strand them again.

Touch devices got nothing from the Build & Mod button's tooltip. On a phone the first tap opened
a 330px sliver pinned beside the button and swallowed the click, so it read as a button that does
nothing. The tooltip now docks to the bottom of the screen as a full-width card on touch, with
two real buttons — "Open the API docs" and "Got it" — instead of requiring a mysterious second
tap. The button carries a small pulsing dot so there is some sign it has more to say.

Navbar: the signed-in cluster was six independently-sized pieces, so nothing shared a baseline
and the row grew until it pushed off a small screen. Everything is one height and centred now,
the username truncates instead of pushing, and the sign-in button matches the cluster rather than
towering over it. The user menu is anchored to the bar below 900px so it can't hang off the
right edge, is capped to the viewport height with its own scroll, and was verified to fit at 320,
412, 560, 768 and 1400 wide plus a landscape phone.

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

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 549b62f9..0b3a086e 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -198,3 +198,23 @@
#page-home .hero-cta.fx-live .hb-vibe, #page-home .hero-cta.fx-live .hb-vibe .hb-ico::after { animation-play-state: running; }
@media (max-width: 520px) { .hb-label small { display: none; } }
@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }
++
++/* Touch devices get no hover, so the one button on the row that has something extra to say needs
++ to advertise it. A small pulsing dot marks it as tappable-for-more; it is pointer-events:none
++ so it never eats the tap itself. */
++@media (hover: none) {
++ .hb[data-ovtip] { position: relative; }
++ .hb[data-ovtip]::after {
++ content: ''; display: block; position: absolute; top: 9px; right: 11px;
++ width: 6px; height: 6px; border-radius: 50%; padding: 0; inset: auto 11px auto auto;
++ background: var(--c); box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 60%, transparent);
++ -webkit-mask: none; mask: none; opacity: 1; z-index: 2; pointer-events: none;
++ animation: hbHintPulse 2.4s ease-out infinite;
++ }
++ @keyframes hbHintPulse {
++ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 55%, transparent); }
++ 70% { box-shadow: 0 0 0 7px transparent; }
++ 100% { box-shadow: 0 0 0 0 transparent; }
++ }
++}
++@media (hover: none) and (prefers-reduced-motion: reduce) { .hb[data-ovtip]::after { animation: none; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex c9d82cdd..6127d4b1 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2598,7 +2598,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
background: rgba(255,255,255,0.028);
border: 1px solid rgba(255,255,255,0.07);
transition: transform .16s ease, background .16s ease, border-color .16s ease, box-shadow .16s ease;
-- animation: ctaRise .5s ease both;
++ /* `both` holds the from-state (opacity 0) for the whole animation-delay, and a subtree whose
++ rendering is skipped or deferred can sit in that state indefinitely — which is how a card
++ ends up as an empty outline in the middle of the list. `backwards` is not needed here: the
++ card's resting style is already the end state, so without a fill mode it is simply visible
++ until the animation runs, and visible after. Nothing can leave it blank. */
++ animation: ctaRise .5s ease;
}
.home-cta-features .home-cta-feature:nth-child(1){ animation-delay: .02s; }
.home-cta-features .home-cta-feature:nth-child(2){ animation-delay: .06s; }
@@@ -14740,7 +14745,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.arena-feed-section h2 small .arena-fresh { color: var(--success); }
.arena-mic-feed-list { display: grid; gap: 8px; }
.arena-mic-feed-list.is-compact .arena-mic-moment { padding: 8px; }
--.arena-mic-moment { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--fc, var(--accent)); animation: arenaMomentIn 0.4s ease both; }
++.arena-mic-moment { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); border-left: 3px solid var(--fc, var(--accent)); animation: arenaMomentIn 0.4s ease; }
.arena-mic-moment.is-beef { border-left-color: var(--live-red); background: linear-gradient(90deg, color-mix(in srgb, var(--live-red) 8%, var(--bg-card)), var(--bg-card)); }
.arena-mic-moment.is-fire { box-shadow: 0 0 18px color-mix(in srgb, var(--live-red) 22%, transparent); }
@keyframes arenaMomentIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@@@ -15171,3 +15176,74 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
}
/* Nothing inside the hero should be re-rasterised by the board changing size. */
#page-home .hero-content > h1, #page-home .hero-tagline, #page-home .hero-philosophy-links { will-change: auto; }
++
++/* ── Navbar right cluster + user menu ────────────────────────────────────────
++ The signed-in cluster is a wallet chip, a coin chip, a bell with a count badge, an avatar, a
++ username and a hamburger, and each was sized independently — so nothing shared a baseline, the
++ bell's badge collided with its neighbour, and the whole row grew until it pushed off a small
++ screen. Everything in it is now the same height and vertically centred, the badge is anchored
++ to its own bell rather than floating over the row, and the username truncates instead of
++ pushing. */
++.nav-right { gap: clamp(5px, 1.2vw, 10px); align-items: center; }
++#nav-auth-user { gap: clamp(5px, 1.2vw, 9px); align-items: center; }
++.nav-right .nav-coins,
++.nav-right .nav-balance {
++ height: 32px; display: inline-flex; align-items: center; gap: 5px;
++ padding: 0 clamp(7px, 1.4vw, 11px); border-radius: 11px;
++ font-size: clamp(0.72rem, 1.7vw, 0.85rem); line-height: 1; white-space: nowrap;
++}
++#openvibe-bell-mount { height: 32px; display: inline-flex; align-items: center; position: relative; }
++#openvibe-bell-mount > * { position: relative; }
++.nav-avatar-wrap {
++ height: 34px; padding: 0 6px 0 3px; gap: 7px; border-radius: 999px;
++ max-width: min(40vw, 190px); min-width: 0;
++}
++.nav-avatar { width: 28px; height: 28px; font-size: 0.78rem; flex: none; }
++#nav-username {
++ font-size: clamp(0.75rem, 1.8vw, 0.9rem); font-weight: 650;
++ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
++}
++.nav-hamburger { height: 34px; width: 34px; display: inline-flex; align-items: center; justify-content: center; flex: none; }
++
++/* The menu itself: anchored to the avatar, never wider than the screen, and scrollable rather
++ than running off the bottom on a short screen or a landscape phone. */
++.user-dropdown {
++ top: calc(100% + 6px); right: 0;
++ min-width: 230px; max-width: min(300px, calc(100vw - 20px));
++ max-height: calc(100vh - var(--navbar-h) - 20px); overflow-y: auto;
++ border-radius: 14px; padding: 5px;
++ box-shadow: 0 22px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05);
++ overscroll-behavior: contain;
++}
++.user-dropdown a {
++ border-radius: 9px; padding: 9px 11px; gap: 11px;
++ font-size: 0.86rem; line-height: 1.25;
++}
++.user-dropdown a > i:first-child { width: 17px; text-align: center; flex: none; opacity: 0.85; }
++.user-dropdown hr { margin: 5px 7px; border: 0; border-top: 1px solid var(--border); }
++
++@media (max-width: 560px) {
++ /* Anchor to the bar rather than the avatar, so a wide menu can't hang off the right edge. */
++ .user-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; left: auto; min-width: 0; width: min(290px, calc(100vw - 16px)); }
++}
++@media (max-height: 520px) and (orientation: landscape) {
++ .user-dropdown { max-height: calc(100vh - var(--navbar-h) - 12px); }
++ .user-dropdown a { padding: 7px 10px; font-size: 0.82rem; }
++}
++/* Signed-out: the sign-in button should match the cluster's height, not tower over it. */
++#nav-auth-anon .btn { height: 34px; padding: 0 clamp(10px, 2vw, 15px); font-size: clamp(0.76rem, 1.8vw, 0.88rem); border-radius: 11px; }
++
++
++/* Belt and braces for the About section's cards: whatever happens with animations or deferred
++ rendering, a card is never allowed to end up as an empty outline. */
++.home-cta-feature { opacity: 1; }
++.home-cta-feature > i, .home-cta-feature > strong, .home-cta-feature > span { opacity: 1; }
++
++/* The user menu at tablet widths: anchored to the avatar it could still reach past the right
++ edge, so the fixed anchoring now covers everything below 900px rather than below 560. */
++@media (max-width: 900px) {
++ .user-dropdown {
++ position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; left: auto;
++ min-width: 0; width: min(290px, calc(100vw - 16px));
++ }
++}
ddiff --git a/public/index.html b/public/index.html
iindex f09cddb6..3de0080c 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,8 +88,8 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=190">
-- <link rel="stylesheet" href="/css/home-fx.css?v=12">
++ <link rel="stylesheet" href="/css/style.css?v=192">
++ <link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -295,7 +295,8 @@
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
-- data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build &amp; Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"
++ data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build &amp; Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
<div class="hero-philosophy-links">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
@@@ -5210,7 +5211,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=3" defer></script>
<script src="/js/ov-mark.js?v=2" defer></script>
--<script src="/shared/tooltip.js?v=1" defer></script>
++<script src="/shared/tooltip.js?v=2" defer></script>
<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>
ddiff --git a/vendor/openvibe-shared/tooltip.js b/vendor/openvibe-shared/tooltip.js
iindex 9a3a055b..21fa61ab 100644
---- a/vendor/openvibe-shared/tooltip.js
++++ b/vendor/openvibe-shared/tooltip.js
@@@ -54,6 +54,26 @@
.ovtip-arrow.is-on{opacity:1}
.ovtip-arrow.from-top{border-top:0;border-left:0}
.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
++.ovtip-actions{display:flex;gap:8px;margin-top:11px;padding-top:10px;border-top:1px solid var(--border,rgba(255,255,255,.1))}
++.ovtip-go,.ovtip-dismiss{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
++ padding:9px 12px;border-radius:10px;font:inherit;font-size:.8rem;font-weight:750;cursor:pointer;
++ transition:transform .15s,filter .15s}
++.ovtip-go{color:#fff;border:0;
++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 60%,#000));
++ box-shadow:0 6px 16px color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 40%,transparent)}
++.ovtip-dismiss{flex:0 0 auto;color:var(--text-muted,#8b93ad);background:none;border:1px solid var(--border,rgba(255,255,255,.12))}
++.ovtip-go:active,.ovtip-dismiss:active{transform:scale(.975)}
++.ovtip-go i{font-size:.72rem}
++/* On a phone a 330px tip pinned beside a button is unreadable and often half off-screen. Dock it
++ to the bottom of the viewport as a full-width card instead. */
++@media (hover:none){
++ .ovtip{position:fixed;left:12px!important;right:12px;top:auto!important;bottom:calc(14px + env(safe-area-inset-bottom,0px));
++ max-width:none;width:auto;padding:14px 16px;border-radius:18px;font-size:.88rem;
++ transform:translateY(14px) scale(1);box-shadow:0 -10px 40px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.05)}
++ .ovtip.is-on{transform:none}
++ .ovtip-arrow{display:none}
++ .ovtip-title{font-size:1rem}
++}
@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;
let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
@@@ -74,14 +94,25 @@
const icon = el.getAttribute('data-ovtip-icon');
const body = el.getAttribute('data-ovtip') || '';
const foot = el.getAttribute('data-ovtip-foot');
++ // On touch the first tap opens the tip and swallows the click, so without an explicit
++ // action the visitor is left tapping a button that appears to do nothing. Give them the
++ // destination as a real button inside the tip — and a way out that isn't "tap elsewhere".
++ const act = isTouch() ? `<div class="ovtip-actions">
++ <button type="button" class="ovtip-dismiss" data-ovtip-dismiss>Got it</button>
++ <button type="button" class="ovtip-go" data-ovtip-go>${esc(el.getAttribute('data-ovtip-go') || 'Open')}<i class="fa-solid fa-arrow-right"></i></button>
++ </div>` : '';
return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
<div class="ovtip-body">${body}</div>
-- ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}`;
++ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}${act}`;
}
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function place() {
if (!current || !tip) return;
if (!current.isConnected) return hide();
++ // Docked to the bottom on touch — CSS owns the position, and following the trigger would
++ // fight it (and hide the tip the moment the page scrolls a little).
++ if (isTouch()) { arrow.classList.remove('is-on'); return; }
const r = current.getBoundingClientRect();
// Off-screen trigger: nothing to point at.
if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
@@@ -111,7 +142,7 @@
tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
tip.innerHTML = content(el);
-- tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive'));
++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive') || isTouch());
tip.classList.remove('is-on'); arrow.classList.remove('is-on');
cancelAnimationFrame(raf);
place();
@@@ -144,6 +175,19 @@
// On touch the first tap opens the tip; a second tap follows the link.
if (isTouch() && current !== t) { e.preventDefault(); show(t); }
}, true);
++ // The touch action buttons.
++ document.addEventListener('click', (e) => {
++ const dismiss = e.target.closest && e.target.closest('[data-ovtip-dismiss]');
++ if (dismiss) { e.preventDefault(); e.stopPropagation(); hide(); return; }
++ const go = e.target.closest && e.target.closest('[data-ovtip-go]');
++ if (go && current) {
++ e.preventDefault(); e.stopPropagation();
++ const target = current;
++ hide();
++ // Replay the trigger's own behaviour: its click handler, then its href.
++ if (typeof target.click === 'function') target.click();
++ }
++ }, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
window.addEventListener('resize', hide, { passive: true });