openvibers/openvibe.live Commits
Touch tooltips, navbar alignment, and a card that could render as an empty outline
Changes
@@@ -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; } }@@@ -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));++ }++}@@@ -88,8 +88,8 @@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=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 — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & 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="fa-solid fa-heart"></i> We love what people build here — 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 & 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>@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 });
@@ -198,3 +198,23 @@
@@ -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; }
#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 (max-width: 520px) { .hb-label small { display: none; } }
@media (prefers-reduced-motion: reduce) { .hb-vibe, .hb-vibe .hb-ico::after { animation: none !important; } }
@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; } }
@@ -2598,7 +2598,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2598,7 +2598,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
background: rgba(255,255,255,0.028);
background: rgba(255,255,255,0.028);
border: 1px solid rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.07);
transition: transform .16s ease, background .16s ease, border-color .16s ease, box-shadow .16s ease;
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(1){ animation-delay: .02s; }
.home-cta-features .home-cta-feature:nth-child(2){ animation-delay: .06s; }
.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
@@ -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-feed-section h2 small .arena-fresh { color: var(--success); }
.arena-mic-feed-list { display: grid; gap: 8px; }
.arena-mic-feed-list { display: grid; gap: 8px; }
.arena-mic-feed-list.is-compact .arena-mic-moment { padding: 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-beef { border-left-color: var(--live-red); background: linear-gradient(90deg, color-mix(in srgb, var(--live-red) 8%, var(--bg-card)), var(--bg-card)); }
.arena-mic-moment.is-fire { box-shadow: 0 0 18px color-mix(in srgb, var(--live-red) 22%, transparent); }
.arena-mic-moment.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; } }
@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
@@ -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. */
/* 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; }
#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));
+ }
+}
@@ -88,8 +88,8 @@
@@ -88,8 +88,8 @@
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=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/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -295,7 +295,8 @@
@@ -295,7 +295,8 @@
data-ovtip-accent="#f472b6"
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
data-ovtip-place="top"
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="Chat, streams, clips, overlays, robots, sound commands, whole games — 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="fa-solid fa-heart"></i> We love what people build here — come make something"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build & 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="fa-solid fa-heart"></i> We love what people build here — 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 & Mod <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
</div>
<div class="hero-philosophy-links">
<div class="hero-philosophy-links">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<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
@@ -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/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=3" defer></script>
<script src="/js/messenger.js?v=3" defer></script>
<script src="/js/ov-mark.js?v=2" 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="/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>
@@ -54,6 +54,26 @@
@@ -54,6 +54,26 @@
.ovtip-arrow.is-on{opacity:1}
.ovtip-arrow.is-on{opacity:1}
.ovtip-arrow.from-top{border-top:0;border-left:0}
.ovtip-arrow.from-top{border-top:0;border-left:0}
.ovtip-arrow.from-bottom{border-bottom:0;border-right: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}}`;
@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;
let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
@@ -74,14 +94,25 @@
@@ -74,14 +94,25 @@
const icon = el.getAttribute('data-ovtip-icon');
const icon = el.getAttribute('data-ovtip-icon');
const body = el.getAttribute('data-ovtip') || '';
const body = el.getAttribute('data-ovtip') || '';
const foot = el.getAttribute('data-ovtip-foot');
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>` : ''}
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>
<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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
function place() {
function place() {
if (!current || !tip) return;
if (!current || !tip) return;
if (!current.isConnected) return hide();
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();
const r = current.getBoundingClientRect();
// Off-screen trigger: nothing to point at.
// Off-screen trigger: nothing to point at.
if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
@@ -111,7 +142,7 @@
@@ -111,7 +142,7 @@
tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
tip.innerHTML = content(el);
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');
tip.classList.remove('is-on'); arrow.classList.remove('is-on');
cancelAnimationFrame(raf);
cancelAnimationFrame(raf);
place();
place();
@@ -144,6 +175,19 @@
@@ -144,6 +175,19 @@
// On touch the first tap opens the tip; a second tap follows the link.
// On touch the first tap opens the tip; a second tap follows the link.
if (isTouch() && current !== t) { e.preventDefault(); show(t); }
if (isTouch() && current !== t) { e.preventDefault(); show(t); }
}, true);
}, 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(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
window.addEventListener('resize', hide, { passive: true });
window.addEventListener('resize', hide, { passive: true });