openvibers/openvibe.live Commits

Hero buttons in even rows, wires that reach the hub, intro pair kept together, and review fixes

OP OpenVibers committed 16 Sept 2026 · 1749633963 · 6 files, +183 −60

**Hero buttons.** auto-fit with max-content columns put however many buttons fitted on the first
row, which came out as four and then two. Six buttons may only be 6x1, 3x2 or 2x3, so the column
count is chosen explicitly and every column is the same width: 2 on phones, 3 on tablets and most
desktops, 6 only from 1500px — at 1280px the hero column is ~870px and six across cut every label
to an ellipsis. Landscape phones get 3x2. Measured: rows [2,2,2] at 390px, [3,3] at 900px.

**The restream diagram.** Wires anchored to the edges of .tour-core, a 320px-wide column around a
52px mark, so every line stopped ~130px short of the logo in empty space. Each wire now lands on a
ring just outside the disc at the point facing its node, arriving along the radius; the ring and
the plug-in ports are drawn in the same SVG from the same numbers, so they cannot drift apart.
Measured: all 9 wire ends within 0.1px of the ring at desktop, tablet and phone widths. Packets
ease along the path instead of moving at constant speed.

The packets are SMIL <animateMotion>, which the section's off-screen CSS pause cannot reach, so
fourteen of them kept moving for the whole visit. An IntersectionObserver now pauses and resumes
the SVG's own clock.

**Intro sections.** "One stream. Everywhere." now sits directly above "Streaming the way it should
be", and they move as a pair. Two bugs in the demotion: it appended to the first .container on the
home page, but the changelog lives in a later one, so "moved to the bottom" landed them above
Recently Online; and the separator used :first-of-type, which matches element type, so it never
applied. Both now go to the true end, after the changelog. The threshold now counts visits where
both sections were actually on screen rather than home page loads; the old count seeds the new
one once so nobody who was already past it gets them back.

**Review of this round's own changes** found three things, all fixed:
- The page-enter animation used transform on .page. A running transform animation makes the
  element a containing block for position:fixed descendants, so for ~400ms on every route change
  the mobile chat sheet and similar fixed children were positioned against the page instead of
  the viewport (measured: a bottom:0 probe 10px below the viewport mid-entry). Now opacity-only.
- fetchServiceJson shared one miss key across /api/game/*, so a single board returning a JSON 404
  would have switched off leaderboards and canvas for six hours. Keys are per URL, and only a
  network failure or a non-JSON body counts as "not there".
- Under reduced motion the brand mark returned before pausing SMIL, so the gradient sweep on the
  V ran for the whole session. It is held still once.

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 d54b1317..c7aad102 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -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 sweeps
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 {
-- 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) */
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex ae0fc781..00736ada 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -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; }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 08c7d34c..c2e099b4 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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
material rather than a pitch, so they get a quieter top margin and a rule separating them from
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: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
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
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 {
-- from { opacity: 0; transform: translate3d(0, 10px, 0); }
-- to { opacity: 1; transform: none; }
++ from { opacity: 0; }
++ to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
ddiff --git a/public/index.html b/public/index.html
iindex fbd2e601..ff5d6b72 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,9 +88,9 @@
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=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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex cde0039f..bcccaffc 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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() {
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 80b650d5..86cf4923 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -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 }; }