openvibers/openvibe.live Commits
Home: the OpenVibe network, remade: all 55 sites by group, animated, crawlable, no tabs (#655)
Changes
@@@ -68,6 +68,8 @@".ch-offline-overlay-close",".ch-offline-reopen",".ch-offline-screen",++ ".ch-panel-bar > .ch-vods-filter-bar",++ ".ch-panel-bar > .clips-taken-filters",".ch-tab",".ch-tab i",".ch-tab-badge",@@@ -77,18 +79,18 @@".ch-tab.active",".ch-tab.active::after",".ch-tabs",++ ".chat-ai-badge",".chat-avatar",".chat-avatar-letter",".chat-avatar-wrap",".chat-badge",".chat-badge i",-- ".chat-badge-admin",-- ".chat-badge-streamer",".chat-badge[data-tip]::after",".chat-badge[data-tip]::before",".chat-crossfeed-badge",".chat-crossfeed-global",".chat-crossfeed-stream",++ ".chat-emote",".chat-header",".chat-header-actions",".chat-input-area",@@@ -161,8 +163,11 @@".fcw-unread-badge",".floating-chat-fab",".floating-chat-widget",++ ".form-group",".form-input",".form-input-sm",++ ".form-row",++ ".form-row .form-group",".gai-badge",".gai-strip-text",".global-chat-header",@@@ -261,6 +266,8 @@".mobile-chat-badge",".mobile-chat-close",".mobile-chat-toggle",++ ".modal-body",++ ".modal-body .form-group",".modal-overlay",".moment-card",".moment-card img",@@@ -320,7 +327,10 @@".ov-v",".page",".page.active",++ ".paste-modal",++ ".paste-modal-body",".paste-modal-overlay",++ ".paste-modal-upload",".pastes-grid",".pastes-pagination",".pc-tools-row > button",@@@ -336,6 +346,7 @@".rewards-panel",".rs-lite .ov-orbit",".rs-lite .ov-spark",++ ".screenshot-details-grid",".section-header",".section-header .badge",".section-header .ovd",@@@ -68,80 +68,7 @@ body.egg-shake { animation: eggShake 0.9s var(--ov-ease-out, cubic-bezier(.36,.0body.egg-rainbow { animation: eggRainbow 2.6s linear; }@media (prefers-reduced-motion: reduce) { .egg-confetti, body.egg-shake, body.egg-rainbow { animation: none !important; } .egg-confetti { display: none; } }--.network-hub {-- background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 50%, var(--bg-secondary) 100%);-- border-top: 1px solid var(--border);-- border-bottom: 1px solid var(--border);-- padding: 32px 24px;-- overflow: hidden;--}--.network-hub-header {-- display: flex; align-items: center; gap: 14px; justify-content: center;-- margin-bottom: 24px; text-align: center;--}--.network-hub-grid {-- display: grid;-- /* minmax(0, 1fr): a plain 1fr floors at the card's min-content, and a nowrap description made-- two columns wider than a 390px phone (the third card was cut off at the edge). */-- grid-template-columns: repeat(3, minmax(0, 1fr));-- gap: 12px;-- max-width: 960px; margin: 0 auto;--}--.network-card {-- display: flex; align-items: center; gap: 12px;-- background: var(--bg-card); border: 1px solid var(--border);-- border-radius: 12px; padding: 14px 16px;-- text-decoration: none; color: var(--text-primary);-- transition: all var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease);--}--.network-card-featured {-- border-color: rgba(231,76,60,0.25);-- background: rgba(231,76,60,0.04);--}--.network-card-featured:hover { border-color: rgba(231,76,60,0.5); }--.network-card-icon {-- width: 40px; height: 40px; border-radius: 10px;-- background: color-mix(in srgb, var(--card-accent, var(--accent)) 12%, transparent);-- display: flex; align-items: center; justify-content: center;-- font-size: 1.1rem; color: var(--card-accent, var(--accent));-- flex-shrink: 0;--}--.network-card-arrow {-- color: var(--text-secondary); font-size: 0.75rem;-- opacity: 0; transition: opacity var(--ov-dur-2, 0.2s), transform var(--ov-dur-2, 0.2s);-- flex-shrink: 0;--}--.network-card:hover .network-card-arrow { opacity: 1; transform: translateX(3px); }----@media (max-width: 960px) {-- .network-hub-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }--}--@media (max-width: 768px) {-- .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }-- .network-hub { padding: 24px 16px; }-- .network-hub-header { flex-direction: column; gap: 8px; }--}--@media (max-width: 480px) {-- .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }-- .network-hub { padding: 20px 12px; }-- .network-card { padding: 12px; gap: 10px; }-- .network-card-icon { width: 36px; height: 36px; font-size: 1rem; border-radius: 8px; }-- .network-card-arrow { display: none; }--}--@media (max-width: 360px) {-- .network-hub-grid { grid-template-columns: 1fr; gap: 8px; }-- .network-hub { padding: 16px 10px; }--}--/* Phones (2026-10-29): the fourteen services are two rows that scroll sideways, not a screen and a half of tiles;-- every one is still a tap away, and the footer lists them too. */@media (max-width: 599px) {-- .network-hub-grid {-- grid-template-columns: none; grid-template-rows: repeat(2, auto); grid-auto-flow: column; grid-auto-columns: 66%;-- overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 12px; scrollbar-width: none;-- margin: 0 -12px; padding: 0 12px 4px;-- }-- .network-hub-grid::-webkit-scrollbar { display: none; }-- .network-hub-grid > .network-card { scroll-snap-align: start; min-width: 0; }/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */.pulse-grid { display: flex; flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; scrollbar-width: none; gap: 10px; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; }.pulse-grid::-webkit-scrollbar { display: none; }@@@ -1193,15 +1120,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.home-changelog-toggle > i { transition: rotate var(--ov-dur-3, 0.3s) var(--ov-ease-out, cubic-bezier(.2, 1, .3, 1)); }.home-changelog-toggle[aria-expanded="true"] > i { rotate: 180deg; }--/* Network hub two-up on a phone: the icon beside the text left ~90px, and "OpenVibe.Community"-- ran out of its card. Icon above text there; names may break after "OpenVibe." (<wbr>). */--.network-card-info strong { overflow-wrap: break-word; }--@media (min-width: 361px) and (max-width: 480px) {-- .network-card { flex-direction: column; align-items: flex-start; gap: 8px; }-- .network-card-info { width: 100%; }--}--/* The ≤480px rule hiding the arrow lost to Font Awesome's later .fa-solid display rule. */--@media (max-width: 480px) { .network-card > .network-card-arrow { display: none; } }/* Terms · Privacy · DMCA wrap as one group ("DMCA" was stranded alone at 360px). */.hero-legal { display: inline-flex; align-items: center; gap: inherit; white-space: nowrap; }@@@ -1317,38 +1235,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }#page-home .hero-egg-reveal { min-height: 44px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; }}--/* New here? (2026-10-30, the short-pages rule): on a phone the explainer at the foot of the home is a row of tabs and-- one section at a time. The content blocks above it (Catch up, Around OpenVibe) are never tabbed: every section shows-- on every screen (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage"). The breakpoint is-- app-home.js's _narrow. */--.home-tabs { display: none; }@media (max-width: 759px) {-- .home-tabs {-- display: flex; gap: 6px; margin: 30px -16px 6px; padding: 2px 16px 4px;-- overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; scroll-padding-inline: 16px;-- }-- .home-tabs::-webkit-scrollbar { display: none; }-- .home-tabs[hidden] { display: none !important; }-- .home-tabs [role="tab"] {-- --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));-- flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px;-- font: inherit; font-size: 0.86rem; font-weight: 750; white-space: nowrap; cursor: pointer;-- color: var(--text-secondary); background: transparent;-- border: 1px solid var(--border); border-radius: 999px;-- transition: color var(--ov-dur-2, 0.18s), background var(--ov-dur-2, 0.18s), border-color var(--ov-dur-2, 0.18s);-- }-- .home-tabs [role="tab"][hidden] { display: none; }-- .home-tabs [role="tab"] i { font-size: 0.78rem; color: var(--sc-on); }-- .home-tabs [role="tab"][aria-selected="true"] {-- color: color-mix(in srgb, var(--sc-on) 30%, var(--text-primary));-- background: color-mix(in srgb, var(--sc-on) 14%, transparent);-- border-color: color-mix(in srgb, var(--sc-on) 48%, transparent);-- }-- .home-tabs [role="tab"]:focus-visible { outline: 2px solid var(--sc-on); outline-offset: 2px; }-- .home-tabbox:not([data-tab="tour"]) [data-panel="tour"],-- .home-tabbox:not([data-tab="about"]) [data-panel="about"],-- .home-tabbox:not([data-tab="network"]) [data-panel="network"] { display: none !important; }-- .home-tabbox [data-panel="network"] .network-hub-header h3 { display: none; }/* Catch up on a phone: each rail is one row of cards that scrolls sideways, so all five fit in a few screensand nothing is behind a tab. Recent Pastes rides the same rail (2026-11-02): it was two cards a row with a@@@ -1386,7 +1273,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.home-rail-tail { display: none; }@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }@media (prefers-reduced-motion: reduce) { .home-rail-skel { animation: none; } }--@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-tabs [role="tab"] { transition: none; } }/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (thefeatured block above already shows it), and when it was the only stream there the grid's header goes too; the@@@ -1397,7 +1283,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }}-- .home-tabbox[data-tab=""] .home-tabs { margin-bottom: 0; }}/* The Agents live row (Agent Streams S2): public AI agent streams, their own row after people's streams. */@@@ -1411,3 +1296,165 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.home-agents .agent-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }.home-agents .agent-card-title { margin: 0; font-size: 0.92rem; font-weight: 700; overflow-wrap: anywhere; }.home-agents .agent-card-meta { margin: 0; color: var(--text-secondary); font-size: 0.76rem; }++++/* ── The OpenVibe network (2026-11-07 redesign) ──────────────────────────────────────────────────────────────++ Every open site of the network, by what people come to do (scripts/build-network-section.js writes the inside from++ OpenVibe.Network's frame). A deep surface with a slow aurora and a field of twinkling points, the count of sites,++ jump links to each group (links, not tabs: nothing is hidden), and one tile per site with its ring icon. Wide++ screens show each group as a grid; a phone shows each as one row that scrolls sideways. Tiles rise in, in order,++ the first time the section is seen (app-home.js initHomeNetwork adds .is-in). Motion stops for reduced motion. */++.ovnet {++ --ovnet-ink: var(--text-primary, #e6edf7);++ --ovnet-soft: var(--text-secondary, #a8b3c4);++ --ovnet-mute: var(--text-muted, #7d8aa0);++ --ovnet-card: color-mix(in srgb, var(--bg-card, #111a2b) 88%, transparent);++ --ovnet-line: color-mix(in srgb, var(--text-primary, #fff) 9%, transparent);++ position: relative; isolation: isolate; overflow: hidden;++ margin: 34px 0 8px; padding: 46px clamp(14px, 4vw, 40px) 34px;++ border-radius: 24px; border: 1px solid var(--ovnet-line);++ background:++ radial-gradient(120% 80% at 10% 0%, color-mix(in srgb, #3b82f6 22%, transparent), transparent 60%),++ radial-gradient(90% 70% at 95% 10%, color-mix(in srgb, #a855f7 20%, transparent), transparent 60%),++ radial-gradient(80% 60% at 50% 110%, color-mix(in srgb, #14b8a6 14%, transparent), transparent 70%),++ var(--bg-secondary, #0c1322);++}++.ovnet::before { /* the aurora: two soft bands drifting across the top */++ content: ''; position: absolute; inset: -40% -20% auto; height: 120%; z-index: -1; pointer-events: none;++ background: conic-gradient(from 200deg at 50% 50%, transparent 0 30%, color-mix(in srgb, #38bdf8 22%, transparent) 40%, transparent 52%, color-mix(in srgb, #f472b6 16%, transparent) 64%, transparent 76%);++ filter: blur(48px); opacity: .7; animation: ovnetAurora 22s ease-in-out infinite alternate;++}++@keyframes ovnetAurora { from { transform: translate3d(-6%, 0, 0) rotate(0deg); } to { transform: translate3d(6%, 4%, 0) rotate(14deg); } }++.ovnet-sky { position: absolute; inset: 0; z-index: -1; pointer-events: none; }++.ovnet-sky i {++ position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; opacity: .0;++ box-shadow: 0 0 8px 1px color-mix(in srgb, #93c5fd 70%, transparent); animation: ovnetTwinkle 5.5s ease-in-out infinite;++}++.ovnet-sky i:nth-child(1) { left: 6%; top: 14%; animation-delay: -.4s; }++.ovnet-sky i:nth-child(2) { left: 18%; top: 34%; animation-delay: -1.8s; }++.ovnet-sky i:nth-child(3) { left: 31%; top: 9%; animation-delay: -3.1s; }++.ovnet-sky i:nth-child(4) { left: 44%; top: 26%; animation-delay: -.9s; }++.ovnet-sky i:nth-child(5) { left: 57%; top: 6%; animation-delay: -2.6s; }++.ovnet-sky i:nth-child(6) { left: 68%; top: 31%; animation-delay: -4.2s; }++.ovnet-sky i:nth-child(7) { left: 79%; top: 12%; animation-delay: -1.2s; }++.ovnet-sky i:nth-child(8) { left: 91%; top: 27%; animation-delay: -3.7s; }++.ovnet-sky i:nth-child(9) { left: 12%; top: 58%; animation-delay: -2.2s; }++.ovnet-sky i:nth-child(10) { left: 86%; top: 62%; animation-delay: -.2s; }++.ovnet-sky i:nth-child(11) { left: 49%; top: 84%; animation-delay: -4.8s; }++.ovnet-sky i:nth-child(12) { left: 72%; top: 88%; animation-delay: -1.5s; }++@keyframes ovnetTwinkle { 0%, 100% { opacity: 0; transform: scale(.6); } 45% { opacity: .85; transform: scale(1); } 60% { opacity: .35; } }++++.ovnet-head { max-width: 760px; margin: 0 auto 26px; text-align: center; }++.ovnet-eyebrow {++ display: inline-flex; align-items: center; gap: 8px; margin: 0 0 14px; padding: 6px 12px; border-radius: 999px;++ border: 1px solid var(--ovnet-line); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 55%, transparent);++ color: var(--ovnet-soft); font-size: .72rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;++}++.ovnet-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 color-mix(in srgb, #22c55e 60%, transparent); animation: ovnetPing 2.2s ease-out infinite; }++@keyframes ovnetPing { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, #22c55e 55%, transparent); } 80%, 100% { box-shadow: 0 0 0 10px transparent; } }++.ovnet-head h2 {++ margin: 0; color: var(--ovnet-ink); font-size: clamp(1.7rem, 4.6vw, 2.9rem); line-height: 1.08; font-weight: 850;++ letter-spacing: -.035em; text-wrap: balance;++}++.ovnet-count {++ display: inline-block; min-width: 2ch; font-variant-numeric: tabular-nums;++ background: linear-gradient(100deg, #38bdf8, #a78bfa 45%, #f472b6 90%); -webkit-background-clip: text; background-clip: text; color: transparent;++}++.ovnet-lede { max-width: 620px; margin: 14px auto 0; color: var(--ovnet-soft); font-size: clamp(.95rem, 2.2vw, 1.06rem); line-height: 1.6; text-wrap: pretty; }++.ovnet-jump { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 22px; }++.ovnet-jump a {++ display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border-radius: 999px;++ border: 1px solid color-mix(in srgb, var(--g) 34%, transparent); background: color-mix(in srgb, var(--g) 10%, transparent);++ color: var(--ovnet-ink); font-size: .8rem; font-weight: 650; text-decoration: none;++ transition: background-color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s);++}++.ovnet-jump a i { color: var(--g); font-size: .78rem; }++.ovnet-jump a b { padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--g) 22%, transparent); font-size: .7rem; font-weight: 800; }++.ovnet-jump a:hover { background: color-mix(in srgb, var(--g) 18%, transparent); border-color: color-mix(in srgb, var(--g) 60%, transparent); transform: translateY(-1px); }++.ovnet-jump a:focus-visible, .ovnet-site:focus-visible, .ovnet-cta:focus-visible, .ovnet-switch:focus-visible { outline: 2px solid var(--g, var(--accent, #3b82f6)); outline-offset: 3px; }++++.ovnet-groups { display: grid; grid-template-columns: minmax(0, 1fr); gap: 30px; max-width: 1180px; margin: 0 auto; }++.ovnet-group { min-width: 0; scroll-margin-top: 84px; }++.ovnet-group h3 { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--ovnet-ink); font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }++.ovnet-group h3 i {++ display: grid; place-items: center; width: 30px; height: 30px; border-radius: 10px; font-size: .82rem;++ color: var(--g); background: color-mix(in srgb, var(--g) 16%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--g) 30%, transparent);++}++.ovnet-group h3::after { content: ''; flex: 1; height: 1px; margin-left: 4px; background: linear-gradient(90deg, color-mix(in srgb, var(--g) 45%, transparent), transparent); }++.ovnet-n { order: 3; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--g) 16%, transparent); color: color-mix(in srgb, var(--g) 68%, var(--ovnet-ink)); font-size: .72rem; font-weight: 800; }++.ovnet-gline { margin: 6px 0 12px 40px; color: var(--ovnet-mute); font-size: .82rem; }++.ovnet-sites { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 212px), 1fr)); gap: 10px; }++.ovnet-site {++ --lift: 0px;++ position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px;++ height: 100%; padding: 12px 14px; border-radius: 16px; text-decoration: none; color: var(--ovnet-ink);++ background: var(--ovnet-card); border: 1px solid var(--ovnet-line);++ box-shadow: 0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset;++ transform: translateY(var(--lift));++ transition: transform var(--ov-dur-2, .18s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .18s), box-shadow var(--ov-dur-2, .18s), background-color var(--ov-dur-2, .18s);++}++.ovnet-site::after { /* the group's glow, lit on hover */++ content: ''; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none; opacity: 0;++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--g) 22%, transparent), transparent 55%);++ transition: opacity var(--ov-dur-2, .18s);++}++@media (hover: hover) {++ .ovnet-site:hover { --lift: -3px; border-color: color-mix(in srgb, var(--g) 55%, transparent); box-shadow: 0 12px 28px -14px color-mix(in srgb, var(--g) 70%, transparent); }++ .ovnet-site:hover::after { opacity: 1; }++}++.ovnet-site .ov-icon { --ovi-accent: var(--g); display: grid; place-items: center; width: 40px; height: 40px; flex: none; }++.ovnet-site .ov-icon:empty::before { /* before the ring icons draw: a ring of the group's colour */++ content: ''; width: 34px; height: 34px; border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--g) 55%, transparent);++ background: color-mix(in srgb, var(--g) 10%, transparent);++}++.ovnet-txt { display: grid; min-width: 0; gap: 1px; }++.ovnet-txt b { font-size: .95rem; font-weight: 750; letter-spacing: -.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovnet-txt small { color: color-mix(in srgb, var(--g) 68%, var(--ovnet-ink)); font-size: .7rem; font-weight: 650; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .9; }++.ovnet-txt span { color: var(--ovnet-soft); font-size: .8rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++++/* The first time the section is seen, its tiles rise in, one after another. */++.ovnet.is-armed .ovnet-site { opacity: 0; --lift: 14px; }++.ovnet.is-in .ovnet-site { opacity: 1; --lift: 0px; transition-delay: calc(min(var(--i, 0), 40) * 22ms); transition-property: transform, opacity, border-color, box-shadow; transition-duration: .55s, .55s, .18s, .18s; }++++.ovnet-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; margin-top: 30px; }++.ovnet-cta {++ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 20px; border-radius: 999px;++ background: linear-gradient(100deg, #3b82f6, #8b5cf6); color: #fff; font-weight: 750; text-decoration: none;++ box-shadow: 0 10px 24px -12px #6366f1; transition: transform var(--ov-dur-2, .15s), box-shadow var(--ov-dur-2, .15s);++}++.ovnet-cta:hover { transform: translateY(-1px); box-shadow: 0 14px 28px -12px #6366f1; }++.ovnet-switch {++ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 999px; cursor: pointer;++ border: 1px solid var(--ovnet-line); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 45%, transparent); color: var(--ovnet-soft); font: inherit; font-size: .85rem;++}++.ovnet-switch b { color: var(--ovnet-ink); }++.ovnet-switch:hover { color: var(--ovnet-ink); border-color: color-mix(in srgb, var(--text-primary, #fff) 22%, transparent); }++++/* A phone: each group is one row of tiles that scrolls sideways (every site is still there and a swipe away). */++@media (max-width: 640px) {++ .ovnet { margin: 26px -4px 4px; padding: 34px 0 26px; border-radius: 20px; }++ .ovnet-head { padding: 0 16px; margin-bottom: 20px; }++ .ovnet-jump { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; margin: 18px -16px 0; padding: 0 16px 2px; scroll-padding-inline: 16px; }++ .ovnet-jump::-webkit-scrollbar { display: none; }++ .ovnet-jump a { flex: none; min-height: 44px; }++ .ovnet-groups { gap: 24px; }++ .ovnet-group h3, .ovnet-gline { padding: 0 16px; }++ .ovnet-gline { margin-left: 40px; }++ .ovnet-sites {++ display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none;++ padding: 2px 16px 6px; scroll-padding-inline: 16px; gap: 10px;++ }++ .ovnet-sites::-webkit-scrollbar { display: none; }++ .ovnet-sites > li { flex: 0 0 min(78%, 280px); scroll-snap-align: start; }++ .ovnet-site { min-height: 76px; }++ .ovnet-foot { padding: 0 16px; }++ .ovnet-cta, .ovnet-switch { width: 100%; justify-content: center; }++}++/* Light themes: the surface follows the page, the aurora softens. */++@media (prefers-color-scheme: light) { :root:not([data-theme]) .ovnet::before { opacity: .35; } }++[data-theme-mode="light"] .ovnet::before, html.light .ovnet::before { opacity: .35; }++@media (prefers-reduced-motion: reduce) {++ .ovnet::before, .ovnet-sky i, .ovnet-dot { animation: none; }++ .ovnet-sky i { opacity: .45; }++ .ovnet.is-armed .ovnet-site { opacity: 1; --lift: 0px; }++ .ovnet-site, .ovnet-jump a, .ovnet-cta { transition: none; }++}@@@ -152,14 +152,14 @@ input, select, textarea { font-family: inherit; }html { -webkit-tap-highlight-color: transparent; }:root { --press-scale: 0.97; --press-scale-card: 0.985; }:where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap):not(:disabled, [aria-disabled="true"]):active { scale: var(--press-scale); }--:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }++:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;}@media (prefers-reduced-motion: reduce) {/* No movement: the press is a brief dim instead. */:root { --press-scale: 1; --press-scale-card: 1; }-- :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }++ :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }}/* ═══════════════════════════════════════════════════════════════@@@ -493,39 +493,6 @@ body.ov-nav-overlay .navbar.ovnav-top {@keyframes eggFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }--.network-hub-header > i {-- font-size: 1.6rem; line-height: 1; color: var(--accent); flex-shrink: 0;-- background: color-mix(in srgb, var(--accent) 10%, transparent); padding: 12px; border-radius: 12px;--}--.network-hub-header h3 {-- font-size: 1.15rem; font-weight: 800; color: var(--text-primary); margin: 0;-- letter-spacing: -0.3px;--}--.network-hub-header p {-- font-size: 0.82rem; color: var(--text-secondary); margin: 3px 0 0;--}--.network-card:hover {-- background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);-- border-color: var(--accent-dark);-- transform: translateY(-2px);-- box-shadow: 0 4px 20px rgba(0,0,0,0.3);--}--.network-card-info { flex: 1; min-width: 0; }--.network-card-info strong {-- display: block; font-size: 0.85rem; font-weight: 700;-- color: var(--text-primary); margin-bottom: 2px;--}--.network-card-info span {-- display: block; font-size: 0.72rem; color: var(--text-secondary);-- line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;--}--@media (max-width: 768px) {-- .network-card-info span { white-space: normal; }--}--@media (max-width: 480px) {-- .network-card-info strong { font-size: 0.8rem; }-- .network-card-info span { font-size: 0.68rem; }--}/* ── OpenVibeApp Popover ─────────────────────────────────────────── */.openvibeapp-popover {@@@ -211,20 +211,15 @@</div></div><!-- /home-catchup -->-- <!-- New here? (2026-10-30, the short-pages rule): the tour, the About banner and the network hub are-- three introductions to the place. On a phone they share one place — a row of tabs and one of them at a-- time. Wider screens hide the tabs and show all three. demoteHomeIntroSections() moves this whole-- block to the end of the page once the reader has seen it often enough. -->-- <div class="home-tabbox home-intro" id="home-intro" data-tab="tour">-- <div class="home-tabs" role="tablist" aria-label="New here?">-- <button type="button" role="tab" id="hi-tab-tour" data-tab="tour" aria-selected="true" aria-controls="home-tour-mount" style="--sc:#22d3ee"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i><span>How it works</span></button>-- <button type="button" role="tab" id="hi-tab-about" data-tab="about" aria-selected="false" aria-controls="home-cta-banner" tabindex="-1" style="--sc:#f472b6"><i class="fa-solid fa-book-open" aria-hidden="true"></i><span>About</span></button>-- <button type="button" role="tab" id="hi-tab-network" data-tab="network" aria-selected="false" aria-controls="home-network-hub" tabindex="-1" style="--sc:#38bdf8"><i class="fa-solid fa-network-wired" aria-hidden="true"></i><span>The network</span></button>-- </div>++ <!-- New here?: the restream tour and the About banner, one after the other on every screen (2026-11-07: the++ tab row that showed one at a time on a phone is gone; the owner's rule is never to hide content behind++ tabs). The network has its own section below. demoteHomeIntroSections() moves this block to the end of++ the page once the reader has seen it often enough. -->++ <div class="home-intro" id="home-intro"><!-- 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-panel="tour" data-fx-viewport></div>++ <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-panel="about" 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-embers" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>@@@ -372,140 +367,28 @@</div><!-- /home-about-body --></div></div>-- <!-- The network: the fourteen services one account reaches. -->-- <div class="network-hub" id="home-network-hub" data-panel="network">-- <div class="network-hub-header">-- <i class="fa-solid fa-network-wired"></i>-- <div>-- <h3>The OpenVibe Network</h3>-- <p>One account, 14 services — streaming, games, maps, food, image tools, audio tools, text generators, logo makers, PDF & document tools, network diagnostics, developer tools, video downloads, and more.</p>-- </div>-- </div>-- <div class="network-hub-grid">-- <a href="https://openvibe.live" class="network-card network-card-featured">-- <div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>-- <div class="network-card-info">-- <strong>OpenVibe.<wbr>Live</strong>-- <span>Live streaming, VODs, clips & chat</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>-- <div class="network-card-info">-- <strong>OpenVibe.<wbr>Tools</strong>-- <span>Every online tool in one index</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.community" class="network-card">-- <div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>-- <div class="network-card-info">-- <strong>OpenVibe.<wbr>Community</strong>-- <span>Pastes, posts and people</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.games" class="network-card">-- <div class="network-card-icon" style="--card-accent:#a855f7"><i class="fa-solid fa-hat-wizard"></i></div>-- <div class="network-card-info">-- <strong>Scraplandia</strong>-- <span>Browser RPG & community games</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.media" class="network-card">-- <div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>-- <div class="network-card-info">-- <strong>OpenVibe.<wbr>Media</strong>-- <span>VODs, clips and files</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://yt.openvibe.tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#ef4444"><i class="fa-solid fa-circle-play"></i></div>-- <div class="network-card-info">-- <strong>YouTube Downloader</strong>-- <span>Save video or audio</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/image-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#ec4899"><i class="fa-solid fa-image"></i></div>-- <div class="network-card-info">-- <strong>Image Tools</strong>-- <span>Convert, compress, resize, crop</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/audio-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#14b8a6"><i class="fa-solid fa-music"></i></div>-- <div class="network-card-info">-- <strong>Audio Tools</strong>-- <span>Convert, trim, pitch and clean up</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/pdf-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#f97316"><i class="fa-solid fa-file-pdf"></i></div>-- <div class="network-card-info">-- <strong>PDF & Documents</strong>-- <span>Merge, split, compress, protect</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/text-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#8b5cf6"><i class="fa-solid fa-font"></i></div>-- <div class="network-card-info">-- <strong>Text & Logo Tools</strong>-- <span>Fancy text, logos, counters</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/developer-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#06b6d4"><i class="fa-solid fa-code"></i></div>-- <div class="network-card-info">-- <strong>Developer Tools</strong>-- <span>JSON, regex, JWT, diff, hashes</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.tools/network-tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#10b981"><i class="fa-solid fa-network-wired"></i></div>-- <div class="network-card-info">-- <strong>Network Tools</strong>-- <span>DNS, WHOIS, SSL, ping, ports</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://maps.openvibe.tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#2ecc71"><i class="fa-solid fa-map-location-dot"></i></div>-- <div class="network-card-info">-- <strong>Survival Map</strong>-- <span>Camps, resources & shelters</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://food.openvibe.tools" class="network-card">-- <div class="network-card-icon" style="--card-accent:#eab308"><i class="fa-solid fa-utensils"></i></div>-- <div class="network-card-info">-- <strong>Budget Food Finder</strong>-- <span>Cheap meals and food banks</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- <a href="https://openvibe.network/my" class="network-card">-- <div class="network-card-icon" style="--card-accent:var(--accent, #3b82f6)"><i class="fa-solid fa-user-circle"></i></div>-- <div class="network-card-info">-- <strong>My Account</strong>-- <span>Profile, themes, history, linked services</span>-- </div>-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>-- </a>-- </div>-- </div></div><!-- /home-intro -->++ <!-- The OpenVibe network (2026-11-07, the owner: "remake this area entirely considering what all the network offers++ … animations/effects … improve the SEO"): every open site, by what people come to do, from OpenVibe.Network's++ frame (the OV mark's site switcher list). The inside is written by scripts/build-network-section.js so crawlers++ get every site as a real link; app-home.js initHomeNetwork() animates it, draws the ring icons and adds a site++ that opened since. On a phone each group is a row that scrolls sideways; nothing is hidden behind a tab. -->++ <div class="ovnet" id="home-network" role="region" aria-labelledby="ovnet-title"><!--ov:network-->++ <div class="ovnet-sky" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>++ <header class="ovnet-head">++ <p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>++ <h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2>++ <p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>++ <nav class="ovnet-jump" aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>++ </header>++ <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>++ <footer class="ovnet-foot">++ <a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>++ <button type="button" class="ovnet-switch" data-ovnet-switcher><i class="fa-solid fa-grip" aria-hidden="true"></i> Jump between sites any time from the <b>OV</b> mark</button>++ </footer>++ <!--/ov:network--></div>++</div><div class="container">@@@ -1197,9 +1197,8 @@ const _introSeen = new Set();/*** One sighting of the introductions: the reader has seen both of them during this visit.*-- * The tour and the About banner are two panels of the same "New here?" block now, and on a phone only one of-- * them is on screen at a time — so the About banner is seen when its tab is opened (initHomeIntro), not only-- * when it scrolls past.++ * The tour and the About banner are the two parts of the "New here?" block, one after the other on every screen++ * (2026-11-07: no tab row); each is seen when it scrolls past.*/function _introSeenNow(id) {if (_introCounted) return;@@@ -1266,111 +1265,130 @@ function demoteHomeIntroSections() {try { last.appendChild(block); } catch { /* */ }}--/**-- * Tabs for a block of home sections. Only the explainer at the foot (New here?) uses them: the content blocks (Catch-- * up, Around OpenVibe) show every section on every screen again (the owner, 2026-10-31: "now I can't see the videos-- * and clips on the homepage… don't reduce size by removing features"). On a phone the block shows one section at a-- * time behind a row of tabs; wider screens never see the tabs. The tabs and the CSS live in the home fragment and-- * features/home.css.-- *-- * initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable })-- * boxId the block's element id. It carries data-tab — the section that is open — and its tab row.-- * shownIds tab key → the id of the element that says the section has something. Each loader already hides-- * that element when its section is empty, so the tab goes with it.-- * storageKey where the reader's last tab is kept for their next visit.-- * onOpen called with the key of the section that just opened (the tour loads when its tab opens).-- * closable the block may have no section open: it starts closed (data-tab="") unless the reader left one open,-- * and pressing the open tab closes it again (2026-10-31: the intro at the foot of the home was 1.5-- * phone screens open by default, under everything a returning viewer came for).-- *-- * A tab shows once its section has something; arrow keys, Home and End move between the tabs that show; the kept-- * tab opens in the first few seconds only, so a section that arrives later never moves the page under the reader;-- * a section that empties out falls back to one that has something, and a block with nothing left in it drops its-- * tab row.-- */--function initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable = false } = {}) {-- const box = document.getElementById(boxId);-- if (!box || box._ovTabs) return;-- const list = box.querySelector('[role="tablist"]');-- const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];-- if (!list || !tabs.length) return;-- box._ovTabs = true;-- const fallback = closable ? '' : (box.dataset.tab || tabs[0].dataset.tab);-- const has = (key) => { const el = shownIds[key] && document.getElementById(shownIds[key]); return !el || el.style.display !== 'none'; };-- let wanted = fallback;-- try { const kept = localStorage.getItem(storageKey); if (kept !== null && (kept || closable)) wanted = kept; } catch { /* */ }-- const pick = (key, { remember = false, focus = false } = {}) => {-- box.dataset.tab = key;-- for (const t of tabs) {-- const on = t.dataset.tab === key;-- t.setAttribute('aria-selected', on ? 'true' : 'false');-- if (on && focus) t.focus();-- }-- focusStop();-- if (remember) { wanted = key; try { localStorage.setItem(storageKey, key); } catch { /* */ } }-- if (key && onOpen) { try { onOpen(key); } catch { /* */ } }++/* ── The OpenVibe network (2026-11-07 redesign) ──────────────────────────────────────────────────────────────++ * The section's tiles are in the page already (scripts/build-network-section.js, from OpenVibe.Network's frame), so++ * crawlers and a reader without scripts get every site. Here it comes alive:++ * - the first time it nears the screen its tiles rise in, in order, and the count of sites counts up;++ * - the ring icons are drawn by openvibe-shared's ov-icons (loaded then, not with the page);++ * - the jump links glide to their group; the "OV mark" button opens the network's site switcher;++ * - "Create your free account" is only for a reader who is not signed in;++ * - the frame the navbar already holds is read again, and a site that opened since the page was built is added to++ * its group (DOM nodes, https OpenVibe addresses only), so the section is never behind for long. */++const _OVNET_HOST = /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/i;++const _OVNET_NEVER = /(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i;++function _ovnetSignedIn(root) {++ const cta = root.querySelector('[data-ovnet-signin]');++ if (cta) cta.hidden = !!window.currentUser;++}++function _ovnetCount(root) {++ const el = root.querySelector('.ovnet-count');++ const n = Number(root.querySelector('.ovnet-groups')?.dataset.sites || el?.dataset.count || 0);++ if (!el || !n) return;++ el.dataset.count = String(n);++ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.textContent = String(n); return; }++ const clock = window.performance || Date;++ const t0 = clock.now(), dur = 900;++ const step = (t) => {++ const k = Math.min(1, (t - t0) / dur);++ el.textContent = String(Math.round(n * (1 - Math.pow(1 - k, 3))));++ if (k < 1) requestAnimationFrame(step);};-- // The row's one focus stop: the open tab, or with nothing open the first tab that shows, so it can be reached.-- function focusStop() {-- const key = box.dataset.tab;-- const first = tabs.find((x) => !x.hidden);-- for (const t of tabs) t.setAttribute('tabindex', (key ? t.dataset.tab === key : t === first) ? '0' : '-1');++ requestAnimationFrame(step);++}++function _ovnetIcons() {++ if (window.OpenVibeIcons) { try { window.OpenVibeIcons.mount(document.getElementById('home-network')); } catch { /* */ } return; }++ if (document.querySelector('script[data-ovnet-icons]')) return;++ const sc = document.createElement('script');++ sc.src = '/shared/ov-icons.js'; sc.defer = true; sc.dataset.ovnetIcons = '1';++ sc.onload = () => { try { window.OpenVibeIcons && window.OpenVibeIcons.mount(document.getElementById('home-network')); } catch { /* */ } };++ document.head.appendChild(sc);++}++/** A site the frame lists that the page does not have yet: added to its group, built from DOM nodes. */++function _ovnetAddMissing(root, frame) {++ const nav = frame && Array.isArray(frame.nav) ? frame.nav : [];++ const have = new Set([...root.querySelectorAll('.ovnet-site[data-site]')].map((a) => a.dataset.site));++ let added = 0;++ for (const s of nav) {++ if (!s || typeof s.url !== 'string' || typeof s.name !== 'string' || !s.id) continue;++ const id = String(s.id).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);++ if (!id || have.has(id)) continue;++ let u; try { u = new URL(s.url); } catch { continue; }++ if (u.protocol !== 'https:' || !_OVNET_HOST.test(u.hostname) || _OVNET_NEVER.test(u.hostname)) continue;++ const gid = String(s.group || '').toLowerCase().replace(/[^a-z0-9-]/g, '');++ const group = (gid && root.querySelector(`.ovnet-group[data-group="${gid}"] .ovnet-sites`)) || root.querySelector('.ovnet-group:last-of-type .ovnet-sites');++ if (!group) continue;++ const li = document.createElement('li');++ const a = document.createElement('a');++ a.className = 'ovnet-site'; a.href = u.href; a.dataset.site = id; a.style.setProperty('--i', String(have.size + added));++ const ic = document.createElement('span');++ ic.className = 'ov-icon'; ic.dataset.icon = String(s.icon || id).replace(/[^a-z0-9-]/gi, '').slice(0, 32); ic.dataset.size = '40'; ic.setAttribute('aria-hidden', 'true');++ const txt = document.createElement('span'); txt.className = 'ovnet-txt';++ const b = document.createElement('b'); b.textContent = String(s.name).slice(0, 40);++ const small = document.createElement('small'); small.textContent = u.hostname;++ txt.append(b, small);++ if (s.tagline) { const line = document.createElement('span'); line.textContent = String(s.tagline).slice(0, 90); txt.append(line); }++ a.append(ic, txt); li.append(a); group.append(li);++ added++;}-- if (closable) pick(wanted && has(wanted) ? wanted : '');-- // The kept tab opens if its section arrives in the first few seconds; later, a section arriving never moves the-- // page under the reader. A section that empties out falls back to one that has something.-- const settleBy = Date.now() + 3000;-- const sync = () => {-- for (const t of tabs) t.hidden = !has(t.dataset.tab);-- const shown = tabs.filter((t) => !t.hidden);-- list.hidden = !shown.length;-- if (!shown.length) return;-- const cur = box.dataset.tab;-- if (closable && !cur) { focusStop(); return; } // closed stays closed until the reader opens a section-- if (!has(cur)) pick(closable ? '' : shown[0].dataset.tab);-- else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);-- };-- list.addEventListener('click', (e) => {-- const t = e.target.closest('[role="tab"]');-- if (!t) return;-- // A closable block: pressing the open tab closes it again.-- pick(closable && box.dataset.tab === t.dataset.tab ? '' : t.dataset.tab, { remember: true });-- });-- // Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).-- list.addEventListener('keydown', (e) => {-- const shown = tabs.filter((t) => !t.hidden);-- const i = shown.indexOf(document.activeElement);-- if (i < 0) return;-- const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: shown.length - 1 }[e.key];-- if (to == null) return;-- e.preventDefault();-- const t = shown[(to + shown.length) % shown.length];-- pick(t.dataset.tab, { remember: true, focus: true });-- t.scrollIntoView({ block: 'nearest', inline: 'nearest' });-- });-- const watch = new MutationObserver(sync);-- for (const id of Object.values(shownIds)) {-- const el = document.getElementById(id);-- if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });++ if (!added) return;++ for (const g of root.querySelectorAll('.ovnet-group')) {++ const n = g.querySelectorAll('.ovnet-site').length;++ const badge = g.querySelector('.ovnet-n'); if (badge) badge.textContent = String(n);++ const jump = [...root.querySelectorAll('.ovnet-jump a')].find((x) => x.getAttribute('href') === `#${g.id}`); const jb = jump && jump.querySelector('b'); if (jb) jb.textContent = String(n);}-- sync();--}----/** New here?: the tour, the About banner and the network hub share one place on a phone. */--const HOME_INTRO_KEY = 'ov_home_intro';--const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };--function initHomeIntro() {-- // On a phone the intro starts closed: three tabs at the foot of the page, each opening its section.-- initHomeTabs('home-intro', _HOME_INTRO_SHOWN, HOME_INTRO_KEY, (key) => {-- // The tour's code loads when its mount nears the viewport; opening its tab is that moment.-- if (key === 'tour') _lazyHomeTour(true);-- // The About banner is behind a tab on a phone, so opening that tab is the reader seeing it.-- if (key === 'about') _introSeenNow('home-cta-banner');-- }, { closable: window.matchMedia('(max-width: 640px)').matches });++ const total = root.querySelectorAll('.ovnet-site').length;++ const groups = root.querySelector('.ovnet-groups'); if (groups) groups.dataset.sites = String(total);++ const count = root.querySelector('.ovnet-count'); if (count) { count.dataset.count = String(total); count.textContent = String(total); }++ if (window.OpenVibeIcons) { try { window.OpenVibeIcons.mount(root); } catch { /* */ } }++}++function initHomeNetwork() {++ const root = document.getElementById('home-network');++ if (!root || root._ovnet) return;++ root._ovnet = true;++ _ovnetSignedIn(root);++ window.addEventListener('openvibe-auth-changed', () => _ovnetSignedIn(root));++ // Glide to a group; the address keeps the group, so the link can be shared.++ root.addEventListener('click', (e) => {++ const jump = e.target.closest('.ovnet-jump a[href^="#"]');++ if (jump) {++ const to = document.getElementById(jump.getAttribute('href').slice(1));++ if (to) { e.preventDefault(); to.scrollIntoView({ behavior: 'smooth', block: 'start' }); try { history.replaceState(history.state, '', jump.getAttribute('href')); } catch { /* */ } }++ return;++ }++ if (e.target.closest('[data-ovnet-switcher]')) {++ const mark = document.getElementById('openvibe-switch-btn');++ if (mark) { window.scrollTo({ top: 0, behavior: 'smooth' }); mark.click(); }++ }++ });++ // The first time it nears the screen: tiles rise in, the count counts up, the ring icons load.++ const wake = () => {++ if (root._ovnetAwake) return;++ root._ovnetAwake = true;++ root.classList.add('is-in');++ _ovnetCount(root);++ _ovnetIcons();++ };++ if ('IntersectionObserver' in window && !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) {++ root.classList.add('is-armed');++ const io = new IntersectionObserver((entries) => {++ if (entries.some((en) => en.isIntersecting)) { io.disconnect(); requestAnimationFrame(wake); }++ }, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });++ io.observe(root);++ // Never left invisible: if the observer has not fired in a few seconds of the section being on screen, show it.++ setTimeout(() => { try { if (!root._ovnetAwake && root.getBoundingClientRect().top < window.innerHeight) wake(); } catch { wake(); } }, 4000);++ } else wake();++ // Sites that opened since the page was built.++ try {++ const F = window.OpenVibeFrame;++ if (F && typeof F.get === 'function') {++ const now = F.get((d) => _ovnetAddMissing(root, d));++ if (now) _ovnetAddMissing(root, now);++ }++ } catch { /* the built list stands */ }}async function loadHome() {-- initHomeIntro(); // the tour, the About banner and the network hub share one place on a phone++ initHomeNetwork(); // the OpenVibe network: every site, alive (no tabs: everything shows)demoteHomeIntroSections();void loadHomeChangelog();_initHomeAbout();nnew file mode 100644@@@ -0,0 +1,136 @@++#!/usr/bin/env node++'use strict';++/**++ * The home page's "OpenVibe network" section (public/fragments/home.html, between <!--ov:network--> and++ * <!--/ov:network-->), rendered from the network's own list of sites: OpenVibe.Network's frame++ * (https://openvibe.network/api/frame), the same list the OV mark's site switcher shows. The owner, 2026-11-07: "this++ * area on live is so outdated and lame. Remake this area entirely considering what all the network offers … improve++ * the SEO". The old section was fourteen hand-written cards; this one is every open site, by what people come to do.++ *++ * The HTML is written into the fragment so crawlers and the first paint get every site's name, address and line as++ * real links (no script needed). In the browser, app-home.js initHomeNetwork() reads the same frame again and adds a++ * site that opened since this was run, so the page is never behind for long; run this again to bake it in.++ *++ * node scripts/build-network-section.js fetch the frame and rewrite the section++ * node scripts/build-network-section.js frame.json from a saved frame (tests, offline)++ *++ * Only https links on OpenVibe's own hosts are written, and the names the owner keeps private never appear.++ */++const fs = require('fs');++const path = require('path');++++const FRAGMENT = path.join(__dirname, '..', 'public', 'fragments', 'home.html');++const OPEN = '<!--ov:network-->';++const CLOSE = '<!--/ov:network-->';++const FRAME_URL = 'https://openvibe.network/api/frame?host=openvibe.live';++++// Hosts a network site may live on, and the ones that are never named in public.++const OURS = /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/i;++const NEVER = /(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i;++++// What each group looks like: an accent, a Font Awesome icon and one plain line.++const GROUPS = {++ streams: { accent: '#ef4444', icon: 'fa-tower-broadcast', line: 'Go live, watch, share pictures and video' },++ games: { accent: '#a855f7', icon: 'fa-gamepad', line: 'Play in the browser, laugh, explore' },++ answers: { accent: '#f59e0b', icon: 'fa-newspaper', line: 'News, reports, reviews and answers' },++ make: { accent: '#3b82f6', icon: 'fa-wand-magic-sparkles', line: 'Tools, sites, bots, designs and code' },++ shop: { accent: '#22c55e', icon: 'fa-basket-shopping', line: 'Deals, jobs, food, homes and trades' },++ people: { accent: '#ec4899', icon: 'fa-user-group', line: 'Your people, your account, your stuff' },++ build: { accent: '#14b8a6', icon: 'fa-code', line: 'APIs and services to build on' },++};++const FALLBACK_GROUP = { accent: '#38bdf8', icon: 'fa-circle-nodes', line: '' };++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const idOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);++++/** The frame's sites that may be shown: { id, name, url, host, icon, tagline, group }. */++function sitesOf(frame) {++ const out = [];++ const seen = new Set();++ for (const s of (frame && Array.isArray(frame.nav) ? frame.nav : [])) {++ if (!s || typeof s.url !== 'string' || typeof s.name !== 'string') continue;++ let u; try { u = new URL(s.url); } catch { continue; }++ if (u.protocol !== 'https:' || u.username || u.password) continue;++ const host = u.hostname.toLowerCase();++ if (!OURS.test(host) || NEVER.test(host)) continue;++ const id = idOf(s.id) || idOf(host);++ if (!id || seen.has(id)) continue;++ seen.add(id);++ out.push({ id, name: s.name.slice(0, 40), url: u.href, host, icon: idOf(s.icon) || id, tagline: String(s.tagline || '').slice(0, 90), group: idOf(s.group) });++ }++ return out;++}++++/** The frame's groups in order, each with its sites; a site in no known group goes to "More". */++function groupsOf(frame, sites) {++ const named = (frame && Array.isArray(frame.groups) ? frame.groups : []).filter((g) => g && g.id && g.name).map((g) => ({ id: idOf(g.id), name: String(g.name).slice(0, 40) }));++ const known = new Set(named.map((g) => g.id));++ const groups = named.map((g) => ({ ...g, sites: sites.filter((s) => s.group === g.id) })).filter((g) => g.sites.length);++ const rest = sites.filter((s) => !known.has(s.group));++ if (rest.length) groups.push({ id: 'more', name: 'More', sites: rest });++ return groups;++}++++function siteHtml(s, i) {++ return `<li><a class="ovnet-site" href="${esc(s.url)}" data-site="${esc(s.id)}" style="--i:${i}">`++ + `<span class="ov-icon" data-icon="${esc(s.icon)}" data-size="40" aria-hidden="true"></span>`++ + `<span class="ovnet-txt"><b>${esc(s.name)}</b><small>${esc(s.host)}</small>${s.tagline ? `<span>${esc(s.tagline)}</span>` : ''}</span></a></li>`;++}++++/** The section's inside: the head, the jump links, every group and the foot. */++function render(frame) {++ const sites = sitesOf(frame);++ const groups = groupsOf(frame, sites);++ const look = (g) => GROUPS[g.id] || FALLBACK_GROUP;++ const jump = groups.map((g) => `<a href="#ovnet-g-${esc(g.id)}" style="--g:${look(g).accent}"><i class="fa-solid ${look(g).icon}" aria-hidden="true"></i>${esc(g.name)}<b>${g.sites.length}</b></a>`).join('');++ let n = 0;++ const blocks = groups.map((g) => {++ const L = look(g);++ const items = g.sites.map((s) => siteHtml(s, n++)).join('');++ return `<div class="ovnet-group" id="ovnet-g-${esc(g.id)}" role="group" aria-labelledby="ovnet-gh-${esc(g.id)}" data-group="${esc(g.id)}" style="--g:${L.accent}">`++ + `<h3 id="ovnet-gh-${esc(g.id)}"><i class="fa-solid ${L.icon}" aria-hidden="true"></i><span>${esc(g.name)}</span><b class="ovnet-n">${g.sites.length}</b></h3>`++ + (L.line ? `<p class="ovnet-gline">${esc(L.line)}</p>` : '')++ + `<ul class="ovnet-sites">${items}</ul></div>`;++ }).join('');++ return `++ <div class="ovnet-sky" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>++ <header class="ovnet-head">++ <p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>++ <h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="${sites.length}">${sites.length}</span> sites.</h2>++ <p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>++ <nav class="ovnet-jump" aria-label="Kinds of OpenVibe sites">${jump}</nav>++ </header>++ <div class="ovnet-groups" data-sites="${sites.length}">${blocks}</div>++ <footer class="ovnet-foot">++ <a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>++ <button type="button" class="ovnet-switch" data-ovnet-switcher><i class="fa-solid fa-grip" aria-hidden="true"></i> Jump between sites any time from the <b>OV</b> mark</button>++ </footer>++ `;++}++++async function frameFrom(arg) {++ if (arg) return JSON.parse(fs.readFileSync(arg, 'utf8'));++ const r = await fetch(FRAME_URL, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) });++ if (!r.ok) throw new Error(`the frame answered ${r.status}`);++ return await r.json();++}++++/** The fragment with its network section replaced. */++function rewrite(html, inner) {++ const a = html.indexOf(OPEN), b = html.indexOf(CLOSE);++ if (a < 0 || b < a) throw new Error(`${path.basename(FRAGMENT)} has no ${OPEN} … ${CLOSE} block`);++ return html.slice(0, a + OPEN.length) + inner + html.slice(b);++}++++if (require.main === module) {++ (async () => {++ const frame = await frameFrom(process.argv[2]);++ const sites = sitesOf(frame);++ if (sites.length < 10) throw new Error(`only ${sites.length} sites in the frame: not rewriting the home page with that`);++ const html = rewrite(fs.readFileSync(FRAGMENT, 'utf8'), render(frame));++ fs.writeFileSync(FRAGMENT, html);++ console.log(`network section: ${sites.length} sites in ${groupsOf(frame, sites).length} groups`);++ })().catch((e) => { console.error(e.message); process.exit(1); });++}++++module.exports = { sitesOf, groupsOf, render, rewrite, OPEN, CLOSE, GROUPS };@@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),// "css" the render-blocking stylesheets.const BUDGETS = {-- htmlRawKB: 129.5, // 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before++ htmlRawKB: 137.7, // 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1305.6, // 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)-- jsBrotliKB: 305.5, // 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ jsRawKB: 1307.1, // 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsBrotliKB: 305.7, // 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};@@@ -61,7 +61,7 @@ check('nothing was removed', () => {'home-about-body']) {at(`id="${id}"`);}-- assert.ok(home.includes('class="network-hub"'));++ assert.ok(home.includes('<div class="ovnet" id="home-network"'));});console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');@@@ -1,8 +1,8 @@/**-- * Home tabs (2026-10-30, the owner's short-pages rule: a phone page is one or two screens).++ * Home tabs: there are none any more (2026-11-07, the owner's rule: never hide content behind tabs).*-- * Only the explainer at the foot is tabbed (New here?: the restream tour, the About banner, the network hub). The-- * content blocks are never tabbed again (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage…++ * The explainer at the foot (New here?: the restream tour and the About banner) shows both, one after the other, and++ * the OpenVibe network is its own section with every site. The content blocks are never tabbed again (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage…* don't reduce size by removing features"): Around OpenVibe (the digest, the star spotlight, the pulse) shows every* section on every screen, and test/home-catchup.test.js holds the rails. Wider screens never see the tabs. The* block is real here, not a string: the test opens the rendered home page in a DOM and drives it.@@@ -88,11 +88,10 @@ function openHome({ views = 0 } = {}) {console.log('Home tabs');--const INTRO = ['tour', 'about', 'network'];const aroundStart = home.indexOf('<div class="home-around" id="home-around"');const aroundEnd = home.indexOf('</div><!-- /home-around -->');const around = home.slice(aroundStart, aroundEnd);--const introStart = home.indexOf('<div class="home-tabbox home-intro" id="home-intro"');++const introStart = home.indexOf('<div class="home-intro" id="home-intro"');const introEnd = home.indexOf('</div><!-- /home-intro -->');const intro = home.slice(introStart, introEnd);@@@ -106,101 +105,87 @@ check('Around OpenVibe shows its three sections, in their order, with the egg whassert.ok(!/\[data-panel="(lately|star|happening)"\]/.test(css), 'no CSS hides one of them');});--check('one tab per section, named for screen readers, pointing at its section', () => {-- assert.ok(intro.includes('<div class="home-tabs" role="tablist" aria-label="New here?">'), 'the New here? tab list');-- for (const [key, block, box] of [['tour', intro, 'home-tour-mount'], ['about', intro, 'home-cta-banner'], ['network', intro, 'home-network-hub']]) {-- const tab = (new RegExp(`<button type="button" role="tab" id="[a-z]+-tab-${key}" data-tab="${key}"[^>]*>`).exec(block) || [])[0];-- assert.ok(tab, `the ${key} tab`);-- const controls = (/aria-controls="([^"]+)"/.exec(tab) || [])[1];-- assert.strictEqual(controls, box, `${key} controls its section`);-- assert.ok(block.includes(`id="${controls}"`), `${controls} is inside the block`);-- }--});----check('How it works is the default tab of New here?', () => {-- assert.match(intro, /id="hi-tab-tour" data-tab="tour" aria-selected="true"/);-- assert.match(intro, /<div class="home-tabbox home-intro" id="home-intro" data-tab="tour">/);--});----check('the tabs and the one-section-at-a-time rule exist only under 760 px', () => {-- assert.match(css, /\.home-tabs \{ display: none; \}/, 'no tabs by default');-- const phone = css.slice(css.indexOf('.home-tabs { display: none; }'));-- const block = phone.slice(phone.indexOf('@media (max-width: 759px) {'));-- for (const key of INTRO) assert.ok(block.includes(`.home-tabbox:not([data-tab="${key}"]) [data-panel="${key}"]`), `${key} hides when another tab is open`);-- assert.match(block, /min-height: 44px/, 'a tab is a 44 px target');-- assert.match(js, /const _narrow = typeof window !== 'undefined' && window\.matchMedia\('\(max-width: 759px\)'\)/, 'the same breakpoint as the rails\' phone sizing');++check('New here? has no tab row: the tour and the About banner both show, and no CSS hides one', () => {++ assert.ok(introStart > 0 && introEnd > introStart, 'the block opens and closes on the home page');++ assert.ok(!home.includes('role="tablist"') && !home.includes('role="tab"'), 'no tab row anywhere on the home');++ assert.ok(!/data-panel=/.test(home), 'no panels');++ assert.ok(!/home-tabbox|\[data-panel|\.home-tabs/.test(css), 'no tab CSS left');++ assert.ok(intro.includes('id="home-tour-mount"') && intro.includes('id="home-cta-banner"'), 'both introductions are in the block');++ assert.ok(!/function initHomeTabs|function initHomeIntro/.test(js), 'the tab controller is gone');});--check('the network hub\'s heading goes while its tab names it', () => {-- assert.ok(css.includes('[data-panel="network"] .network-hub-header h3'), 'the network heading goes');--});----check('a tab shows once its section does: the ids the script watches are the sections\' own', () => {-- const introIds = /const _HOME_INTRO_SHOWN = \{([^}]+)\}/.exec(js);-- assert.ok(introIds, 'the map of watched sections');-- assert.ok(!/initHomeAround|initHomeCatchup|_HOME_AROUND_SHOWN|_HOME_CATCHUP_SHOWN/.test(js), 'the content blocks have no tabs to set up');-- for (const id of introIds[1].match(/'([a-z-]+)'/g).map((s) => s.slice(1, -1))) assert.ok(intro.includes(`id="${id}"`), `${id} is in the block`);++const net = home.slice(home.indexOf('<div class="ovnet" id="home-network"'), home.indexOf('<!--/ov:network-->'));++check('the OpenVibe network is its own section: every site a real link, by group, for crawlers too', () => {++ assert.ok(home.indexOf('id="home-network"') > introEnd, 'after New here?, outside it');++ assert.match(net, /<h2 id="ovnet-title">One account\. <span class="ovnet-count" data-count="(\d+)">\1<\/span> sites\.<\/h2>/, 'the headline counts the sites in the page');++ const sites = [...net.matchAll(/<a class="ovnet-site" href="([^"]+)" data-site="([a-z0-9-]+)"/g)];++ assert.ok(sites.length >= 40, `every site of the network (${sites.length})`);++ for (const [, href] of sites) {++ const u = new URL(href);++ assert.strictEqual(u.protocol, 'https:', href);++ assert.ok(/(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(u.hostname), `an OpenVibe address: ${href}`);++ assert.ok(!/(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i.test(u.hostname), `never a private name: ${href}`);++ }++ const groups = [...net.matchAll(/<div class="ovnet-group" id="ovnet-g-([a-z0-9-]+)" role="group" aria-labelledby="ovnet-gh-\1"/g)].map((m) => m[1]);++ assert.ok(groups.length >= 5, 'grouped by what people come to do');++ for (const g of groups) assert.ok(net.includes(`<a href="#ovnet-g-${g}"`), `a jump link to ${g}`);++ assert.ok(/<span class="ov-icon" data-icon="[a-z0-9-]+" data-size="40" aria-hidden="true"><\/span>/.test(net), 'ring icons are drawn later, not inlined');++ assert.ok(net.includes('data-ovnet-switcher') && net.includes('data-ovnet-signin'), 'the switcher button and the sign-up');});--check('the demotion moves the whole block, not the sections one by one', () => {-- const demote = /function demoteHomeIntroSections\(\) \{([\s\S]*?)\n\}/.exec(js);-- assert.ok(demote, 'the function');-- assert.match(demote[1], /getElementById\('home-intro'\)/, 'it moves the block');-- assert.match(demote[1], /last\.appendChild\(block\)/, 'it appends the block');-- assert.ok(!/"home-tour-mount", "home-cta-banner"/.test(js), 'the two sections are no longer moved on their own');++check('the network section is built by its script, between its markers, and nothing in it is a tab', () => {++ const frag = fs.readFileSync(path.join(__dirname, '../public/fragments/home.html'), 'utf8');++ assert.strictEqual(frag.split('<!--ov:network-->').length, 2, 'one start marker');++ assert.strictEqual(frag.split('<!--/ov:network-->').length, 2, 'one end marker');++ const build = require('../scripts/build-network-section');++ const out = build.render({ groups: [{ id: 'streams', name: 'Streams & places' }], nav: [++ { id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live', tagline: 'Go live <b>now</b>', group: 'streams' },++ { id: 'x', name: 'Bad', url: 'https://openvibe.fund/', group: 'streams' },++ { id: 'y', name: 'Plain', url: 'http://openvibe.food/', group: 'streams' },++ { id: 'z', name: 'Elsewhere', url: 'https://example.com/', group: 'streams' },++ { id: 'w', name: 'Wanderer', url: 'https://openvibe.wiki/', group: 'nowhere' },++ ] });++ assert.ok(out.includes('href="https://openvibe.live/"') && out.includes('Go live <b>now</b>'), 'escaped');++ assert.ok(!/openvibe\.fund|http:\/\/openvibe\.food|example\.com/.test(out), 'only https OpenVibe addresses, never a private name');++ assert.ok(out.includes('id="ovnet-g-more"') && out.includes('openvibe.wiki'), 'a site in no known group goes to More');++ assert.ok(!/role="tab/.test(out));});--check('the tour loads when its tab opens, not only when the mount nears the viewport', () => {-- assert.match(js, /if \(key === 'tour'\) _lazyHomeTour\(true\);/, 'opening the tab loads the tour');-- assert.match(js, /function _lazyHomeTour\(force\)/, 'the loader can be forced');++check('the tour still loads when its mount nears the viewport', () => {++ assert.match(js, /function _lazyHomeTour\(force\)/, 'the loader');});(async () => {-- await check('a tab appears once its section has something, and a block with nothing drops its tab row', async () => {-- const p = openHome();-- p.run('initHomeIntro();');-- await tick();-- for (const id of ['home-tour-mount', 'home-cta-banner', 'home-network-hub']) hide(p.el(id));-- await tick();-- assert.strictEqual(p.el('home-intro').querySelector('[role="tablist"]').hidden, true, 'nothing to show, so no tab row');-- show(p.el('home-cta-banner'));-- await tick();-- assert.strictEqual(p.el('hi-tab-about').hidden, false, 'the banner arrived, so its tab is there');-- assert.strictEqual(p.el('hi-tab-network').hidden, true, 'the hub has nothing, so its tab is hidden');-- });---- await check('the tour loads when its tab opens', async () => {++ await check('initHomeNetwork adds a site that opened since the page was built, and only an OpenVibe one', async () => {const p = openHome();-- p.run('initHomeIntro();');-- await tick();-- assert.deepStrictEqual(p.loaded, [], 'nothing loads on its own in a page that never scrolls');-- p.click('hi-tab-tour');++ const before = p.window.document.querySelectorAll('.ovnet-site').length;++ p.window.OpenVibeFrame = { get: () => ({ groups: [], nav: [++ { id: 'brandnew', name: 'Brand New', url: 'https://openvibe.brandnew/', icon: 'ov', tagline: 'Just opened', group: 'games' },++ { id: 'evil', name: 'Evil', url: 'https://evil.example/', group: 'games' },++ { id: 'secret', name: 'Secret', url: 'https://openvibe.recipes/', group: 'games' },++ { id: 'live', name: 'Live', url: 'https://openvibe.live/', group: 'streams' },++ ] }) };++ p.run('initHomeNetwork();');await tick();-- assert.deepStrictEqual(p.loaded, ['homeTour'], 'opening the tab loads the tour');++ const sites = [...p.window.document.querySelectorAll('.ovnet-site')];++ assert.strictEqual(sites.length, before + 1, 'one new site, the rest refused or already there');++ const added = sites.find((a) => a.dataset.site === 'brandnew');++ assert.ok(added && added.closest('.ovnet-group').dataset.group === 'games', 'in its group');++ assert.strictEqual(added.querySelector('b').textContent, 'Brand New');++ assert.strictEqual(p.window.document.querySelector('.ovnet-count').textContent, String(before + 1), 'the count follows');});-- await check('on a phone the intro starts closed; a tap opens a section, a second tap closes it, and that is kept', async () => {++ await check('the sign-up is only for a reader who is not signed in', async () => {const p = openHome();-- p.run('initHomeIntro();');++ p.window.currentUser = { id: 1, username: 'a' };++ p.run('initHomeNetwork();');await tick();-- const block = p.el('home-intro');-- assert.strictEqual(block.dataset.tab, '', 'nothing open at first: three tabs at the foot of the page');-- assert.ok(['tour', 'about', 'network'].every((k) => p.el(`hi-tab-${k}`).getAttribute('aria-selected') === 'false'), 'no tab says it is open');-- const first = ['tour', 'about', 'network'].map((k) => p.el(`hi-tab-${k}`)).find((t) => !t.hidden);-- assert.ok(first && first.getAttribute('tabindex') === '0', 'the first tab that shows still takes the focus stop');-- p.click('hi-tab-about');-- await tick();-- assert.strictEqual(block.dataset.tab, 'about', 'a tap opens its section');-- p.click('hi-tab-about');-- await tick();-- assert.strictEqual(block.dataset.tab, '', 'the same tab again closes it');-- assert.strictEqual(p.store.get('ov_home_intro'), '', 'closed is kept for the next visit');-- assert.match(css, /\.home-tabbox:not\(\[data-tab="tour"\]\) \[data-panel="tour"\]/, 'with nothing open, the CSS hides every section');++ assert.strictEqual(p.window.document.querySelector('[data-ovnet-signin]').hidden, true);});await check('a first visit leaves the block where it is', async () => {const p = openHome();-- p.run('initHomeIntro(); demoteHomeIntroSections();');++ p.run('initHomeNetwork(); demoteHomeIntroSections();');await tick();const block = p.el('home-intro');assert.strictEqual(block.dataset.ovDemoted, undefined, 'it was not demoted');@@@ -211,7 +196,7 @@ check('the tour loads when its tab opens, not only when the mount nears the viewawait check('after enough visits the whole block moves to the end of the page, and still works there', async () => {const p = openHome({ views: 18 });-- p.run('initHomeIntro(); demoteHomeIntroSections();');++ p.run('initHomeNetwork(); demoteHomeIntroSections();');await tick();const block = p.el('home-intro');assert.strictEqual(block.dataset.ovDemoted, '1', 'it was demoted');@@@ -219,17 +204,10 @@ check('the tour loads when its tab opens, not only when the mount nears the viewconst container = p.el('home-changelog-wrapper').parentElement;assert.strictEqual(block.parentElement, container, 'it moved into the changelog\'s container');assert.strictEqual(container.lastElementChild, block, 'and to the end of it');-- for (const id of ['home-tour-mount', 'home-cta-banner', 'home-network-hub']) {++ for (const id of ['home-tour-mount', 'home-cta-banner']) {assert.ok(block.contains(p.el(id)), `${id} travelled with it`);}-- // The tabs still work where it landed.-- p.click('hi-tab-network');-- await tick();-- assert.strictEqual(block.dataset.tab, 'network', 'the tab opened its section');-- assert.strictEqual(p.el('hi-tab-network').getAttribute('aria-selected'), 'true', 'and says so');-- p.click('hi-tab-tour');-- await tick();-- assert.deepStrictEqual(p.loaded, ['homeTour'], 'the tour loads from its new place too');++ assert.ok(!block.contains(p.el('home-network')), 'the network section stays where it is');});console.log(failures ? `\n${failures} failed` : '\nall passed');
@@ -68,6 +68,8 @@
@@ -68,6 +68,8 @@
".ch-offline-overlay-close",
".ch-offline-overlay-close",
".ch-offline-reopen",
".ch-offline-reopen",
".ch-offline-screen",
".ch-offline-screen",
+ ".ch-panel-bar > .ch-vods-filter-bar",
+ ".ch-panel-bar > .clips-taken-filters",
".ch-tab",
".ch-tab",
".ch-tab i",
".ch-tab i",
".ch-tab-badge",
".ch-tab-badge",
@@ -77,18 +79,18 @@
@@ -77,18 +79,18 @@
".ch-tab.active",
".ch-tab.active",
".ch-tab.active::after",
".ch-tab.active::after",
".ch-tabs",
".ch-tabs",
+ ".chat-ai-badge",
".chat-avatar",
".chat-avatar",
".chat-avatar-letter",
".chat-avatar-letter",
".chat-avatar-wrap",
".chat-avatar-wrap",
".chat-badge",
".chat-badge",
".chat-badge i",
".chat-badge i",
- ".chat-badge-admin",
- ".chat-badge-streamer",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::before",
".chat-badge[data-tip]::before",
".chat-crossfeed-badge",
".chat-crossfeed-badge",
".chat-crossfeed-global",
".chat-crossfeed-global",
".chat-crossfeed-stream",
".chat-crossfeed-stream",
+ ".chat-emote",
".chat-header",
".chat-header",
".chat-header-actions",
".chat-header-actions",
".chat-input-area",
".chat-input-area",
@@ -161,8 +163,11 @@
@@ -161,8 +163,11 @@
".fcw-unread-badge",
".fcw-unread-badge",
".floating-chat-fab",
".floating-chat-fab",
".floating-chat-widget",
".floating-chat-widget",
+ ".form-group",
".form-input",
".form-input",
".form-input-sm",
".form-input-sm",
+ ".form-row",
+ ".form-row .form-group",
".gai-badge",
".gai-badge",
".gai-strip-text",
".gai-strip-text",
".global-chat-header",
".global-chat-header",
@@ -261,6 +266,8 @@
@@ -261,6 +266,8 @@
".mobile-chat-badge",
".mobile-chat-badge",
".mobile-chat-close",
".mobile-chat-close",
".mobile-chat-toggle",
".mobile-chat-toggle",
+ ".modal-body",
+ ".modal-body .form-group",
".modal-overlay",
".modal-overlay",
".moment-card",
".moment-card",
".moment-card img",
".moment-card img",
@@ -320,7 +327,10 @@
@@ -320,7 +327,10 @@
".ov-v",
".ov-v",
".page",
".page",
".page.active",
".page.active",
+ ".paste-modal",
+ ".paste-modal-body",
".paste-modal-overlay",
".paste-modal-overlay",
+ ".paste-modal-upload",
".pastes-grid",
".pastes-grid",
".pastes-pagination",
".pastes-pagination",
".pc-tools-row > button",
".pc-tools-row > button",
@@ -336,6 +346,7 @@
@@ -336,6 +346,7 @@
".rewards-panel",
".rewards-panel",
".rs-lite .ov-orbit",
".rs-lite .ov-orbit",
".rs-lite .ov-spark",
".rs-lite .ov-spark",
+ ".screenshot-details-grid",
".section-header",
".section-header",
".section-header .badge",
".section-header .badge",
".section-header .ovd",
".section-header .ovd",
@@ -68,80 +68,7 @@ body.egg-shake { animation: eggShake 0.9s var(--ov-ease-out, cubic-bezier(.36,.0
@@ -68,80 +68,7 @@ body.egg-shake { animation: eggShake 0.9s var(--ov-ease-out, cubic-bezier(.36,.0
body.egg-rainbow { animation: eggRainbow 2.6s linear; }
body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@media (prefers-reduced-motion: reduce) { .egg-confetti, body.egg-shake, body.egg-rainbow { animation: none !important; } .egg-confetti { display: none; } }
@media (prefers-reduced-motion: reduce) { .egg-confetti, body.egg-shake, body.egg-rainbow { animation: none !important; } .egg-confetti { display: none; } }
-.network-hub {
- background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 50%, var(--bg-secondary) 100%);
- border-top: 1px solid var(--border);
- border-bottom: 1px solid var(--border);
- padding: 32px 24px;
- overflow: hidden;
-}
-.network-hub-header {
- display: flex; align-items: center; gap: 14px; justify-content: center;
- margin-bottom: 24px; text-align: center;
-}
-.network-hub-grid {
- display: grid;
- /* minmax(0, 1fr): a plain 1fr floors at the card's min-content, and a nowrap description made
- two columns wider than a 390px phone (the third card was cut off at the edge). */
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- max-width: 960px; margin: 0 auto;
-}
-.network-card {
- display: flex; align-items: center; gap: 12px;
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: 12px; padding: 14px 16px;
- text-decoration: none; color: var(--text-primary);
- transition: all var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease);
-}
-.network-card-featured {
- border-color: rgba(231,76,60,0.25);
- background: rgba(231,76,60,0.04);
-}
-.network-card-featured:hover { border-color: rgba(231,76,60,0.5); }
-.network-card-icon {
- width: 40px; height: 40px; border-radius: 10px;
- background: color-mix(in srgb, var(--card-accent, var(--accent)) 12%, transparent);
- display: flex; align-items: center; justify-content: center;
- font-size: 1.1rem; color: var(--card-accent, var(--accent));
- flex-shrink: 0;
-}
-.network-card-arrow {
- color: var(--text-secondary); font-size: 0.75rem;
- opacity: 0; transition: opacity var(--ov-dur-2, 0.2s), transform var(--ov-dur-2, 0.2s);
- flex-shrink: 0;
-}
-.network-card:hover .network-card-arrow { opacity: 1; transform: translateX(3px); }
-
-@media (max-width: 960px) {
- .network-hub-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
-}
-@media (max-width: 768px) {
- .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
- .network-hub { padding: 24px 16px; }
- .network-hub-header { flex-direction: column; gap: 8px; }
-}
-@media (max-width: 480px) {
- .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
- .network-hub { padding: 20px 12px; }
- .network-card { padding: 12px; gap: 10px; }
- .network-card-icon { width: 36px; height: 36px; font-size: 1rem; border-radius: 8px; }
- .network-card-arrow { display: none; }
-}
-@media (max-width: 360px) {
- .network-hub-grid { grid-template-columns: 1fr; gap: 8px; }
- .network-hub { padding: 16px 10px; }
-}
-/* Phones (2026-10-29): the fourteen services are two rows that scroll sideways, not a screen and a half of tiles;
- every one is still a tap away, and the footer lists them too. */
@media (max-width: 599px) {
@media (max-width: 599px) {
- .network-hub-grid {
- grid-template-columns: none; grid-template-rows: repeat(2, auto); grid-auto-flow: column; grid-auto-columns: 66%;
- overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 12px; scrollbar-width: none;
- margin: 0 -12px; padding: 0 12px 4px;
- }
- .network-hub-grid::-webkit-scrollbar { display: none; }
- .network-hub-grid > .network-card { scroll-snap-align: start; min-width: 0; }
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
/* The pulse (goals, the latest activity, the week's leaders) is one row that scrolls sideways, not five stacked cards. */
.pulse-grid { display: flex; flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; scrollbar-width: none; gap: 10px; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; }
.pulse-grid { display: flex; flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; scrollbar-width: none; gap: 10px; margin-left: -16px; margin-right: -16px; padding: 0 16px 4px; }
.pulse-grid::-webkit-scrollbar { display: none; }
.pulse-grid::-webkit-scrollbar { display: none; }
@@ -1193,15 +1120,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1193,15 +1120,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-changelog-toggle > i { transition: rotate var(--ov-dur-3, 0.3s) var(--ov-ease-out, cubic-bezier(.2, 1, .3, 1)); }
.home-changelog-toggle > i { transition: rotate var(--ov-dur-3, 0.3s) var(--ov-ease-out, cubic-bezier(.2, 1, .3, 1)); }
.home-changelog-toggle[aria-expanded="true"] > i { rotate: 180deg; }
.home-changelog-toggle[aria-expanded="true"] > i { rotate: 180deg; }
-/* Network hub two-up on a phone: the icon beside the text left ~90px, and "OpenVibe.Community"
- ran out of its card. Icon above text there; names may break after "OpenVibe." (<wbr>). */
-.network-card-info strong { overflow-wrap: break-word; }
-@media (min-width: 361px) and (max-width: 480px) {
- .network-card { flex-direction: column; align-items: flex-start; gap: 8px; }
- .network-card-info { width: 100%; }
-}
-/* The ≤480px rule hiding the arrow lost to Font Awesome's later .fa-solid display rule. */
-@media (max-width: 480px) { .network-card > .network-card-arrow { display: none; } }
/* Terms · Privacy · DMCA wrap as one group ("DMCA" was stranded alone at 360px). */
/* Terms · Privacy · DMCA wrap as one group ("DMCA" was stranded alone at 360px). */
.hero-legal { display: inline-flex; align-items: center; gap: inherit; white-space: nowrap; }
.hero-legal { display: inline-flex; align-items: center; gap: inherit; white-space: nowrap; }
@@ -1317,38 +1235,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1317,38 +1235,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
#page-home .hero-egg-reveal { min-height: 44px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; }
#page-home .hero-egg-reveal { min-height: 44px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; }
}
}
-/* New here? (2026-10-30, the short-pages rule): on a phone the explainer at the foot of the home is a row of tabs and
- one section at a time. The content blocks above it (Catch up, Around OpenVibe) are never tabbed: every section shows
- on every screen (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage"). The breakpoint is
- app-home.js's _narrow. */
-.home-tabs { display: none; }
@media (max-width: 759px) {
@media (max-width: 759px) {
- .home-tabs {
- display: flex; gap: 6px; margin: 30px -16px 6px; padding: 2px 16px 4px;
- overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; scroll-padding-inline: 16px;
- }
- .home-tabs::-webkit-scrollbar { display: none; }
- .home-tabs[hidden] { display: none !important; }
- .home-tabs [role="tab"] {
- --sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
- flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px;
- font: inherit; font-size: 0.86rem; font-weight: 750; white-space: nowrap; cursor: pointer;
- color: var(--text-secondary); background: transparent;
- border: 1px solid var(--border); border-radius: 999px;
- transition: color var(--ov-dur-2, 0.18s), background var(--ov-dur-2, 0.18s), border-color var(--ov-dur-2, 0.18s);
- }
- .home-tabs [role="tab"][hidden] { display: none; }
- .home-tabs [role="tab"] i { font-size: 0.78rem; color: var(--sc-on); }
- .home-tabs [role="tab"][aria-selected="true"] {
- color: color-mix(in srgb, var(--sc-on) 30%, var(--text-primary));
- background: color-mix(in srgb, var(--sc-on) 14%, transparent);
- border-color: color-mix(in srgb, var(--sc-on) 48%, transparent);
- }
- .home-tabs [role="tab"]:focus-visible { outline: 2px solid var(--sc-on); outline-offset: 2px; }
- .home-tabbox:not([data-tab="tour"]) [data-panel="tour"],
- .home-tabbox:not([data-tab="about"]) [data-panel="about"],
- .home-tabbox:not([data-tab="network"]) [data-panel="network"] { display: none !important; }
- .home-tabbox [data-panel="network"] .network-hub-header h3 { display: none; }
/* Catch up on a phone: each rail is one row of cards that scrolls sideways, so all five fit in a few screens
/* Catch up on a phone: each rail is one row of cards that scrolls sideways, so all five fit in a few screens
and nothing is behind a tab. Recent Pastes rides the same rail (2026-11-02): it was two cards a row with a
and nothing is behind a tab. Recent Pastes rides the same rail (2026-11-02): it was two cards a row with a
@@ -1386,7 +1273,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1386,7 +1273,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-rail-tail { display: none; }
.home-rail-tail { display: none; }
@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .home-rail-skel { animation: none; } }
@media (prefers-reduced-motion: reduce) { .home-rail-skel { animation: none; } }
-@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-tabs [role="tab"] { transition: none; } }
/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
featured block above already shows it), and when it was the only stream there the grid's header goes too; the
featured block above already shows it), and when it was the only stream there the grid's header goes too; the
@@ -1397,7 +1283,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1397,7 +1283,6 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }
#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }
.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }
.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }
}
}
- .home-tabbox[data-tab=""] .home-tabs { margin-bottom: 0; }
}
}
/* The Agents live row (Agent Streams S2): public AI agent streams, their own row after people's streams. */
/* The Agents live row (Agent Streams S2): public AI agent streams, their own row after people's streams. */
@@ -1411,3 +1296,165 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1411,3 +1296,165 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-agents .agent-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
.home-agents .agent-card-body { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px 12px; }
.home-agents .agent-card-title { margin: 0; font-size: 0.92rem; font-weight: 700; overflow-wrap: anywhere; }
.home-agents .agent-card-title { margin: 0; font-size: 0.92rem; font-weight: 700; overflow-wrap: anywhere; }
.home-agents .agent-card-meta { margin: 0; color: var(--text-secondary); font-size: 0.76rem; }
.home-agents .agent-card-meta { margin: 0; color: var(--text-secondary); font-size: 0.76rem; }
+
+/* ── The OpenVibe network (2026-11-07 redesign) ──────────────────────────────────────────────────────────────
+ Every open site of the network, by what people come to do (scripts/build-network-section.js writes the inside from
+ OpenVibe.Network's frame). A deep surface with a slow aurora and a field of twinkling points, the count of sites,
+ jump links to each group (links, not tabs: nothing is hidden), and one tile per site with its ring icon. Wide
+ screens show each group as a grid; a phone shows each as one row that scrolls sideways. Tiles rise in, in order,
+ the first time the section is seen (app-home.js initHomeNetwork adds .is-in). Motion stops for reduced motion. */
+.ovnet {
+ --ovnet-ink: var(--text-primary, #e6edf7);
+ --ovnet-soft: var(--text-secondary, #a8b3c4);
+ --ovnet-mute: var(--text-muted, #7d8aa0);
+ --ovnet-card: color-mix(in srgb, var(--bg-card, #111a2b) 88%, transparent);
+ --ovnet-line: color-mix(in srgb, var(--text-primary, #fff) 9%, transparent);
+ position: relative; isolation: isolate; overflow: hidden;
+ margin: 34px 0 8px; padding: 46px clamp(14px, 4vw, 40px) 34px;
+ border-radius: 24px; border: 1px solid var(--ovnet-line);
+ background:
+ radial-gradient(120% 80% at 10% 0%, color-mix(in srgb, #3b82f6 22%, transparent), transparent 60%),
+ radial-gradient(90% 70% at 95% 10%, color-mix(in srgb, #a855f7 20%, transparent), transparent 60%),
+ radial-gradient(80% 60% at 50% 110%, color-mix(in srgb, #14b8a6 14%, transparent), transparent 70%),
+ var(--bg-secondary, #0c1322);
+}
+.ovnet::before { /* the aurora: two soft bands drifting across the top */
+ content: ''; position: absolute; inset: -40% -20% auto; height: 120%; z-index: -1; pointer-events: none;
+ background: conic-gradient(from 200deg at 50% 50%, transparent 0 30%, color-mix(in srgb, #38bdf8 22%, transparent) 40%, transparent 52%, color-mix(in srgb, #f472b6 16%, transparent) 64%, transparent 76%);
+ filter: blur(48px); opacity: .7; animation: ovnetAurora 22s ease-in-out infinite alternate;
+}
+@keyframes ovnetAurora { from { transform: translate3d(-6%, 0, 0) rotate(0deg); } to { transform: translate3d(6%, 4%, 0) rotate(14deg); } }
+.ovnet-sky { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
+.ovnet-sky i {
+ position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; opacity: .0;
+ box-shadow: 0 0 8px 1px color-mix(in srgb, #93c5fd 70%, transparent); animation: ovnetTwinkle 5.5s ease-in-out infinite;
+}
+.ovnet-sky i:nth-child(1) { left: 6%; top: 14%; animation-delay: -.4s; }
+.ovnet-sky i:nth-child(2) { left: 18%; top: 34%; animation-delay: -1.8s; }
+.ovnet-sky i:nth-child(3) { left: 31%; top: 9%; animation-delay: -3.1s; }
+.ovnet-sky i:nth-child(4) { left: 44%; top: 26%; animation-delay: -.9s; }
+.ovnet-sky i:nth-child(5) { left: 57%; top: 6%; animation-delay: -2.6s; }
+.ovnet-sky i:nth-child(6) { left: 68%; top: 31%; animation-delay: -4.2s; }
+.ovnet-sky i:nth-child(7) { left: 79%; top: 12%; animation-delay: -1.2s; }
+.ovnet-sky i:nth-child(8) { left: 91%; top: 27%; animation-delay: -3.7s; }
+.ovnet-sky i:nth-child(9) { left: 12%; top: 58%; animation-delay: -2.2s; }
+.ovnet-sky i:nth-child(10) { left: 86%; top: 62%; animation-delay: -.2s; }
+.ovnet-sky i:nth-child(11) { left: 49%; top: 84%; animation-delay: -4.8s; }
+.ovnet-sky i:nth-child(12) { left: 72%; top: 88%; animation-delay: -1.5s; }
+@keyframes ovnetTwinkle { 0%, 100% { opacity: 0; transform: scale(.6); } 45% { opacity: .85; transform: scale(1); } 60% { opacity: .35; } }
+
+.ovnet-head { max-width: 760px; margin: 0 auto 26px; text-align: center; }
+.ovnet-eyebrow {
+ display: inline-flex; align-items: center; gap: 8px; margin: 0 0 14px; padding: 6px 12px; border-radius: 999px;
+ border: 1px solid var(--ovnet-line); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 55%, transparent);
+ color: var(--ovnet-soft); font-size: .72rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
+}
+.ovnet-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 color-mix(in srgb, #22c55e 60%, transparent); animation: ovnetPing 2.2s ease-out infinite; }
+@keyframes ovnetPing { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, #22c55e 55%, transparent); } 80%, 100% { box-shadow: 0 0 0 10px transparent; } }
+.ovnet-head h2 {
+ margin: 0; color: var(--ovnet-ink); font-size: clamp(1.7rem, 4.6vw, 2.9rem); line-height: 1.08; font-weight: 850;
+ letter-spacing: -.035em; text-wrap: balance;
+}
+.ovnet-count {
+ display: inline-block; min-width: 2ch; font-variant-numeric: tabular-nums;
+ background: linear-gradient(100deg, #38bdf8, #a78bfa 45%, #f472b6 90%); -webkit-background-clip: text; background-clip: text; color: transparent;
+}
+.ovnet-lede { max-width: 620px; margin: 14px auto 0; color: var(--ovnet-soft); font-size: clamp(.95rem, 2.2vw, 1.06rem); line-height: 1.6; text-wrap: pretty; }
+.ovnet-jump { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 22px; }
+.ovnet-jump a {
+ display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--g) 34%, transparent); background: color-mix(in srgb, var(--g) 10%, transparent);
+ color: var(--ovnet-ink); font-size: .8rem; font-weight: 650; text-decoration: none;
+ transition: background-color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s);
+}
+.ovnet-jump a i { color: var(--g); font-size: .78rem; }
+.ovnet-jump a b { padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, var(--g) 22%, transparent); font-size: .7rem; font-weight: 800; }
+.ovnet-jump a:hover { background: color-mix(in srgb, var(--g) 18%, transparent); border-color: color-mix(in srgb, var(--g) 60%, transparent); transform: translateY(-1px); }
+.ovnet-jump a:focus-visible, .ovnet-site:focus-visible, .ovnet-cta:focus-visible, .ovnet-switch:focus-visible { outline: 2px solid var(--g, var(--accent, #3b82f6)); outline-offset: 3px; }
+
+.ovnet-groups { display: grid; grid-template-columns: minmax(0, 1fr); gap: 30px; max-width: 1180px; margin: 0 auto; }
+.ovnet-group { min-width: 0; scroll-margin-top: 84px; }
+.ovnet-group h3 { display: flex; align-items: center; gap: 10px; margin: 0; color: var(--ovnet-ink); font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
+.ovnet-group h3 i {
+ display: grid; place-items: center; width: 30px; height: 30px; border-radius: 10px; font-size: .82rem;
+ color: var(--g); background: color-mix(in srgb, var(--g) 16%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--g) 30%, transparent);
+}
+.ovnet-group h3::after { content: ''; flex: 1; height: 1px; margin-left: 4px; background: linear-gradient(90deg, color-mix(in srgb, var(--g) 45%, transparent), transparent); }
+.ovnet-n { order: 3; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--g) 16%, transparent); color: color-mix(in srgb, var(--g) 68%, var(--ovnet-ink)); font-size: .72rem; font-weight: 800; }
+.ovnet-gline { margin: 6px 0 12px 40px; color: var(--ovnet-mute); font-size: .82rem; }
+.ovnet-sites { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 212px), 1fr)); gap: 10px; }
+.ovnet-site {
+ --lift: 0px;
+ position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px;
+ height: 100%; padding: 12px 14px; border-radius: 16px; text-decoration: none; color: var(--ovnet-ink);
+ background: var(--ovnet-card); border: 1px solid var(--ovnet-line);
+ box-shadow: 0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset;
+ transform: translateY(var(--lift));
+ transition: transform var(--ov-dur-2, .18s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .18s), box-shadow var(--ov-dur-2, .18s), background-color var(--ov-dur-2, .18s);
+}
+.ovnet-site::after { /* the group's glow, lit on hover */
+ content: ''; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none; opacity: 0;
+ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--g) 22%, transparent), transparent 55%);
+ transition: opacity var(--ov-dur-2, .18s);
+}
+@media (hover: hover) {
+ .ovnet-site:hover { --lift: -3px; border-color: color-mix(in srgb, var(--g) 55%, transparent); box-shadow: 0 12px 28px -14px color-mix(in srgb, var(--g) 70%, transparent); }
+ .ovnet-site:hover::after { opacity: 1; }
+}
+.ovnet-site .ov-icon { --ovi-accent: var(--g); display: grid; place-items: center; width: 40px; height: 40px; flex: none; }
+.ovnet-site .ov-icon:empty::before { /* before the ring icons draw: a ring of the group's colour */
+ content: ''; width: 34px; height: 34px; border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--g) 55%, transparent);
+ background: color-mix(in srgb, var(--g) 10%, transparent);
+}
+.ovnet-txt { display: grid; min-width: 0; gap: 1px; }
+.ovnet-txt b { font-size: .95rem; font-weight: 750; letter-spacing: -.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovnet-txt small { color: color-mix(in srgb, var(--g) 68%, var(--ovnet-ink)); font-size: .7rem; font-weight: 650; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .9; }
+.ovnet-txt span { color: var(--ovnet-soft); font-size: .8rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+
+/* The first time the section is seen, its tiles rise in, one after another. */
+.ovnet.is-armed .ovnet-site { opacity: 0; --lift: 14px; }
+.ovnet.is-in .ovnet-site { opacity: 1; --lift: 0px; transition-delay: calc(min(var(--i, 0), 40) * 22ms); transition-property: transform, opacity, border-color, box-shadow; transition-duration: .55s, .55s, .18s, .18s; }
+
+.ovnet-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; margin-top: 30px; }
+.ovnet-cta {
+ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 20px; border-radius: 999px;
+ background: linear-gradient(100deg, #3b82f6, #8b5cf6); color: #fff; font-weight: 750; text-decoration: none;
+ box-shadow: 0 10px 24px -12px #6366f1; transition: transform var(--ov-dur-2, .15s), box-shadow var(--ov-dur-2, .15s);
+}
+.ovnet-cta:hover { transform: translateY(-1px); box-shadow: 0 14px 28px -12px #6366f1; }
+.ovnet-switch {
+ display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 999px; cursor: pointer;
+ border: 1px solid var(--ovnet-line); background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 45%, transparent); color: var(--ovnet-soft); font: inherit; font-size: .85rem;
+}
+.ovnet-switch b { color: var(--ovnet-ink); }
+.ovnet-switch:hover { color: var(--ovnet-ink); border-color: color-mix(in srgb, var(--text-primary, #fff) 22%, transparent); }
+
+/* A phone: each group is one row of tiles that scrolls sideways (every site is still there and a swipe away). */
+@media (max-width: 640px) {
+ .ovnet { margin: 26px -4px 4px; padding: 34px 0 26px; border-radius: 20px; }
+ .ovnet-head { padding: 0 16px; margin-bottom: 20px; }
+ .ovnet-jump { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; scrollbar-width: none; margin: 18px -16px 0; padding: 0 16px 2px; scroll-padding-inline: 16px; }
+ .ovnet-jump::-webkit-scrollbar { display: none; }
+ .ovnet-jump a { flex: none; min-height: 44px; }
+ .ovnet-groups { gap: 24px; }
+ .ovnet-group h3, .ovnet-gline { padding: 0 16px; }
+ .ovnet-gline { margin-left: 40px; }
+ .ovnet-sites {
+ display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none;
+ padding: 2px 16px 6px; scroll-padding-inline: 16px; gap: 10px;
+ }
+ .ovnet-sites::-webkit-scrollbar { display: none; }
+ .ovnet-sites > li { flex: 0 0 min(78%, 280px); scroll-snap-align: start; }
+ .ovnet-site { min-height: 76px; }
+ .ovnet-foot { padding: 0 16px; }
+ .ovnet-cta, .ovnet-switch { width: 100%; justify-content: center; }
+}
+/* Light themes: the surface follows the page, the aurora softens. */
+@media (prefers-color-scheme: light) { :root:not([data-theme]) .ovnet::before { opacity: .35; } }
+[data-theme-mode="light"] .ovnet::before, html.light .ovnet::before { opacity: .35; }
+@media (prefers-reduced-motion: reduce) {
+ .ovnet::before, .ovnet-sky i, .ovnet-dot { animation: none; }
+ .ovnet-sky i { opacity: .45; }
+ .ovnet.is-armed .ovnet-site { opacity: 1; --lift: 0px; }
+ .ovnet-site, .ovnet-jump a, .ovnet-cta { transition: none; }
+}
@@ -152,14 +152,14 @@ input, select, textarea { font-family: inherit; }
@@ -152,14 +152,14 @@ input, select, textarea { font-family: inherit; }
html { -webkit-tap-highlight-color: transparent; }
html { -webkit-tap-highlight-color: transparent; }
:root { --press-scale: 0.97; --press-scale-card: 0.985; }
:root { --press-scale: 0.97; --press-scale-card: 0.985; }
:where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap):not(:disabled, [aria-disabled="true"]):active { scale: var(--press-scale); }
:where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap):not(:disabled, [aria-disabled="true"]):active { scale: var(--press-scale); }
-:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }
+:where(a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):active { scale: var(--press-scale-card); }
:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {
:where(a, button, summary, [role="button"], [tabindex]:not([tabindex="-1"]), input[type="checkbox"], input[type="radio"], input[type="range"]):focus-visible {
outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;
outline: 2px solid var(--accent-light, var(--accent)); outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
/* No movement: the press is a brief dim instead. */
/* No movement: the press is a brief dim instead. */
:root { --press-scale: 1; --press-scale-card: 1; }
:root { --press-scale: 1; --press-scale-card: 1; }
- :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.network-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }
+ :where(button, [role="button"], .btn, .ov-press, a.badge, .hb, .section-action, .digest-chip, .digest-recap, a.stream-card, a.moment-card, a.pulse-card, a.home-paste-card, a.streamer-group-stream, .ov-press-card):not(:disabled):active { opacity: 0.82; }
}
}
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
@@ -493,39 +493,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -493,39 +493,6 @@ body.ov-nav-overlay .navbar.ovnav-top {
@keyframes eggFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
@keyframes eggFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }
@keyframes eggShake { 10%,90%{transform:translate3d(-2px,0,0)} 20%,80%{transform:translate3d(4px,0,0)} 30%,50%,70%{transform:translate3d(-8px,0,0)} 40%,60%{transform:translate3d(8px,0,0)} }
@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }
@keyframes eggRainbow { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} }
-.network-hub-header > i {
- font-size: 1.6rem; line-height: 1; color: var(--accent); flex-shrink: 0;
- background: color-mix(in srgb, var(--accent) 10%, transparent); padding: 12px; border-radius: 12px;
-}
-.network-hub-header h3 {
- font-size: 1.15rem; font-weight: 800; color: var(--text-primary); margin: 0;
- letter-spacing: -0.3px;
-}
-.network-hub-header p {
- font-size: 0.82rem; color: var(--text-secondary); margin: 3px 0 0;
-}
-.network-card:hover {
- background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);
- border-color: var(--accent-dark);
- transform: translateY(-2px);
- box-shadow: 0 4px 20px rgba(0,0,0,0.3);
-}
-.network-card-info { flex: 1; min-width: 0; }
-.network-card-info strong {
- display: block; font-size: 0.85rem; font-weight: 700;
- color: var(--text-primary); margin-bottom: 2px;
-}
-.network-card-info span {
- display: block; font-size: 0.72rem; color: var(--text-secondary);
- line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-@media (max-width: 768px) {
- .network-card-info span { white-space: normal; }
-}
-@media (max-width: 480px) {
- .network-card-info strong { font-size: 0.8rem; }
- .network-card-info span { font-size: 0.68rem; }
-}
/* ── OpenVibeApp Popover ─────────────────────────────────────────── */
/* ── OpenVibeApp Popover ─────────────────────────────────────────── */
.openvibeapp-popover {
.openvibeapp-popover {
@@ -211,20 +211,15 @@
@@ -211,20 +211,15 @@
</div>
</div>
</div><!-- /home-catchup -->
</div><!-- /home-catchup -->
- <!-- New here? (2026-10-30, the short-pages rule): the tour, the About banner and the network hub are
- three introductions to the place. On a phone they share one place — a row of tabs and one of them at a
- time. Wider screens hide the tabs and show all three. demoteHomeIntroSections() moves this whole
- block to the end of the page once the reader has seen it often enough. -->
- <div class="home-tabbox home-intro" id="home-intro" data-tab="tour">
- <div class="home-tabs" role="tablist" aria-label="New here?">
- <button type="button" role="tab" id="hi-tab-tour" data-tab="tour" aria-selected="true" aria-controls="home-tour-mount" style="--sc:#22d3ee"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i><span>How it works</span></button>
- <button type="button" role="tab" id="hi-tab-about" data-tab="about" aria-selected="false" aria-controls="home-cta-banner" tabindex="-1" style="--sc:#f472b6"><i class="fa-solid fa-book-open" aria-hidden="true"></i><span>About</span></button>
- <button type="button" role="tab" id="hi-tab-network" data-tab="network" aria-selected="false" aria-controls="home-network-hub" tabindex="-1" style="--sc:#38bdf8"><i class="fa-solid fa-network-wired" aria-hidden="true"></i><span>The network</span></button>
- </div>
+ <!-- New here?: the restream tour and the About banner, one after the other on every screen (2026-11-07: the
+ tab row that showed one at a time on a phone is gone; the owner's rule is never to hide content behind
+ tabs). The network has its own section below. demoteHomeIntroSections() moves this block to the end of
+ the page once the reader has seen it often enough. -->
+ <div class="home-intro" id="home-intro">
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<!-- 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-panel="tour" data-fx-viewport></div>
+ <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-panel="about" 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>
<div class="cta-embers" aria-hidden="true">
<div class="cta-embers" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
@@ -372,140 +367,28 @@
@@ -372,140 +367,28 @@
</div><!-- /home-about-body -->
</div><!-- /home-about-body -->
</div>
</div>
</div>
</div>
- <!-- The network: the fourteen services one account reaches. -->
- <div class="network-hub" id="home-network-hub" data-panel="network">
- <div class="network-hub-header">
- <i class="fa-solid fa-network-wired"></i>
- <div>
- <h3>The OpenVibe Network</h3>
- <p>One account, 14 services — streaming, games, maps, food, image tools, audio tools, text generators, logo makers, PDF & document tools, network diagnostics, developer tools, video downloads, and more.</p>
- </div>
- </div>
- <div class="network-hub-grid">
- <a href="https://openvibe.live" class="network-card network-card-featured">
- <div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>
- <div class="network-card-info">
- <strong>OpenVibe.<wbr>Live</strong>
- <span>Live streaming, VODs, clips & chat</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>
- <div class="network-card-info">
- <strong>OpenVibe.<wbr>Tools</strong>
- <span>Every online tool in one index</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.community" class="network-card">
- <div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>
- <div class="network-card-info">
- <strong>OpenVibe.<wbr>Community</strong>
- <span>Pastes, posts and people</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.games" class="network-card">
- <div class="network-card-icon" style="--card-accent:#a855f7"><i class="fa-solid fa-hat-wizard"></i></div>
- <div class="network-card-info">
- <strong>Scraplandia</strong>
- <span>Browser RPG & community games</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.media" class="network-card">
- <div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>
- <div class="network-card-info">
- <strong>OpenVibe.<wbr>Media</strong>
- <span>VODs, clips and files</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://yt.openvibe.tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#ef4444"><i class="fa-solid fa-circle-play"></i></div>
- <div class="network-card-info">
- <strong>YouTube Downloader</strong>
- <span>Save video or audio</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/image-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#ec4899"><i class="fa-solid fa-image"></i></div>
- <div class="network-card-info">
- <strong>Image Tools</strong>
- <span>Convert, compress, resize, crop</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/audio-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#14b8a6"><i class="fa-solid fa-music"></i></div>
- <div class="network-card-info">
- <strong>Audio Tools</strong>
- <span>Convert, trim, pitch and clean up</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/pdf-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#f97316"><i class="fa-solid fa-file-pdf"></i></div>
- <div class="network-card-info">
- <strong>PDF & Documents</strong>
- <span>Merge, split, compress, protect</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/text-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#8b5cf6"><i class="fa-solid fa-font"></i></div>
- <div class="network-card-info">
- <strong>Text & Logo Tools</strong>
- <span>Fancy text, logos, counters</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/developer-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#06b6d4"><i class="fa-solid fa-code"></i></div>
- <div class="network-card-info">
- <strong>Developer Tools</strong>
- <span>JSON, regex, JWT, diff, hashes</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.tools/network-tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#10b981"><i class="fa-solid fa-network-wired"></i></div>
- <div class="network-card-info">
- <strong>Network Tools</strong>
- <span>DNS, WHOIS, SSL, ping, ports</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://maps.openvibe.tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#2ecc71"><i class="fa-solid fa-map-location-dot"></i></div>
- <div class="network-card-info">
- <strong>Survival Map</strong>
- <span>Camps, resources & shelters</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://food.openvibe.tools" class="network-card">
- <div class="network-card-icon" style="--card-accent:#eab308"><i class="fa-solid fa-utensils"></i></div>
- <div class="network-card-info">
- <strong>Budget Food Finder</strong>
- <span>Cheap meals and food banks</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- <a href="https://openvibe.network/my" class="network-card">
- <div class="network-card-icon" style="--card-accent:var(--accent, #3b82f6)"><i class="fa-solid fa-user-circle"></i></div>
- <div class="network-card-info">
- <strong>My Account</strong>
- <span>Profile, themes, history, linked services</span>
- </div>
- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
- </a>
- </div>
- </div>
</div><!-- /home-intro -->
</div><!-- /home-intro -->
+ <!-- The OpenVibe network (2026-11-07, the owner: "remake this area entirely considering what all the network offers
+ … animations/effects … improve the SEO"): every open site, by what people come to do, from OpenVibe.Network's
+ frame (the OV mark's site switcher list). The inside is written by scripts/build-network-section.js so crawlers
+ get every site as a real link; app-home.js initHomeNetwork() animates it, draws the ring icons and adds a site
+ that opened since. On a phone each group is a row that scrolls sideways; nothing is hidden behind a tab. -->
+ <div class="ovnet" id="home-network" role="region" aria-labelledby="ovnet-title"><!--ov:network-->
+ <div class="ovnet-sky" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
+ <header class="ovnet-head">
+ <p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>
+ <h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2>
+ <p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>
+ <nav class="ovnet-jump" aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>
+ </header>
+ <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites"><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
+ <footer class="ovnet-foot">
+ <a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
+ <button type="button" class="ovnet-switch" data-ovnet-switcher><i class="fa-solid fa-grip" aria-hidden="true"></i> Jump between sites any time from the <b>OV</b> mark</button>
+ </footer>
+ <!--/ov:network--></div>
+
</div>
</div>
<div class="container">
<div class="container">
@@ -1197,9 +1197,8 @@ const _introSeen = new Set();
@@ -1197,9 +1197,8 @@ const _introSeen = new Set();
/**
/**
* One sighting of the introductions: the reader has seen both of them during this visit.
* One sighting of the introductions: the reader has seen both of them during this visit.
*
*
- * The tour and the About banner are two panels of the same "New here?" block now, and on a phone only one of
- * them is on screen at a time — so the About banner is seen when its tab is opened (initHomeIntro), not only
- * when it scrolls past.
+ * The tour and the About banner are the two parts of the "New here?" block, one after the other on every screen
+ * (2026-11-07: no tab row); each is seen when it scrolls past.
*/
*/
function _introSeenNow(id) {
function _introSeenNow(id) {
if (_introCounted) return;
if (_introCounted) return;
@@ -1266,111 +1265,130 @@ function demoteHomeIntroSections() {
@@ -1266,111 +1265,130 @@ function demoteHomeIntroSections() {
try { last.appendChild(block); } catch { /* */ }
try { last.appendChild(block); } catch { /* */ }
}
}
-/**
- * Tabs for a block of home sections. Only the explainer at the foot (New here?) uses them: the content blocks (Catch
- * up, Around OpenVibe) show every section on every screen again (the owner, 2026-10-31: "now I can't see the videos
- * and clips on the homepage… don't reduce size by removing features"). On a phone the block shows one section at a
- * time behind a row of tabs; wider screens never see the tabs. The tabs and the CSS live in the home fragment and
- * features/home.css.
- *
- * initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable })
- * boxId the block's element id. It carries data-tab — the section that is open — and its tab row.
- * shownIds tab key → the id of the element that says the section has something. Each loader already hides
- * that element when its section is empty, so the tab goes with it.
- * storageKey where the reader's last tab is kept for their next visit.
- * onOpen called with the key of the section that just opened (the tour loads when its tab opens).
- * closable the block may have no section open: it starts closed (data-tab="") unless the reader left one open,
- * and pressing the open tab closes it again (2026-10-31: the intro at the foot of the home was 1.5
- * phone screens open by default, under everything a returning viewer came for).
- *
- * A tab shows once its section has something; arrow keys, Home and End move between the tabs that show; the kept
- * tab opens in the first few seconds only, so a section that arrives later never moves the page under the reader;
- * a section that empties out falls back to one that has something, and a block with nothing left in it drops its
- * tab row.
- */
-function initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable = false } = {}) {
- const box = document.getElementById(boxId);
- if (!box || box._ovTabs) return;
- const list = box.querySelector('[role="tablist"]');
- const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];
- if (!list || !tabs.length) return;
- box._ovTabs = true;
- const fallback = closable ? '' : (box.dataset.tab || tabs[0].dataset.tab);
- const has = (key) => { const el = shownIds[key] && document.getElementById(shownIds[key]); return !el || el.style.display !== 'none'; };
- let wanted = fallback;
- try { const kept = localStorage.getItem(storageKey); if (kept !== null && (kept || closable)) wanted = kept; } catch { /* */ }
- const pick = (key, { remember = false, focus = false } = {}) => {
- box.dataset.tab = key;
- for (const t of tabs) {
- const on = t.dataset.tab === key;
- t.setAttribute('aria-selected', on ? 'true' : 'false');
- if (on && focus) t.focus();
- }
- focusStop();
- if (remember) { wanted = key; try { localStorage.setItem(storageKey, key); } catch { /* */ } }
- if (key && onOpen) { try { onOpen(key); } catch { /* */ } }
+/* ── The OpenVibe network (2026-11-07 redesign) ──────────────────────────────────────────────────────────────
+ * The section's tiles are in the page already (scripts/build-network-section.js, from OpenVibe.Network's frame), so
+ * crawlers and a reader without scripts get every site. Here it comes alive:
+ * - the first time it nears the screen its tiles rise in, in order, and the count of sites counts up;
+ * - the ring icons are drawn by openvibe-shared's ov-icons (loaded then, not with the page);
+ * - the jump links glide to their group; the "OV mark" button opens the network's site switcher;
+ * - "Create your free account" is only for a reader who is not signed in;
+ * - the frame the navbar already holds is read again, and a site that opened since the page was built is added to
+ * its group (DOM nodes, https OpenVibe addresses only), so the section is never behind for long. */
+const _OVNET_HOST = /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/i;
+const _OVNET_NEVER = /(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i;
+function _ovnetSignedIn(root) {
+ const cta = root.querySelector('[data-ovnet-signin]');
+ if (cta) cta.hidden = !!window.currentUser;
+}
+function _ovnetCount(root) {
+ const el = root.querySelector('.ovnet-count');
+ const n = Number(root.querySelector('.ovnet-groups')?.dataset.sites || el?.dataset.count || 0);
+ if (!el || !n) return;
+ el.dataset.count = String(n);
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.textContent = String(n); return; }
+ const clock = window.performance || Date;
+ const t0 = clock.now(), dur = 900;
+ const step = (t) => {
+ const k = Math.min(1, (t - t0) / dur);
+ el.textContent = String(Math.round(n * (1 - Math.pow(1 - k, 3))));
+ if (k < 1) requestAnimationFrame(step);
};
};
- // The row's one focus stop: the open tab, or with nothing open the first tab that shows, so it can be reached.
- function focusStop() {
- const key = box.dataset.tab;
- const first = tabs.find((x) => !x.hidden);
- for (const t of tabs) t.setAttribute('tabindex', (key ? t.dataset.tab === key : t === first) ? '0' : '-1');
+ requestAnimationFrame(step);
+}
+function _ovnetIcons() {
+ if (window.OpenVibeIcons) { try { window.OpenVibeIcons.mount(document.getElementById('home-network')); } catch { /* */ } return; }
+ if (document.querySelector('script[data-ovnet-icons]')) return;
+ const sc = document.createElement('script');
+ sc.src = '/shared/ov-icons.js'; sc.defer = true; sc.dataset.ovnetIcons = '1';
+ sc.onload = () => { try { window.OpenVibeIcons && window.OpenVibeIcons.mount(document.getElementById('home-network')); } catch { /* */ } };
+ document.head.appendChild(sc);
+}
+/** A site the frame lists that the page does not have yet: added to its group, built from DOM nodes. */
+function _ovnetAddMissing(root, frame) {
+ const nav = frame && Array.isArray(frame.nav) ? frame.nav : [];
+ const have = new Set([...root.querySelectorAll('.ovnet-site[data-site]')].map((a) => a.dataset.site));
+ let added = 0;
+ for (const s of nav) {
+ if (!s || typeof s.url !== 'string' || typeof s.name !== 'string' || !s.id) continue;
+ const id = String(s.id).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);
+ if (!id || have.has(id)) continue;
+ let u; try { u = new URL(s.url); } catch { continue; }
+ if (u.protocol !== 'https:' || !_OVNET_HOST.test(u.hostname) || _OVNET_NEVER.test(u.hostname)) continue;
+ const gid = String(s.group || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
+ const group = (gid && root.querySelector(`.ovnet-group[data-group="${gid}"] .ovnet-sites`)) || root.querySelector('.ovnet-group:last-of-type .ovnet-sites');
+ if (!group) continue;
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.className = 'ovnet-site'; a.href = u.href; a.dataset.site = id; a.style.setProperty('--i', String(have.size + added));
+ const ic = document.createElement('span');
+ ic.className = 'ov-icon'; ic.dataset.icon = String(s.icon || id).replace(/[^a-z0-9-]/gi, '').slice(0, 32); ic.dataset.size = '40'; ic.setAttribute('aria-hidden', 'true');
+ const txt = document.createElement('span'); txt.className = 'ovnet-txt';
+ const b = document.createElement('b'); b.textContent = String(s.name).slice(0, 40);
+ const small = document.createElement('small'); small.textContent = u.hostname;
+ txt.append(b, small);
+ if (s.tagline) { const line = document.createElement('span'); line.textContent = String(s.tagline).slice(0, 90); txt.append(line); }
+ a.append(ic, txt); li.append(a); group.append(li);
+ added++;
}
}
- if (closable) pick(wanted && has(wanted) ? wanted : '');
- // The kept tab opens if its section arrives in the first few seconds; later, a section arriving never moves the
- // page under the reader. A section that empties out falls back to one that has something.
- const settleBy = Date.now() + 3000;
- const sync = () => {
- for (const t of tabs) t.hidden = !has(t.dataset.tab);
- const shown = tabs.filter((t) => !t.hidden);
- list.hidden = !shown.length;
- if (!shown.length) return;
- const cur = box.dataset.tab;
- if (closable && !cur) { focusStop(); return; } // closed stays closed until the reader opens a section
- if (!has(cur)) pick(closable ? '' : shown[0].dataset.tab);
- else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);
- };
- list.addEventListener('click', (e) => {
- const t = e.target.closest('[role="tab"]');
- if (!t) return;
- // A closable block: pressing the open tab closes it again.
- pick(closable && box.dataset.tab === t.dataset.tab ? '' : t.dataset.tab, { remember: true });
- });
- // Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).
- list.addEventListener('keydown', (e) => {
- const shown = tabs.filter((t) => !t.hidden);
- const i = shown.indexOf(document.activeElement);
- if (i < 0) return;
- const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: shown.length - 1 }[e.key];
- if (to == null) return;
- e.preventDefault();
- const t = shown[(to + shown.length) % shown.length];
- pick(t.dataset.tab, { remember: true, focus: true });
- t.scrollIntoView({ block: 'nearest', inline: 'nearest' });
- });
- const watch = new MutationObserver(sync);
- for (const id of Object.values(shownIds)) {
- const el = document.getElementById(id);
- if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });
+ if (!added) return;
+ for (const g of root.querySelectorAll('.ovnet-group')) {
+ const n = g.querySelectorAll('.ovnet-site').length;
+ const badge = g.querySelector('.ovnet-n'); if (badge) badge.textContent = String(n);
+ const jump = [...root.querySelectorAll('.ovnet-jump a')].find((x) => x.getAttribute('href') === `#${g.id}`); const jb = jump && jump.querySelector('b'); if (jb) jb.textContent = String(n);
}
}
- sync();
-}
-
-/** New here?: the tour, the About banner and the network hub share one place on a phone. */
-const HOME_INTRO_KEY = 'ov_home_intro';
-const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };
-function initHomeIntro() {
- // On a phone the intro starts closed: three tabs at the foot of the page, each opening its section.
- initHomeTabs('home-intro', _HOME_INTRO_SHOWN, HOME_INTRO_KEY, (key) => {
- // The tour's code loads when its mount nears the viewport; opening its tab is that moment.
- if (key === 'tour') _lazyHomeTour(true);
- // The About banner is behind a tab on a phone, so opening that tab is the reader seeing it.
- if (key === 'about') _introSeenNow('home-cta-banner');
- }, { closable: window.matchMedia('(max-width: 640px)').matches });
+ const total = root.querySelectorAll('.ovnet-site').length;
+ const groups = root.querySelector('.ovnet-groups'); if (groups) groups.dataset.sites = String(total);
+ const count = root.querySelector('.ovnet-count'); if (count) { count.dataset.count = String(total); count.textContent = String(total); }
+ if (window.OpenVibeIcons) { try { window.OpenVibeIcons.mount(root); } catch { /* */ } }
+}
+function initHomeNetwork() {
+ const root = document.getElementById('home-network');
+ if (!root || root._ovnet) return;
+ root._ovnet = true;
+ _ovnetSignedIn(root);
+ window.addEventListener('openvibe-auth-changed', () => _ovnetSignedIn(root));
+ // Glide to a group; the address keeps the group, so the link can be shared.
+ root.addEventListener('click', (e) => {
+ const jump = e.target.closest('.ovnet-jump a[href^="#"]');
+ if (jump) {
+ const to = document.getElementById(jump.getAttribute('href').slice(1));
+ if (to) { e.preventDefault(); to.scrollIntoView({ behavior: 'smooth', block: 'start' }); try { history.replaceState(history.state, '', jump.getAttribute('href')); } catch { /* */ } }
+ return;
+ }
+ if (e.target.closest('[data-ovnet-switcher]')) {
+ const mark = document.getElementById('openvibe-switch-btn');
+ if (mark) { window.scrollTo({ top: 0, behavior: 'smooth' }); mark.click(); }
+ }
+ });
+ // The first time it nears the screen: tiles rise in, the count counts up, the ring icons load.
+ const wake = () => {
+ if (root._ovnetAwake) return;
+ root._ovnetAwake = true;
+ root.classList.add('is-in');
+ _ovnetCount(root);
+ _ovnetIcons();
+ };
+ if ('IntersectionObserver' in window && !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) {
+ root.classList.add('is-armed');
+ const io = new IntersectionObserver((entries) => {
+ if (entries.some((en) => en.isIntersecting)) { io.disconnect(); requestAnimationFrame(wake); }
+ }, { rootMargin: '0px 0px -10% 0px', threshold: 0.05 });
+ io.observe(root);
+ // Never left invisible: if the observer has not fired in a few seconds of the section being on screen, show it.
+ setTimeout(() => { try { if (!root._ovnetAwake && root.getBoundingClientRect().top < window.innerHeight) wake(); } catch { wake(); } }, 4000);
+ } else wake();
+ // Sites that opened since the page was built.
+ try {
+ const F = window.OpenVibeFrame;
+ if (F && typeof F.get === 'function') {
+ const now = F.get((d) => _ovnetAddMissing(root, d));
+ if (now) _ovnetAddMissing(root, now);
+ }
+ } catch { /* the built list stands */ }
}
}
async function loadHome() {
async function loadHome() {
- initHomeIntro(); // the tour, the About banner and the network hub share one place on a phone
+ initHomeNetwork(); // the OpenVibe network: every site, alive (no tabs: everything shows)
demoteHomeIntroSections();
demoteHomeIntroSections();
void loadHomeChangelog();
void loadHomeChangelog();
_initHomeAbout();
_initHomeAbout();
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The home page's "OpenVibe network" section (public/fragments/home.html, between <!--ov:network--> and
+ * <!--/ov:network-->), rendered from the network's own list of sites: OpenVibe.Network's frame
+ * (https://openvibe.network/api/frame), the same list the OV mark's site switcher shows. The owner, 2026-11-07: "this
+ * area on live is so outdated and lame. Remake this area entirely considering what all the network offers … improve
+ * the SEO". The old section was fourteen hand-written cards; this one is every open site, by what people come to do.
+ *
+ * The HTML is written into the fragment so crawlers and the first paint get every site's name, address and line as
+ * real links (no script needed). In the browser, app-home.js initHomeNetwork() reads the same frame again and adds a
+ * site that opened since this was run, so the page is never behind for long; run this again to bake it in.
+ *
+ * node scripts/build-network-section.js fetch the frame and rewrite the section
+ * node scripts/build-network-section.js frame.json from a saved frame (tests, offline)
+ *
+ * Only https links on OpenVibe's own hosts are written, and the names the owner keeps private never appear.
+ */
+const fs = require('fs');
+const path = require('path');
+
+const FRAGMENT = path.join(__dirname, '..', 'public', 'fragments', 'home.html');
+const OPEN = '<!--ov:network-->';
+const CLOSE = '<!--/ov:network-->';
+const FRAME_URL = 'https://openvibe.network/api/frame?host=openvibe.live';
+
+// Hosts a network site may live on, and the ones that are never named in public.
+const OURS = /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/i;
+const NEVER = /(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i;
+
+// What each group looks like: an accent, a Font Awesome icon and one plain line.
+const GROUPS = {
+ streams: { accent: '#ef4444', icon: 'fa-tower-broadcast', line: 'Go live, watch, share pictures and video' },
+ games: { accent: '#a855f7', icon: 'fa-gamepad', line: 'Play in the browser, laugh, explore' },
+ answers: { accent: '#f59e0b', icon: 'fa-newspaper', line: 'News, reports, reviews and answers' },
+ make: { accent: '#3b82f6', icon: 'fa-wand-magic-sparkles', line: 'Tools, sites, bots, designs and code' },
+ shop: { accent: '#22c55e', icon: 'fa-basket-shopping', line: 'Deals, jobs, food, homes and trades' },
+ people: { accent: '#ec4899', icon: 'fa-user-group', line: 'Your people, your account, your stuff' },
+ build: { accent: '#14b8a6', icon: 'fa-code', line: 'APIs and services to build on' },
+};
+const FALLBACK_GROUP = { accent: '#38bdf8', icon: 'fa-circle-nodes', line: '' };
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const idOf = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32);
+
+/** The frame's sites that may be shown: { id, name, url, host, icon, tagline, group }. */
+function sitesOf(frame) {
+ const out = [];
+ const seen = new Set();
+ for (const s of (frame && Array.isArray(frame.nav) ? frame.nav : [])) {
+ if (!s || typeof s.url !== 'string' || typeof s.name !== 'string') continue;
+ let u; try { u = new URL(s.url); } catch { continue; }
+ if (u.protocol !== 'https:' || u.username || u.password) continue;
+ const host = u.hostname.toLowerCase();
+ if (!OURS.test(host) || NEVER.test(host)) continue;
+ const id = idOf(s.id) || idOf(host);
+ if (!id || seen.has(id)) continue;
+ seen.add(id);
+ out.push({ id, name: s.name.slice(0, 40), url: u.href, host, icon: idOf(s.icon) || id, tagline: String(s.tagline || '').slice(0, 90), group: idOf(s.group) });
+ }
+ return out;
+}
+
+/** The frame's groups in order, each with its sites; a site in no known group goes to "More". */
+function groupsOf(frame, sites) {
+ const named = (frame && Array.isArray(frame.groups) ? frame.groups : []).filter((g) => g && g.id && g.name).map((g) => ({ id: idOf(g.id), name: String(g.name).slice(0, 40) }));
+ const known = new Set(named.map((g) => g.id));
+ const groups = named.map((g) => ({ ...g, sites: sites.filter((s) => s.group === g.id) })).filter((g) => g.sites.length);
+ const rest = sites.filter((s) => !known.has(s.group));
+ if (rest.length) groups.push({ id: 'more', name: 'More', sites: rest });
+ return groups;
+}
+
+function siteHtml(s, i) {
+ return `<li><a class="ovnet-site" href="${esc(s.url)}" data-site="${esc(s.id)}" style="--i:${i}">`
+ + `<span class="ov-icon" data-icon="${esc(s.icon)}" data-size="40" aria-hidden="true"></span>`
+ + `<span class="ovnet-txt"><b>${esc(s.name)}</b><small>${esc(s.host)}</small>${s.tagline ? `<span>${esc(s.tagline)}</span>` : ''}</span></a></li>`;
+}
+
+/** The section's inside: the head, the jump links, every group and the foot. */
+function render(frame) {
+ const sites = sitesOf(frame);
+ const groups = groupsOf(frame, sites);
+ const look = (g) => GROUPS[g.id] || FALLBACK_GROUP;
+ const jump = groups.map((g) => `<a href="#ovnet-g-${esc(g.id)}" style="--g:${look(g).accent}"><i class="fa-solid ${look(g).icon}" aria-hidden="true"></i>${esc(g.name)}<b>${g.sites.length}</b></a>`).join('');
+ let n = 0;
+ const blocks = groups.map((g) => {
+ const L = look(g);
+ const items = g.sites.map((s) => siteHtml(s, n++)).join('');
+ return `<div class="ovnet-group" id="ovnet-g-${esc(g.id)}" role="group" aria-labelledby="ovnet-gh-${esc(g.id)}" data-group="${esc(g.id)}" style="--g:${L.accent}">`
+ + `<h3 id="ovnet-gh-${esc(g.id)}"><i class="fa-solid ${L.icon}" aria-hidden="true"></i><span>${esc(g.name)}</span><b class="ovnet-n">${g.sites.length}</b></h3>`
+ + (L.line ? `<p class="ovnet-gline">${esc(L.line)}</p>` : '')
+ + `<ul class="ovnet-sites">${items}</ul></div>`;
+ }).join('');
+ return `
+ <div class="ovnet-sky" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
+ <header class="ovnet-head">
+ <p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>
+ <h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="${sites.length}">${sites.length}</span> sites.</h2>
+ <p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>
+ <nav class="ovnet-jump" aria-label="Kinds of OpenVibe sites">${jump}</nav>
+ </header>
+ <div class="ovnet-groups" data-sites="${sites.length}">${blocks}</div>
+ <footer class="ovnet-foot">
+ <a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
+ <button type="button" class="ovnet-switch" data-ovnet-switcher><i class="fa-solid fa-grip" aria-hidden="true"></i> Jump between sites any time from the <b>OV</b> mark</button>
+ </footer>
+ `;
+}
+
+async function frameFrom(arg) {
+ if (arg) return JSON.parse(fs.readFileSync(arg, 'utf8'));
+ const r = await fetch(FRAME_URL, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) });
+ if (!r.ok) throw new Error(`the frame answered ${r.status}`);
+ return await r.json();
+}
+
+/** The fragment with its network section replaced. */
+function rewrite(html, inner) {
+ const a = html.indexOf(OPEN), b = html.indexOf(CLOSE);
+ if (a < 0 || b < a) throw new Error(`${path.basename(FRAGMENT)} has no ${OPEN} … ${CLOSE} block`);
+ return html.slice(0, a + OPEN.length) + inner + html.slice(b);
+}
+
+if (require.main === module) {
+ (async () => {
+ const frame = await frameFrom(process.argv[2]);
+ const sites = sitesOf(frame);
+ if (sites.length < 10) throw new Error(`only ${sites.length} sites in the frame: not rewriting the home page with that`);
+ const html = rewrite(fs.readFileSync(FRAGMENT, 'utf8'), render(frame));
+ fs.writeFileSync(FRAGMENT, html);
+ console.log(`network section: ${sites.length} sites in ${groupsOf(frame, sites).length} groups`);
+ })().catch((e) => { console.error(e.message); process.exit(1); });
+}
+
+module.exports = { sitesOf, groupsOf, render, rewrite, OPEN, CLOSE, GROUPS };
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
@@ -19,11 +19,11 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
// "css" the render-blocking stylesheets.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 129.5, // 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
+ htmlRawKB: 137.7, // 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1305.6, // 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
- jsBrotliKB: 305.5, // 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ jsRawKB: 1307.1, // 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsBrotliKB: 305.7, // 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
@@ -61,7 +61,7 @@ check('nothing was removed', () => {
@@ -61,7 +61,7 @@ check('nothing was removed', () => {
'home-about-body']) {
'home-about-body']) {
at(`id="${id}"`);
at(`id="${id}"`);
}
}
- assert.ok(home.includes('class="network-hub"'));
+ assert.ok(home.includes('<div class="ovnet" id="home-network"'));
});
});
console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');
console.log(failures ? `\n${failures} check(s) failed` : '\nhome order: all checks passed');
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
/**
/**
- * Home tabs (2026-10-30, the owner's short-pages rule: a phone page is one or two screens).
+ * Home tabs: there are none any more (2026-11-07, the owner's rule: never hide content behind tabs).
*
*
- * Only the explainer at the foot is tabbed (New here?: the restream tour, the About banner, the network hub). The
- * content blocks are never tabbed again (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage…
+ * The explainer at the foot (New here?: the restream tour and the About banner) shows both, one after the other, and
+ * the OpenVibe network is its own section with every site. The content blocks are never tabbed again (the owner, 2026-10-31: "now I can't see the videos and clips on the homepage…
* don't reduce size by removing features"): Around OpenVibe (the digest, the star spotlight, the pulse) shows every
* don't reduce size by removing features"): Around OpenVibe (the digest, the star spotlight, the pulse) shows every
* section on every screen, and test/home-catchup.test.js holds the rails. Wider screens never see the tabs. The
* section on every screen, and test/home-catchup.test.js holds the rails. Wider screens never see the tabs. The
* block is real here, not a string: the test opens the rendered home page in a DOM and drives it.
* block is real here, not a string: the test opens the rendered home page in a DOM and drives it.
@@ -88,11 +88,10 @@ function openHome({ views = 0 } = {}) {
@@ -88,11 +88,10 @@ function openHome({ views = 0 } = {}) {
console.log('Home tabs');
console.log('Home tabs');
-const INTRO = ['tour', 'about', 'network'];
const aroundStart = home.indexOf('<div class="home-around" id="home-around"');
const aroundStart = home.indexOf('<div class="home-around" id="home-around"');
const aroundEnd = home.indexOf('</div><!-- /home-around -->');
const aroundEnd = home.indexOf('</div><!-- /home-around -->');
const around = home.slice(aroundStart, aroundEnd);
const around = home.slice(aroundStart, aroundEnd);
-const introStart = home.indexOf('<div class="home-tabbox home-intro" id="home-intro"');
+const introStart = home.indexOf('<div class="home-intro" id="home-intro"');
const introEnd = home.indexOf('</div><!-- /home-intro -->');
const introEnd = home.indexOf('</div><!-- /home-intro -->');
const intro = home.slice(introStart, introEnd);
const intro = home.slice(introStart, introEnd);
@@ -106,101 +105,87 @@ check('Around OpenVibe shows its three sections, in their order, with the egg wh
@@ -106,101 +105,87 @@ check('Around OpenVibe shows its three sections, in their order, with the egg wh
assert.ok(!/\[data-panel="(lately|star|happening)"\]/.test(css), 'no CSS hides one of them');
assert.ok(!/\[data-panel="(lately|star|happening)"\]/.test(css), 'no CSS hides one of them');
});
});
-check('one tab per section, named for screen readers, pointing at its section', () => {
- assert.ok(intro.includes('<div class="home-tabs" role="tablist" aria-label="New here?">'), 'the New here? tab list');
- for (const [key, block, box] of [['tour', intro, 'home-tour-mount'], ['about', intro, 'home-cta-banner'], ['network', intro, 'home-network-hub']]) {
- const tab = (new RegExp(`<button type="button" role="tab" id="[a-z]+-tab-${key}" data-tab="${key}"[^>]*>`).exec(block) || [])[0];
- assert.ok(tab, `the ${key} tab`);
- const controls = (/aria-controls="([^"]+)"/.exec(tab) || [])[1];
- assert.strictEqual(controls, box, `${key} controls its section`);
- assert.ok(block.includes(`id="${controls}"`), `${controls} is inside the block`);
- }
-});
-
-check('How it works is the default tab of New here?', () => {
- assert.match(intro, /id="hi-tab-tour" data-tab="tour" aria-selected="true"/);
- assert.match(intro, /<div class="home-tabbox home-intro" id="home-intro" data-tab="tour">/);
-});
-
-check('the tabs and the one-section-at-a-time rule exist only under 760 px', () => {
- assert.match(css, /\.home-tabs \{ display: none; \}/, 'no tabs by default');
- const phone = css.slice(css.indexOf('.home-tabs { display: none; }'));
- const block = phone.slice(phone.indexOf('@media (max-width: 759px) {'));
- for (const key of INTRO) assert.ok(block.includes(`.home-tabbox:not([data-tab="${key}"]) [data-panel="${key}"]`), `${key} hides when another tab is open`);
- assert.match(block, /min-height: 44px/, 'a tab is a 44 px target');
- assert.match(js, /const _narrow = typeof window !== 'undefined' && window\.matchMedia\('\(max-width: 759px\)'\)/, 'the same breakpoint as the rails\' phone sizing');
+check('New here? has no tab row: the tour and the About banner both show, and no CSS hides one', () => {
+ assert.ok(introStart > 0 && introEnd > introStart, 'the block opens and closes on the home page');
+ assert.ok(!home.includes('role="tablist"') && !home.includes('role="tab"'), 'no tab row anywhere on the home');
+ assert.ok(!/data-panel=/.test(home), 'no panels');
+ assert.ok(!/home-tabbox|\[data-panel|\.home-tabs/.test(css), 'no tab CSS left');
+ assert.ok(intro.includes('id="home-tour-mount"') && intro.includes('id="home-cta-banner"'), 'both introductions are in the block');
+ assert.ok(!/function initHomeTabs|function initHomeIntro/.test(js), 'the tab controller is gone');
});
});
-check('the network hub\'s heading goes while its tab names it', () => {
- assert.ok(css.includes('[data-panel="network"] .network-hub-header h3'), 'the network heading goes');
-});
-
-check('a tab shows once its section does: the ids the script watches are the sections\' own', () => {
- const introIds = /const _HOME_INTRO_SHOWN = \{([^}]+)\}/.exec(js);
- assert.ok(introIds, 'the map of watched sections');
- assert.ok(!/initHomeAround|initHomeCatchup|_HOME_AROUND_SHOWN|_HOME_CATCHUP_SHOWN/.test(js), 'the content blocks have no tabs to set up');
- for (const id of introIds[1].match(/'([a-z-]+)'/g).map((s) => s.slice(1, -1))) assert.ok(intro.includes(`id="${id}"`), `${id} is in the block`);
+const net = home.slice(home.indexOf('<div class="ovnet" id="home-network"'), home.indexOf('<!--/ov:network-->'));
+check('the OpenVibe network is its own section: every site a real link, by group, for crawlers too', () => {
+ assert.ok(home.indexOf('id="home-network"') > introEnd, 'after New here?, outside it');
+ assert.match(net, /<h2 id="ovnet-title">One account\. <span class="ovnet-count" data-count="(\d+)">\1<\/span> sites\.<\/h2>/, 'the headline counts the sites in the page');
+ const sites = [...net.matchAll(/<a class="ovnet-site" href="([^"]+)" data-site="([a-z0-9-]+)"/g)];
+ assert.ok(sites.length >= 40, `every site of the network (${sites.length})`);
+ for (const [, href] of sites) {
+ const u = new URL(href);
+ assert.strictEqual(u.protocol, 'https:', href);
+ assert.ok(/(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(u.hostname), `an OpenVibe address: ${href}`);
+ assert.ok(!/(^|\.)(openvibe\.(fund|ai|recipes|page)|openvalve\.games|therecipelist\.com)$/i.test(u.hostname), `never a private name: ${href}`);
+ }
+ const groups = [...net.matchAll(/<div class="ovnet-group" id="ovnet-g-([a-z0-9-]+)" role="group" aria-labelledby="ovnet-gh-\1"/g)].map((m) => m[1]);
+ assert.ok(groups.length >= 5, 'grouped by what people come to do');
+ for (const g of groups) assert.ok(net.includes(`<a href="#ovnet-g-${g}"`), `a jump link to ${g}`);
+ assert.ok(/<span class="ov-icon" data-icon="[a-z0-9-]+" data-size="40" aria-hidden="true"><\/span>/.test(net), 'ring icons are drawn later, not inlined');
+ assert.ok(net.includes('data-ovnet-switcher') && net.includes('data-ovnet-signin'), 'the switcher button and the sign-up');
});
});
-check('the demotion moves the whole block, not the sections one by one', () => {
- const demote = /function demoteHomeIntroSections\(\) \{([\s\S]*?)\n\}/.exec(js);
- assert.ok(demote, 'the function');
- assert.match(demote[1], /getElementById\('home-intro'\)/, 'it moves the block');
- assert.match(demote[1], /last\.appendChild\(block\)/, 'it appends the block');
- assert.ok(!/"home-tour-mount", "home-cta-banner"/.test(js), 'the two sections are no longer moved on their own');
+check('the network section is built by its script, between its markers, and nothing in it is a tab', () => {
+ const frag = fs.readFileSync(path.join(__dirname, '../public/fragments/home.html'), 'utf8');
+ assert.strictEqual(frag.split('<!--ov:network-->').length, 2, 'one start marker');
+ assert.strictEqual(frag.split('<!--/ov:network-->').length, 2, 'one end marker');
+ const build = require('../scripts/build-network-section');
+ const out = build.render({ groups: [{ id: 'streams', name: 'Streams & places' }], nav: [
+ { id: 'live', name: 'Live', url: 'https://openvibe.live/', icon: 'live', tagline: 'Go live <b>now</b>', group: 'streams' },
+ { id: 'x', name: 'Bad', url: 'https://openvibe.fund/', group: 'streams' },
+ { id: 'y', name: 'Plain', url: 'http://openvibe.food/', group: 'streams' },
+ { id: 'z', name: 'Elsewhere', url: 'https://example.com/', group: 'streams' },
+ { id: 'w', name: 'Wanderer', url: 'https://openvibe.wiki/', group: 'nowhere' },
+ ] });
+ assert.ok(out.includes('href="https://openvibe.live/"') && out.includes('Go live <b>now</b>'), 'escaped');
+ assert.ok(!/openvibe\.fund|http:\/\/openvibe\.food|example\.com/.test(out), 'only https OpenVibe addresses, never a private name');
+ assert.ok(out.includes('id="ovnet-g-more"') && out.includes('openvibe.wiki'), 'a site in no known group goes to More');
+ assert.ok(!/role="tab/.test(out));
});
});
-check('the tour loads when its tab opens, not only when the mount nears the viewport', () => {
- assert.match(js, /if \(key === 'tour'\) _lazyHomeTour\(true\);/, 'opening the tab loads the tour');
- assert.match(js, /function _lazyHomeTour\(force\)/, 'the loader can be forced');
+check('the tour still loads when its mount nears the viewport', () => {
+ assert.match(js, /function _lazyHomeTour\(force\)/, 'the loader');
});
});
(async () => {
(async () => {
- await check('a tab appears once its section has something, and a block with nothing drops its tab row', async () => {
- const p = openHome();
- p.run('initHomeIntro();');
- await tick();
- for (const id of ['home-tour-mount', 'home-cta-banner', 'home-network-hub']) hide(p.el(id));
- await tick();
- assert.strictEqual(p.el('home-intro').querySelector('[role="tablist"]').hidden, true, 'nothing to show, so no tab row');
- show(p.el('home-cta-banner'));
- await tick();
- assert.strictEqual(p.el('hi-tab-about').hidden, false, 'the banner arrived, so its tab is there');
- assert.strictEqual(p.el('hi-tab-network').hidden, true, 'the hub has nothing, so its tab is hidden');
- });
-
- await check('the tour loads when its tab opens', async () => {
+ await check('initHomeNetwork adds a site that opened since the page was built, and only an OpenVibe one', async () => {
const p = openHome();
const p = openHome();
- p.run('initHomeIntro();');
- await tick();
- assert.deepStrictEqual(p.loaded, [], 'nothing loads on its own in a page that never scrolls');
- p.click('hi-tab-tour');
+ const before = p.window.document.querySelectorAll('.ovnet-site').length;
+ p.window.OpenVibeFrame = { get: () => ({ groups: [], nav: [
+ { id: 'brandnew', name: 'Brand New', url: 'https://openvibe.brandnew/', icon: 'ov', tagline: 'Just opened', group: 'games' },
+ { id: 'evil', name: 'Evil', url: 'https://evil.example/', group: 'games' },
+ { id: 'secret', name: 'Secret', url: 'https://openvibe.recipes/', group: 'games' },
+ { id: 'live', name: 'Live', url: 'https://openvibe.live/', group: 'streams' },
+ ] }) };
+ p.run('initHomeNetwork();');
await tick();
await tick();
- assert.deepStrictEqual(p.loaded, ['homeTour'], 'opening the tab loads the tour');
+ const sites = [...p.window.document.querySelectorAll('.ovnet-site')];
+ assert.strictEqual(sites.length, before + 1, 'one new site, the rest refused or already there');
+ const added = sites.find((a) => a.dataset.site === 'brandnew');
+ assert.ok(added && added.closest('.ovnet-group').dataset.group === 'games', 'in its group');
+ assert.strictEqual(added.querySelector('b').textContent, 'Brand New');
+ assert.strictEqual(p.window.document.querySelector('.ovnet-count').textContent, String(before + 1), 'the count follows');
});
});
- await check('on a phone the intro starts closed; a tap opens a section, a second tap closes it, and that is kept', async () => {
+ await check('the sign-up is only for a reader who is not signed in', async () => {
const p = openHome();
const p = openHome();
- p.run('initHomeIntro();');
+ p.window.currentUser = { id: 1, username: 'a' };
+ p.run('initHomeNetwork();');
await tick();
await tick();
- const block = p.el('home-intro');
- assert.strictEqual(block.dataset.tab, '', 'nothing open at first: three tabs at the foot of the page');
- assert.ok(['tour', 'about', 'network'].every((k) => p.el(`hi-tab-${k}`).getAttribute('aria-selected') === 'false'), 'no tab says it is open');
- const first = ['tour', 'about', 'network'].map((k) => p.el(`hi-tab-${k}`)).find((t) => !t.hidden);
- assert.ok(first && first.getAttribute('tabindex') === '0', 'the first tab that shows still takes the focus stop');
- p.click('hi-tab-about');
- await tick();
- assert.strictEqual(block.dataset.tab, 'about', 'a tap opens its section');
- p.click('hi-tab-about');
- await tick();
- assert.strictEqual(block.dataset.tab, '', 'the same tab again closes it');
- assert.strictEqual(p.store.get('ov_home_intro'), '', 'closed is kept for the next visit');
- assert.match(css, /\.home-tabbox:not\(\[data-tab="tour"\]\) \[data-panel="tour"\]/, 'with nothing open, the CSS hides every section');
+ assert.strictEqual(p.window.document.querySelector('[data-ovnet-signin]').hidden, true);
});
});
await check('a first visit leaves the block where it is', async () => {
await check('a first visit leaves the block where it is', async () => {
const p = openHome();
const p = openHome();
- p.run('initHomeIntro(); demoteHomeIntroSections();');
+ p.run('initHomeNetwork(); demoteHomeIntroSections();');
await tick();
await tick();
const block = p.el('home-intro');
const block = p.el('home-intro');
assert.strictEqual(block.dataset.ovDemoted, undefined, 'it was not demoted');
assert.strictEqual(block.dataset.ovDemoted, undefined, 'it was not demoted');
@@ -211,7 +196,7 @@ check('the tour loads when its tab opens, not only when the mount nears the view
@@ -211,7 +196,7 @@ check('the tour loads when its tab opens, not only when the mount nears the view
await check('after enough visits the whole block moves to the end of the page, and still works there', async () => {
await check('after enough visits the whole block moves to the end of the page, and still works there', async () => {
const p = openHome({ views: 18 });
const p = openHome({ views: 18 });
- p.run('initHomeIntro(); demoteHomeIntroSections();');
+ p.run('initHomeNetwork(); demoteHomeIntroSections();');
await tick();
await tick();
const block = p.el('home-intro');
const block = p.el('home-intro');
assert.strictEqual(block.dataset.ovDemoted, '1', 'it was demoted');
assert.strictEqual(block.dataset.ovDemoted, '1', 'it was demoted');
@@ -219,17 +204,10 @@ check('the tour loads when its tab opens, not only when the mount nears the view
@@ -219,17 +204,10 @@ check('the tour loads when its tab opens, not only when the mount nears the view
const container = p.el('home-changelog-wrapper').parentElement;
const container = p.el('home-changelog-wrapper').parentElement;
assert.strictEqual(block.parentElement, container, 'it moved into the changelog\'s container');
assert.strictEqual(block.parentElement, container, 'it moved into the changelog\'s container');
assert.strictEqual(container.lastElementChild, block, 'and to the end of it');
assert.strictEqual(container.lastElementChild, block, 'and to the end of it');
- for (const id of ['home-tour-mount', 'home-cta-banner', 'home-network-hub']) {
+ for (const id of ['home-tour-mount', 'home-cta-banner']) {
assert.ok(block.contains(p.el(id)), `${id} travelled with it`);
assert.ok(block.contains(p.el(id)), `${id} travelled with it`);
}
}
- // The tabs still work where it landed.
- p.click('hi-tab-network');
- await tick();
- assert.strictEqual(block.dataset.tab, 'network', 'the tab opened its section');
- assert.strictEqual(p.el('hi-tab-network').getAttribute('aria-selected'), 'true', 'and says so');
- p.click('hi-tab-tour');
- await tick();
- assert.deepStrictEqual(p.loaded, ['homeTour'], 'the tour loads from its new place too');
+ assert.ok(!block.contains(p.el('home-network')), 'the network section stays where it is');
});
});
console.log(failures ? `\n${failures} failed` : '\nall passed');
console.log(failures ? `\n${failures} failed` : '\nall passed');