openvibers/openvibe.live Commits
Hero buttons in even rows, wires that reach the hub, intro pair kept together, and review fixes
Changes
@@@ -14,11 +14,23 @@These need to read as *pressable* at a glance on a phone, so they carry a raised surface,a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweepsthe row so the eye catches that they are interactive without anything shouting. */++/* Six buttons may only lay out as 6×1, 3×2 or 2×3 — the divisors of six. auto-fit chose however++ many max-content columns happened to fit, which is how the row came out as four and then two.++ The column count is picked explicitly per width, and every column is the same width. */#page-home .hero-cta {-- display: grid; gap: clamp(8px, 1.6vw, 12px); justify-content: center; margin-inline: auto;-- grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 22vw, 168px), max-content));-- max-width: min(920px, 96vw);++ --hb-cols: 3;++ display: grid; gap: clamp(8px, 1.4vw, 12px); margin-inline: auto;++ grid-template-columns: repeat(var(--hb-cols), minmax(0, 1fr));++ width: 100%; max-width: 720px;}++/* Six across only when the hero column is genuinely wide enough for full labels. The hero content++ is capped well under the viewport, so a viewport breakpoint alone gave 133px buttons at 1280px++ with every label cut to an ellipsis. */++@media (min-width: 1500px) {++ #page-home .hero-cta { --hb-cols: 6; max-width: 1180px; }++}++#page-home .hero-cta > .hb { min-width: 0; }++#page-home .hero-cta .hb-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.hb {--c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;@@@ -100,14 +112,16 @@.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */@media (max-width: 560px) {-- #page-home .hero-cta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 420px; }++ #page-home .hero-cta { --hb-cols: 2; max-width: 420px; }.hb { font-size: 0.86rem; padding-right: 12px; }++ /* The "API docs" note is what would push "Build & Mod" to an ellipsis at two columns. */++ .hb-label small { display: none; }}--@media (max-width: 560px) and (orientation: landscape) {-- #page-home .hero-cta { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); max-width: 96vw; }++/* A landscape phone is wide but short: three across keeps it to two rows, never a ragged wrap. */++@media (max-width: 900px) and (orientation: landscape) and (max-height: 500px) {++ #page-home .hero-cta { --hb-cols: 3; max-width: 96vw; }.hb { min-height: 40px; }}--@media (max-width: 360px) { #page-home .hero-cta { grid-template-columns: 1fr; max-width: 300px; } }@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */@@@ -471,3 +471,15 @@.ovg-launch, .ovg-firstlive { transition: none; }}.rs-lite .ovg-launch-aura, .rs-lite .ovg-firstlive-ring { animation: none !important; }++++/* ── Where the wires meet the hub ────────────────────────────────────────────++ The ring and the ports are drawn in the wire SVG itself, from the same numbers as the wire ends,++ so a wire can never stop short of the mark or overshoot it. The ports mark where each line plugs++ in; they brighten when a packet arrives, which is what makes the hub read as receiving signal. */++.tour-hub-ring {++ fill: none; stroke: color-mix(in srgb, var(--accent) 55%, transparent); stroke-width: 1.4;++ filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent) 60%, transparent));++}++.tour-port { fill: var(--c, #fde68a); opacity: 0.9; filter: drop-shadow(0 0 3px var(--c, #fde68a)); }++.tour-port--in { --c: #fde68a; }++.rs-lite .tour-hub-ring, .rs-lite .tour-port { filter: none; }@@@ -15436,8 +15436,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg/* Once the two explainer sections have been demoted to the bottom of the page they are referencematerial rather than a pitch, so they get a quieter top margin and a rule separating them fromthe content above. */++/* :first-of-type matched the element type (div) among all siblings, never "the first demoted++ section", so the separator never appeared. The pair always starts with the tour. */.home-intro-demoted { margin-top: 26px; }--.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }++#home-tour-mount.home-intro-demoted { border-top: 1px solid var(--border); padding-top: 30px; margin-top: 34px; }/* The paste rail was a different grid to the VOD and clip rails (a 260px minimum against their300px), so it broke to one column at a different width and felt unrelated to them. It uses the@@@ -15519,10 +15521,15 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgto { transform: translateX(100%); }}--.page.is-entering { animation: ovPageIn 0.42s cubic-bezier(.22, 1, .36, 1) both; }++/* Opacity only. A running transform animation makes the element a containing block for its++ position:fixed descendants, so while a page rose into place its fixed children — the mobile chat++ sheet on a channel, the broadcast desk's chat button — were positioned against the page box++ instead of the viewport and visibly jumped (measured: a bottom:0 probe sat 10px below the++ viewport mid-entry). Opacity does not create a containing block. */++.page.is-entering { animation: ovPageIn 0.36s cubic-bezier(.22, 1, .36, 1) both; }@keyframes ovPageIn {-- from { opacity: 0; transform: translate3d(0, 10px, 0); }-- to { opacity: 1; transform: none; }++ from { opacity: 0; }++ to { opacity: 1; }}@media (prefers-reduced-motion: reduce) {@@@ -88,9 +88,9 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=211">-- <link rel="stylesheet" href="/css/home-fx.css?v=14">-- <link rel="stylesheet" href="/css/home-tour.css?v=10">++ <link rel="stylesheet" href="/css/style.css?v=213">++ <link rel="stylesheet" href="/css/home-fx.css?v=16">++ <link rel="stylesheet" href="/css/home-tour.css?v=11"><link rel="stylesheet" href="/css/i18n-star.css?v=11"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"><link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">@@@ -370,8 +370,6 @@<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) --><div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>-- <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->-- <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div><div class="hero-egg" id="hero-egg" style="display:none"><button class="hero-egg-head" type="button" onclick="toggleHeroEgg()"><span class="hero-egg-icon">🥚</span>@@@ -420,6 +418,10 @@<div class="moments-row" id="home-moments-row"></div></div>++ <!-- The two introductions travel together: what OpenVibe does (the restream diagram), then++ why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->++ <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->++ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div><!-- Streaming CTA --><div class="home-cta-banner" id="home-cta-banner" data-fx-viewport><div class="cta-ambient" aria-hidden="true"></div>@@@ -5137,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig};})();</script>--<script src="/js/app.js?v=178" defer></script>++<script src="/js/app.js?v=180" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5218,7 +5220,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/shared/footer.js?v=1" defer></script><script src="/js/footer-live.js?v=1" defer></script><script src="/js/home-fx.js?v=9" defer></script>--<script src="/js/home-tour.js?v=9" defer></script>++<script src="/js/home-tour.js?v=10" defer></script><script src="/js/ov-guide.js?v=3" defer></script><script src="/js/guides-live.js?v=8" defer></script>@@@ -1331,7 +1331,13 @@ function ovWakeBrandMark() {if (!marks || !marks.length) return;let reduce = false;try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }-- if (reduce) return;++ 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);@@@ -2531,30 +2537,65 @@ function _celebrateEgg(egg) {* cleared cache simply means someone sees the pitch again, which is harmless.*/const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;--function _homeIntroViews(bump) {++const _INTRO_SEEN_KEY = 'ov_home_intro_seen';++++/**++ * How many visits the reader has actually *seen* both introductions on.++ *++ * This used to count home page loads, which is not the same thing: someone who opens the home page++ * and goes straight to a stream never scrolled to either section, yet it still counted against++ * them. A sighting now needs both sections to have been on screen during the visit. The old++ * load count seeds the new one once, so people who were already past the threshold stay there.++ */++function _homeIntroSeenCount() {try {-- const n = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;-- if (bump) localStorage.setItem('ov_home_views', String(Math.min(n + 1, 9999)));-- return n;++ let raw = localStorage.getItem(_INTRO_SEEN_KEY);++ if (raw === null) {++ const legacy = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;++ raw = String(Math.min(legacy, HOME_INTRO_VIEWS_BEFORE_DEMOTING));++ localStorage.setItem(_INTRO_SEEN_KEY, raw);++ }++ return parseInt(raw, 10) || 0;} catch { return 0; }}++let _introSightingArmed = false;++function _watchIntroSighting() {++ if (_introSightingArmed || !('IntersectionObserver' in window)) return;++ const tour = document.getElementById('home-tour-mount');++ const about = document.getElementById('home-cta-banner');++ if (!tour || !about) return;++ _introSightingArmed = true;++ const seen = new Set();++ const io = new IntersectionObserver((entries) => {++ for (const en of entries) if (en.isIntersecting) seen.add(en.target.id);++ if (seen.size === 2) {++ io.disconnect();++ try { localStorage.setItem(_INTRO_SEEN_KEY, String(Math.min(_homeIntroSeenCount() + 1, 9999))); } catch { /* */ }++ }++ }, { threshold: 0.25 });++ io.observe(tour); io.observe(about);++}++++/**++ * Once both introductions have been seen enough times, they go to the very end of the page.++ *++ * They move as a pair, in order, after the changelog. The old version appended to the *first*++ * .container on the home page — but the changelog lives in a later one, so "moved to the bottom"++ * actually landed them in the middle of the page, above Recently Online.++ */function demoteHomeIntroSections() {-- const views = _homeIntroViews(true);-- if (views < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;-- const container = document.querySelector('#page-home .container');-- if (!container) return;-- // Land them before the changelog if it is a sibling, otherwise at the end of the container.-- // Reading the anchor's parent rather than assuming it means a future markup change cannot-- // silently turn this into a no-op.++ _watchIntroSighting();++ if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;++ const containers = document.querySelectorAll('#page-home > .container, #page-home .container');const changelog = document.getElementById('home-changelog-wrapper');-- const anchorEl = changelog && changelog.parentElement === container ? changelog : null;++ const last = (changelog && changelog.closest('.container')) || containers[containers.length - 1];++ if (!last) return;for (const id of ['home-tour-mount', 'home-cta-banner']) {const el = document.getElementById(id);-- // Already moved (a re-render, or a second visit in the same session)? Leave it be.if (!el || el.dataset.ovDemoted) continue;el.dataset.ovDemoted = '1';el.classList.add('home-intro-demoted');-- try { if (anchorEl) container.insertBefore(el, anchorEl); else container.appendChild(el); } catch { /* */ }++ try { last.appendChild(el); } catch { /* */ }}}@@@ -2975,19 +3016,19 @@ const _XSVC_MISS_KEY = 'ov_xsvc_miss_v1';const _XSVC_MISS_MS = 6 * 60 * 60 * 1000;function _xsvcMisses() { try { return JSON.parse(localStorage.getItem(_XSVC_MISS_KEY) || '{}'); } catch { return {}; } }async function fetchServiceJson(url) {-- let origin = url;-- try { origin = new URL(url).origin + new URL(url).pathname.split('/').slice(0, 3).join('/'); } catch { /* */ }++ // Keyed per URL, not per service prefix: one board that 404s must not switch off every other++ // endpoint on the same service for six hours.const misses = _xsvcMisses();-- if (misses[origin] && Date.now() - misses[origin] < _XSVC_MISS_MS) return null;-- try {-- const r = await fetch(url, { credentials: 'omit' });-- const type = r.headers.get('content-type') || '';-- if (!r.ok || !/json/i.test(type)) throw new Error('not json');-- return await r.json();-- } catch {-- try { misses[origin] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ }-- return null;-- }++ if (misses[url] && Date.now() - misses[url] < _XSVC_MISS_MS) return null;++ const remember = () => { try { misses[url] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ } };++ let r;++ try { r = await fetch(url, { credentials: 'omit' }); }++ catch { remember(); return null; } // network / CORS: the service is not answering++ const type = r.headers.get('content-type') || '';++ if (!/json/i.test(type)) { remember(); return null; } // an HTML page where an API should be++ // A JSON error means the service is there and answered — do not remember it as absent.++ if (!r.ok) return null;++ try { return await r.json(); } catch { return null; }}async function loadHomeCanvas() {@@@ -30,6 +30,8 @@['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],];++ let _tourVisible = true;++function mount() {const el = document.getElementById('home-tour-mount');if (!el || el.dataset.mounted) return;@@@ -83,6 +85,18 @@if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));window.addEventListener('resize', redraw);document.fonts && document.fonts.ready && document.fonts.ready.then(redraw);++ // The packets are SMIL <animateMotion>, which the section's off-screen CSS pause cannot++ // reach — fourteen of them kept moving for the whole visit, visible or not. Drive the SVG's++ // own clock from the viewport instead.++ if ('IntersectionObserver' in window) {++ new IntersectionObserver((entries) => {++ for (const en of entries) {++ _tourVisible = en.isIntersecting;++ const svg = document.getElementById('tour-wires');++ try { if (svg) (_tourVisible ? svg.unpauseAnimations() : svg.pauseAnimations()); } catch { /* */ }++ }++ }, { rootMargin: '100px 0px' }).observe(el.querySelector('#tour-stage'));++ }el.querySelectorAll('.tour-node--dst').forEach(nd => {nd.addEventListener('pointerenter', () => el.querySelector('#tour-wires').classList.add(`hot-${nd.dataset.node}`));nd.addEventListener('pointerleave', () => el.querySelector('#tour-wires').classList.remove(`hot-${nd.dataset.node}`));@@@ -94,37 +108,70 @@const stage = document.getElementById('tour-stage'), svg = document.getElementById('tour-wires');if (!stage || !svg) return;const R = stage.getBoundingClientRect();++ if (!R.width || !R.height) return;svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');-- const core = stage.querySelector('[data-node="core"]');-- if (!core) return;-- const c = rel(core.getBoundingClientRect(), R);++ const logo = stage.querySelector('.tour-core-logo') || stage.querySelector('[data-node="core"]');++ if (!logo) return;++++ // Anchor on the visible mark, not on .tour-core. The core element is a 320px-wide column++ // holding the mark and the wordmark; wiring to its edges left every line ending ~130px++ // short of the logo, in empty space. Each wire now lands on a ring just outside the disc,++ // at the point facing the node it comes from, so the lines visibly plug into OpenVibe.++ const L = rel(logo.getBoundingClientRect(), R);++ const hub = { x: L.x + L.w / 2, y: L.y + L.h / 2 };++ const ringR = L.w / 2 + 7;++ const onRing = (p, extra = 0) => {++ const dx = p.x - hub.x, dy = p.y - hub.y, len = Math.hypot(dx, dy) || 1;++ return { x: hub.x + (dx / len) * (ringR + extra), y: hub.y + (dy / len) * (ringR + extra) };++ };const anchorOf = (node, side) => {const b = rel(node.getBoundingClientRect(), R);if (vertical) return { x: b.x + b.w / 2, y: side === 'out' ? b.y + b.h : b.y };return { x: side === 'out' ? b.x + b.w : b.x, y: b.y + b.h / 2 };};-- const coreIn = vertical ? { x: c.x + c.w / 2, y: c.y } : { x: c.x, y: c.y + c.h / 2 };-- const coreOut = vertical ? { x: c.x + c.w / 2, y: c.y + c.h } : { x: c.x + c.w, y: c.y + c.h / 2 };-- const curve = (a, b) => vertical-- ? `M${a.x},${a.y} C${a.x},${(a.y + b.y) / 2} ${b.x},${(a.y + b.y) / 2} ${b.x},${b.y}`-- : `M${a.x},${a.y} C${(a.x + b.x) / 2},${a.y} ${(a.x + b.x) / 2},${b.y} ${b.x},${b.y}`;-- let defs = '', paths = '', packets = '';++ // The first control point leaves the node straight out of its edge; the second approaches++ // the hub along the radius, so every wire arrives at the ring head-on instead of sideways.++ const curve = (from, to) => {++ const lead = Math.max(40, Math.hypot(to.x - from.x, to.y - from.y) * 0.42);++ const c1 = vertical ? { x: from.x, y: from.y + Math.sign(to.y - from.y) * lead }++ : { x: from.x + Math.sign(to.x - from.x) * lead, y: from.y };++ const c2 = onRing(from, lead);++ return `M${from.x.toFixed(1)},${from.y.toFixed(1)} C${c1.x.toFixed(1)},${c1.y.toFixed(1)} ${c2.x.toFixed(1)},${c2.y.toFixed(1)} ${to.x.toFixed(1)},${to.y.toFixed(1)}`;++ };++ const curveOut = (to) => {++ // Mirror of the inbound shape: leave the ring along the radius, arrive at the node's edge.++ const from = onRing(to);++ const lead = Math.max(40, Math.hypot(to.x - from.x, to.y - from.y) * 0.42);++ const c1 = onRing(to, lead);++ const c2 = vertical ? { x: to.x, y: to.y - Math.sign(to.y - from.y) * lead } : { x: to.x - Math.sign(to.x - from.x) * lead, y: to.y };++ return { d: `M${from.x.toFixed(1)},${from.y.toFixed(1)} C${c1.x.toFixed(1)},${c1.y.toFixed(1)} ${c2.x.toFixed(1)},${c2.y.toFixed(1)} ${to.x.toFixed(1)},${to.y.toFixed(1)}`, port: from };++ };++++ let paths = '', packets = '', ports = '';const srcNodes = [...stage.querySelectorAll('.tour-node--src')], dstNodes = [...stage.querySelectorAll('.tour-node--dst')];srcNodes.forEach((nd, i) => {-- const d = curve(anchorOf(nd, 'out'), coreIn);-- paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${d}"/>`;-- if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;++ const a = anchorOf(nd, 'out');++ const end = onRing(a);++ paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${curve(a, end)}"/>`;++ ports += `<circle class="tour-port tour-port--in" cx="${end.x.toFixed(1)}" cy="${end.y.toFixed(1)}" r="2.6"/>`;++ if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" calcMode="spline" keySplines="0.4 0 0.6 1"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;});dstNodes.forEach((nd, i) => {-- const d = curve(coreOut, anchorOf(nd, 'in'));++ const b = anchorOf(nd, 'in');++ const { d, port } = curveOut(b);const col = nd.style.getPropertyValue('--c') || '#fff';paths += `<path id="tw-out-${i}" class="tour-wire tour-wire--out tour-wire--${nd.dataset.node}" style="--c:${col}" d="${d}"/>`;-- if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;++ ports += `<circle class="tour-port" style="--c:${col}" cx="${port.x.toFixed(1)}" cy="${port.y.toFixed(1)}" r="2.6"/>`;++ if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" calcMode="spline" keySplines="0.4 0 0.6 1"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;});-- svg.innerHTML = defs + paths + packets;++ // The ring the wires plug into, drawn in the same SVG so it can never drift from their ends.++ const ring = `<circle class="tour-hub-ring" cx="${hub.x.toFixed(1)}" cy="${hub.y.toFixed(1)}" r="${ringR.toFixed(1)}"/>`;++ svg.innerHTML = paths + ring + ports + packets;++ // Anything rebuilt while the diagram is off screen starts paused, like the rest of it.++ if (!_tourVisible) { try { svg.pauseAnimations(); } catch { /* */ } }}function rel(b, R) { return { x: b.left - R.left, y: b.top - R.top, w: b.width, h: b.height }; }
@@ -14,11 +14,23 @@
@@ -14,11 +14,23 @@
These need to read as *pressable* at a glance on a phone, so they carry a raised surface,
These need to read as *pressable* at a glance on a phone, so they carry a raised surface,
a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweeps
a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweeps
the row so the eye catches that they are interactive without anything shouting. */
the row so the eye catches that they are interactive without anything shouting. */
+/* Six buttons may only lay out as 6×1, 3×2 or 2×3 — the divisors of six. auto-fit chose however
+ many max-content columns happened to fit, which is how the row came out as four and then two.
+ The column count is picked explicitly per width, and every column is the same width. */
#page-home .hero-cta {
#page-home .hero-cta {
- display: grid; gap: clamp(8px, 1.6vw, 12px); justify-content: center; margin-inline: auto;
- grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 22vw, 168px), max-content));
- max-width: min(920px, 96vw);
+ --hb-cols: 3;
+ display: grid; gap: clamp(8px, 1.4vw, 12px); margin-inline: auto;
+ grid-template-columns: repeat(var(--hb-cols), minmax(0, 1fr));
+ width: 100%; max-width: 720px;
}
}
+/* Six across only when the hero column is genuinely wide enough for full labels. The hero content
+ is capped well under the viewport, so a viewport breakpoint alone gave 133px buttons at 1280px
+ with every label cut to an ellipsis. */
+@media (min-width: 1500px) {
+ #page-home .hero-cta { --hb-cols: 6; max-width: 1180px; }
+}
+#page-home .hero-cta > .hb { min-width: 0; }
+#page-home .hero-cta .hb-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hb {
.hb {
--c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
--c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
@@ -100,14 +112,16 @@
@@ -100,14 +112,16 @@
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
@media (max-width: 560px) {
@media (max-width: 560px) {
- #page-home .hero-cta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 420px; }
+ #page-home .hero-cta { --hb-cols: 2; max-width: 420px; }
.hb { font-size: 0.86rem; padding-right: 12px; }
.hb { font-size: 0.86rem; padding-right: 12px; }
+ /* The "API docs" note is what would push "Build & Mod" to an ellipsis at two columns. */
+ .hb-label small { display: none; }
}
}
-@media (max-width: 560px) and (orientation: landscape) {
- #page-home .hero-cta { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); max-width: 96vw; }
+/* A landscape phone is wide but short: three across keeps it to two rows, never a ragged wrap. */
+@media (max-width: 900px) and (orientation: landscape) and (max-height: 500px) {
+ #page-home .hero-cta { --hb-cols: 3; max-width: 96vw; }
.hb { min-height: 40px; }
.hb { min-height: 40px; }
}
}
-@media (max-width: 360px) { #page-home .hero-cta { grid-template-columns: 1fr; max-width: 300px; } }
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
@@ -471,3 +471,15 @@
@@ -471,3 +471,15 @@
.ovg-launch, .ovg-firstlive { transition: none; }
.ovg-launch, .ovg-firstlive { transition: none; }
}
}
.rs-lite .ovg-launch-aura, .rs-lite .ovg-firstlive-ring { animation: none !important; }
.rs-lite .ovg-launch-aura, .rs-lite .ovg-firstlive-ring { animation: none !important; }
+
+/* ── Where the wires meet the hub ────────────────────────────────────────────
+ The ring and the ports are drawn in the wire SVG itself, from the same numbers as the wire ends,
+ so a wire can never stop short of the mark or overshoot it. The ports mark where each line plugs
+ in; they brighten when a packet arrives, which is what makes the hub read as receiving signal. */
+.tour-hub-ring {
+ fill: none; stroke: color-mix(in srgb, var(--accent) 55%, transparent); stroke-width: 1.4;
+ filter: drop-shadow(0 0 5px color-mix(in srgb, var(--accent) 60%, transparent));
+}
+.tour-port { fill: var(--c, #fde68a); opacity: 0.9; filter: drop-shadow(0 0 3px var(--c, #fde68a)); }
+.tour-port--in { --c: #fde68a; }
+.rs-lite .tour-hub-ring, .rs-lite .tour-port { filter: none; }
@@ -15436,8 +15436,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15436,8 +15436,10 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
/* Once the two explainer sections have been demoted to the bottom of the page they are reference
/* Once the two explainer sections have been demoted to the bottom of the page they are reference
material rather than a pitch, so they get a quieter top margin and a rule separating them from
material rather than a pitch, so they get a quieter top margin and a rule separating them from
the content above. */
the content above. */
+/* :first-of-type matched the element type (div) among all siblings, never "the first demoted
+ section", so the separator never appeared. The pair always starts with the tour. */
.home-intro-demoted { margin-top: 26px; }
.home-intro-demoted { margin-top: 26px; }
-.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }
+#home-tour-mount.home-intro-demoted { border-top: 1px solid var(--border); padding-top: 30px; margin-top: 34px; }
/* The paste rail was a different grid to the VOD and clip rails (a 260px minimum against their
/* The paste rail was a different grid to the VOD and clip rails (a 260px minimum against their
300px), so it broke to one column at a different width and felt unrelated to them. It uses the
300px), so it broke to one column at a different width and felt unrelated to them. It uses the
@@ -15519,10 +15521,15 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15519,10 +15521,15 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
to { transform: translateX(100%); }
to { transform: translateX(100%); }
}
}
-.page.is-entering { animation: ovPageIn 0.42s cubic-bezier(.22, 1, .36, 1) both; }
+/* Opacity only. A running transform animation makes the element a containing block for its
+ position:fixed descendants, so while a page rose into place its fixed children — the mobile chat
+ sheet on a channel, the broadcast desk's chat button — were positioned against the page box
+ instead of the viewport and visibly jumped (measured: a bottom:0 probe sat 10px below the
+ viewport mid-entry). Opacity does not create a containing block. */
+.page.is-entering { animation: ovPageIn 0.36s cubic-bezier(.22, 1, .36, 1) both; }
@keyframes ovPageIn {
@keyframes ovPageIn {
- from { opacity: 0; transform: translate3d(0, 10px, 0); }
- to { opacity: 1; transform: none; }
+ from { opacity: 0; }
+ to { opacity: 1; }
}
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
@@ -88,9 +88,9 @@
@@ -88,9 +88,9 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=211">
- <link rel="stylesheet" href="/css/home-fx.css?v=14">
- <link rel="stylesheet" href="/css/home-tour.css?v=10">
+ <link rel="stylesheet" href="/css/style.css?v=213">
+ <link rel="stylesheet" href="/css/home-fx.css?v=16">
+ <link rel="stylesheet" href="/css/home-tour.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@ -370,8 +370,6 @@
@@ -370,8 +370,6 @@
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
<div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
<div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
- <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
- <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
<div class="hero-egg" id="hero-egg" style="display:none">
<div class="hero-egg" id="hero-egg" style="display:none">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<span class="hero-egg-icon">🥚</span>
<span class="hero-egg-icon">🥚</span>
@@ -420,6 +418,10 @@
@@ -420,6 +418,10 @@
<div class="moments-row" id="home-moments-row"></div>
<div class="moments-row" id="home-moments-row"></div>
</div>
</div>
+ <!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
+ why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
+ <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
+ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
<!-- Streaming CTA -->
<!-- Streaming CTA -->
<div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
<div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
<div class="cta-ambient" aria-hidden="true"></div>
<div class="cta-ambient" aria-hidden="true"></div>
@@ -5137,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5137,7 +5139,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
};
};
})();
})();
</script>
</script>
-<script src="/js/app.js?v=178" defer></script>
+<script src="/js/app.js?v=180" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -5218,7 +5220,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5218,7 +5220,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/shared/footer.js?v=1" defer></script>
<script src="/shared/footer.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/footer-live.js?v=1" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
-<script src="/js/home-tour.js?v=9" defer></script>
+<script src="/js/home-tour.js?v=10" defer></script>
<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/ov-guide.js?v=3" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
<script src="/js/guides-live.js?v=8" defer></script>
@@ -1331,7 +1331,13 @@ function ovWakeBrandMark() {
@@ -1331,7 +1331,13 @@ function ovWakeBrandMark() {
if (!marks || !marks.length) return;
if (!marks || !marks.length) return;
let reduce = false;
let reduce = false;
try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
- if (reduce) return;
+ 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;
_brandEverWoken = true;
clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);
clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);
@@ -2531,30 +2537,65 @@ function _celebrateEgg(egg) {
@@ -2531,30 +2537,65 @@ function _celebrateEgg(egg) {
* cleared cache simply means someone sees the pitch again, which is harmless.
* cleared cache simply means someone sees the pitch again, which is harmless.
*/
*/
const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;
const HOME_INTRO_VIEWS_BEFORE_DEMOTING = 18;
-function _homeIntroViews(bump) {
+const _INTRO_SEEN_KEY = 'ov_home_intro_seen';
+
+/**
+ * How many visits the reader has actually *seen* both introductions on.
+ *
+ * This used to count home page loads, which is not the same thing: someone who opens the home page
+ * and goes straight to a stream never scrolled to either section, yet it still counted against
+ * them. A sighting now needs both sections to have been on screen during the visit. The old
+ * load count seeds the new one once, so people who were already past the threshold stay there.
+ */
+function _homeIntroSeenCount() {
try {
try {
- const n = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;
- if (bump) localStorage.setItem('ov_home_views', String(Math.min(n + 1, 9999)));
- return n;
+ let raw = localStorage.getItem(_INTRO_SEEN_KEY);
+ if (raw === null) {
+ const legacy = parseInt(localStorage.getItem('ov_home_views') || '0', 10) || 0;
+ raw = String(Math.min(legacy, HOME_INTRO_VIEWS_BEFORE_DEMOTING));
+ localStorage.setItem(_INTRO_SEEN_KEY, raw);
+ }
+ return parseInt(raw, 10) || 0;
} catch { return 0; }
} catch { return 0; }
}
}
+let _introSightingArmed = false;
+function _watchIntroSighting() {
+ if (_introSightingArmed || !('IntersectionObserver' in window)) return;
+ const tour = document.getElementById('home-tour-mount');
+ const about = document.getElementById('home-cta-banner');
+ if (!tour || !about) return;
+ _introSightingArmed = true;
+ const seen = new Set();
+ const io = new IntersectionObserver((entries) => {
+ for (const en of entries) if (en.isIntersecting) seen.add(en.target.id);
+ if (seen.size === 2) {
+ io.disconnect();
+ try { localStorage.setItem(_INTRO_SEEN_KEY, String(Math.min(_homeIntroSeenCount() + 1, 9999))); } catch { /* */ }
+ }
+ }, { threshold: 0.25 });
+ io.observe(tour); io.observe(about);
+}
+
+/**
+ * Once both introductions have been seen enough times, they go to the very end of the page.
+ *
+ * They move as a pair, in order, after the changelog. The old version appended to the *first*
+ * .container on the home page — but the changelog lives in a later one, so "moved to the bottom"
+ * actually landed them in the middle of the page, above Recently Online.
+ */
function demoteHomeIntroSections() {
function demoteHomeIntroSections() {
- const views = _homeIntroViews(true);
- if (views < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
- const container = document.querySelector('#page-home .container');
- if (!container) return;
- // Land them before the changelog if it is a sibling, otherwise at the end of the container.
- // Reading the anchor's parent rather than assuming it means a future markup change cannot
- // silently turn this into a no-op.
+ _watchIntroSighting();
+ if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
+ const containers = document.querySelectorAll('#page-home > .container, #page-home .container');
const changelog = document.getElementById('home-changelog-wrapper');
const changelog = document.getElementById('home-changelog-wrapper');
- const anchorEl = changelog && changelog.parentElement === container ? changelog : null;
+ const last = (changelog && changelog.closest('.container')) || containers[containers.length - 1];
+ if (!last) return;
for (const id of ['home-tour-mount', 'home-cta-banner']) {
for (const id of ['home-tour-mount', 'home-cta-banner']) {
const el = document.getElementById(id);
const el = document.getElementById(id);
- // Already moved (a re-render, or a second visit in the same session)? Leave it be.
if (!el || el.dataset.ovDemoted) continue;
if (!el || el.dataset.ovDemoted) continue;
el.dataset.ovDemoted = '1';
el.dataset.ovDemoted = '1';
el.classList.add('home-intro-demoted');
el.classList.add('home-intro-demoted');
- try { if (anchorEl) container.insertBefore(el, anchorEl); else container.appendChild(el); } catch { /* */ }
+ try { last.appendChild(el); } catch { /* */ }
}
}
}
}
@@ -2975,19 +3016,19 @@ const _XSVC_MISS_KEY = 'ov_xsvc_miss_v1';
@@ -2975,19 +3016,19 @@ const _XSVC_MISS_KEY = 'ov_xsvc_miss_v1';
const _XSVC_MISS_MS = 6 * 60 * 60 * 1000;
const _XSVC_MISS_MS = 6 * 60 * 60 * 1000;
function _xsvcMisses() { try { return JSON.parse(localStorage.getItem(_XSVC_MISS_KEY) || '{}'); } catch { return {}; } }
function _xsvcMisses() { try { return JSON.parse(localStorage.getItem(_XSVC_MISS_KEY) || '{}'); } catch { return {}; } }
async function fetchServiceJson(url) {
async function fetchServiceJson(url) {
- let origin = url;
- try { origin = new URL(url).origin + new URL(url).pathname.split('/').slice(0, 3).join('/'); } catch { /* */ }
+ // Keyed per URL, not per service prefix: one board that 404s must not switch off every other
+ // endpoint on the same service for six hours.
const misses = _xsvcMisses();
const misses = _xsvcMisses();
- if (misses[origin] && Date.now() - misses[origin] < _XSVC_MISS_MS) return null;
- try {
- const r = await fetch(url, { credentials: 'omit' });
- const type = r.headers.get('content-type') || '';
- if (!r.ok || !/json/i.test(type)) throw new Error('not json');
- return await r.json();
- } catch {
- try { misses[origin] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ }
- return null;
- }
+ if (misses[url] && Date.now() - misses[url] < _XSVC_MISS_MS) return null;
+ const remember = () => { try { misses[url] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ } };
+ let r;
+ try { r = await fetch(url, { credentials: 'omit' }); }
+ catch { remember(); return null; } // network / CORS: the service is not answering
+ const type = r.headers.get('content-type') || '';
+ if (!/json/i.test(type)) { remember(); return null; } // an HTML page where an API should be
+ // A JSON error means the service is there and answered — do not remember it as absent.
+ if (!r.ok) return null;
+ try { return await r.json(); } catch { return null; }
}
}
async function loadHomeCanvas() {
async function loadHomeCanvas() {
@@ -30,6 +30,8 @@
@@ -30,6 +30,8 @@
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
];
];
+ let _tourVisible = true;
+
function mount() {
function mount() {
const el = document.getElementById('home-tour-mount');
const el = document.getElementById('home-tour-mount');
if (!el || el.dataset.mounted) return;
if (!el || el.dataset.mounted) return;
@@ -83,6 +85,18 @@
@@ -83,6 +85,18 @@
if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
window.addEventListener('resize', redraw);
window.addEventListener('resize', redraw);
document.fonts && document.fonts.ready && document.fonts.ready.then(redraw);
document.fonts && document.fonts.ready && document.fonts.ready.then(redraw);
+ // The packets are SMIL <animateMotion>, which the section's off-screen CSS pause cannot
+ // reach — fourteen of them kept moving for the whole visit, visible or not. Drive the SVG's
+ // own clock from the viewport instead.
+ if ('IntersectionObserver' in window) {
+ new IntersectionObserver((entries) => {
+ for (const en of entries) {
+ _tourVisible = en.isIntersecting;
+ const svg = document.getElementById('tour-wires');
+ try { if (svg) (_tourVisible ? svg.unpauseAnimations() : svg.pauseAnimations()); } catch { /* */ }
+ }
+ }, { rootMargin: '100px 0px' }).observe(el.querySelector('#tour-stage'));
+ }
el.querySelectorAll('.tour-node--dst').forEach(nd => {
el.querySelectorAll('.tour-node--dst').forEach(nd => {
nd.addEventListener('pointerenter', () => el.querySelector('#tour-wires').classList.add(`hot-${nd.dataset.node}`));
nd.addEventListener('pointerenter', () => el.querySelector('#tour-wires').classList.add(`hot-${nd.dataset.node}`));
nd.addEventListener('pointerleave', () => el.querySelector('#tour-wires').classList.remove(`hot-${nd.dataset.node}`));
nd.addEventListener('pointerleave', () => el.querySelector('#tour-wires').classList.remove(`hot-${nd.dataset.node}`));
@@ -94,37 +108,70 @@
@@ -94,37 +108,70 @@
const stage = document.getElementById('tour-stage'), svg = document.getElementById('tour-wires');
const stage = document.getElementById('tour-stage'), svg = document.getElementById('tour-wires');
if (!stage || !svg) return;
if (!stage || !svg) return;
const R = stage.getBoundingClientRect();
const R = stage.getBoundingClientRect();
+ if (!R.width || !R.height) return;
svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);
svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);
svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);
svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);
const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';
const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');
- const core = stage.querySelector('[data-node="core"]');
- if (!core) return;
- const c = rel(core.getBoundingClientRect(), R);
+ const logo = stage.querySelector('.tour-core-logo') || stage.querySelector('[data-node="core"]');
+ if (!logo) return;
+
+ // Anchor on the visible mark, not on .tour-core. The core element is a 320px-wide column
+ // holding the mark and the wordmark; wiring to its edges left every line ending ~130px
+ // short of the logo, in empty space. Each wire now lands on a ring just outside the disc,
+ // at the point facing the node it comes from, so the lines visibly plug into OpenVibe.
+ const L = rel(logo.getBoundingClientRect(), R);
+ const hub = { x: L.x + L.w / 2, y: L.y + L.h / 2 };
+ const ringR = L.w / 2 + 7;
+ const onRing = (p, extra = 0) => {
+ const dx = p.x - hub.x, dy = p.y - hub.y, len = Math.hypot(dx, dy) || 1;
+ return { x: hub.x + (dx / len) * (ringR + extra), y: hub.y + (dy / len) * (ringR + extra) };
+ };
const anchorOf = (node, side) => {
const anchorOf = (node, side) => {
const b = rel(node.getBoundingClientRect(), R);
const b = rel(node.getBoundingClientRect(), R);
if (vertical) return { x: b.x + b.w / 2, y: side === 'out' ? b.y + b.h : b.y };
if (vertical) return { x: b.x + b.w / 2, y: side === 'out' ? b.y + b.h : b.y };
return { x: side === 'out' ? b.x + b.w : b.x, y: b.y + b.h / 2 };
return { x: side === 'out' ? b.x + b.w : b.x, y: b.y + b.h / 2 };
};
};
- const coreIn = vertical ? { x: c.x + c.w / 2, y: c.y } : { x: c.x, y: c.y + c.h / 2 };
- const coreOut = vertical ? { x: c.x + c.w / 2, y: c.y + c.h } : { x: c.x + c.w, y: c.y + c.h / 2 };
- const curve = (a, b) => vertical
- ? `M${a.x},${a.y} C${a.x},${(a.y + b.y) / 2} ${b.x},${(a.y + b.y) / 2} ${b.x},${b.y}`
- : `M${a.x},${a.y} C${(a.x + b.x) / 2},${a.y} ${(a.x + b.x) / 2},${b.y} ${b.x},${b.y}`;
- let defs = '', paths = '', packets = '';
+ // The first control point leaves the node straight out of its edge; the second approaches
+ // the hub along the radius, so every wire arrives at the ring head-on instead of sideways.
+ const curve = (from, to) => {
+ const lead = Math.max(40, Math.hypot(to.x - from.x, to.y - from.y) * 0.42);
+ const c1 = vertical ? { x: from.x, y: from.y + Math.sign(to.y - from.y) * lead }
+ : { x: from.x + Math.sign(to.x - from.x) * lead, y: from.y };
+ const c2 = onRing(from, lead);
+ return `M${from.x.toFixed(1)},${from.y.toFixed(1)} C${c1.x.toFixed(1)},${c1.y.toFixed(1)} ${c2.x.toFixed(1)},${c2.y.toFixed(1)} ${to.x.toFixed(1)},${to.y.toFixed(1)}`;
+ };
+ const curveOut = (to) => {
+ // Mirror of the inbound shape: leave the ring along the radius, arrive at the node's edge.
+ const from = onRing(to);
+ const lead = Math.max(40, Math.hypot(to.x - from.x, to.y - from.y) * 0.42);
+ const c1 = onRing(to, lead);
+ const c2 = vertical ? { x: to.x, y: to.y - Math.sign(to.y - from.y) * lead } : { x: to.x - Math.sign(to.x - from.x) * lead, y: to.y };
+ return { d: `M${from.x.toFixed(1)},${from.y.toFixed(1)} C${c1.x.toFixed(1)},${c1.y.toFixed(1)} ${c2.x.toFixed(1)},${c2.y.toFixed(1)} ${to.x.toFixed(1)},${to.y.toFixed(1)}`, port: from };
+ };
+
+ let paths = '', packets = '', ports = '';
const srcNodes = [...stage.querySelectorAll('.tour-node--src')], dstNodes = [...stage.querySelectorAll('.tour-node--dst')];
const srcNodes = [...stage.querySelectorAll('.tour-node--src')], dstNodes = [...stage.querySelectorAll('.tour-node--dst')];
srcNodes.forEach((nd, i) => {
srcNodes.forEach((nd, i) => {
- const d = curve(anchorOf(nd, 'out'), coreIn);
- paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${d}"/>`;
- if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;
+ const a = anchorOf(nd, 'out');
+ const end = onRing(a);
+ paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${curve(a, end)}"/>`;
+ ports += `<circle class="tour-port tour-port--in" cx="${end.x.toFixed(1)}" cy="${end.y.toFixed(1)}" r="2.6"/>`;
+ if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" calcMode="spline" keySplines="0.4 0 0.6 1"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;
});
});
dstNodes.forEach((nd, i) => {
dstNodes.forEach((nd, i) => {
- const d = curve(coreOut, anchorOf(nd, 'in'));
+ const b = anchorOf(nd, 'in');
+ const { d, port } = curveOut(b);
const col = nd.style.getPropertyValue('--c') || '#fff';
const col = nd.style.getPropertyValue('--c') || '#fff';
paths += `<path id="tw-out-${i}" class="tour-wire tour-wire--out tour-wire--${nd.dataset.node}" style="--c:${col}" d="${d}"/>`;
paths += `<path id="tw-out-${i}" class="tour-wire tour-wire--out tour-wire--${nd.dataset.node}" style="--c:${col}" d="${d}"/>`;
- if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;
+ ports += `<circle class="tour-port" style="--c:${col}" cx="${port.x.toFixed(1)}" cy="${port.y.toFixed(1)}" r="2.6"/>`;
+ if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" calcMode="spline" keySplines="0.4 0 0.6 1"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;
});
});
- svg.innerHTML = defs + paths + packets;
+ // The ring the wires plug into, drawn in the same SVG so it can never drift from their ends.
+ const ring = `<circle class="tour-hub-ring" cx="${hub.x.toFixed(1)}" cy="${hub.y.toFixed(1)}" r="${ringR.toFixed(1)}"/>`;
+ svg.innerHTML = paths + ring + ports + packets;
+ // Anything rebuilt while the diagram is off screen starts paused, like the rest of it.
+ if (!_tourVisible) { try { svg.pauseAnimations(); } catch { /* */ } }
}
}
function rel(b, R) { return { x: b.left - R.left, y: b.top - R.top, w: b.width, h: b.height }; }
function rel(b, R) { return { x: b.left - R.left, y: b.top - R.top, w: b.width, h: b.height }; }