openvibers/openvibe.shared navbar.js
navbar.js
| 1 | // OpenVibe — the shared navbar (the OpenVibe Frame's top bar). Options and behaviour: README.md, "The navbar". |
| 2 | |
| 3 | (function (root) { |
| 4 | ; |
| 5 | |
| 6 | let _config = { |
| 7 | service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', frameBase: null, |
| 8 | onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null, |
| 9 | brand: null, brandName: null, brandIcon: null, compact: 'auto', |
| 10 | links: null, menu: null, recent: true, |
| 11 | history: null, silentLogin: null, fedcm: 'optional', fedcmLogin: null, // README.md, "The navbar" |
| 12 | }; |
| 13 | const _runtimeMenu = { before: [], after: [] }; |
| 14 | let _own = []; // the You tab's small items |
| 15 | let _runtimeLinks = null; |
| 16 | let _navEl = null; |
| 17 | // A site's own buttons (lead before the brand, actions before the bell): nav-slots.js. |
| 18 | let _lead = null; |
| 19 | const _actions = new Map(); |
| 20 | |
| 21 | function injectStyles() { |
| 22 | if (document.getElementById('openvibe-navbar-styles')) return; |
| 23 | const s = document.createElement('style'); |
| 24 | s.id = 'openvibe-navbar-styles'; |
| 25 | s.textContent = ` |
| 26 | .openvibe-navbar { |
| 27 | position: sticky; top: 0; z-index: 10000; |
| 28 | height: 52px; display: flex; align-items: center; padding: 0 clamp(8px, 1.6vw, 16px); gap: 6px; |
| 29 | background: var(--bg-secondary, #252530); |
| 30 | border-bottom: 1px solid var(--border, #333340); |
| 31 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; |
| 32 | color: var(--text-primary, #e0e0e0); |
| 33 | } |
| 34 | /* At the very top the bar fades into the page (no background, no border); a scroll brings the frosted bar back. |
| 35 | A page whose hero runs under the bar opts in with body.ov-nav-overlay: the bar then sits over the hero, with a |
| 36 | soft scrim for the text. ovnav-top is kept by the navbar itself on every render and scroll. */ |
| 37 | /* A tap area past the edge made pages 392 px at 390 (4.222.0). */ |
| 38 | .openvibe-navbar { overflow-x: clip; } |
| 39 | .openvibe-navbar { transition: background-color .45s cubic-bezier(.2,.7,.2,1), border-color .45s cubic-bezier(.2,.7,.2,1), box-shadow .45s cubic-bezier(.2,.7,.2,1), -webkit-backdrop-filter .45s, backdrop-filter .45s, transform .32s cubic-bezier(.2,.7,.2,1); } |
| 40 | /* On a phone the bar steps away while you read down the page and comes back the moment you scroll up (4.25.0; |
| 41 | the owner, 2026-10-28: the bar's at-the-top effect "doesn't work on mobile… the whole thing needs to be |
| 42 | improved"). Never while one of its menus is open. A site turns it off with autoHide: false. */ |
| 43 | .openvibe-navbar.ovnav-hide { transform: translateY(calc(-100% - 10px)); box-shadow: none; } |
| 44 | #navbar-mount:has(> .ovnav-hide) { pointer-events: none; } |
| 45 | /* The top bar while a page changes (4.159.0): the OpenVibe gradient with a soft glow and a bright head that runs. */ |
| 46 | .ovrt-bar { position: fixed; left: 0; top: 0; height: 3px; width: 100%; z-index: 2147483000; pointer-events: none; transform-origin: 0 50%; transform: scaleX(0); opacity: 0; |
| 47 | background: linear-gradient(90deg, var(--accent, #3b82f6), var(--accent-light, #60a5fa) 60%, var(--accent-2, #a78bfa)); |
| 48 | box-shadow: 0 0 10px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent), 0 0 3px var(--accent-light, #60a5fa); border-radius: 0 3px 3px 0; } |
| 49 | .ovrt-bar::after { content: ''; position: absolute; right: 0; top: -2px; width: 90px; height: 7px; border-radius: 50%; background: var(--accent-light, #93c5fd); filter: blur(4px); opacity: .9; } |
| 50 | .ovrt-bar.is-run { opacity: 1; transform: scaleX(.88); transition: transform 3s cubic-bezier(.08, .7, .2, 1), opacity .1s; } |
| 51 | .ovrt-bar.is-done { opacity: 0; transform: scaleX(1); transition: transform .2s ease, opacity .35s .15s; } |
| 52 | html[data-ov-motion=off] .ovrt-bar::after{display: none} |
| 53 | .openvibe-navbar::before { content: ""; position: absolute; inset: 0 0 -28px; pointer-events: none; z-index: -1; opacity: 0; transition: opacity .45s cubic-bezier(.2,.7,.2,1); background: linear-gradient(180deg, rgba(4,7,14,.62), rgba(4,7,14,.28) 55%, rgba(4,7,14,0)); } |
| 54 | .openvibe-navbar:not(.ovnav-top) { background-color: color-mix(in srgb, var(--bg-secondary, #252530) 84%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); box-shadow: 0 12px 32px -14px rgba(0,0,0,.6); } |
| 55 | .openvibe-navbar.ovnav-top { background-color: transparent; border-bottom-color: transparent; box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; } |
| 56 | /* The bar is sticky, but a #navbar-mount wrapper exactly its height gave it no room to stick in: the wrapper sticks. */ |
| 57 | #navbar-mount { position: sticky; top: 0; z-index: 10000; } |
| 58 | body.ov-nav-overlay .openvibe-navbar.ovnav-top::before { opacity: 1; } |
| 59 | body.ov-nav-overlay > #navbar-mount, body.ov-nav-overlay > .openvibe-navbar { margin-bottom: calc(-1 * var(--ovnav-real-h, 52px)); } |
| 60 | body.ov-panel-open .openvibe-navbar.ovnav-top { background-color: var(--bg-secondary, #252530); border-bottom-color: var(--border, #333340); } |
| 61 | /* Names: the brand face (ov-brand.woff2: Nunito, Latin, 700-900). */ |
| 62 | @font-face { font-family: 'OV Brand'; src: url(${siblingV('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; } |
| 63 | .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; } |
| 64 | /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host |
| 65 | page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */ |
| 66 | .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; } |
| 67 | :where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; } |
| 68 | .openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); } |
| 69 | .openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } |
| 70 | [data-ov-scroll-focus]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; } |
| 71 | .openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; } |
| 72 | .openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 34px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; } |
| 73 | .openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); } |
| 74 | .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-family: 'OV Brand', Nunito, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 19px; letter-spacing: -.25px; } |
| 75 | .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 1px; border-radius: 6px; transition: color .15s, background .15s; } |
| 76 | .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 900; } |
| 77 | .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; } |
| 78 | .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; } |
| 79 | .openvibe-navbar .openvibe-navbar-brand .name a.has-drop::after { content: ""; flex: none; display: inline-block; width: 5px; height: 5px; margin: 0 2px 3px 6px; border: solid currentColor; border-width: 0 1.8px 1.8px 0; transform: rotate(45deg); opacity: .65; transition: transform .2s; } |
| 80 | .openvibe-navbar .openvibe-navbar-brand .name { overflow-clip-margin: 8px; } |
| 81 | .openvibe-navbar .openvibe-navbar-brand .name a.has-drop { border-radius: 8px; transition: color .15s, background .15s, box-shadow .26s cubic-bezier(.2,1.4,.3,1); } |
| 82 | .openvibe-navbar .openvibe-navbar-brand .name a.has-drop:hover { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t); } |
| 83 | .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"] { --ovn-t: color-mix(in srgb, var(--accent, #3b82f6) 17%, transparent); background: var(--ovn-t); box-shadow: 0 0 0 6px var(--ovn-t), 0 0 0 7px color-mix(in srgb, var(--accent, #3b82f6) 32%, transparent); } |
| 84 | .openvibe-navbar .openvibe-navbar-brand .name a.has-drop[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); } |
| 85 | .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; } |
| 86 | .openvibe-navbar .ovnav-brand { all: unset; box-sizing: border-box; position: relative; display: inline-flex; align-items: center; gap: 7px; min-width: 0; height: 40px; padding: 0 12px 0 4px; border-radius: 12px; cursor: pointer; color: inherit; transition: background-color .15s, box-shadow .26s cubic-bezier(.2,1.4,.3,1); } |
| 87 | .openvibe-navbar .ovnav-brand::before { content: ""; position: absolute; inset: -3px 0; } |
| 88 | .openvibe-navbar .ovnav-brand::after { content: ""; flex: none; width: 6px; height: 6px; margin: 0 0 3px 2px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .6; transition: transform .2s; } |
| 89 | .openvibe-navbar .ovnav-brand:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); } |
| 90 | .openvibe-navbar .ovnav-brand[aria-expanded="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 17%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 32%, transparent); } |
| 91 | .openvibe-navbar .ovnav-brand[aria-expanded="true"]::after { transform: translateY(2px) rotate(225deg); } |
| 92 | .openvibe-navbar .ovnav-brand:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } |
| 93 | .openvibe-navbar .ovnav-brand .flame, .openvibe-navbar .ovnav-brand .flame:hover { background: none; } |
| 94 | .openvibe-navbar .openvibe-navbar-brand .name .b-sub { color: var(--text-primary, #e6edf7); font-weight: 900; } |
| 95 | .openvibe-navbar .openvibe-navbar-brand .name .b-core { color: var(--text-primary, #e6edf7); font-weight: 800; } |
| 96 | .openvibe-navbar .openvibe-navbar-brand .name .b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; } |
| 97 | .openvibe-navbar .openvibe-navbar-brand.has-sub .name .b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name .b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; } |
| 98 | .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); } |
| 99 | .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; } |
| 100 | .openvibe-navbar .openvibe-navbar-brand.joined .name .b-core { padding-right: 0; } |
| 101 | .openvibe-navbar .openvibe-navbar-brand.joined .name .b-tld { padding-left: 0; } |
| 102 | .openvibe-navbar .openvibe-navbar-brand a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } |
| 103 | /* A finger needs about 44px: on touch screens the wordmark's links reach the navbar's height and the mark |
| 104 | gets a clear ring, without moving anything (they were 22px and 30px tall). */ |
| 105 | @media (pointer: coarse) { |
| 106 | .openvibe-navbar .openvibe-navbar-brand .name a { padding-top: 14px; padding-bottom: 14px; margin-top: -11px; margin-bottom: -11px; } |
| 107 | .openvibe-navbar .openvibe-navbar-brand .name .b-tld { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; box-sizing: border-box; } |
| 108 | .openvibe-navbar .openvibe-navbar-brand .flame { position: relative; } |
| 109 | .openvibe-navbar .openvibe-navbar-brand .flame::after { content: ''; position: absolute; inset: -8px -4px -8px -12px; } |
| 110 | .openvibe-navbar .openvibe-navbar-login, .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-orb { position: relative; } |
| 111 | /* Sign in's taps stop at the bar's edge (it clips) and go a little left, off the menu's. */ |
| 112 | .openvibe-navbar .openvibe-navbar-login::after { content: ''; position: absolute; inset: -5px -6px -5px -4px; } |
| 113 | .openvibe-navbar .ovnav-burger::after { content: ''; position: absolute; inset: -3px -5px; } |
| 114 | /* The orb grows up and down, and sideways mostly to the right: the site's name sits 8px to its left and keeps |
| 115 | its own taps (4px there); the extra width goes to the open bar on its right (12px), so it measures 44px |
| 116 | wide on a phone (it was 36.9 in the 11-03 re-audit). */ |
| 117 | .openvibe-navbar .ovnav-orb::after { content: ''; position: absolute; inset: -6px -12px -6px -4px; } |
| 118 | } |
| 119 | .openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; } |
| 120 | .openvibe-navbar .ovnav-dd-menu { display: none; position: fixed; top: 56px; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; } |
| 121 | .openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; } |
| 122 | .openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; } |
| 123 | .ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; } |
| 124 | /* A single balance chip (computers): the same calm surface, the icon in its colour, the number in the text's. */ |
| 125 | .openvibe-navbar .ovnav-chip[data-tone] { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); } |
| 126 | .openvibe-navbar .ovnav-chip[data-tone] .ovnav-ic { color: var(--chip-c); width: 15px; height: 15px; } |
| 127 | .openvibe-navbar .ovnav-chip[data-tone]:hover { background: color-mix(in srgb, var(--chip-c) 12%, transparent); border-color: color-mix(in srgb, var(--chip-c) 35%, transparent); } |
| 128 | .openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; } |
| 129 | .openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; } |
| 130 | .openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; } |
| 131 | .openvibe-navbar-dropdown-header a.name.ud-me { line-height: inherit; display: block; } |
| 132 | .openvibe-navbar-dropdown-header a.name.ud-me:hover, .openvibe-navbar-dropdown-header a.name.ud-me:focus-visible { text-decoration: underline; } |
| 133 | .openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); } |
| 134 | /* 4.119.0: the featured link's phone button */ |
| 135 | .openvibe-navbar .ovnav-featured-phone { all: unset; box-sizing: border-box; position: relative; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 999px; align-items: center; justify-content: center; flex: none; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); } |
| 136 | .openvibe-navbar .ovnav-featured-phone::after { content: ''; position: absolute; inset: -5px; } |
| 137 | .openvibe-navbar .ovnav-featured-phone :is(.ovnav-ic, svg, i) { width: 17px; height: 17px; font-size: 16px; } |
| 138 | .openvibe-navbar .ovnav-featured-phone:focus-visible { outline: 2px solid var(--accent-light, #60a5fa); outline-offset: 2px; } |
| 139 | .openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; position: relative; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; } |
| 140 | .openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; } |
| 141 | .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); } |
| 142 | .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; } |
| 143 | .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); } |
| 144 | .openvibe-navbar .ovnav-burger:hover, .openvibe-navbar .ovnav-burger[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); } |
| 145 | .ovnav-drawer { display: none; position: absolute; top: calc(100% + 6px); right: 8px; width: min(300px, calc(100vw - 16px)); padding: 7px; border-radius: 16px; z-index: 1001; flex-direction: column; gap: 2px; |
| 146 | background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 22px 48px rgba(0,0,0,.45); |
| 147 | max-height: calc(100vh - var(--ovnav-h, 56px) - 16px); max-height: calc(100dvh - var(--ovnav-h, 56px) - 16px - env(safe-area-inset-bottom, 0px)); overflow-y: auto; overscroll-behavior: contain; } |
| 148 | .ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; } |
| 149 | .ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; } |
| 150 | .ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); } |
| 151 | /* A featured link in the phone's menu: the same one accent, as a quiet line rather than a pill. */ |
| 152 | .ovnav-drawer a.ovnav-link--featured { color: var(--accent-light, #60a5fa); font-weight: 650; } |
| 153 | .ovnav-drawer a.ovnav-link--featured .icon { color: var(--accent-light, #60a5fa); } |
| 154 | .ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); } |
| 155 | @media (hover: hover) { .ovnav-drawer a:hover { background: var(--accent-glow, rgba(59,130,246,.14)); } } |
| 156 | .ovnav-drawer a[hidden] { display: none; } |
| 157 | .ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); } |
| 158 | .ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; } |
| 159 | .ovnav-drawer-net a.ovnav-net { display: flex !important; } |
| 160 | /* "Across OpenVibe" in the menu (owner, 2026-10-27: icons, and set apart from the site's own links): a tinted |
| 161 | section, its own header with "All sites", a grid of the other sites, each with its mark. */ |
| 162 | .ovnav-drawer-net:not(:empty) { margin: 8px 0 0; padding: 6px; border: 0; border-radius: 13px; background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); } |
| 163 | .ovnav-drawer-net .ovnav-net-head { display: flex; align-items: center; gap: 8px; padding: 6px 6px 8px; } |
| 164 | .ovnav-drawer-net .ovnav-net-head .ud-label { padding: 0; flex: 1; } |
| 165 | .ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; } |
| 166 | .ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); } |
| 167 | .ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; } |
| 168 | .ovnav-display { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px; padding: 10px 6px 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); } |
| 169 | .ovnav-display[hidden] { display: none; } |
| 170 | .ovnav-display .ovnav-dl { flex-basis: 100%; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; } |
| 171 | .ovnav-display .ovnav-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; } |
| 172 | .ovnav-display .ovnav-seg button { all: unset; cursor: pointer; padding: 6px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); } |
| 173 | .ovnav-display .ovnav-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); } |
| 174 | .ovnav-display .ovnav-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; } |
| 175 | .ovnav-drawer .ovnav-display a { display: inline; padding: 6px 4px; margin-left: auto; font-size: 12px; color: var(--accent-light, var(--accent, #60a5fa)); } |
| 176 | .ovnav-display a.ovnav-disp-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 52px; margin: 0; padding: 4px 6px; border-radius: 14px; color: var(--text-primary, #e6edf7); text-decoration: none; } |
| 177 | .ovnav-display a.ovnav-disp-row:hover { background: var(--bg-hover, rgba(255,255,255,.06)); } |
| 178 | .ovnav-disp-ic { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; background: linear-gradient(150deg, hsl(285 72% 58% / .34), hsl(285 70% 38% / .18)); color: hsl(285 90% 82%); } |
| 179 | .ovnav-disp-row > span:nth-child(2) { flex: 1; display: grid; gap: 1px; } .ovnav-disp-row b { font-size: 14px; } .ovnav-disp-row small { font-size: 12px; color: var(--text-muted, #7d8aa0); } |
| 180 | .ovnav-disp-row[aria-expanded="true"] > svg:last-child { transform: rotate(180deg); } |
| 181 | .ovnav-display .ovnav-disp { display: flex; align-items: center; gap: 8px; width: 100%; } |
| 182 | .ovnav-display .ovnav-disp .ovnav-dl { flex-basis: auto; margin-right: auto; } |
| 183 | .ovnav-display .ovnav-seg-ic button { display: inline-grid; place-items: center; width: 40px; height: 34px; padding: 0; } |
| 184 | .ovnav-display .ovnav-seg-ic svg { width: 17px; height: 17px; } |
| 185 | .ovnav-display a.ovnav-disp-more { display: inline-flex; align-items: center; gap: 6px; height: 34px; margin: 0; padding: 0 12px; border-radius: 9px; background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); color: var(--accent-light, #60a5fa); font-size: 12.5px; font-weight: 650; text-decoration: none; } |
| 186 | .ovnav-display a.ovnav-disp-more svg { width: 16px; height: 16px; } |
| 187 | .ovnav-display .ud-look { flex-basis: 100%; } |
| 188 | .ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; } |
| 189 | .ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 190 | /* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */ |
| 191 | .ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; } |
| 192 | .ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; } |
| 193 | /* An app icon (design rule 142) holds its square at its size before ov-icons.js draws it, on every page. */ |
| 194 | .ov-appicon { display: inline-block; flex: none; width: var(--ova-size, 56px); height: var(--ova-size, 56px); border-radius: 30%; vertical-align: middle; } |
| 195 | .ov-appicon[data-size="20"] { --ova-size: 20px; } .ov-appicon[data-size="26"] { --ova-size: 26px; } .ov-appicon[data-size="32"] { --ova-size: 32px; } |
| 196 | .ov-appicon[data-size="44"] { --ova-size: 44px; } .ov-appicon[data-size="56"] { --ova-size: 56px; } .ov-appicon[data-size="64"] { --ova-size: 64px; } |
| 197 | .ov-appicon:empty { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); } |
| 198 | .ovnav-drawer .ov-appicon > .ov-icon { width: 100%; height: 100%; border-radius: 0; } |
| 199 | .ovnav-drawer .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); } |
| 200 | /* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site |
| 201 | switcher next to the logo); a tap lists each in full. */ |
| 202 | /* The balances (owner, 2026-10-28: "the coins and vibes badges look a little weird… polish… use the space"): one |
| 203 | calm pill on the bar's own surface, each balance a coloured icon and a plain number, a hairline between. */ |
| 204 | /* The balances: one pill, one showing (purse.js turns between them). */ |
| 205 | .openvibe-navbar .ovnav-chip.ovnav-chip--all { display: inline-grid; gap: 0; padding: 0 1px; font-size: 13px; --chip-c: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); } |
| 206 | .openvibe-navbar .ovnav-chip--all:hover, .openvibe-navbar .ovnav-chip--all[aria-expanded="true"] { background: color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent); } |
| 207 | .ovnav-chip-mini { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 0 7px; color: var(--text-primary, #e6edf7); opacity: 0; } |
| 208 | .ovnav-chip-mini.is-on { opacity: 1; } |
| 209 | .ovnav-chip-mini .ovnav-ic { color: var(--chip-c, inherit); width: 15px; height: 15px; } |
| 210 | .ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; } |
| 211 | .openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; } |
| 212 | .ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px; |
| 213 | background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); } |
| 214 | .ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; } |
| 215 | .ovnav-chips-pop button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; font-size: 14px; color: var(--text-primary, #e6edf7); } |
| 216 | .ovnav-chips-pop button:hover, .ovnav-chips-pop button:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); } |
| 217 | .ovnav-chips-pop button .ovnav-ic { color: var(--chip-c, inherit); width: 16px; height: 16px; } |
| 218 | .ovnav-chips-pop button[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chips-pop button[data-tone="green"] { --chip-c: #4ade80; } |
| 219 | .ovnav-chips-pop b { margin-left: auto; font-variant-numeric: tabular-nums; } |
| 220 | @media (max-width: 860px) { .openvibe-navbar .ovnav-burger, .openvibe-navbar .ovnav-featured-phone { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } } |
| 221 | @media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } } |
| 222 | /* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed |
| 223 | in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px |
| 224 | the folded balances show the first one only (a tap lists them all). */ |
| 225 | .openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; } |
| 226 | .openvibe-navbar .openvibe-navbar-brand .flame { flex: none; } |
| 227 | .openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; } |
| 228 | /* Both balances on every phone (the owner, 2026-10-28: "you can only see one… use the full space"): the pill |
| 229 | tightens instead of folding to one. */ |
| 230 | @media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; } |
| 231 | .openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .ovnav-featured-phone { width: 34px; height: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; } |
| 232 | .openvibe-navbar .openvibe-navbar-brand .name { font-size: 16.5px; letter-spacing: -.25px; } .openvibe-navbar .openvibe-navbar-brand .flame { min-width: 28px; height: 28px; padding: 0 2px; } .openvibe-navbar .openvibe-navbar-brand .flame .ov-mark { width: 34px !important; } } |
| 233 | /* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */ |
| 234 | @media (max-width: 350px) { .openvibe-navbar .openvibe-navbar-brand .name { display: none; } } |
| 235 | /* The site's name reads whole on a signed-in phone (the owner, 2026-11-02: "the label or the branding of the |
| 236 | website you're on seems to get cut off", openvibe.live showing "Li…" beside 161k | 400, the messages, the bell |
| 237 | and the picture). Up to 480px the two balances stack in one pill, each with its icon (both still show, as asked |
| 238 | on 10-28): the pill is about 53px instead of 90-103px, and the name has that room. */ |
| 239 | .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; } |
| 240 | .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; } |
| 241 | /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */ |
| 242 | .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); } |
| 243 | .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); } |
| 244 | .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #60a5fa)); } |
| 245 | .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; } |
| 246 | .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; } |
| 247 | .openvibe-navbar-brand .b-dot { color: var(--accent, #3b82f6); opacity: .75; margin: 0 1px; font-weight: 800; } |
| 248 | .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; } |
| 249 | .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #60a5fa); } |
| 250 | .openvibe-navbar-brand:hover .b-dot { opacity: 1; } |
| 251 | .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #60a5fa); background: var(--accent-glow, rgba(59,130,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; } |
| 252 | /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */ |
| 253 | .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core, |
| 254 | .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld, |
| 255 | .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; } |
| 256 | @media (max-width: 860px) { |
| 257 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core, |
| 258 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld, |
| 259 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; } |
| 260 | } |
| 261 | @media (min-width: 401px) and (max-width: 480px) { |
| 262 | .openvibe-navbar .openvibe-navbar-brand .flame .ov-mark { width: 28px !important; } |
| 263 | .openvibe-navbar .openvibe-navbar-brand .name { font-size: 17px; letter-spacing: -.25px; } |
| 264 | .openvibe-navbar .openvibe-navbar-brand { gap: 4px; } |
| 265 | } |
| 266 | @media (max-width: 480px) { |
| 267 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-core, |
| 268 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-dot { display: none; } |
| 269 | .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; } |
| 270 | } |
| 271 | .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; } |
| 272 | .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); } |
| 273 | .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); } |
| 274 | .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; } |
| 275 | .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); } |
| 276 | .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); } |
| 277 | .openvibe-navbar-dropdown-header .ud-wallet { display: inline-block; margin-right: 6px; } |
| 278 | .openvibe-navbar-dropdown-header .ud-wallet[hidden] { display: none; } |
| 279 | .openvibe-navbar-dropdown-header .ud-vibes a { color: #4ade80; background: rgba(74,222,128,.12); border-color: rgba(74,222,128,.3); } |
| 280 | .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); } |
| 281 | .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; } |
| 282 | |
| 283 | .openvibe-navbar-links { display: flex; align-items: center; gap: 2px; margin-left: 2px; } |
| 284 | .openvibe-navbar-links > *, .openvibe-navbar-right { flex: none; white-space: nowrap; } |
| 285 | @media (min-width: 861px) { .openvibe-navbar .openvibe-navbar-brand { flex: none; } } |
| 286 | .openvibe-navbar-links > .ovnav-out { display: none; } |
| 287 | .openvibe-navbar-links a { |
| 288 | padding: 6px 12px; border-radius: 6px; font-size: 13px; font-weight: 500; |
| 289 | color: var(--text-secondary, #b0b0b8); text-decoration: none; |
| 290 | transition: all .15s; |
| 291 | } |
| 292 | .openvibe-navbar-links a:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); } |
| 293 | .openvibe-navbar-links a.active { background: var(--bg-tertiary, #2a2a38); color: var(--accent-light, #60a5fa); } |
| 294 | /* A featured link (4.117.0): one quiet accent pill with its glyph, for the piece a site is |
| 295 | built around (the Node on the Network). Deliberate, not loud: one hairline, one tint, the |
| 296 | glyph, and nothing that moves. */ |
| 297 | .openvibe-navbar-links a.ovnav-link--featured { |
| 298 | display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px 5px 10px; |
| 299 | border-radius: 999px; font-weight: 650; color: var(--accent-light, #60a5fa); |
| 300 | border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); |
| 301 | background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); |
| 302 | transition: none; |
| 303 | } |
| 304 | .openvibe-navbar-links a.ovnav-link--featured:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); color: var(--accent-light, #60a5fa); } |
| 305 | .openvibe-navbar-links a.ovnav-link--featured .icon { opacity: 1; margin-right: 0; color: var(--accent-light, #60a5fa); } |
| 306 | .openvibe-navbar-links a.ovnav-link--featured .ovnav-ic { width: 15px; height: 15px; } |
| 307 | |
| 308 | .openvibe-navbar-spacer { flex: 1; } |
| 309 | |
| 310 | .openvibe-navbar-right { display: flex; align-items: center; gap: 6px; } |
| 311 | /* The person (or Sign In) is last, after the menu button, with a little air between them. */ |
| 312 | .openvibe-navbar .ovnav-burger + :is(.openvibe-navbar-avatar, .openvibe-navbar-login) { margin-left: 3px; } |
| 313 | .openvibe-navbar .ovnav-burger + .openvibe-navbar-login { margin-left: 6px; } |
| 314 | /* The OpenVibe menu's button (4.187.0; the owner's pick of 2026-11-10: a search bar over three apps). Under |
| 315 | the pointer its parts lift in turn; while the menu loads the apps breathe; open, the bar and the apps cross |
| 316 | into the close mark. Transform, opacity and fill only, and nothing moves while it rests. */ |
| 317 | .openvibe-navbar .ovnav-orb { all: unset; box-sizing: border-box; position: relative; display: inline-grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 11px; cursor: pointer; color: var(--text-secondary, #a8b3c4); --ovo-a: var(--accent-light, var(--accent, #60a5fa)); --ovo-b: color-mix(in oklab, var(--accent, #3b82f6) 45%, #e879f9); } |
| 318 | .openvibe-navbar .ovnav-orb::before { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--accent, #3b82f6) 38%, transparent), transparent); opacity: 0; transform: scale(.5); transition: opacity .25s, transform .4s cubic-bezier(.2,1.4,.3,1); pointer-events: none; } |
| 319 | .openvibe-navbar .ovnav-orb:hover::before, .openvibe-navbar .ovnav-orb:focus-visible::before, .openvibe-navbar .ovnav-orb[aria-expanded="true"]::before { opacity: 1; transform: none; } |
| 320 | .openvibe-navbar .ovnav-orb svg { position: relative; display: block; overflow: visible; } |
| 321 | .ovmb { display: block; overflow: visible; } |
| 322 | .ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.9; } |
| 323 | .ovmb-app { fill: currentColor; } |
| 324 | .ovmb-dot, .ovmb-a1 { fill: var(--ovo-a, var(--accent-light, #60a5fa)); } |
| 325 | .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; } |
| 326 | .ovmb-a0 { transition-delay: .03s; } .ovmb-a1 { transition-delay: .06s; } .ovmb-a2 { transition-delay: .09s; } |
| 327 | /* Under the pointer (and once, the first time it is seen): the bar and the three apps lift in turn. */ |
| 328 | :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: ovmb-wave .7s ease-in-out both; } |
| 329 | .ovmb .ovmb-a0 { animation-delay: .08s; } .ovmb .ovmb-a1 { animation-delay: .15s; } .ovmb .ovmb-a2 { animation-delay: .22s; } |
| 330 | :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); } |
| 331 | :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-a1 { fill: var(--ovo-b, #e879f9); } |
| 332 | /* While the menu loads: the apps breathe in turn. */ |
| 333 | .ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; } |
| 334 | /* Open: the bar is one stroke of the close mark, the three apps the other. */ |
| 335 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; } |
| 336 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; } |
| 337 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; } |
| 338 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); } |
| 339 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); } |
| 340 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); } |
| 341 | :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); } |
| 342 | /* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */ |
| 343 | .ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; } |
| 344 | @keyframes ovmb-form { from { transform: none; } } |
| 345 | .ovnav-orb:not([aria-expanded="true"]) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; } |
| 346 | .ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; } |
| 347 | @keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } } |
| 348 | @keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } } |
| 349 | @keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .5; } 50% { transform: scale(1.18); opacity: 1; } } |
| 350 | .openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); } |
| 351 | .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; } |
| 352 | .openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); } |
| 353 | .openvibe-navbar .openvibe-navbar-brand .ovnav-orb { margin-left: 1px; } |
| 354 | /* The menu button is the orb, at the bar's right on every width (4.148.0 a phone; 4.193.0 a computer too: the |
| 355 | owner, "on the right, even on desktop"). The orb beside the name is not drawn where this one is. */ |
| 356 | .openvibe-navbar .ovnav-orb.ovnav-burger { display: inline-grid; } |
| 357 | .openvibe-navbar:has(.ovnav-burger) .ovnav-orb:not(.ovnav-burger) { display: none; } |
| 358 | /* At the bar's right edge its tap area grows to the left (the brand orb's reached 12 px right, past the screen). */ |
| 359 | .openvibe-navbar .ovnav-orb.ovnav-burger::after { inset: -6px -4px -6px -10px; } |
| 360 | .openvibe-navbar .ovnav-orb.ovnav-burger:hover, .openvibe-navbar .ovnav-orb.ovnav-burger[aria-expanded="true"] { background: none; } |
| 361 | @media (max-width: 860px) { .openvibe-navbar .ovnav-orb.ovnav-burger { display: inline-grid; } } |
| 362 | @media (max-width: 400px) { .openvibe-navbar .ovnav-orb { width: 28px; height: 32px; } } |
| 363 | /* Messages (4.25.0; the owner, 2026-10-28: the floating DM button was "buggy as hell on mobile… app style"): a |
| 364 | site that has messages puts them in the bar beside the bell, with the unread count, instead of a bubble |
| 365 | over the page. */ |
| 366 | .ovnav-ovg { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; overflow: visible; vertical-align: -.2em; } |
| 367 | .ovnav-chip .ovnav-ovg, .ovnav-chip-mini .ovnav-ovg, .ovnav-chips-pop .ovnav-ovg { stroke-width: 2.3; } |
| 368 | .ovnav-ovg > .ovg-s { display: none; fill: currentColor; stroke: none; } |
| 369 | .ovnav-ovg > .ovg-s .ln { fill: none; stroke: currentColor; stroke-width: 2.6px; } |
| 370 | html[data-ov-icons=solid] .ovnav-ovg.has-s, .ovnav-ab.is-here .ovnav-ovg.has-s { stroke-width: 0; } |
| 371 | html[data-ov-icons=solid] .ovnav-ovg.has-s > .ovg-s, .ovnav-ab.is-here .ovnav-ovg.has-s > .ovg-s { display: inline; } |
| 372 | |
| 373 | .openvibe-navbar-avatar { |
| 374 | width: 32px; height: 32px; border-radius: 50%; cursor: pointer; |
| 375 | border: 2px solid var(--border, #333340); transition: border-color .2s; |
| 376 | object-fit: cover; |
| 377 | } |
| 378 | .openvibe-navbar-avatar:hover { border-color: var(--accent, #3b82f6); } |
| 379 | .openvibe-navbar .ovnav-avatar-wrap { position: relative; display: inline-flex; flex: none; } |
| 380 | .openvibe-navbar .ovnav-avatar-count { position: absolute; top: -5px; right: -6px; min-width: 17px; height: 17px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; border-radius: 999px; background: var(--live-red, #e5484d); color: #fff; font-family: inherit; font-size: 10px; font-weight: 700; line-height: 1; } |
| 381 | .openvibe-navbar .ovnav-avatar-count[hidden] { display: none; } |
| 382 | |
| 383 | /* Sign in: a filled circle where the person's picture will be; a figure and an arrow coming in take turns. */ |
| 384 | .openvibe-navbar-login { display: inline-grid; place-items: center; background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border: none; cursor: pointer; text-decoration: none; transition: filter .15s, box-shadow .25s, transform .18s cubic-bezier(.2, 1.4, .3, 1); } |
| 385 | .openvibe-navbar-login:hover, .openvibe-navbar-login:focus-visible { filter: brightness(1.12); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent); } |
| 386 | .openvibe-navbar-login:active { transform: scale(.9); } |
| 387 | .ovsi { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; overflow: visible; } |
| 388 | .ovsi-p { animation: ovsi-p 6s ease-in-out 2s infinite; } |
| 389 | .ovsi-a { opacity: 0; animation: ovsi-a 6s ease-in-out 2s infinite; } |
| 390 | :hover > .ovsi .ovsi-p { animation: none; opacity: 0; } |
| 391 | :hover > .ovsi .ovsi-a { animation: none; opacity: 1; } |
| 392 | html[data-ov-motion=off] .ovsi :is(.ovsi-p, .ovsi-a) { animation: none; } |
| 393 | .ovsi-o { opacity: 0; stroke-dasharray: 30 39; } |
| 394 | .is-going .ovsi-o { opacity: 1; animation: ovsi-go 1s linear infinite; } |
| 395 | .is-going .ovsi-p { opacity: .55; } |
| 396 | @keyframes ovsi-p { 0%, 62%, 100% { opacity: 1; transform: none; } 70%, 92% { opacity: 0; transform: translateX(4px); } } |
| 397 | @keyframes ovsi-a { 0%, 62%, 100% { opacity: 0; transform: translateX(-5px); } 70%, 92% { opacity: 1; transform: none; } } |
| 398 | @keyframes ovsi-go { to { stroke-dashoffset: -69; } } |
| 399 | /* 4.148.0 (the owner, 11-08: "the things need to be more consistent in the nav bar in size"): one height for |
| 400 | every control at the bar's right, 36 px (34 px on a phone under 400 px): Sign In, the orb, messages, the |
| 401 | picture, the balances and a featured link. The orb draws its dots at 26 px (24) so it reads as large as |
| 402 | Sign In beside it; Sign In's corners follow the orb's. */ |
| 403 | .openvibe-navbar .ovnav-orb { width: 36px; height: 36px; } |
| 404 | .openvibe-navbar .ovnav-orb svg { width: 26px; height: 26px; } |
| 405 | .openvibe-navbar .ovnav-featured-phone { width: 36px; height: 36px; } |
| 406 | .openvibe-navbar .openvibe-navbar-avatar { width: 36px; height: 36px; box-sizing: border-box; } |
| 407 | .openvibe-navbar .ovnav-chip { height: 36px; } |
| 408 | .openvibe-navbar .openvibe-navbar-login { box-sizing: border-box; width: 36px; height: 36px; padding: 0; border-radius: 50%; } |
| 409 | @media (max-width: 400px) { |
| 410 | .openvibe-navbar .ovnav-orb, .openvibe-navbar .ovnav-featured-phone, .openvibe-navbar .openvibe-navbar-avatar { width: 34px; height: 34px; } |
| 411 | .openvibe-navbar .ovnav-orb svg { width: 24px; height: 24px; } |
| 412 | .openvibe-navbar .ovnav-chip, .openvibe-navbar .openvibe-navbar-login { height: 34px; } |
| 413 | .openvibe-navbar .openvibe-navbar-login { width: 34px; } |
| 414 | } |
| 415 | |
| 416 | /* The account's block (4.188.0): not a popup. It rests in the bar out of sight and is a block of the menu's |
| 417 | You tab while the menu is open: no position, no shadow, no height of its own. */ |
| 418 | .openvibe-navbar-dropdown { display: none; flex-direction: column; border: 1px solid var(--border, #333340); } |
| 419 | .openvibe-navbar-dropdown > * { flex-shrink: 0; } |
| 420 | .ovh-account > .openvibe-navbar-dropdown.ovnav-account .openvibe-navbar-dropdown-header .ud-hbtn { min-width: 44px; min-height: 44px; } |
| 421 | .ovh-account > .openvibe-navbar-dropdown.ovnav-account { display: flex; overflow: hidden; margin: 2px 4px 10px; border-radius: 16px; |
| 422 | background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 10%, transparent); } |
| 423 | @keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } |
| 424 | .ov-pop { transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ov-pop .34s cubic-bezier(.2, 1.1, .3, 1) both !important; } |
| 425 | .ov-pop-out { transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ov-pop-out .16s ease-in both !important; pointer-events: none; } |
| 426 | @keyframes ov-pop { 0% { opacity: 0; transform: scale(.88); clip-path: circle(0 at var(--ov-pop-x, 50%) var(--ov-pop-y, 0)); filter: blur(5px); } 55% { opacity: 1; filter: blur(0); } 100% { opacity: 1; transform: none; clip-path: circle(150% at var(--ov-pop-x, 50%) var(--ov-pop-y, 0)); } } |
| 427 | @keyframes ov-pop-out { to { opacity: 0; transform: scale(.94); filter: blur(3px); } } |
| 428 | @keyframes ov-fade { from { opacity: 0; } } |
| 429 | @keyframes ov-pop-calm { from { opacity: 0; transform: scale(.97); } } |
| 430 | html[data-ov-motion=reduced] .ov-pop { animation-name: ov-pop-calm !important; animation-duration: .18s !important; } |
| 431 | html[data-ov-motion=reduced] .ov-pop-out { animation-name: ov-fade !important; animation-duration: .12s !important; animation-direction: reverse !important; } |
| 432 | |
| 433 | .openvibe-navbar-dropdown-header { |
| 434 | padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340); |
| 435 | display: flex; align-items: center; gap: 11px; |
| 436 | } |
| 437 | .openvibe-navbar-dropdown-header img { width: 40px; height: 40px; border-radius: 50%; } |
| 438 | .openvibe-navbar-dropdown-header .ud-hacts { display: flex; gap: 2px; align-self: flex-start; } |
| 439 | .openvibe-navbar-dropdown .ud-hbtn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); text-decoration: none; } |
| 440 | .openvibe-navbar-dropdown .ud-hbtn:hover, .openvibe-navbar-dropdown .ud-hbtn:focus-visible { background: var(--bg-hover, rgba(255,255,255,.08)); color: var(--text-primary, #e6edf7); } |
| 441 | .openvibe-navbar-dropdown .ud-hbtn .icon { font-size: 15px; } |
| 442 | .openvibe-navbar-dropdown-header .info { line-height: 1.3; } |
| 443 | .openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; } |
| 444 | .openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); } |
| 445 | .openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); } |
| 446 | |
| 447 | /* Other accounts: a row of small pills under the name (one tap switches); Anonymous is one of them. */ |
| 448 | .openvibe-navbar-dropdown-accounts { |
| 449 | display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border, #333340); |
| 450 | overflow-x: auto; scrollbar-width: none; |
| 451 | } |
| 452 | .openvibe-navbar-dropdown-accounts .account-item { |
| 453 | display: inline-flex; align-items: center; gap: 6px; flex: none; max-width: 150px; height: 32px; padding: 0 10px 0 4px; |
| 454 | border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); cursor: pointer; font-size: 12.5px; font-weight: 600; |
| 455 | color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s; |
| 456 | } |
| 457 | .openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 458 | .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; max-width: none; color: var(--text-secondary, #a8b3c4); } |
| 459 | .openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); } |
| 460 | .openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; } |
| 461 | .openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; } |
| 462 | |
| 463 | .openvibe-navbar-dropdown-menu { padding: 6px 8px; } |
| 464 | .openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button { |
| 465 | display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box; |
| 466 | padding: 7px 8px; border-radius: 8px; font-size: 13px; font-weight: 500; |
| 467 | background: none; border: none; color: var(--text-primary, #e0e0e0); |
| 468 | cursor: pointer; text-align: left; text-decoration: none; |
| 469 | transition: background .12s; |
| 470 | } |
| 471 | .openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); } |
| 472 | .openvibe-navbar-dropdown-menu .danger { color: var(--live-red-text, var(--live-red, #e74c3c)); } |
| 473 | /* Light / Dark / Auto (theme-loader.js scheme): three equal segments, the one in use filled. */ |
| 474 | /* Your places: a grid of icons over short names, four across (the menu was 13 rows and a display block). */ |
| 475 | .openvibe-navbar-dropdown-menu .ud-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 4px; } |
| 476 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { flex-direction: column; justify-content: flex-start; gap: 6px; min-height: 66px; padding: 9px 2px 7px; text-align: center; border-radius: 12px; font-size: 11.5px; font-weight: 600; line-height: 1.2; } |
| 477 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile[hidden] { display: none; } |
| 478 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .icon { width: 38px; height: 30px; border-radius: 10px; font-size: 16px; background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); } |
| 479 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile:hover .icon { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); } |
| 480 | .openvibe-navbar-dropdown-menu .ud-tl { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; } |
| 481 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .ud-val { margin: 0; font-size: 10.5px; } |
| 482 | .openvibe-navbar-dropdown-menu .ud-more { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin: 2px -8px 0; padding: 2px 8px; } |
| 483 | .openvibe-navbar-dropdown-menu .ud-more summary { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 600; color: var(--text-secondary, #a8b3c4); } |
| 484 | .openvibe-navbar-dropdown-menu .ud-more summary::-webkit-details-marker { display: none; } |
| 485 | .openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); } |
| 486 | .openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); } |
| 487 | .openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); } |
| 488 | /* The tiles never leave one alone on a row: five or fewer share one row, six make two of three, seven and |
| 489 | eight two of four (the owner's phone, 2026-10-28: an Admin tile on a row of its own). */ |
| 490 | .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(3):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); } |
| 491 | .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(5):last-child) { grid-template-columns: repeat(5, minmax(0, 1fr)); } |
| 492 | .openvibe-navbar-dropdown-menu .ud-grid:has(> .ud-tile:nth-child(6):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); } |
| 493 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { min-height: 60px; padding: 7px 2px 6px; gap: 5px; } |
| 494 | .openvibe-navbar-dropdown-header .ud-me img { width: 52px; height: 52px; } |
| 495 | /* The balances in the header: OpenCoins and Vibes side by side, each a coloured icon, a number and its name. */ |
| 496 | .openvibe-navbar-dropdown-header .ud-wallet a small { font-weight: 600; opacity: .75; font-size: 10.5px; } |
| 497 | /* On a phone the menu is a sheet the width of the screen under the bar, not a narrow card off to the right. */ |
| 498 | @media (max-width: 560px) { |
| 499 | .openvibe-navbar .openvibe-navbar-dropdown { left: 8px; right: 8px; width: auto; max-width: none; border-radius: 18px; } |
| 500 | .openvibe-navbar-dropdown-header { padding: 14px 10px 12px 14px; } |
| 501 | .openvibe-navbar-dropdown-header img { width: 48px; height: 48px; } |
| 502 | .openvibe-navbar-dropdown-header .info .name { font-size: 16px; } |
| 503 | .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { min-height: 72px; font-size: 12px; } |
| 504 | .openvibe-navbar-dropdown-accounts .account-item { max-width: 46vw; } |
| 505 | } |
| 506 | /* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */ |
| 507 | .openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); } |
| 508 | .openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); } |
| 509 | .openvibe-navbar-dropdown-menu .ud-foot > .ud-ib { flex: 1 1 0; } |
| 510 | .openvibe-navbar-dropdown-menu .ud-foot > .ud-disp { flex: 0 0 auto; gap: 7px; padding: 0 12px; font-size: 13px; font-weight: 650; } |
| 511 | .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; } |
| 512 | .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; } |
| 513 | .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; } |
| 514 | .openvibe-navbar-dropdown-menu .ud-foot .ud-ib[aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); } |
| 515 | .openvibe-navbar-dropdown-menu .ud-foot .ud-scheme .ud-ib[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); } |
| 516 | .openvibe-navbar-dropdown .ud-hbtn.ud-out { all: unset; box-sizing: border-box; cursor: pointer; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); } |
| 517 | .openvibe-navbar-dropdown .ud-hbtn.ud-out:hover, .openvibe-navbar-dropdown .ud-hbtn.ud-out:focus-visible { color: var(--live-red-text, var(--live-red, #f87171)); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); } |
| 518 | .openvibe-navbar-dropdown-menu .ud-look, .ovnav-display .ud-look { flex-basis: 100%; } |
| 519 | .openvibe-navbar-dropdown-menu .ud-scheme { display: inline-flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.06)); } |
| 520 | .openvibe-navbar-dropdown-menu .ud-scheme[hidden] { display: none; } |
| 521 | .openvibe-navbar-dropdown-menu .ud-scheme button { width: 32px; min-width: 0; height: 30px; padding: 0; justify-content: center; border-radius: 9px; color: var(--text-secondary, #a8b3c4); } |
| 522 | .openvibe-navbar-dropdown-menu .ud-scheme button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); } |
| 523 | .openvibe-navbar-dropdown-menu .ud-scheme button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } |
| 524 | .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 14px; } |
| 525 | /* One box for every icon in the menu: a glyph keeps its height and is centred, whatever its width (an id card |
| 526 | is wider than a bell), so the rows' icons line up and read as one set. */ |
| 527 | .openvibe-navbar-dropdown-menu .icon { width: 20px; height: 16px; font-size: 15px; color: var(--text-secondary, #a8b3c4); } |
| 528 | .openvibe-navbar-dropdown-menu .icon .ovnav-ic { width: 1.25em !important; height: 1em; } |
| 529 | .openvibe-navbar-dropdown-menu a:hover .icon, .openvibe-navbar-dropdown-menu button:hover .icon { color: var(--text-primary, #e0e0e0); } |
| 530 | .openvibe-navbar-dropdown-menu .danger .icon { color: inherit; } |
| 531 | .openvibe-navbar-dropdown-menu .ud-go { font-size: 16px; font-weight: 700; color: var(--text-muted, #7d8aa0); } |
| 532 | |
| 533 | .openvibe-navbar .openvibe-network-badge { |
| 534 | font-size: 10px; padding: 2px 8px; border-radius: 4px; |
| 535 | background: rgba(59,130,246,0.1); color: var(--accent-light, #60a5fa); |
| 536 | font-weight: 500; cursor: pointer; border: 1px solid transparent; |
| 537 | transition: all .15s; |
| 538 | } |
| 539 | .openvibe-navbar .openvibe-network-badge:hover { border-color: var(--accent-dark, #2563eb); } |
| 540 | |
| 541 | @media (max-width: 600px) { |
| 542 | .openvibe-navbar-links { display: none; } |
| 543 | .openvibe-navbar .openvibe-network-badge { display: none; } |
| 544 | } |
| 545 | |
| 546 | /* No square tap flash: a slight press (scale composes with transforms) and one accent focus ring, at zero specificity. */ |
| 547 | .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer { -webkit-tap-highlight-color: transparent; } |
| 548 | :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; } |
| 549 | :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible { |
| 550 | outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px; |
| 551 | } |
| 552 | html[data-ov-motion=off] :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active{scale: none;opacity: .82} |
| 553 | `; |
| 554 | document.head.appendChild(s); |
| 555 | } |
| 556 | |
| 557 | // ── Icons ──────────────────────────────────────────────── |
| 558 | // BEGIN generated by scripts/build-navbar-icons.js from nav-icons.js; do not edit by hand |
| 559 | // The glyphs the navbar draws itself: [0, line, solid], OpenVibe glyphs on a 24-unit grid. The rest of the set is nav-icons.js. |
| 560 | const NAV_ICONS = { |
| 561 | 'fa-bell': [0, '<path d="M6 14.5V9.5a6 6 0 0 1 12 0v5l1.5 2h-15.5ZM10 18.5a2 2 0 0 0 4 0"/>', '<path d="M5 15.5V9.5a7 7 0 0 1 13.5 0v5.5l2 2.5H3.5ZM9.5 19.5h4.5a2.5 2.5 0 0 1-4.5 0Z"/>'], |
| 562 | 'fa-clock-rotate-left': [0, '<path d="M4 12a8.5 8.5 0 1 0 2.5-6L4 8.5M4 4v4h4"/><path d="M12 8v4.5l3 2"/>', '<path d="M6 6A9 9 0 1 1 3 12h3a6 6 0 1 0 2-4.5ZM2.5 2.5v7h7ZM11 8h2v3.5l2.5 1.5l-1 2l-4-2.5Z"/>'], |
| 563 | 'fa-coins': [0, '<circle cx="12" cy="12" r="8.5"/><path d="M14.5 9c-0.5-1-1.5-1-2.5-1-1.5 0-2.5 1-2.5 2s1 1.5 2.5 2 2.5 1 2.5 2-1 2-2.5 2c-1 0-2-0.5-2.5-1M12 6.5v11"/>', '<path fill-rule="evenodd" d="M13 2.5a9.5 9.5 0 1 0 0 18.5a9.5 9.5 0 0 0 0-18.5ZM12 6h2v1c1 0 2 1 2.5 1.5l-1.5 1c-0.5-0.5-1-1-2-1c-1 0-1.5 0.5-1.5 1s0.5 1 2 1c2 0.5 3.5 1 3.5 3c0 1.5-1 2.5-3 3V18h-2v-1.5c-1 0-2.5-1-3-2l1.5-1c0.5 0.5 1 1 2 1c1 0 1.5-0.5 1.5-1s-0.5-1-2-1c-1.5-0.5-3.5-1-3.5-3c0-1.5 1-2.5 2.5-2.5Z"/>'], |
| 564 | 'fa-id-card': [0, '<rect x="3.7" y="5.5" width="16.7" height="13" rx="1.9"/><circle cx="9.2" cy="11" r="1.9"/><path d="M6.5 15.5c0.5-1.5 1.5-2 3-2s2.5 1 3 2M15 10h3M15 13.5h3"/>', '<path fill-rule="evenodd" d="M5 4.5h14.5a2.5 2.5 0 0 1 2.5 2.5v10.5a2.5 2.5 0 0 1-2.5 2.5H5A2.5 2.5 0 0 1 2.5 17V7A2.5 2.5 0 0 1 5 4.5Zm4 4a2 2 0 1 0 0 4.5a2 2 0 0 0 0-4.5Zm-3 7.5h6.5c-0.5-1.5-2-2.5-3-2.5s-2.5 1-3 2.5Zm9-7v1.5h4V9.5Zm0 3.5v1.5h4v-1.5Z"/>'], |
| 565 | 'fa-palette': [0, '<path d="M12 4a8.5 8.5 0 1 0 0 17c1 0 1.5-1 1.5-1.5c0-1-1-1.5-1-2.5s1-1.5 2-1.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 17 4 12 4Z"/><circle cx="8" cy="11.4" r="1.1"/><circle cx="10.3" cy="7.9" r="1.1"/><circle cx="14.5" cy="7.9" r="1.1"/>', '<path fill-rule="evenodd" d="M12 2.5a9.5 9.5 0 1 0 0 19c1.5 0 2-1 2-2c0-1-1-1.5-1-2.5s1-1.5 2-1.5H17.5a4.5 4.5 0 0 0 4.5-4.5C21.5 6.5 17.5 2.5 12 2.5ZM8 9.5a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Zm2.5-3.5a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Zm4.5 0a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3Z"/>'], |
| 566 | 'fa-people-group': [0, '<circle cx="9.2" cy="7.9" r="3"/><path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5M15.5 5a3 3 0 0 1 0 6M17.5 14.5c2 0.5 3 2.5 3 5"/>', '<circle cx="9.1" cy="7.1" r="3.5"/><path d="M2.5 19.5c0-3.5 3-6.5 6.5-6.5s6.5 2.5 6.5 6.5Z"/><circle cx="16.5" cy="6.6" r="2.8"/><path d="M16.5 12.5c3 0.5 4.5 2.5 4.5 6V19.5h-4.5c0-3-1-5-2.5-6.5Z"/>'], |
| 567 | 'fa-plus': [0, '<path d="M12 3.5v17M3.5 12h17"/>', ''], |
| 568 | 'fa-right-from-bracket': [0, '<path d="M12.5 3.5h4a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2h-4M8.5 16l-4-4l4-4M4 12h10.5"/>', ''], |
| 569 | 'fa-shield-halved': [0, '<path d="M12 3.5L5.5 6.5v5.5c0 4 3 7 6.5 8.5c3.5-1.5 6.5-4 6.5-8.5V6.5Z"/>', '<path d="M12 2.5 4.5 6V12c0 4.5 3 8 7.5 9.5 4.5-1.5 7.5-5 7.5-9.5V6Z"/>'], |
| 570 | 'fa-tower-broadcast': [0, '<circle cx="12" cy="11.8" r="2"/><path d="M8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7M6.5 6a8 8 0 0 0 0 12M17.5 6a8 8 0 0 1 0 12"/>', '<circle cx="12" cy="12" r="2.5"/><path class="ln" d="M8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7M6 6a8 8 0 0 0 0 11.5M18 6a8 8 0 0 1 0 11.5"/>'], |
| 571 | 'fa-user-secret': [0, '<circle cx="12" cy="7.4" r="3.8"/><path d="M4.5 20.5c0-4 3.5-6.5 7.5-6.5s7.5 2.5 7.5 6.5"/>', '<circle cx="12" cy="6.7" r="4.5"/><path d="M3.5 21c0-4.5 4-8 8.5-8s8.5 3 8.5 8Z"/>'], |
| 572 | 'fa-wallet': [0, '<path d="M3.5 7A2.5 2.5 0 0 1 6 4H18.5v4M3.5 7V18a2 2 0 0 0 2 2h15V8.5H6A2.5 2.5 0 0 1 3.5 7Z"/><path d="M16 14h0"/>', '<path fill-rule="evenodd" d="M5.5 3H19v4.5h1.5a1 1 0 0 1 1 1v12.5H5a2.5 2.5 0 0 1-2.5-2.5V6a3 3 0 0 1 3-3Zm0 2a1 1 0 0 0 0 2h11V5ZM16.5 13a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3Z"/>'], |
| 573 | 'fa-chevron-down': [0, '<path d="M3.5 8l8.5 8.5l8.5-8.5"/>', ''], |
| 574 | 'fa-gear': [0, '<circle cx="12" cy="12.1" r="2.3"/><path d="M17.5 14.5a1.5 1.5 0 0 0 0 1.5l0 0a1.5 1.5 0 1 1-2.5 2.5l0 0a1.5 1.5 0 0 0-1.5 0a1.5 1.5 0 0 0-1 1.5V19a1.5 1.5 0 1 1-3.5 0v0a1.5 1.5 0 0 0-1-1.5a1.5 1.5 0 0 0-1.5 0l0 0a1.5 1.5 0 1 1-2.5-2.5l0 0a1.5 1.5 0 0 0 0-1.5a1.5 1.5 0 0 0-1.5-1H5.5a1.5 1.5 0 1 1 0-3.5h0a1.5 1.5 0 0 0 1.5-1a1.5 1.5 0 0 0 0-1.5l0 0a1.5 1.5 0 1 1 2.5-2.5l0 0a1.5 1.5 0 0 0 1.5 0H10a1.5 1.5 0 0 0 1-1.5V5.5a1.5 1.5 0 1 1 3.5 0v0a1.5 1.5 0 0 0 1 1.5a1.5 1.5 0 0 0 1.5 0l0 0a1.5 1.5 0 1 1 2.5 2.5l0 0a1.5 1.5 0 0 0 0 1.5V10a1.5 1.5 0 0 0 1.5 1H18.5a1.5 1.5 0 1 1 0 3.5h0a1.5 1.5 0 0 0-1.5 1Z"/>', '<path fill-rule="evenodd" d="M18 14.5a1.5 1.5 0 0 0 0.5 1.5l0 0a1.5 1.5 0 1 1-2.5 2.5l0 0a1.5 1.5 0 0 0-1.5-0.5a1.5 1.5 0 0 0-1 1.5V19.5a1.5 1.5 0 1 1-4 0v0a1.5 1.5 0 0 0-1-1.5a1.5 1.5 0 0 0-1.5 0.5l0 0a1.5 1.5 0 1 1-2.5-2.5l0 0a1.5 1.5 0 0 0 0.5-1.5a1.5 1.5 0 0 0-1.5-1H4.5a1.5 1.5 0 1 1 0-4h0a1.5 1.5 0 0 0 1.5-1a1.5 1.5 0 0 0-0.5-1.5l0 0a1.5 1.5 0 1 1 2.5-2.5l0 0a1.5 1.5 0 0 0 1.5 0.5H9.5a1.5 1.5 0 0 0 1-1.5V4.5a1.5 1.5 0 1 1 4 0v0a1.5 1.5 0 0 0 1 1.5a1.5 1.5 0 0 0 1.5-0.5l0 0a1.5 1.5 0 1 1 2.5 2.5l0 0a1.5 1.5 0 0 0-0.5 1.5V9.5a1.5 1.5 0 0 0 1.5 1H19.5a1.5 1.5 0 1 1 0 4h0a1.5 1.5 0 0 0-1.5 1ZM12 9.5a3 3 0 1 0 0 6.5a3 3 0 0 0 0-6.5Z"/>'], |
| 575 | 'fa-circle-half-stroke': [0, '<circle cx="11.7" cy="12" r="8.4"/><path d="M11.5 3.5v16.5M11.5 8h6.5M11.5 12h8.5M11.5 16h6.5"/>', '<path fill-rule="evenodd" d="M12.5 2.5a9.5 9.5 0 1 0 0 18.5a9.5 9.5 0 0 0 0-18.5Zm0 2a7 7 0 0 1 0 14.5Z"/>'], |
| 576 | 'fa-moon': [0, '<path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11Z"/>', '<path d="M21.5 14.5A9.5 9.5 0 1 1 9.5 2.5a8 8 0 0 0 12.5 12.5Z"/>'], |
| 577 | 'fa-sun': [0, '<circle cx="11.9" cy="11.9" r="3.4"/><path d="M12 3.5v2M12 18.5v2M6 6l1 1M16.5 16.5l1 1M3.5 12h2M18.5 12h2M6 18l1-1M16.5 7l1-1"/>', '<circle cx="12" cy="12" r="3.8"/><path class="ln" d="M12 4v1.5M12 18.5v1.5M6.5 6l1 1M17 17L18 18M4 12h1.5M18.5 12h1.5M6.5 18l1-1M17 7L18 6"/>'], |
| 578 | 'fa-brain': [0, '<path d="M9 4a3 3 0 0 0-3 3a3 3 0 0 0-2 5a3 3 0 0 0 2 5a3 3 0 0 0 6 1V6A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3a3 3 0 0 1 2 5a3 3 0 0 1-2 5a3 3 0 0 1-6 1"/>', '<path d="M9 4a3 3 0 0 0-3 3a3 3 0 0 0-2 5.5a3 3 0 0 0 2 5a3 3 0 0 0 5.5 1.5V5.5A2.5 2.5 0 0 0 9 4Z"/><path d="M15 4a3 3 0 0 1 3 3a3 3 0 0 1 2 5.5a3 3 0 0 1-2 5a3 3 0 0 1-5.5 1.5V5.5A2.5 2.5 0 0 1 15 4Z"/>'], |
| 579 | }; |
| 580 | // Every other glyph in nav-icons.js, by width (0: a 24-unit OpenVibe glyph), so its slot has its size before the path arrives. |
| 581 | const NAV_ICONS_LAZY = { |
| 582 | 0: 'fa-book fa-camera fa-comments fa-gamepad fa-gauge-high fa-hand-fist fa-house fa-link fa-list-check fa-network-wired fa-paste fa-people-arrows fa-scissors fa-screwdriver-wrench fa-shirt fa-text-height fa-user fa-video fa-wand-magic-sparkles fa-arrow-up-right-from-square fa-chevron-right fa-bars fa-xmark fa-magnifying-glass fa-file-pdf fa-arrow-down fa-arrow-right fa-clock fa-envelope fa-star fa-heart fa-play fa-circle-info fa-triangle-exclamation fa-check fa-globe fa-code fa-robot fa-photo-film fa-tower-cell fa-diagram-project fa-toolbox fa-comment-dots fa-money-bill-wave fa-crown fa-server fa-newspaper fa-tag fa-ticket fa-chart-line fa-pen-nib fa-key fa-plug fa-bolt fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music fa-file-lines fa-font fa-location-dot fa-lock fa-wave-square fa-map fa-utensils fa-user-shield fa-gauge fa-pen fa-earth-americas fa-hashtag fa-inbox fa-folder fa-puzzle-piece fa-shield fa-display fa-microphone fa-infinity fa-list fa-flag-checkered fa-tv fa-compass', |
| 583 | }; |
| 584 | const NAV_ICONS_REV = '46fab66e2bf7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever) |
| 585 | // END generated |
| 586 | // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs. |
| 587 | // Other icons load from nav-icons.js after first paint; a name the bar's set lacks is drawn by ov-glyphs.js. |
| 588 | const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''; |
| 589 | let _lazyWidth = null; |
| 590 | let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable |
| 591 | // "Ask Vibe": the selected text (≤1000) and the page's address (≤400) into a new chat with Vibe. |
| 592 | function selectedText() { try { const s = root.getSelection(); return s && !s.isCollapsed ? String(s).replace(/\s+/g, ' ').trim().slice(0, 1000) : ''; } catch { return ''; } } |
| 593 | function askVibeHref(t) { |
| 594 | const c = 'https://openvibe.chat/vibe', u = new URL(location.href); u.hash = ''; |
| 595 | return t ? `${c}?ask=${encodeURIComponent(t)}${u.protocol === 'https:' && u.href.length <= 400 ? `&from=${encodeURIComponent(u.href)}` : ''}` : c; |
| 596 | } |
| 597 | function lazyWidth(name) { |
| 598 | if (!_lazyWidth) { _lazyWidth = {}; for (const w in NAV_ICONS_LAZY) NAV_ICONS_LAZY[w].split(' ').forEach(n => { _lazyWidth[n] = Number(w); }); } |
| 599 | return Object.prototype.hasOwnProperty.call(_lazyWidth, name) ? _lazyWidth[name] : -1; // 0: an OpenVibe glyph (24×24) |
| 600 | } |
| 601 | function adoptIcons() { |
| 602 | const set = root.OpenVibeNavIcons; |
| 603 | if (!set || typeof set !== 'object') return false; |
| 604 | Object.assign(NAV_ICONS, set); _iconState = 2; |
| 605 | return true; |
| 606 | } |
| 607 | /** |
| 608 | * A shared browser file next to this navbar.js: the same origin and the same release (a site that |
| 609 | * serves its own pinned copy at /shared/ gets its own), else the network's copy. |
| 610 | */ |
| 611 | // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs. |
| 612 | const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '2ee871bd8ffa', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '14fc6ffd84b8', 'dock.js': 'a17fcbc5fe4c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'a36477cdd6e4', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '51840ff5c0b9', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': 'fbc3cc30fd2e' }; |
| 613 | const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`; |
| 614 | |
| 615 | // ov-tip.js loads at the first hover over a tooltip's owner. |
| 616 | function tipWait() { |
| 617 | if (root.__ovTip || !document.addEventListener) return; |
| 618 | root.__ovTip = 1; |
| 619 | const tip = (e) => { |
| 620 | if (!e.target.closest('[data-ov-tip]')) return; |
| 621 | document.removeEventListener('pointerover', tip); |
| 622 | if (!root.OVTip) { const sc = document.createElement('script'); sc.src = siblingV('ov-tip.js'); sc.async = true; document.head.appendChild(sc); } |
| 623 | }; |
| 624 | document.addEventListener('pointerover', tip, { passive: true }); |
| 625 | } |
| 626 | // ov-glyphs.js, when a page shows an icon (<i data-ov-icon>, <i data-ov-brand>, or a Font Awesome name while sites |
| 627 | // still write them): now, or the first time one appears. One watch per page, |
| 628 | // made here and not in the bar's render, so it holds nothing of a bar that a page move (Boost) re-renders. A |
| 629 | // mutation only schedules one look 250 ms later, and the watch ends once the glyphs are loading. |
| 630 | const GLYPH_ICONS = '[data-ov-icon],[data-ov-brand],i[class*="fa-"],span[class*="fa-"]'; |
| 631 | let glyphWatch = null; |
| 632 | function loadGlyphs() { |
| 633 | if (root.OVGlyphs || document.getElementById('ov-glyphs-loader') || document.querySelector('script[src*="/ov-glyphs.js"]')) return true; |
| 634 | if (!document.querySelector(GLYPH_ICONS)) return false; |
| 635 | try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } |
| 636 | return true; |
| 637 | } |
| 638 | function watchGlyphs() { |
| 639 | if (glyphWatch || !document.querySelector || loadGlyphs() || typeof MutationObserver !== 'function' || !document.body) return; |
| 640 | let soon = 0; |
| 641 | glyphWatch = new MutationObserver(() => { |
| 642 | if (soon) return; |
| 643 | soon = setTimeout(() => { soon = 0; if (loadGlyphs() && glyphWatch) { glyphWatch.disconnect(); glyphWatch = true; } }, 250); |
| 644 | }); |
| 645 | glyphWatch.observe(document.body, { childList: true, subtree: true }); |
| 646 | } |
| 647 | // ov-labels.js (content labels, L1), when a page shows something labelled: now, or the first time one appears. |
| 648 | let labelWatch = null; |
| 649 | function loadLabels() { |
| 650 | if (root.OVLabels || document.getElementById('ov-labels-loader')) return true; |
| 651 | if (!document.querySelector('[data-ov-labels]')) return false; |
| 652 | try { const sc = document.createElement('script'); sc.id = 'ov-labels-loader'; sc.src = siblingV('ov-labels.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } |
| 653 | return true; |
| 654 | } |
| 655 | function watchLabels() { |
| 656 | if (labelWatch || !document.querySelector || loadLabels() || typeof MutationObserver !== 'function' || !document.body) return; |
| 657 | let soon = 0; |
| 658 | labelWatch = new MutationObserver(() => { |
| 659 | if (soon) return; |
| 660 | soon = setTimeout(() => { soon = 0; if (loadLabels() && labelWatch) { labelWatch.disconnect(); labelWatch = true; } }, 250); |
| 661 | }); |
| 662 | labelWatch.observe(document.body, { childList: true, subtree: true }); |
| 663 | } |
| 664 | // ov-forms.js and ov-forms.css (4.109.0, the owner 2026-11-03: "a custom system for those select inputs … shared |
| 665 | // across the network and all websites rather than using default stuff"): OpenVibe's own select and form controls, |
| 666 | // loaded once the page settles, for a page with a select or a checkbox, radio, slider or file input — now, or the |
| 667 | // first time one appears. <html data-ov-forms="off"> keeps the browser's own; data-ov-native keeps one control's. |
| 668 | const FORM_CONTROLS = 'select,input[type="checkbox"],input[type="radio"],input[type="range"],input[type="file"]'; |
| 669 | let formWatch = null; |
| 670 | // Only a control that is drawn counts (4.141.0): a closed dialog's or a hidden box's select does not, so a page whose |
| 671 | // shell carries hidden modal forms (Live's) no longer fetches 17 KB it may never use. The hidden ones are watched, |
| 672 | // and the kit loads when one is drawn (a dialog opens, a fold unfolds). |
| 673 | let formSeen = null; |
| 674 | function formsWanted() { |
| 675 | const all = document.querySelectorAll(FORM_CONTROLS); |
| 676 | if (!all.length) return false; |
| 677 | for (let i = 0; i < all.length && i < 200; i++) if (all[i].getClientRects().length) return true; |
| 678 | if (typeof IntersectionObserver === 'function') { |
| 679 | formSeen = formSeen || new IntersectionObserver((es) => { |
| 680 | if (!es.some((e) => e.isIntersecting || e.boundingClientRect.width)) return; |
| 681 | formSeen.disconnect(); formSeen = null; loadForms(); |
| 682 | }); |
| 683 | for (let i = 0; i < all.length && i < 60; i++) formSeen.observe(all[i]); |
| 684 | } |
| 685 | return false; |
| 686 | } |
| 687 | // 4.230.0: the look of every field and button is in ov-forms.css, so a page with any text field, text area or button |
| 688 | // gets the stylesheet once it settles; the select kit's script still waits for a select, a box or a file input. |
| 689 | function formsCss() { |
| 690 | if (document.documentElement.getAttribute('data-ov-forms') === 'off' || document.querySelector('link[href*="ov-forms.css"]')) return; |
| 691 | if (!document.querySelector(FORM_CONTROLS + ',input,textarea,button')) return; |
| 692 | try { const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-forms.css'); document.head.appendChild(ln); } catch { /* */ } |
| 693 | } |
| 694 | function loadForms() { |
| 695 | if (root.OVForms || document.getElementById('ov-forms-loader')) return true; |
| 696 | if (document.documentElement.getAttribute('data-ov-forms') === 'off') return true; |
| 697 | formsCss(); |
| 698 | if (!formsWanted()) return false; |
| 699 | try { |
| 700 | if (!document.querySelector('link[href*="ov-forms.css"]')) { |
| 701 | const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-forms.css'); document.head.appendChild(ln); |
| 702 | } |
| 703 | const sc = document.createElement('script'); sc.id = 'ov-forms-loader'; sc.src = siblingV('ov-forms.js'); sc.async = true; document.head.appendChild(sc); |
| 704 | } catch { /* */ } |
| 705 | return true; |
| 706 | } |
| 707 | // ov-share.js and ov-share.css (4.114.0, plan track Q): the one Share sheet, fetched the first time someone presses a |
| 708 | // [data-ov-share-sheet] button, so a site gets it by putting the button on its page and nothing loads before that. |
| 709 | function shareClick(e) { |
| 710 | const b = e.target && e.target.closest ? e.target.closest('[data-ov-share-sheet]') : null; |
| 711 | if (!b || root.OVShare || b.disabled) return; |
| 712 | e.preventDefault(); |
| 713 | if (document.getElementById('ov-share-loader')) return; |
| 714 | try { |
| 715 | if (!document.querySelector('link[href*="ov-share.css"]')) { |
| 716 | const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = siblingV('ov-share.css'); document.head.appendChild(ln); |
| 717 | } |
| 718 | const sc = document.createElement('script'); sc.id = 'ov-share-loader'; sc.src = siblingV('ov-share.js'); sc.async = true; |
| 719 | sc.onload = () => { if (root.OVShare) root.OVShare.open(root.OVShare.fromButton(b)); }; |
| 720 | document.head.appendChild(sc); |
| 721 | } catch { /* */ } |
| 722 | } |
| 723 | function watchForms() { |
| 724 | if (formWatch || !document.querySelector || typeof MutationObserver !== 'function' || !document.body) return; |
| 725 | const go = () => { |
| 726 | if (loadForms()) return; |
| 727 | let soon = 0; |
| 728 | formWatch = new MutationObserver(() => { |
| 729 | if (soon) return; |
| 730 | soon = setTimeout(() => { soon = 0; if (loadForms() && formWatch) { formWatch.disconnect(); formWatch = true; } }, 250); |
| 731 | }); |
| 732 | formWatch.observe(document.body, { childList: true, subtree: true }); |
| 733 | }; |
| 734 | formWatch = true; |
| 735 | if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => { formWatch = null; go(); }, { timeout: 2000 }); |
| 736 | else { const t = setTimeout(() => { formWatch = null; go(); }, 1200); if (t && typeof t.unref === 'function') t.unref(); } |
| 737 | } |
| 738 | // A scrolling box with nothing focusable gets tabindex="0" so a keyboard can scroll it (WCAG 2.1.1); a page's own wins. |
| 739 | const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]'; |
| 740 | function focusScrollers() { |
| 741 | if (!document.body || !document.body.getElementsByTagName) return 0; |
| 742 | let n = 0; |
| 743 | const all = document.body.getElementsByTagName('*'); |
| 744 | for (let i = 0; i < all.length; i++) { |
| 745 | const el = all[i]; |
| 746 | // Cheap first: only a box whose content is bigger than it is, and that has no tabindex of its own. |
| 747 | if (el.scrollWidth <= el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) continue; |
| 748 | if (el.hasAttribute('tabindex') || el.closest('#openvibe-navbar, .openvibe-navbar, .ovf, footer')) continue; |
| 749 | const cs = getComputedStyle(el); |
| 750 | const sx = /(auto|scroll)/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 1; |
| 751 | const sy = /(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1; |
| 752 | if (!sx && !sy) continue; |
| 753 | if (el.querySelector(SCROLL_FOCUSABLE)) continue; |
| 754 | el.setAttribute('tabindex', '0'); |
| 755 | el.setAttribute('data-ov-scroll-focus', ''); |
| 756 | n++; |
| 757 | } |
| 758 | return n; |
| 759 | } |
| 760 | let scrollWatch = false; |
| 761 | function watchScrollers() { |
| 762 | if (scrollWatch || typeof window === 'undefined' || !document.body) return; |
| 763 | scrollWatch = true; |
| 764 | const idle = (fn, t) => (typeof root.requestIdleCallback === 'function' ? root.requestIdleCallback(fn, { timeout: t }) : setTimeout(fn, Math.min(t, 1500))); |
| 765 | const run = () => { try { if (typeof getComputedStyle === 'function') focusScrollers(); } catch { /* never a page's problem */ } }; |
| 766 | idle(run, 3000); |
| 767 | setTimeout(() => idle(run, 2000), 4000); |
| 768 | let soon = 0; |
| 769 | if (typeof window.addEventListener === 'function') window.addEventListener('resize', () => { clearTimeout(soon); soon = setTimeout(run, 400); }, { passive: true }); |
| 770 | } |
| 771 | function sibling(name) { |
| 772 | const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc); |
| 773 | return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`; |
| 774 | } |
| 775 | /** nav-icons.js next to this navbar.js (same origin, same release), else the network's copy. */ |
| 776 | function iconsUrl() { |
| 777 | if (_config.iconsUrl) return _config.iconsUrl; |
| 778 | return `${sibling('nav-icons.js')}?v=${NAV_ICONS_REV}`; |
| 779 | } |
| 780 | function iconSlots() { |
| 781 | const out = new Set(); |
| 782 | [_navEl, typeof document !== 'undefined' ? document : null].forEach((r) => { |
| 783 | try { if (r && r.querySelectorAll) r.querySelectorAll('svg[data-ovnav-ic]').forEach(el => out.add(el)); } catch { /* */ } |
| 784 | }); |
| 785 | return out; |
| 786 | } |
| 787 | function fillIcons() { |
| 788 | iconSlots().forEach((el) => { |
| 789 | const g = NAV_ICONS[el.getAttribute('data-ovnav-ic')]; if (!g) return; |
| 790 | el.removeAttribute('data-ovnav-ic'); el.removeAttribute('data-ovnav-cls'); |
| 791 | el.innerHTML = g[1] + (g[2] ? `<g class="ovg-s">${g[2]}</g>` : ''); if (g[2]) el.classList.add('has-s'); |
| 792 | }); |
| 793 | } |
| 794 | function iconsUnavailable() { |
| 795 | _iconState = 3; |
| 796 | iconSlots().forEach((el) => { try { el.outerHTML = `<i class="fa-solid ${escapeAttr(el.getAttribute('data-ovnav-cls'))}"></i>`; } catch { /* */ } }); |
| 797 | } |
| 798 | function loadIcons() { |
| 799 | if (_iconState) return; _iconState = 1; |
| 800 | let started = false; |
| 801 | const start = () => { |
| 802 | if (started) return; started = true; |
| 803 | if (adoptIcons()) return fillIcons(); |
| 804 | try { |
| 805 | const sc = document.createElement('script'); sc.id = 'ov-nav-icons-loader'; sc.src = iconsUrl(); sc.async = true; |
| 806 | try { sc.fetchPriority = 'low'; } catch { /* */ } |
| 807 | sc.onload = () => { if (adoptIcons()) fillIcons(); else iconsUnavailable(); }; |
| 808 | sc.onerror = iconsUnavailable; |
| 809 | document.head.appendChild(sc); |
| 810 | } catch { iconsUnavailable(); } |
| 811 | }; |
| 812 | // After first paint: the frame that shows the navbar goes out before the request does. |
| 813 | try { requestAnimationFrame(() => setTimeout(start, 0)); } catch { /* */ } |
| 814 | setTimeout(start, 300); // background tabs get no animation frames |
| 815 | } |
| 816 | // One hand in the bar (4.71.0; the owner, 2026-11-01: "both solid and border-line icons… weird"): every glyph the |
| 817 | // OpenVibe set has is drawn from it (openvibe-shared/glyphs, the line drawing and, where there is one, the solid one); |
| 818 | // an entry [0, line, solid] in NAV_ICONS. The bar is line; it goes solid where the person prefers solid icons |
| 819 | // (display setting `icons`, <html data-ov-icons>), and the app bar's tab you are on wears the solid one. |
| 820 | const ovSvg = (g, extra = '') => `<svg class="ovnav-ic ovnav-ovg${g && g[2] ? ' has-s' : ''}" viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra}>${g ? g[1] + (g[2] ? `<g class="ovg-s">${g[2]}</g>` : '') : ''}</svg>`; |
| 821 | function navIcon(cls) { |
| 822 | if (!cls) return ''; |
| 823 | const key = String(cls).split(/\s+/).find(c => NAV_ICONS[c] || (_iconState !== 3 && lazyWidth(c) >= 0)); |
| 824 | // Not one of the bar's own: an OpenVibe glyph by name (<i data-ov-icon>, drawn by ov-glyphs.js), or, while a |
| 825 | // site still passes a Font Awesome name the bar's set lacks, that name for ov-glyphs.js to map (4.231.0). |
| 826 | if (!key) { |
| 827 | const name = String(cls).trim(); |
| 828 | if (/^[a-z][a-z0-9-]*$/.test(name) && !name.startsWith('fa-')) return `<i class="ovnav-ic" data-ov-icon="${escapeAttr(name)}" aria-hidden="true"></i>`; |
| 829 | return `<i class="fa-solid ${escapeAttr(cls)}"></i>`; |
| 830 | } |
| 831 | const g = NAV_ICONS[key] || (adoptIcons() ? NAV_ICONS[key] : null); |
| 832 | if (g) return ovSvg(g); |
| 833 | loadIcons(); |
| 834 | return ovSvg(null, ` data-ovnav-ic="${key}" data-ovnav-cls="${escapeAttr(cls)}"`); |
| 835 | } |
| 836 | function upgradeIconsWhenFontsReady() { /* icons no longer depend on webfonts */ } |
| 837 | |
| 838 | // Release watch (ADR-016): a few seconds after the page settles, load release-watch.js from the |
| 839 | // same place as this navbar, so every site that serves /release.json keeps open tabs current with |
| 840 | // no page changes. Opt out with init({ releaseWatch: false }); an object is its OVReleaseConfig |
| 841 | // ({ metricsUrl, url, inPlace }). |
| 842 | function loadReleaseWatch() { |
| 843 | try { |
| 844 | const rw = _config.releaseWatch; |
| 845 | if (rw === false || root.OVRelease || document.querySelector('script[src*="release-watch.js"]')) return; |
| 846 | if (rw && typeof rw === 'object' && !root.OVReleaseConfig) root.OVReleaseConfig = rw; |
| 847 | const sc = document.createElement('script'); sc.src = siblingV('release-watch.js'); sc.async = true; |
| 848 | try { sc.fetchPriority = 'low'; } catch { /* */ } |
| 849 | document.head.appendChild(sc); |
| 850 | } catch { /* optional */ } |
| 851 | } |
| 852 | |
| 853 | // ─── Brand from hostname ─────────────────────────────────── |
| 854 | // Every property is <sub?>.openvibe.<tld> (plus openre.stream and open.<tld>). The navbar spells the |
| 855 | // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and |
| 856 | // the TLD are what tell a visitor where they are in the network. |
| 857 | const TLD_LABELS = { |
| 858 | live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', |
| 859 | community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', |
| 860 | news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', |
| 861 | deals: 'Deals', coupons: 'Coupons', space: 'Space', services: 'Services', events: 'Events', bot: 'Bot', |
| 862 | }; |
| 863 | const SUB_LABELS = { |
| 864 | json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT', |
| 865 | uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD', |
| 866 | yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN', |
| 867 | rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM', |
| 868 | dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP', |
| 869 | httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF', |
| 870 | splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF', |
| 871 | reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF', |
| 872 | unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF', |
| 873 | pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP', |
| 874 | avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF', |
| 875 | bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC', |
| 876 | opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN', |
| 877 | ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo', |
| 878 | textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste', |
| 879 | dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest', |
| 880 | play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev', |
| 881 | net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food', |
| 882 | text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status', |
| 883 | }; |
| 884 | const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community', space: 'space' }; |
| 885 | const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' }; |
| 886 | |
| 887 | function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; } |
| 888 | // Descriptive hosts ('youtube-downloader') are for search engines; the brand shows the name people use. |
| 889 | const LONG_SUBS = { 'youtube-downloader': 'YT', youtubedownloader: 'YT', 'youtube-download': 'YT', youtube: 'YT', ytdl: 'YT' }; |
| 890 | function subLabel(sub) { return SUB_LABELS[sub] || SUB_LABELS[LONG_SUBS[sub] && LONG_SUBS[sub].toLowerCase()] || LONG_SUBS[sub] || titleCase(String(sub).replace(/-/g, ' ')).replace(/ /g, ''); } |
| 891 | |
| 892 | /** |
| 893 | * { sub, core, tld, name, short, icon, variant } for the current page. |
| 894 | * sub 'Pastes' | null tld 'Tools' core 'OpenVibe' |
| 895 | * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps) |
| 896 | * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist |
| 897 | */ |
| 898 | function resolveBrand() { |
| 899 | const b = Object.assign({}, _config.brand || {}); |
| 900 | const host = currentHost().toLowerCase(); |
| 901 | // OpenRestream lives at openre.stream: its brand is one word, "OpenRe" + "stream" with no dot between, so it |
| 902 | // reads as OpenRestream while the two colours still show the domain. |
| 903 | let sub = null, tld = null, core = 'OpenVibe', joined = false; |
| 904 | let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/); |
| 905 | if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; } |
| 906 | else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; joined = true; } |
| 907 | // A site on its own open.<tld> domain (open.shoes) is named for it: Open.Shoes, not OpenVibe.Shoes. |
| 908 | else if ((m = host.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'Open'; tld = m[2]; } |
| 909 | // Off-network hosts (localhost, previews): fall back to the service id. |
| 910 | if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; } |
| 911 | // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments. |
| 912 | if (_config.brandName && !b.sub && !b.tld) { |
| 913 | const parts = String(_config.brandName).split('.'); |
| 914 | if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase(); |
| 915 | else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase(); |
| 916 | else if (parts.length === 1) sub = parts[0].toLowerCase(); |
| 917 | } |
| 918 | if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null; |
| 919 | if (b.tld) tld = String(b.tld).toLowerCase(); |
| 920 | if (b.core && !joined) core = String(b.core); |
| 921 | const subText = b.subLabel || (sub ? subLabel(sub) : null); |
| 922 | const tldText = b.tldLabel || (joined ? tld : TLD_LABELS[tld] || titleCase(tld)); |
| 923 | const site = joined ? `${core}${tldText}` : `${core}.${tldText}`; |
| 924 | const name = b.name || [subText, site].filter(Boolean).join('.'); |
| 925 | const icon = b.icon || _config.brandIcon || null; |
| 926 | const variant = b.variant || (joined ? 'stream' : tld); |
| 927 | // The site's own front door: openre.stream, open.shoes, openvibe.<tld>. |
| 928 | const apex = b.apex || (joined ? 'https://openre.stream/' : `https://${core.toLowerCase()}.${tld}/`); |
| 929 | return { sub, subText, core, tld, tldText, joined, site, name, short: subText || site, icon, variant, tag: b.tag || null, href: b.href || '/', apex }; |
| 930 | } |
| 931 | |
| 932 | /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */ |
| 933 | function brandLinks(brand) { |
| 934 | const apex = brand.apex || (brand.joined ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`); |
| 935 | if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex }; |
| 936 | return { sub: brand.href, core: brand.joined ? apex : 'https://openvibe.network/', tld: apex, apex }; |
| 937 | } |
| 938 | |
| 939 | // ── The season (theme-loader.js Seasons) ──────────────────────────────────────── |
| 940 | // The OpenVibe mark wears it (ov-mark.js); the menus carry the switch (the account menu's "Seasonal themes" row and |
| 941 | // the display box). → { s, on, off } during a season, else null. |
| 942 | function seasonState() { |
| 943 | const L = root.OpenVibeThemeLoader, s = L && L.seasons && L.seasons.now(); |
| 944 | return s ? { s, on: !!L.seasons.showing(), off: L.display.get().seasonal === 'off' } : null; |
| 945 | } |
| 946 | |
| 947 | function brandHTML(brand) { |
| 948 | const to = brandLinks(brand); |
| 949 | const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`; |
| 950 | const dot = '<span class="b-dot">.</span>'; |
| 951 | const join = brand.joined ? '' : dot; |
| 952 | const text = brand.subText |
| 953 | ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, brand.core === 'OpenVibe' ? 'OpenVibe Network' : brand.site) + join + seg('b-tld', brand.tldText, to.tld, `All of ${brand.site}`) |
| 954 | : seg('b-core', brand.core, to.core, brand.name) + join + seg('b-tld', brand.tldText, to.tld, brand.name); |
| 955 | const mark = brand.icon |
| 956 | ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>` |
| 957 | : `<span class="ov-mark" data-size="42" data-wide data-variant="${escapeAttr(brand.variant)}"></span>`; // 42 since 4.188.0 (was 34: beside a short name it read as smaller than the word) |
| 958 | const tag = brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''; |
| 959 | // 347: the mark and the name are one button (the site's pages, then every site). |
| 960 | const one = (cls, t) => `<span class="${cls}">${escapeAttr(t)}</span>`; |
| 961 | const words = brand.subText ? one('b-sub', brand.subText) + dot + one('b-core', brand.core) + join + one('b-tld', brand.tldText) : one('b-core', brand.core) + join + one('b-tld', brand.tldText); |
| 962 | return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}"> |
| 963 | ${_config.switcher === false |
| 964 | ? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a><span class="name">${text}${tag}</span>` |
| 965 | : `<button type="button" class="ovnav-brand" id="openvibe-brand-btn" aria-haspopup="menu" aria-expanded="false" aria-label="${escapeAttr(brand.name)}: its pages and every OpenVibe site"><span class="flame">${mark}</span><span class="name">${words}${tag}</span></button>`} |
| 966 | ${_config.search === false ? '' : orbHTML()} |
| 967 | </div>`; |
| 968 | } |
| 969 | |
| 970 | // The on-screen keyboard: html.ov-kb-open and --ov-kb (its height, against the tallest view seen without one). |
| 971 | let _kbWatch = false; |
| 972 | function watchKeyboard() { |
| 973 | if (_kbWatch || typeof window === 'undefined' || !window.visualViewport || typeof document === 'undefined') return; |
| 974 | _kbWatch = true; |
| 975 | const vv = window.visualViewport; const html = document.documentElement; |
| 976 | let raf = 0; let base = 0; let turned = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : ''; |
| 977 | const typing = () => { const a = document.activeElement; return !!a && (a.isContentEditable || a.tagName === 'TEXTAREA' || (a.tagName === 'INPUT' && !/^(button|checkbox|radio|range|color|file|submit|reset|image|hidden)$/i.test(a.type || ''))); }; |
| 978 | const paint = () => { |
| 979 | raf = 0; |
| 980 | const o = typeof screen !== 'undefined' && screen.orientation ? screen.orientation.type : ''; |
| 981 | if (o !== turned) { turned = o; base = 0; } |
| 982 | const t = typing(); |
| 983 | if (!t) base = Math.max(base, vv.height); |
| 984 | const kb = Math.max(0, Math.round(Math.max(base, window.innerHeight) - vv.height - vv.offsetTop)); |
| 985 | const on = t && kb > 120; |
| 986 | if (html.classList.contains('ov-kb-open') !== on) html.classList.toggle('ov-kb-open', on); |
| 987 | html.style.setProperty('--ov-kb', `${on ? kb : 0}px`); |
| 988 | }; |
| 989 | const later = () => { if (!raf) raf = requestAnimationFrame(paint); }; |
| 990 | vv.addEventListener('resize', later); vv.addEventListener('scroll', later); |
| 991 | document.addEventListener('focusin', later); document.addEventListener('focusout', () => setTimeout(later, 60)); |
| 992 | later(); |
| 993 | } |
| 994 | |
| 995 | // Panels grow out of their trigger: OpenVibeNavbar.pop(el, trigger) / .unpop(el, done). |
| 996 | function popFrom(el, from) { |
| 997 | try { |
| 998 | const r = el.getBoundingClientRect(), f = from && from.getBoundingClientRect ? from.getBoundingClientRect() : null; |
| 999 | el.style.setProperty('--ov-pop-x', `${Math.round(f ? f.left + f.width / 2 - r.left : r.width / 2)}px`); |
| 1000 | el.style.setProperty('--ov-pop-y', `${Math.round(f ? f.top + f.height / 2 - r.top : 0)}px`); |
| 1001 | el.classList.remove('ov-pop', 'ov-pop-out'); void el.offsetWidth; el.classList.add('ov-pop'); |
| 1002 | } catch { /* */ } |
| 1003 | } |
| 1004 | function unpop(el, done) { |
| 1005 | let ended = false; const end = () => { if (ended) return; ended = true; el.classList.remove('ov-pop-out'); if (done) done(); }; |
| 1006 | el.classList.remove('ov-pop'); el.classList.add('ov-pop-out'); |
| 1007 | el.addEventListener('animationend', end, { once: true }); setTimeout(end, 220); |
| 1008 | } |
| 1009 | // A site's buttons: nav-slots.js draws them, loaded on first use; each render paints them again. |
| 1010 | function paintSlots() { |
| 1011 | if (!_navEl || typeof document === 'undefined') return; |
| 1012 | if (root.OpenVibeNavSlots) { root.OpenVibeNavSlots.paint(_navEl, _lead, _actions); return; } |
| 1013 | if (!_lead && !_actions.size) return; |
| 1014 | if (document.getElementById('ov-slots-loader')) return; |
| 1015 | try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ } |
| 1016 | } |
| 1017 | |
| 1018 | // The OpenVibe menu's button (the owner's pick, 2026-11-10: the Arena's M57, "Search and apps"): the menu itself, |
| 1019 | // small: a search bar over three apps. Open, the bar turns into one stroke of the close mark and the three apps |
| 1020 | // line up as the other, so the button that opens the menu is, in the same place, the one that closes it. One |
| 1021 | // drawing for the bar, the menu's sheet and the Dock's (root.OpenVibeOrb); its styles are below (.ovmb). |
| 1022 | const ORB_PARTS = '<rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/>' |
| 1023 | + '<rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/>'; |
| 1024 | function orbSVG(size = 22, open = false) { |
| 1025 | return `<svg class="ovmb${open ? ' is-x' : ''}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${ORB_PARTS}</svg>`; |
| 1026 | } |
| 1027 | root.OpenVibeOrb = { svg: orbSVG }; |
| 1028 | function orbHTML() { |
| 1029 | return `<button type="button" class="ovnav-search ovnav-orb" id="openvibe-search-btn" aria-label="OpenVibe menu: search, every site and tool (/)" data-ov-tip="Menu" data-ov-tip-sub="Search, this site, every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>`; |
| 1030 | } |
| 1031 | |
| 1032 | |
| 1033 | // ── Frame data (https://openvibe.network/api/frame) ───────────────────────────── |
| 1034 | // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30 |
| 1035 | // minutes in localStorage and refreshed in the background, so pages paint from cache and the |
| 1036 | // order never jumps while someone is looking at it. Defined once, shared by navbar + footer. |
| 1037 | const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () { |
| 1038 | const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group |
| 1039 | let inflight = null; |
| 1040 | const host = () => (typeof location !== 'undefined' ? location.hostname : ''); |
| 1041 | const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; |
| 1042 | function clean(d) { |
| 1043 | if (!d || !Array.isArray(d.nav)) return null; |
| 1044 | const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null); |
| 1045 | const f = d.footer || {}, lg = f.legal || {}; |
| 1046 | return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24), |
| 1047 | footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10), |
| 1048 | legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null }, |
| 1049 | // The menu's fields (dropped here until 4.132.0); search.js checks each entry. |
| 1050 | cards: Array.isArray(d.cards) ? d.cards.slice(0, 12) : [], next: d.next && typeof d.next === 'object' ? d.next : null, |
| 1051 | discover_ids: Array.isArray(d.discover_ids) ? d.discover_ids.slice(0, 40) : [], menu: d.menu && https(d.menu.beacon) ? { beacon: d.menu.beacon } : null }; |
| 1052 | } |
| 1053 | function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } } |
| 1054 | function refresh() { |
| 1055 | if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null); |
| 1056 | inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' }) |
| 1057 | .then(r => (r.ok ? r.json() : null)).then(clean) |
| 1058 | .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; }) |
| 1059 | .catch(() => null); |
| 1060 | return inflight; |
| 1061 | } |
| 1062 | /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */ |
| 1063 | function get(onFirst) { |
| 1064 | const c = cached(); |
| 1065 | if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); } |
| 1066 | return c ? c.data : null; |
| 1067 | } |
| 1068 | return { get, refresh }; |
| 1069 | })()); |
| 1070 | |
| 1071 | // A render's document/window listeners and panels; the next render (each boost page move) aborts them, so the old bar can go. |
| 1072 | let _renderAbort = null; |
| 1073 | const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined); |
| 1074 | |
| 1075 | /** One anonymous page-view count per page load (host only), which orders the network's navigation; skipped when |
| 1076 | * the browser asks not to be tracked. `?in=1` says this browser is signed in, never who; it waits 2.5 s for that. */ |
| 1077 | let _counted = false; |
| 1078 | function countView() { |
| 1079 | if (_counted || _config.countViews === false) return; _counted = true; |
| 1080 | try { |
| 1081 | if (navigator.globalPrivacyControl || navigator.doNotTrack === '1' || !/^https:$/.test(location.protocol)) return; |
| 1082 | const hit = 'https://openvibe.network/api/frame/hit'; |
| 1083 | let sent = false; |
| 1084 | const send = () => { if (sent) return; sent = true; try { if (navigator.sendBeacon) navigator.sendBeacon(_config.user && !_config.user.is_anon ? `${hit}?in=1` : hit); } catch { /* */ } }; |
| 1085 | // A page that knows its person, and a browser with no token at all (a guest), count now; only a browser |
| 1086 | // that may be signed in waits for the shared sign-in state, never longer than 2.5 s. |
| 1087 | if (_config.user || _config.auth === 'external' || !resolveToken()) return send(); |
| 1088 | document.addEventListener('openvibe-navbar-auth', send, { once: true }); |
| 1089 | setTimeout(send, 2500); |
| 1090 | } catch { /* */ } |
| 1091 | } |
| 1092 | |
| 1093 | /** Status chips beside the bell (balances, counters): [{ id, icon, value, valueId, title, tone, onClick, hidden }]. */ |
| 1094 | function chipsHTML(chips, cls) { |
| 1095 | return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join(''); |
| 1096 | } |
| 1097 | /** "160,774" → "161k", for the folded chip; anything that is not a number stays as it is. */ |
| 1098 | function shortValue(v) { |
| 1099 | const s = v == null ? '' : String(v); const n = Number(s.replace(/[,\s]/g, '')); |
| 1100 | if (!s || !isFinite(n)) return s; |
| 1101 | const a = Math.abs(n); |
| 1102 | if (a >= 1e6) return `${(n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`; |
| 1103 | if (a >= 1e4) return `${Math.round(n / 1e3)}k`; |
| 1104 | if (a >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k`; |
| 1105 | return String(Math.round(n)); |
| 1106 | } |
| 1107 | const chipLabel = (c) => c.label || String(c.title || c.id).split(':')[0]; |
| 1108 | // New messages are counted on the bell (4.187.0; the owner, 2026-11-10: "instead of having messaging and |
| 1109 | // notifications, just have the notifications … it'll show you new messages as well"): there is no Messages button. |
| 1110 | let _accountEl = null; // the account's block, built at render, hosted by the menu's You tab |
| 1111 | let _msgCount = 0; |
| 1112 | // New messages, for the bell's count: Chat's GET /api/dm/unread, at most once a minute (README, "The navbar"). |
| 1113 | let _unreadAt = 0; |
| 1114 | function loadUnread() { |
| 1115 | const m = _config.messages, u = _config.user; |
| 1116 | if (!m || !m.__default || !u || u.is_anon || !_config.token || typeof fetch !== 'function' || Date.now() - _unreadAt < 60000) return; |
| 1117 | _unreadAt = Date.now(); |
| 1118 | fetch(`${String(_config.dockChatBase || 'https://openvibe.chat').replace(/\/+$/, '')}/api/dm/unread`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'omit' }) |
| 1119 | .then((r) => (r.ok ? r.json() : null)).then((b) => { if (b && _config.messages.__default) OpenVibeNavbar.setMessages(Number(b.unread) || 0); }).catch(() => {}); |
| 1120 | } |
| 1121 | |
| 1122 | function chipsAllHTML(chips) { |
| 1123 | const list = (Array.isArray(chips) ? chips : []).filter((c) => c && !c.hidden); |
| 1124 | if (list.length < 2 && !(list[0] && ['coins', 'vibes', 'balance'].includes(list[0].id))) return ''; |
| 1125 | return `<button type="button" class="ovnav-chip ovnav-chip--all" id="openvibe-chips-all" aria-haspopup="true" aria-expanded="false" aria-label="Your balances: ${escapeAttr(list.map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', '))}">${list.map((c, i) => `<span class="ovnav-chip-mini${i ? '' : ' is-on'}" data-chip-mini="${escapeAttr(c.id)}" data-v="${escapeAttr(c.value == null ? '' : c.value)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-mv">${escapeAttr(shortValue(c.value))}</span></span>`).join('')}</button>`; |
| 1126 | } |
| 1127 | function bindChipsAll(nav) { |
| 1128 | const btn = nav.querySelector('#openvibe-chips-all'); if (!btn || typeof btn.addEventListener !== 'function') return; |
| 1129 | const right = typeof btn.closest === 'function' ? btn.closest('.openvibe-navbar-right') : null; |
| 1130 | if (right && right.classList) right.classList.add('has-chips-all'); |
| 1131 | if (root.OpenVibePurse) root.OpenVibePurse.mount(btn); |
| 1132 | else if (!document.getElementById('ov-purse-loader')) { const sc = document.createElement('script'); sc.id = 'ov-purse-loader'; sc.src = siblingV('purse.js'); sc.async = true; document.head.appendChild(sc); } |
| 1133 | let pop = null; |
| 1134 | const close = () => { if (pop) pop.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }; |
| 1135 | btn.addEventListener('click', (e) => { |
| 1136 | e.stopPropagation(); |
| 1137 | if (_config.wallet) { close(); openWallet(); return; } |
| 1138 | if (pop && pop.classList.contains('open')) return close(); |
| 1139 | if (!pop) { pop = document.createElement('div'); pop.className = 'ovnav-chips-pop'; pop.setAttribute('role', 'menu'); nav.appendChild(pop); regPanel(pop, 'chips', close, btn); } |
| 1140 | const list = (_config.chips || []).filter((c) => c && !c.hidden); |
| 1141 | pop.innerHTML = list.map((c) => `<button type="button" role="menuitem" data-chip-row="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}>${c.icon ? navIcon(c.icon) : ''}<span>${escapeAttr(chipLabel(c))}</span><b>${escapeAttr(c.value == null ? '' : c.value)}</b></button>`).join(''); |
| 1142 | pop.querySelectorAll('[data-chip-row]').forEach((row) => row.addEventListener('click', (ev) => { |
| 1143 | ev.stopPropagation(); close(); |
| 1144 | const c = list.find((x) => x.id === row.getAttribute('data-chip-row')); |
| 1145 | if (c && typeof c.onClick === 'function') c.onClick(ev); |
| 1146 | })); |
| 1147 | pop.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); |
| 1148 | const first = pop.querySelector('button'); if (first) first.focus(); |
| 1149 | }); |
| 1150 | } |
| 1151 | /** ov-icons.js draws every .ov-icon: loaded once after the page settles, or at once on intent; slots keep their size. */ |
| 1152 | let _iconsQueued = false; |
| 1153 | function loadOvIcons(scope) { |
| 1154 | if (root.OpenVibeIcons) { if (scope && root.OpenVibeIcons.mount) root.OpenVibeIcons.mount(scope); return; } |
| 1155 | if (document.getElementById('ov-icons-loader')) return; |
| 1156 | try { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = siblingV('ov-icons.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } |
| 1157 | } |
| 1158 | function queueIcons() { |
| 1159 | if (_iconsQueued || root.OpenVibeIcons) return; |
| 1160 | _iconsQueued = true; |
| 1161 | const go = () => loadOvIcons(); |
| 1162 | if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 4000 }); |
| 1163 | else { const t = setTimeout(go, 2500); if (t && typeof t.unref === 'function') t.unref(); } |
| 1164 | } |
| 1165 | /** ov-fx.js (4.66.0): the network's effects, loaded once the page settles, only for a page that has data-fx. */ |
| 1166 | function queueFx() { |
| 1167 | const go = () => { |
| 1168 | if (root.OVFx || typeof document.querySelector !== 'function' || document.getElementById('ov-fx-loader') || !document.querySelector('[data-fx]')) return; |
| 1169 | try { const sc = document.createElement('script'); sc.id = 'ov-fx-loader'; sc.src = siblingV('ov-fx.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } |
| 1170 | }; |
| 1171 | if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 3000 }); |
| 1172 | else { const t = setTimeout(go, 1500); if (t && typeof t.unref === 'function') t.unref(); } |
| 1173 | } |
| 1174 | function bindChips(scope) { |
| 1175 | (_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => { |
| 1176 | if (!c || typeof c.onClick !== 'function') return; |
| 1177 | scope.querySelectorAll(`[data-chip-id="${c.id}"]`).forEach(el => { if (!el.__b) { el.__b = true; el.addEventListener('click', (e) => { e.stopPropagation(); c.onClick(e); }); } }); |
| 1178 | }); |
| 1179 | } |
| 1180 | |
| 1181 | /** A link on this site as the path a router takes (/@name?tab=x#y), or null for another site. The account menu's |
| 1182 | * links are full addresses (https://openvibe.live/@name on every site), and a router handed one put it under '/' |
| 1183 | * (Live, 2026-10-28: "My channel" opened /https://openvibe.live/@name). */ |
| 1184 | function sitePath(a) { |
| 1185 | try { const u = new URL(a.href, location.href); return u.origin === location.origin ? `${u.pathname}${u.search}${u.hash}` : null; } catch { return null; } |
| 1186 | } |
| 1187 | |
| 1188 | /** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */ |
| 1189 | function bindLinks(nav, links) { |
| 1190 | const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l])); |
| 1191 | const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger'); |
| 1192 | // The menu's own Close is the burger's ✕ on a phone (search.js hides its in-box Close then). |
| 1193 | try { document.documentElement.classList.toggle('ovnav-has-burger', !!burger); } catch { /* */ } |
| 1194 | const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); }; |
| 1195 | // A finger or pointer on its way to the menu fetches it now (warmMenu above). |
| 1196 | ['pointerenter', 'pointerdown', 'focusin'].forEach((t) => nav.addEventListener(t, (e) => { if (e.target && e.target.closest && e.target.closest('#openvibe-burger, #openvibe-search-btn')) warmMenu(); }, { passive: true, capture: t === 'pointerenter' })); |
| 1197 | nav.addEventListener('click', (e) => { |
| 1198 | const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return; |
| 1199 | if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 1200 | const item = byId.get(a.getAttribute('data-link-id')); |
| 1201 | // A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link. |
| 1202 | const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null; |
| 1203 | if (dd && a.getAttribute('data-link-id') === 'ovnav-more') { e.preventDefault(); const o = !dd.classList.contains('open'); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); if (o) { dd.classList.add('open'); placeDd(dd); } return; } |
| 1204 | if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); placeDd(dd); return; } |
| 1205 | if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; } |
| 1206 | if (typeof _config.onNavigate === 'function' && a.target !== '_blank') { |
| 1207 | const path = sitePath(a); |
| 1208 | if (path != null && _config.onNavigate(path, e) === false) e.preventDefault(); |
| 1209 | } |
| 1210 | closeDrawer(); |
| 1211 | }); |
| 1212 | nav.querySelectorAll('.ovnav-dd').forEach((dd) => regPanel(dd, 'nav-dropdown', () => dd.classList.remove('open'), dd.querySelector(':scope > a'))); |
| 1213 | // Dropdowns are fixed under their link: the links row scrolls sideways and would clip them. |
| 1214 | const placeDd = (dd) => { |
| 1215 | const m = dd && dd.querySelector(':scope>.ovnav-dd-menu'); if (!m) return; |
| 1216 | let cb = dd.parentElement, o = { top: 0, left: 0 }; |
| 1217 | for (; cb && cb !== document.body; cb = cb.parentElement) { const c = getComputedStyle(cb); if (c.transform + c.filter + (c.backdropFilter || 'none') !== 'nonenonenone') { o = cb.getBoundingClientRect(); break; } } |
| 1218 | const r = dd.getBoundingClientRect(); |
| 1219 | m.style.top = `${r.bottom - o.top}px`; m.style.left = `${Math.max(8, Math.min(r.left, innerWidth - 188)) - o.left}px`; |
| 1220 | }; |
| 1221 | const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd')); |
| 1222 | nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place); |
| 1223 | if (burger && drawer) { |
| 1224 | if (!_config.user) { bindDisplayControls(drawer.querySelector('.ovnav-display')); bindLook(drawer); } // guests: members have them in the account menu |
| 1225 | // One menu (the owner, 11-07: "combining this menu and the other menu … the main button on the right-hand side"): |
| 1226 | // the burger opens the OpenVibe menu, from the burger, leading with this site's pages. The old drawer stays for a |
| 1227 | // site that turns the menu off (search: false) or asks for it (drawer: 'legacy'). |
| 1228 | burger.addEventListener('click', (e) => { |
| 1229 | e.stopPropagation(); |
| 1230 | if (_config.search !== false && _config.drawer !== 'legacy') { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch('', 'here', burger); return; } |
| 1231 | const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); |
| 1232 | }); |
| 1233 | drawer.addEventListener('click', (e) => { |
| 1234 | const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return; |
| 1235 | e.preventDefault(); e.stopPropagation(); closeDrawer(); |
| 1236 | openSearch('', 'sites'); |
| 1237 | }); |
| 1238 | document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument()); |
| 1239 | regPanel(drawer, 'nav-drawer', closeDrawer, burger); |
| 1240 | } |
| 1241 | } |
| 1242 | |
| 1243 | /** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation. |
| 1244 | * Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */ |
| 1245 | const _panelQueue = []; |
| 1246 | function regPanel(el, id, close, trigger) { |
| 1247 | if (!el) return; |
| 1248 | const opts = Object.assign({ el, openClass: 'open', id, close, trigger }, onDocument()); |
| 1249 | if (root.OpenVibePanels) return root.OpenVibePanels.register(opts); |
| 1250 | _panelQueue.push(opts); |
| 1251 | if (document.getElementById('ov-panels-loader')) return; |
| 1252 | const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = siblingV('panels.js'); sc.async = true; |
| 1253 | sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); }; |
| 1254 | document.head.appendChild(sc); |
| 1255 | } |
| 1256 | |
| 1257 | /** Sites in the user menu: most used first (Frame data), never the one we are on. */ |
| 1258 | /** Light / Dark / Auto: the theme's other half, or the device's (theme-loader.js scheme). */ |
| 1259 | function bindScheme(scope) { |
| 1260 | const L = root.OpenVibeThemeLoader, box = scope.querySelector('.ud-scheme'); |
| 1261 | if (!box || !L || !L.scheme) return; |
| 1262 | const paint = () => { const now = L.scheme.get(); box.querySelectorAll('[data-ov-scheme]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-ov-scheme') === now))); }; |
| 1263 | box.addEventListener('click', (e) => { const b = e.target.closest('[data-ov-scheme]'); if (!b) return; e.stopPropagation(); L.scheme.set(b.getAttribute('data-ov-scheme')); paint(); }); |
| 1264 | ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, paint, onDocument())); |
| 1265 | box.hidden = false; paint(); |
| 1266 | } |
| 1267 | |
| 1268 | /** The Look panel (ov-look.js, 4.64.0): the palette opens it in place; without the script the link goes to Design. */ |
| 1269 | function bindLook(scope) { |
| 1270 | scope.addEventListener('click', (e) => { |
| 1271 | const a = e.target.closest && e.target.closest('[data-ov-look-open]'); if (!a) return; |
| 1272 | const box = a.parentElement && a.parentElement.parentElement && a.parentElement.parentElement.querySelector('.ud-look') || scope.querySelector('.ud-look'); if (!box) return; |
| 1273 | e.preventDefault(); e.stopPropagation(); |
| 1274 | const open = box.hidden; box.hidden = !open; a.setAttribute('aria-expanded', String(open)); |
| 1275 | if (open) popFrom(box, a); |
| 1276 | if (!open || box.__ovlk) return; |
| 1277 | const go = () => { if (root.OVLook) root.OVLook.mount(box, { signedIn: !!_config.user, season: !!_config.user }); else location.href = a.href; }; |
| 1278 | if (root.OVLook) return go(); |
| 1279 | const sc = document.createElement('script'); sc.src = siblingV('ov-look.js'); sc.async = true; sc.onload = go; sc.onerror = () => { location.href = a.href; }; document.head.appendChild(sc); |
| 1280 | }); |
| 1281 | } |
| 1282 | |
| 1283 | /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */ |
| 1284 | function bindDisplayRows(dropdown) { |
| 1285 | bindScheme(dropdown); |
| 1286 | const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]'); |
| 1287 | if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; } |
| 1288 | const LABEL = { text: { 100: '', 112: '+', 125: '++' }, motion: { auto: '', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } }; |
| 1289 | const SAY = { text: { 100: 'default', 112: 'large', 125: 'largest' }, motion: { auto: 'on', reduced: 'calm' } }; |
| 1290 | const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; const say = `${k === 'text' ? 'Text size' : 'Animations'}: ${(SAY[k] || {})[d[k]] || d[k]}`; r.title = say; r.setAttribute('aria-label', say); r.setAttribute('aria-pressed', String(k === 'motion' ? d[k] === 'reduced' : d[k] !== 100 && d[k] !== '100')); }); }; |
| 1291 | rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); })); |
| 1292 | root.addEventListener('ov:display', paint, onDocument()); paint(); |
| 1293 | } |
| 1294 | |
| 1295 | /** OpenCoins (Network) and Vibes (OpenVibe.Billing) in the menu header: one request each when the menu first opens. */ |
| 1296 | let _walletLoaded = false; |
| 1297 | async function loadWallet(dropdown) { |
| 1298 | // The bar's chips (a site's own, or wallet: true) show the balances already: the menu does not ask twice. |
| 1299 | const inBar = !!_config.wallet || (Array.isArray(_config.chips) && _config.chips.some(c => c && (c.id === 'coins' || c.id === 'vibes'))); |
| 1300 | if (_walletLoaded || !_config.token || inBar || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true; |
| 1301 | const auth = { Authorization: `Bearer ${_config.token}` }; |
| 1302 | const chip = (id, html) => { const el = dropdown.querySelector(id); if (el) { el.innerHTML = html; el.hidden = false; } }; |
| 1303 | const showVibes = (v) => { if (isFinite(v)) chip('#openvibe-vibes', `<a href="https://openvibe.live/#vibes" title="Your Vibes">${navIcon('fa-wallet')} ${v.toLocaleString()} <small>Vibes</small></a>`); }; |
| 1304 | // Both balances from the Network in one request (it asks Billing): every site's connect-src allows |
| 1305 | // openvibe.network and none allowed billing.openvibe.network, so Vibes never showed (2026-10-28). |
| 1306 | const both = await fetch(`${_config.apiBase}/api/coins/me?with=vibes`, { headers: auth, credentials: 'include' }).then((r) => (r.ok ? r.json() : null)).catch(() => null); |
| 1307 | if (both) { |
| 1308 | const bal = Number(both.balance ?? (both.wallet && both.wallet.balance)); |
| 1309 | if (isFinite(bal)) chip('#openvibe-wallet', `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()} <small>OpenCoins</small></a>`); |
| 1310 | } |
| 1311 | // Vibes only from the Network: no site's CSP allows billing.openvibe.network (no direct fallback since 4.172.0). |
| 1312 | if (both && both.vibes != null) showVibes(Number(both.vibes)); |
| 1313 | } |
| 1314 | |
| 1315 | /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */ |
| 1316 | function networkLinksHTML(pageLinks, inDrawer) { |
| 1317 | if (_config.networkLinks === false) return ''; |
| 1318 | const frame = OVFrame.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } }); |
| 1319 | if (!frame) return ''; |
| 1320 | const here = currentHost().toLowerCase(); |
| 1321 | const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } })); |
| 1322 | const max = inDrawer ? 8 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : _config.networkLinks === true ? 4 : 0); |
| 1323 | if (!max) return ''; |
| 1324 | const pick = frame.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max); |
| 1325 | if (!pick.length) return ''; |
| 1326 | if (inDrawer) { |
| 1327 | return `<div class="ovnav-net-head"><span class="ov-icon" data-icon="ov" data-size="22" data-fx="none"></span><span class="ud-label">Across OpenVibe</span>${_config.launcher === false ? '' : '<button type="button" class="ovnav-net-all" data-open-launcher>All sites</button>'}</div>` |
| 1328 | + `<div class="ovnav-net-grid">${pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}"><span class="ov-appicon" data-icon="${escapeAttr(n.icon || n.id)}" data-size="26"></span><span>${escapeAttr(n.name)}</span></a>`).join('')}</div>`; |
| 1329 | } |
| 1330 | return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" data-ov-net-link href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join(''); |
| 1331 | } |
| 1332 | |
| 1333 | /** The menu's Display row: light, dark or auto as icons, and Settings opening the Look panel (text size, motion, theme, |
| 1334 | * icons) in place, the same as the account menu's (11-09, the owner: "moved to a settings menu… more compact"). */ |
| 1335 | function bindDisplayControls(box) { |
| 1336 | const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return; |
| 1337 | const say = () => { const sch = L.scheme ? L.scheme.get() : null; const d = L.display.get(); return [{ light: 'Light', dark: 'Dark', auto: 'Auto' }[sch] || '', d.motion === 'reduced' ? 'Calm' : '', String(d.text) !== '100' ? 'Large text' : ''].filter(Boolean).join(' · '); }; |
| 1338 | box.innerHTML = `<a class="ovnav-disp-row" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false"><span class="ovnav-disp-ic">${navIcon('fa-palette')}</span><span><b>Display and theme</b><small data-disp-say></small></span>${navIcon('fa-chevron-down')}</a><div class="ud-look" hidden></div>`; |
| 1339 | const paint = () => { const s = box.querySelector('[data-disp-say]'); if (s) s.textContent = say(); }; |
| 1340 | bindLook(box); |
| 1341 | if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); } |
| 1342 | paint(); |
| 1343 | box.hidden = false; |
| 1344 | } |
| 1345 | |
| 1346 | // ── Notifications (count and list are mounted in You) ───── |
| 1347 | function mountNotifications(nav, u) { |
| 1348 | if (_config.notifications === false || !u || u.is_anon || !_config.token) return; |
| 1349 | const avatar = nav.querySelector('#openvibe-avatar-btn'); |
| 1350 | const badge = avatar?.parentElement?.querySelector('.ovnav-avatar-count'); |
| 1351 | if (badge) badge.hidden = true; |
| 1352 | const go = () => { |
| 1353 | const N = root.OpenVibeNotifications; if (!N) return; |
| 1354 | try { |
| 1355 | if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } |
| 1356 | const paint = () => { if (!badge) return; const n = Number(N.unread()) || 0; badge.textContent = n > 99 ? '99+' : String(n); badge.hidden = !n; }; |
| 1357 | paint(); document.addEventListener('openvibe-notifications-count', paint); |
| 1358 | } catch { /* */ } |
| 1359 | }; |
| 1360 | if (root.OpenVibeNotifications) return go(); |
| 1361 | if (document.getElementById('ov-notify-loader')) return; |
| 1362 | const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = siblingV('notification-ui.js'); sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc); |
| 1363 | } |
| 1364 | |
| 1365 | // ── The menu's Chat and Vibe (dock.js's panels; 4.220.0: no floating Dock). For a signed-in person dock.js reads their |
| 1366 | // state (a Network without the route is not asked again for a day) and paints its status on the menu button. |
| 1367 | const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, ''); |
| 1368 | const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling, |
| 1369 | status: (s) => { _menuStatus = s || null; }, openMenu: (id) => openMenuOn(id), closeMenu: () => { if (root.OpenVibeSearch && menuOpen()) root.OpenVibeSearch.close(); } }); |
| 1370 | let _menuStatus = null; |
| 1371 | /** The menu on one of its places ('chats', 'vibe', 'code', 'agent'): opened there, or moved there if it is open. */ |
| 1372 | /** 4.229.0: Back to a page the menu was left from opens the menu again, where it was (its place, its door, its |
| 1373 | * scroll), whether the page comes back from the browser's cache, loads again, or is swapped in place. Only for a |
| 1374 | * Back or Forward, and only within half an hour of leaving. */ |
| 1375 | function restoreMenu(ev) { |
| 1376 | let st = null; |
| 1377 | try { st = history.state; } catch { return; } |
| 1378 | const b = st && st.ovsBack; |
| 1379 | if (!b || typeof b !== 'object' || !(Date.now() - Number(b.at || 0) < 30 * 60000)) return; |
| 1380 | let nav = null; |
| 1381 | try { nav = performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; } catch { nav = null; } |
| 1382 | if (!(ev && ev.persisted) && !(nav && nav.type === 'back_forward')) return; |
| 1383 | if (menuOpen()) return; |
| 1384 | const tab = /^(here|feed|you)$/.test(String(b.tab || '')) ? b.tab : 'here'; |
| 1385 | const door = /^(vibe|chats|agent|code|tasks)$/.test(String(b.door || '')) ? b.door : undefined; |
| 1386 | const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : []; |
| 1387 | openSearch('', tab, orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, door, { restore: { scroll: Math.max(0, Number(b.scroll) || 0) } }); |
| 1388 | } |
| 1389 | /** 4.229.0 (the owner, 11-12: "other tools and buttons can open specific areas of the menu"): a menu address is |
| 1390 | * place[/part]. Places: here, feed, you, and the doors vibe, chats, agent, code, tasks. Parts: You's notifications, |
| 1391 | * recent, apps (All apps), feeds, display; this site's live, places, tools, all. A part alone names its place |
| 1392 | * ("notifications" is you/notifications). Anything else opens the menu on this site. */ |
| 1393 | const MENU_PARTS = { here: /^(live|places|tools|all|discover|news)$/, you: /^(notifications|recent|apps|feeds|display|home)$/, feed: /^$/ }; |
| 1394 | function menuAddress(addr) { |
| 1395 | const [a = '', b = ''] = String(addr || '').trim().toLowerCase().replace(/^#?ov-menu=/, '').split('/'); |
| 1396 | if (/^(vibe|chats|agent|code|tasks)$/.test(a)) return { door: a }; |
| 1397 | if (MENU_PARTS.you.test(a)) return { tab: 'you', part: a }; |
| 1398 | if (MENU_PARTS.here.test(a)) return { tab: 'here', part: a }; |
| 1399 | const tab = /^(here|feed|you)$/.test(a) ? a : 'here'; |
| 1400 | return { tab, part: MENU_PARTS[tab].test(b) && b ? b : null }; |
| 1401 | } |
| 1402 | function openMenuAt(addr, fromEl) { |
| 1403 | const m = menuAddress(addr); |
| 1404 | if (m.door) return openMenuOn(m.door); |
| 1405 | openSearch('', m.tab, fromEl && fromEl.getBoundingClientRect ? fromEl : null, m.part); |
| 1406 | } |
| 1407 | /** An address in the page's own: #ov-menu=you/notifications (alone, or after other hash words with &). It opens |
| 1408 | * as the page loads or when a link on the page changes the hash, and leaves the address so Back is the page. */ |
| 1409 | function menuFromHash() { |
| 1410 | let h = ''; |
| 1411 | try { h = root.location.hash || ''; } catch { return; } |
| 1412 | const m = /(?:^#|&)ov-menu=([a-z/-]{1,40})(?=&|$)/.exec(h); |
| 1413 | if (!m) return; |
| 1414 | const rest = h.replace(m[0], '').replace(/^#&/, '#'); |
| 1415 | try { history.replaceState(history.state, '', root.location.pathname + root.location.search + (rest === '#' ? '' : rest)); } catch { /* the address keeps it */ } |
| 1416 | openMenuAt(m[1]); |
| 1417 | } |
| 1418 | function openMenuOn(id) { |
| 1419 | if (menuOpen() && root.OpenVibeSearch && root.OpenVibeSearch.door && root.OpenVibeSearch.door(id)) return; |
| 1420 | const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : []; |
| 1421 | openSearch('', 'here', orbs.find((x) => x.getBoundingClientRect && x.getBoundingClientRect().width > 0) || null, null, id); |
| 1422 | } |
| 1423 | function loadDock(then) { |
| 1424 | if (root.OpenVibeDock) return then(); |
| 1425 | let sc = document.getElementById('ov-dock-loader'); |
| 1426 | if (!sc) { sc = document.createElement('script'); sc.id = 'ov-dock-loader'; sc.src = siblingV('dock.js'); sc.async = true; document.head.appendChild(sc); } |
| 1427 | sc.addEventListener('load', () => setTimeout(() => { try { then(); } catch { /* */ } }, 0)); |
| 1428 | } |
| 1429 | let _dockOn = false; |
| 1430 | function mountDock(u) { |
| 1431 | const on = _config.dock !== false && !!u && !u.is_anon && !!_config.token; |
| 1432 | _dockOn = on; |
| 1433 | if (root.OpenVibeDock) return root.OpenVibeDock.mount(on ? dockOpts() : null); |
| 1434 | try { const k = JSON.parse(localStorage.getItem('ov-dock') || 'null'); if (!on || (k && k.off > Date.now())) return; } catch { if (!on) return; } |
| 1435 | loadDock(() => root.OpenVibeDock.mount(dockOpts())); |
| 1436 | } |
| 1437 | // Your online status (presence.js, 4.131.0): its dot on your picture, a heartbeat, the picker. presence: false turns it off. |
| 1438 | function mountPresence(u) { |
| 1439 | if (_config.presence === false || !u || u.is_anon || !_config.token) return; |
| 1440 | const go = () => root.OpenVibePresence && root.OpenVibePresence.start({ token: () => _config.token, apiBase: dockBase(), user: u }); |
| 1441 | if (root.OpenVibePresence) return go(); |
| 1442 | if (document.getElementById('ov-presence-loader')) return; |
| 1443 | const sc = document.createElement('script'); sc.id = 'ov-presence-loader'; sc.src = siblingV('presence.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc); |
| 1444 | } |
| 1445 | // Load portable cards on arrival or on request. |
| 1446 | function loadOverlay(then) { |
| 1447 | if (root.OVOverlay) { if (then) then(root.OVOverlay); return; } |
| 1448 | let sc = document.getElementById('ov-overlay-loader'); |
| 1449 | if (!sc) { sc = document.createElement('script'); sc.id = 'ov-overlay-loader'; sc.src = siblingV('overlay.js'); sc.async = true; document.head.appendChild(sc); } |
| 1450 | if (then) sc.addEventListener('load', () => { if (root.OVOverlay) then(root.OVOverlay); }, { once: true }); |
| 1451 | } |
| 1452 | function watchOverlays() { |
| 1453 | let waiting = false; |
| 1454 | try { waiting = /(?:^#|&)ovo=/.test(root.location.hash || '') || !!sessionStorage.getItem('ov-overlay'); } catch { /* private mode */ } |
| 1455 | if (waiting) loadOverlay(); |
| 1456 | } |
| 1457 | // Faster to the next OpenVibe site: when a pointer or finger reaches for a link to another one, connect to it then. |
| 1458 | function warmLinks() { |
| 1459 | if (root.__ovWarm || typeof document.addEventListener !== 'function') return; |
| 1460 | root.__ovWarm = true; |
| 1461 | const done = new Set(); |
| 1462 | const warm = (e) => { |
| 1463 | const a = e.target && e.target.closest && e.target.closest('a[href]'); |
| 1464 | if (!a) return; |
| 1465 | let u; try { u = new URL(a.href, root.location.href); } catch { return; } |
| 1466 | if (u.protocol !== 'https:' || u.origin === root.location.origin || done.has(u.origin) || !/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || done.size > 12) return; |
| 1467 | done.add(u.origin); |
| 1468 | const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; l.crossOrigin = ''; document.head.appendChild(l); |
| 1469 | const d = document.createElement('link'); d.rel = 'dns-prefetch'; d.href = u.origin; document.head.appendChild(d); |
| 1470 | }; |
| 1471 | document.addEventListener('pointerover', warm, { passive: true, capture: true }); |
| 1472 | document.addEventListener('touchstart', warm, { passive: true, capture: true }); |
| 1473 | document.addEventListener('focusin', warm, { passive: true, capture: true }); |
| 1474 | // A link that leaves this page shows the top bar at once (the owner, 11-09: "a progress indicator at the very |
| 1475 | // top… a glow… OpenVibe looking"); Boost's own moves draw the same bar (route-transition.js). Listening on the |
| 1476 | // window, after the page's own handlers: a click a page or Boost took (defaultPrevented) draws nothing here. |
| 1477 | if (typeof root.addEventListener !== 'function') return; |
| 1478 | root.addEventListener('click', (e) => { |
| 1479 | if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 1480 | const a = e.target && e.target.closest && e.target.closest('a[href]'); |
| 1481 | if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return; |
| 1482 | let u; try { u = new URL(a.href, root.location.href); } catch { return; } |
| 1483 | const here = root.location; |
| 1484 | if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return; |
| 1485 | let b = document.getElementById('ovnav-leave'); |
| 1486 | if (!b) { b = document.createElement('div'); b.id = 'ovnav-leave'; b.className = 'ovrt-bar'; b.setAttribute('aria-hidden', 'true'); document.body.appendChild(b); } |
| 1487 | b.classList.remove('is-done'); void b.offsetWidth; b.classList.add('is-run'); |
| 1488 | }); |
| 1489 | // Back from the next page (the browser's page cache): the bar is gone. |
| 1490 | root.addEventListener('pageshow', () => { const b = document.getElementById('ovnav-leave'); if (b) b.classList.remove('is-run'); }); |
| 1491 | } |
| 1492 | // The menu on a place ('chats' | 'vibe' | 'code' | 'agent'); opts.task marks an agent's step and brings it into view. |
| 1493 | function openDock(tab, opts) { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.open(Object.assign({ tab }, opts || {})); }); } |
| 1494 | // Ask your agent (4.135.0): Vibe opens with the question in its box; signed out, sign in first. |
| 1495 | function askAgent(text) { |
| 1496 | if (!_config.token || _config.dock === false) { const u = resolveLoginHref(currentHost(), location.href); if (u) location.href = u; return; } |
| 1497 | loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); |
| 1498 | } |
| 1499 | if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('click', (e) => { const b = e.target && e.target.closest && e.target.closest('[data-ov-ask]'); if (!b) return; e.preventDefault(); askAgent(b.getAttribute('data-ov-ask') || ''); }); |
| 1500 | |
| 1501 | /** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */ |
| 1502 | function sectionsHTML(sections) { |
| 1503 | return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec => |
| 1504 | `<div class="ud-sec"${sec.id ? ` data-sec-id="${escapeAttr(sec.id)}"` : ''}${sec.hidden ? ' hidden' : ''}>${sec.label ? `<div class="ud-label">${escapeAttr(sec.label)}</div>` : ''}${sec.items.map(menuItemHTML).join('')}</div><div class="sep"></div>`).join(''); |
| 1505 | } |
| 1506 | const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []); |
| 1507 | |
| 1508 | // What this person opens from the menu, counted on this device (no request, nothing leaves the browser): the grid |
| 1509 | // puts their most used first once a place has been opened twice; until then the site's own order stands. |
| 1510 | const USE_KEY = 'ov-menu-use'; |
| 1511 | const useKey = (item) => String(item.id || item.href || item.label || '').slice(0, 80); |
| 1512 | function useCounts() { try { return JSON.parse(localStorage.getItem(USE_KEY) || '{}') || {}; } catch { return {}; } } |
| 1513 | function countUse(key) { |
| 1514 | if (!key) return; |
| 1515 | try { const c = useCounts(); c[key] = Math.min(9999, (Number(c[key]) || 0) + 1); const keys = Object.keys(c); if (keys.length > 60) keys.sort((x, y) => c[x] - c[y]).slice(0, keys.length - 60).forEach((k) => delete c[k]); localStorage.setItem(USE_KEY, JSON.stringify(c)); } catch { /* private mode */ } |
| 1516 | } |
| 1517 | function byUse(items) { |
| 1518 | const c = useCounts(), n = (it) => { const v = Number(c[useKey(it)]) || 0; return v >= 2 ? v : 0; }; |
| 1519 | return items.map((it, i) => [it, i]).sort((x, y) => (x[0].hidden ? 1 : 0) - (y[0].hidden ? 1 : 0) || n(y[0]) - n(x[0]) || x[1] - y[1]).map(([it]) => it); |
| 1520 | } |
| 1521 | /** One place in the account menu's grid: an icon over a short name (item.short, else its label). */ |
| 1522 | function tileHTML(item) { |
| 1523 | const attrs = ` class="ud-tile${item.danger ? ' danger' : ''}" data-use="${escapeAttr(useKey(item))}" title="${escapeAttr(item.label)}"${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}${item.mark ? ` ${item.mark}` : ''}`; |
| 1524 | const inner = `<span class="icon">${item.drawn || navIcon(item.icon || 'fa-circle-info')}</span><span class="ud-tl">${escapeAttr(item.short || item.label)}</span>${item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : ''}`; |
| 1525 | return item.href ? `<a href="${escapeAttr(item.href)}"${attrs}${item.external ? ' target="_blank" rel="noopener"' : ''}>${inner}</a>` : `<button type="button"${attrs}>${inner}</button>`; |
| 1526 | } |
| 1527 | |
| 1528 | function menuItemHTML(item) { |
| 1529 | if (!item) return ''; |
| 1530 | if (item.sep) return '<div class="sep"></div>'; |
| 1531 | const icon = item.drawn ? `<span class="icon">${item.drawn}</span>` : item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>'; |
| 1532 | const cls = item.danger ? ' class="danger"' : ''; |
| 1533 | const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''} data-use="${escapeAttr(useKey(item))}"${item.mark ? ` ${item.mark}` : ''}`; |
| 1534 | const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : ''; |
| 1535 | if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`; |
| 1536 | return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</button>`; |
| 1537 | } |
| 1538 | |
| 1539 | function bindMenuItems(container, items) { |
| 1540 | for (const item of items) { |
| 1541 | if (!item || !item.id || typeof item.onClick !== 'function') continue; |
| 1542 | container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); }); |
| 1543 | } |
| 1544 | } |
| 1545 | |
| 1546 | /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, |
| 1547 | featured, children: [...] }. featured: true draws it as the site's one accent pill (4.117.0). */ |
| 1548 | function linkHTML(l, inDrawer) { |
| 1549 | const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`; |
| 1550 | const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : []; |
| 1551 | const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`; |
| 1552 | const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}${l.featured ? ' ovnav-link--featured' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`; |
| 1553 | if (!kids.length) return a; |
| 1554 | const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join(''); |
| 1555 | return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`; |
| 1556 | } |
| 1557 | /** 4.119.0: a featured link with an icon keeps a button on the phone's bar. */ |
| 1558 | function featuredPhoneHTML(links) { |
| 1559 | const f = (links || []).find(l => l && l.featured && !l.hidden && l.icon); |
| 1560 | if (!f) return ''; |
| 1561 | return `<a class="ovnav-featured-phone${f.active ? ' active' : ''}" href="${escapeAttr(f.href || '#')}" aria-label="${escapeAttr(f.label)}" title="${escapeAttr(f.label)}"${f.active ? ' aria-current="page"' : ''}>${navIcon(f.icon)}</a>`; |
| 1562 | } |
| 1563 | const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []); |
| 1564 | |
| 1565 | /** The drawer's order: a featured link leads the menu (the phone's first entry), the rest keep theirs. */ |
| 1566 | function drawerOrder(list) { |
| 1567 | const items = (list || []).filter(Boolean); |
| 1568 | return items.filter(l => l.featured && !l.hidden).concat(items.filter(l => !(l.featured && !l.hidden))); |
| 1569 | } |
| 1570 | |
| 1571 | function currentLinks() { |
| 1572 | const list = _runtimeLinks || _config.links || linksFor(_config.service); |
| 1573 | const path = (typeof location !== 'undefined' && location.pathname) || '/'; |
| 1574 | const activePage = _activePage; |
| 1575 | return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) })); |
| 1576 | } |
| 1577 | |
| 1578 | let _activePage = null; // setActive(): single-page apps tell the navbar where they are |
| 1579 | const SERVICE_LINKS = { |
| 1580 | live: [ |
| 1581 | { label: 'Watch', href: '/' }, |
| 1582 | { label: 'Chat', href: '/chat' }, |
| 1583 | { label: 'VODs', href: '/vods' }, |
| 1584 | { label: 'Game', href: '/game' }, |
| 1585 | ], |
| 1586 | tools: [ |
| 1587 | { label: 'Home', href: '/' }, |
| 1588 | ], |
| 1589 | games: [ |
| 1590 | { label: 'Play', href: '/game' }, |
| 1591 | { label: 'Canvas', href: '/canvas' }, |
| 1592 | { label: 'Leaderboard', href: '/leaderboard' }, |
| 1593 | ], |
| 1594 | media: [ |
| 1595 | { label: 'Home', href: '/' }, |
| 1596 | { label: 'Network', href: 'https://openvibe.network' }, |
| 1597 | { label: 'Live', href: 'https://openvibe.live' }, |
| 1598 | { label: 'Tools', href: 'https://openvibe.tools' }, |
| 1599 | ], |
| 1600 | network: [ |
| 1601 | { label: 'Home', href: '/' }, |
| 1602 | { label: 'My Account', href: '/my' }, |
| 1603 | { label: 'Themes', href: '/themes' }, |
| 1604 | ], |
| 1605 | maps: [ |
| 1606 | { label: 'Map', href: '/' }, |
| 1607 | { label: 'Camps', href: '/camps' }, |
| 1608 | ], |
| 1609 | food: [ |
| 1610 | { label: 'Food Banks', href: '/' }, |
| 1611 | { label: 'Meal Plan', href: '/#meal-plan' }, |
| 1612 | ], |
| 1613 | img: [ |
| 1614 | { label: 'Convert', href: 'https://convert.openvibe.tools' }, |
| 1615 | { label: 'Compress', href: 'https://compress.openvibe.tools' }, |
| 1616 | { label: 'Resize', href: 'https://resize.openvibe.tools' }, |
| 1617 | { label: 'Crop', href: 'https://crop.openvibe.tools' }, |
| 1618 | ], |
| 1619 | yt: [ |
| 1620 | { label: 'Download', href: '/' }, |
| 1621 | ], |
| 1622 | audio: [ |
| 1623 | { label: 'Convert', href: 'https://audio.openvibe.tools' }, |
| 1624 | { label: 'Trim', href: 'https://trim.openvibe.tools' }, |
| 1625 | { label: 'Pitch', href: 'https://pitch.openvibe.tools' }, |
| 1626 | { label: 'Reverb', href: 'https://reverb.openvibe.tools' }, |
| 1627 | ], |
| 1628 | text: [ |
| 1629 | { label: 'Fancy', href: 'https://fancy.openvibe.tools' }, |
| 1630 | { label: 'Zalgo', href: 'https://zalgo.openvibe.tools' }, |
| 1631 | { label: 'ASCII', href: 'https://ascii.openvibe.tools' }, |
| 1632 | { label: 'Symbols', href: 'https://symbols.openvibe.tools' }, |
| 1633 | ], |
| 1634 | logo: [ |
| 1635 | { label: 'Title', href: 'https://title.openvibe.tools' }, |
| 1636 | { label: 'Wordmark', href: 'https://wordmark.openvibe.tools' }, |
| 1637 | { label: 'Badge', href: 'https://badge.openvibe.tools' }, |
| 1638 | { label: 'Thumbnail', href: 'https://thumbnail.openvibe.tools' }, |
| 1639 | ], |
| 1640 | docs: [ |
| 1641 | { label: 'Merge', href: 'https://mergepdf.openvibe.tools' }, |
| 1642 | { label: 'Split', href: 'https://splitpdf.openvibe.tools' }, |
| 1643 | { label: 'Compress', href: 'https://compresspdf.openvibe.tools' }, |
| 1644 | { label: 'Images→PDF', href: 'https://image2pdf.openvibe.tools' }, |
| 1645 | ], |
| 1646 | net: [ |
| 1647 | { label: 'Lookup', href: 'https://lookup.openvibe.tools' }, |
| 1648 | { label: 'My IP', href: 'https://myip.openvibe.tools' }, |
| 1649 | { label: 'DNS', href: 'https://dns.openvibe.tools' }, |
| 1650 | { label: 'Ping', href: 'https://ping.openvibe.tools' }, |
| 1651 | { label: 'SSL', href: 'https://ssl.openvibe.tools' }, |
| 1652 | ], |
| 1653 | dev: [ |
| 1654 | { label: 'JSON', href: 'https://json.openvibe.tools' }, |
| 1655 | { label: 'Base64', href: 'https://base64.openvibe.tools' }, |
| 1656 | { label: 'JWT', href: 'https://jwt.openvibe.tools' }, |
| 1657 | { label: 'Regex', href: 'https://regex.openvibe.tools' }, |
| 1658 | { label: 'Diff', href: 'https://diff.openvibe.tools' }, |
| 1659 | ], |
| 1660 | }; |
| 1661 | |
| 1662 | // The server shell calls this same table so its HTML matches the browser defaults. |
| 1663 | function linksFor(service) { |
| 1664 | return (SERVICE_LINKS[service] || []).map(link => ({ ...link })); |
| 1665 | } |
| 1666 | |
| 1667 | // Accounts seen here (no tokens). |
| 1668 | const SEEN_KEY = 'openvibe_seen_accounts'; |
| 1669 | function readList(key) { try { const v = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } } |
| 1670 | function getAccounts() { |
| 1671 | const ids = new Set(); |
| 1672 | return readList('openvibe_accounts').concat(readList(SEEN_KEY)).filter((a) => { |
| 1673 | if (!a || a.id == null || ids.has(String(a.id))) return false; |
| 1674 | ids.add(String(a.id)); |
| 1675 | return true; |
| 1676 | }); |
| 1677 | } |
| 1678 | function rememberAccount(u) { |
| 1679 | if (!u || u.is_anon || u.id == null || !u.username) return; |
| 1680 | try { |
| 1681 | const seen = readList(SEEN_KEY).filter((a) => a && String(a.id) !== String(u.id)); |
| 1682 | seen.unshift({ id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null }); |
| 1683 | localStorage.setItem(SEEN_KEY, JSON.stringify(seen.slice(0, 10))); |
| 1684 | } catch { /* storage unavailable */ } |
| 1685 | } |
| 1686 | function signInBack() { |
| 1687 | if (onNetworkDomain()) return window.location.href; |
| 1688 | try { return new URL(resolveLoginHref(currentHost(), window.location.href), window.location.href).toString(); } |
| 1689 | catch { return window.location.href; } |
| 1690 | } |
| 1691 | function switchHref(account) { |
| 1692 | const q = new URLSearchParams({ switch_to: String(account.id), return: signInBack() }); |
| 1693 | if (account.username) q.set('login_hint', String(account.username)); |
| 1694 | return `${_config.apiBase}/login?${q.toString()}`; |
| 1695 | } |
| 1696 | function addAccountHref() { |
| 1697 | return `${_config.apiBase}/login?${new URLSearchParams({ add_account: '1', return: signInBack() }).toString()}`; |
| 1698 | } |
| 1699 | |
| 1700 | // ─── SSO auth resolution ─────────────────────────────────── |
| 1701 | // ov_token is host-only on openvibe.network and Domain=.openvibe.tools on the tools (CONTRACTS.md). The tools' |
| 1702 | // apex owns the OAuth client (/auth/*); its subdomains only read the cookie, so sign-in/out goes through the apex. |
| 1703 | // Order: the cookie, localStorage, the page's token, then config.sessionUrl. |
| 1704 | const TOOLS_APEX = 'openvibe.tools'; |
| 1705 | const TOKEN_KEY = 'ov_token'; |
| 1706 | |
| 1707 | function currentHost() { |
| 1708 | return (typeof location !== 'undefined' && location.hostname) || ''; |
| 1709 | } |
| 1710 | |
| 1711 | function onToolsDomain(host) { |
| 1712 | const h = host !== undefined ? host : currentHost(); |
| 1713 | return h === TOOLS_APEX || h.endsWith('.' + TOOLS_APEX); |
| 1714 | } |
| 1715 | |
| 1716 | function onNetworkDomain(host) { |
| 1717 | const h = host !== undefined ? host : currentHost(); |
| 1718 | try { return h !== '' && h === new URL(_config.apiBase).hostname; } catch { return false; } |
| 1719 | } |
| 1720 | |
| 1721 | function parseCookieToken(cookieStr) { |
| 1722 | const m = String(cookieStr || '').match(/(?:^|;\s*)ov_token=([^;]*)/); |
| 1723 | if (!m || !m[1]) return null; |
| 1724 | try { return decodeURIComponent(m[1]); } catch { return m[1]; } |
| 1725 | } |
| 1726 | |
| 1727 | function getCookieToken() { |
| 1728 | if (typeof document === 'undefined') return null; |
| 1729 | return parseCookieToken(document.cookie); |
| 1730 | } |
| 1731 | |
| 1732 | function getStoredToken() { |
| 1733 | try { return localStorage.getItem(TOKEN_KEY) || null; } catch { return null; } |
| 1734 | } |
| 1735 | |
| 1736 | function resolveToken() { |
| 1737 | return getCookieToken() || getStoredToken() || _config.token || null; |
| 1738 | } |
| 1739 | |
| 1740 | /** Where "Sign In" goes on this host. */ |
| 1741 | function resolveLoginHref(host, returnUrl) { |
| 1742 | if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl); |
| 1743 | if (onToolsDomain(host)) { |
| 1744 | // *.openvibe.tools signs in through the gateway apex (its OAuth state; ov_token on Domain=.openvibe.tools). |
| 1745 | return `https://${TOOLS_APEX}/auth/login?next=${encodeURIComponent(returnUrl)}`; |
| 1746 | } |
| 1747 | if (onNetworkDomain(host)) return `/login?return=${encodeURIComponent(returnUrl)}`; |
| 1748 | return `${_config.apiBase}/login?return=${encodeURIComponent(returnUrl)}`; |
| 1749 | } |
| 1750 | |
| 1751 | function setAuthCookie(token) { |
| 1752 | if (typeof document === 'undefined') return; |
| 1753 | const maxAge = 24 * 60 * 60; // matches the 24h access JWT |
| 1754 | if (onToolsDomain()) { |
| 1755 | document.cookie = `ov_token=${token};path=/;max-age=${maxAge};domain=.${TOOLS_APEX};SameSite=Lax;Secure`; |
| 1756 | } else { |
| 1757 | const secure = (typeof location !== 'undefined' && location.protocol === 'https:') ? ';Secure' : ''; |
| 1758 | document.cookie = `ov_token=${token};path=/;max-age=${maxAge};SameSite=Lax${secure}`; |
| 1759 | } |
| 1760 | } |
| 1761 | |
| 1762 | function clearAuthState() { |
| 1763 | if (typeof document !== 'undefined') { |
| 1764 | document.cookie = 'ov_token=;path=/;max-age=0;SameSite=Lax'; |
| 1765 | if (onToolsDomain()) { |
| 1766 | document.cookie = `ov_token=;path=/;max-age=0;domain=.${TOOLS_APEX};SameSite=Lax`; |
| 1767 | } |
| 1768 | } |
| 1769 | try { |
| 1770 | localStorage.removeItem(TOKEN_KEY); |
| 1771 | localStorage.removeItem('openvibe_anon_token'); |
| 1772 | localStorage.removeItem('openvibe_active_account'); |
| 1773 | } catch { /* storage unavailable */ } |
| 1774 | } |
| 1775 | |
| 1776 | function persistToken(token) { |
| 1777 | try { localStorage.setItem(TOKEN_KEY, token); } catch { /* storage unavailable */ } |
| 1778 | setAuthCookie(token); |
| 1779 | } |
| 1780 | |
| 1781 | /** GET {apiBase}/api/auth/me with the Bearer token. */ |
| 1782 | async function fetchMe(token) { |
| 1783 | try { |
| 1784 | const res = await fetch(`${_config.apiBase}/api/auth/me`, { |
| 1785 | headers: { Authorization: `Bearer ${token}` }, |
| 1786 | }); |
| 1787 | if (res.ok) { |
| 1788 | const data = await res.json(); |
| 1789 | return { ok: true, user: (data && (data.user || data)) || null }; |
| 1790 | } |
| 1791 | return { ok: false, unauthorized: res.status === 401 || res.status === 403 }; |
| 1792 | } catch { |
| 1793 | return { ok: false, unauthorized: false }; |
| 1794 | } |
| 1795 | } |
| 1796 | |
| 1797 | /** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */ |
| 1798 | function fillUrl(template, returnUrl) { |
| 1799 | const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/'); |
| 1800 | let path = '/'; |
| 1801 | try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ } |
| 1802 | return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path)); |
| 1803 | } |
| 1804 | |
| 1805 | /** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */ |
| 1806 | async function gatewayRefresh() { |
| 1807 | try { |
| 1808 | const res = await fetch('/auth/refresh', { method: 'POST', credentials: 'same-origin' }); |
| 1809 | if (!res.ok) return null; |
| 1810 | const data = await res.json(); |
| 1811 | return data && data.token ? data : null; |
| 1812 | } catch { return null; } |
| 1813 | } |
| 1814 | |
| 1815 | /** Network-side refresh — accepts tokens expired up to 7 days ago (grace). */ |
| 1816 | async function networkRefresh(token) { |
| 1817 | try { |
| 1818 | const res = await fetch(`${_config.apiBase}/api/auth/refresh`, { |
| 1819 | method: 'POST', |
| 1820 | headers: { Authorization: `Bearer ${token}` }, |
| 1821 | }); |
| 1822 | if (!res.ok) return null; |
| 1823 | const data = await res.json(); |
| 1824 | return data && data.token ? data : null; |
| 1825 | } catch { return null; } |
| 1826 | } |
| 1827 | |
| 1828 | /** |
| 1829 | * Resolve the signed-in user from the shared SSO state. |
| 1830 | * With a token: validate it against the Network. If it is rejected, make |
| 1831 | * ONE refresh attempt (same-origin gateway refresh first on tools hosts, |
| 1832 | * then the Network's grace refresh) before giving up — never leave a |
| 1833 | * stale "Sign In" when a recoverable session exists. |
| 1834 | */ |
| 1835 | async function resolveSessionUser() { |
| 1836 | const token = resolveToken(); |
| 1837 | if (token) { |
| 1838 | const me = await fetchMe(token); |
| 1839 | if (me.ok && me.user) return { user: me.user, token }; |
| 1840 | if (me.unauthorized) { |
| 1841 | if (onToolsDomain()) { |
| 1842 | const r = await gatewayRefresh(); |
| 1843 | if (r) { |
| 1844 | const user = r.user || (await fetchMe(r.token)).user; |
| 1845 | if (user) { persistToken(r.token); return { user, token: r.token }; } |
| 1846 | } |
| 1847 | } |
| 1848 | const n = await networkRefresh(token); |
| 1849 | if (n) { |
| 1850 | const user = n.user || (await fetchMe(n.token)).user; |
| 1851 | if (user) { persistToken(n.token); return { user, token: n.token }; } |
| 1852 | } |
| 1853 | // A stored token nobody accepts any more: drop it, so it cannot keep hiding a |
| 1854 | // good session (the site's own below, or the next sign-in). |
| 1855 | try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ } |
| 1856 | } |
| 1857 | if (!_config.sessionUrl) return null; |
| 1858 | } |
| 1859 | // No usable token — ask the page's own session endpoint if it has one (a site with a |
| 1860 | // server-side session, such as openvibe.blog, knows who is signed in even when this |
| 1861 | // browser's stored token is stale or was never shared with the page). |
| 1862 | if (_config.sessionUrl) { |
| 1863 | try { |
| 1864 | const res = await fetch(_config.sessionUrl, { credentials: 'include' }); |
| 1865 | if (res.ok) { |
| 1866 | const data = await res.json(); |
| 1867 | const user = (data && (data.user || data)) || null; |
| 1868 | if (user && (user.username || user.id)) { |
| 1869 | let token = data && data.token || null; |
| 1870 | // Sites with HTTP-only access cookies expose a verified access token through a |
| 1871 | // same-origin, no-store endpoint for the Dock and the notification bell. |
| 1872 | if (!token && _config.tokenUrl) { |
| 1873 | try { |
| 1874 | const tokenRes = await fetch(_config.tokenUrl, { credentials: 'same-origin', cache: 'no-store' }); |
| 1875 | if (tokenRes.ok) token = (await tokenRes.json()).token || null; |
| 1876 | } catch { /* the account menu still works without the Dock */ } |
| 1877 | } |
| 1878 | return { user, token }; |
| 1879 | } |
| 1880 | } |
| 1881 | } catch { /* signed out */ } |
| 1882 | } |
| 1883 | return null; |
| 1884 | } |
| 1885 | |
| 1886 | let _authInFlight = null; |
| 1887 | let _hintPainted = false, _hintSettled = false; |
| 1888 | |
| 1889 | function ssoHint() { |
| 1890 | const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/); |
| 1891 | if (m) return m[1]; |
| 1892 | try { return localStorage.getItem('ov_sso_hint'); } catch { return null; } |
| 1893 | } |
| 1894 | |
| 1895 | /** |
| 1896 | * The first paint, before the session check: a hint that this browser signed in here. It is not |
| 1897 | * authorization and carries no token; only the account's own name may ride along (account:<name>). |
| 1898 | */ |
| 1899 | function hintUser() { |
| 1900 | let raw = ssoHint() || ''; |
| 1901 | try { raw = decodeURIComponent(raw); } catch { /* as written */ } |
| 1902 | const m = /^account(?::(.{1,64}))?$/.exec(raw); |
| 1903 | if (!m) return null; |
| 1904 | let name = (m[1] || '').replace(/^@/, ''); |
| 1905 | return { is_anon: false, username: /^[A-Za-z0-9_.-]{1,64}$/.test(name) ? name : '', __ssoHint: true }; |
| 1906 | } |
| 1907 | |
| 1908 | /** One silent sign-in per tab, only after an earlier sign-in here (never after 'guest', never for bots): silent=1. */ |
| 1909 | let _ssoClientLoading = null; |
| 1910 | function loadSsoClient() { |
| 1911 | if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO); |
| 1912 | if (_ssoClientLoading) return _ssoClientLoading; |
| 1913 | _ssoClientLoading = new Promise((resolve) => { |
| 1914 | const sc = document.createElement('script'); sc.async = true; sc.src = siblingV('sso-client.js'); |
| 1915 | sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null); |
| 1916 | document.head.appendChild(sc); |
| 1917 | }); |
| 1918 | return _ssoClientLoading; |
| 1919 | } |
| 1920 | |
| 1921 | /** Signed in here: cross-site links carry the session along (see sso-client.js). */ |
| 1922 | function enableHandoff() { |
| 1923 | loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } }); |
| 1924 | } |
| 1925 | |
| 1926 | function silentLoginNow() { |
| 1927 | const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href)); |
| 1928 | location.replace(url); |
| 1929 | return true; |
| 1930 | } |
| 1931 | |
| 1932 | /** |
| 1933 | * Use a top-level prompt=none round trip so Network's session is first-party even when |
| 1934 | * third-party cookies are blocked. An iframe's negative answer cannot distinguish a guest |
| 1935 | * from a signed-in person in those browsers, and first-use FedCM needs interaction. |
| 1936 | * At most once per tab per 10 minutes; never after a sign-out ('guest'); never for bots. |
| 1937 | * The SDK returns guests with sso=none instead of showing an interactive login page. |
| 1938 | */ |
| 1939 | function maybeSilentLogin() { |
| 1940 | if (!_config.silentLogin || typeof location === 'undefined') return false; |
| 1941 | if (ssoHint() === 'guest') return false; |
| 1942 | if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false; |
| 1943 | if (/[?&]sso=none\b/.test(location.search)) return false; |
| 1944 | try { |
| 1945 | const last = +sessionStorage.getItem('ov_silent_sso_at') || 0; |
| 1946 | if (Date.now() - last < 10 * 60 * 1000) return false; |
| 1947 | sessionStorage.setItem('ov_silent_sso_at', String(Date.now())); |
| 1948 | } catch { return false; } |
| 1949 | // The top-level round trip sees Network's session even where third-party cookies are blocked (phones), but it |
| 1950 | // costs a redirect. Take it when there is a reason to think this person has one: this site signed them in |
| 1951 | // before ('account'), or they came here from another OpenVibe site (the owner, 11-08: signed in to the network, |
| 1952 | // opened openvibe.link from the menu, saw Sign In). A first visit from a search engine keeps the quiet iframe |
| 1953 | // check and FedCM instead, and Sign in is one tap. |
| 1954 | if (ssoHint() === 'account' || fromOpenVibe()) return silentLoginNow(); |
| 1955 | checkNetworkSession().then((state) => { |
| 1956 | if (state && state.signedIn) return silentLoginNow(); |
| 1957 | if (_config.fedcm === false) return; |
| 1958 | loadSsoClient().then(async (sso) => { |
| 1959 | if (!sso || !sso.fedcmAvailable()) return; |
| 1960 | const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' }); |
| 1961 | if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); } |
| 1962 | }); |
| 1963 | }); |
| 1964 | return false; |
| 1965 | } |
| 1966 | /** This page was opened from another OpenVibe site (its referrer's host is first-party, and not this one). */ |
| 1967 | function fromOpenVibe() { |
| 1968 | try { |
| 1969 | const r = new URL(document.referrer); |
| 1970 | return r.protocol === 'https:' && r.hostname !== location.hostname && /(^|\.)(openvibe\.[a-z]+|open\.shoes|openre\.stream)$/.test(r.hostname); |
| 1971 | } catch { return false; } |
| 1972 | } |
| 1973 | |
| 1974 | /** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */ |
| 1975 | function checkNetworkSession(timeoutMs = 4000) { |
| 1976 | return new Promise((resolve) => { |
| 1977 | let done = false, frame = null, timer = null; |
| 1978 | const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); }; |
| 1979 | const onMsg = (e) => { |
| 1980 | if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return; |
| 1981 | finish({ signedIn: !!e.data.signedIn, username: e.data.username || null }); |
| 1982 | }; |
| 1983 | try { |
| 1984 | window.addEventListener('message', onMsg); |
| 1985 | frame = document.createElement('iframe'); |
| 1986 | frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1'); |
| 1987 | frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none'; |
| 1988 | frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`; |
| 1989 | (document.body || document.documentElement).appendChild(frame); |
| 1990 | timer = setTimeout(() => finish(null), timeoutMs); |
| 1991 | } catch { finish(null); } |
| 1992 | }); |
| 1993 | } |
| 1994 | |
| 1995 | function recordHistory() { |
| 1996 | const h = _config.history; |
| 1997 | if (!h || !_config.user || _config.user.is_anon) return; |
| 1998 | const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h); |
| 1999 | const H = root.OpenVibeHistory; |
| 2000 | if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; } |
| 2001 | if (!document.getElementById('ov-history-loader')) { |
| 2002 | const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true; |
| 2003 | sc.src = siblingV('history.js'); |
| 2004 | sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } }; |
| 2005 | document.head.appendChild(sc); |
| 2006 | } else { |
| 2007 | document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } }); |
| 2008 | } |
| 2009 | } |
| 2010 | |
| 2011 | let _questGuideStarted = false; |
| 2012 | function startQuestGuide() { |
| 2013 | if (_questGuideStarted || !_config.token || !root.location || !/[?&]qstep=/.test(root.location.search || '')) return; |
| 2014 | _questGuideStarted = true; |
| 2015 | const script = document.createElement('script'); |
| 2016 | script.src = siblingV('guide.js'); |
| 2017 | script.onload = () => { |
| 2018 | if (!root.OpenVibeQuestGuide) return; |
| 2019 | void root.OpenVibeQuestGuide.startFromLocation({ load: async (id) => { |
| 2020 | try { |
| 2021 | const response = await fetch(`https://openvibe.quest/api/v1/quests/today/steps/${encodeURIComponent(id)}`, { |
| 2022 | headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'omit', |
| 2023 | }); |
| 2024 | return response.ok ? response.json() : null; |
| 2025 | } catch { return null; } |
| 2026 | } }); |
| 2027 | }; |
| 2028 | document.head.appendChild(script); |
| 2029 | } |
| 2030 | |
| 2031 | function refreshAuthState() { |
| 2032 | if (_authInFlight) return _authInFlight; |
| 2033 | _authInFlight = resolveSessionUser() |
| 2034 | .then((session) => { |
| 2035 | _authInFlight = null; |
| 2036 | if (session && session.user) { |
| 2037 | _config.user = session.user; |
| 2038 | if (session.token) _config.token = session.token; |
| 2039 | render(); |
| 2040 | recordHistory(); |
| 2041 | enableHandoff(); |
| 2042 | startQuestGuide(); |
| 2043 | } else { |
| 2044 | const navigating = maybeSilentLogin(); |
| 2045 | // The hint was only a first paint: no session behind it, so draw the guest bar. |
| 2046 | if (_hintPainted && !navigating) { _hintPainted = false; _hintSettled = true; render(); } |
| 2047 | } |
| 2048 | try { |
| 2049 | document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', { |
| 2050 | detail: { user: session ? session.user : null, token: session ? session.token : null }, |
| 2051 | })); |
| 2052 | } catch { /* non-DOM environment */ } |
| 2053 | return session ? session.user : null; |
| 2054 | }) |
| 2055 | .catch(() => { _authInFlight = null; return null; }); |
| 2056 | return _authInFlight; |
| 2057 | } |
| 2058 | |
| 2059 | function escapeAttr(value) { |
| 2060 | return String(value ?? '').replace(/&/g, '&').replace(/"/g, '"'); |
| 2061 | } |
| 2062 | function escapeHtml(value) { |
| 2063 | return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| 2064 | } |
| 2065 | |
| 2066 | function getAvatarInitial(user) { |
| 2067 | const source = user?.display_name || user?.username || 'O'; |
| 2068 | return String(source).trim().charAt(0).toUpperCase() || 'O'; |
| 2069 | } |
| 2070 | |
| 2071 | function makeAvatarPlaceholder(user, size = 64) { |
| 2072 | const initial = getAvatarInitial(user); |
| 2073 | const bg = user?.profile_color || '#3b82f6'; |
| 2074 | const svg = ` |
| 2075 | <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"> |
| 2076 | <rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/> |
| 2077 | <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#ffffff">${initial}</text> |
| 2078 | </svg>`; |
| 2079 | return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg.replace(/\s+/g, ' ').trim())}`; |
| 2080 | } |
| 2081 | |
| 2082 | // The account's picture now (the Network's /avatar/<username>): a record can predate its picture (CHANGELOG 4.12.0). |
| 2083 | function currentAvatarUrl(user, size) { |
| 2084 | if (!user || user.is_anon || user.id === 'anon' || !user.username) return null; |
| 2085 | const network = /(^|\.)openvibe\.network$/.test(currentHost()) ? '' : 'https://openvibe.network'; |
| 2086 | return `${network}/avatar/${encodeURIComponent(user.username)}?s=${size}`; |
| 2087 | } |
| 2088 | |
| 2089 | function avatarSrc(user, size = 64) { |
| 2090 | const url = user?.avatar_url; |
| 2091 | if (/^https:\/\/openvibe\.media\/o\/med_\w+$/.test(url || '')) return `${url}?w=${size}&fmt=webp`; |
| 2092 | if (/^https:\/\/openvibe\.media\//.test(url || '')) return url; |
| 2093 | return currentAvatarUrl(user, size) || makeAvatarPlaceholder(user, size); |
| 2094 | } |
| 2095 | |
| 2096 | function avatarImg(user, size = 64, className = 'openvibe-navbar-avatar', id = '') { |
| 2097 | const fallback = makeAvatarPlaceholder(user, size); |
| 2098 | const src = avatarSrc(user, size); |
| 2099 | const current = currentAvatarUrl(user, size); |
| 2100 | const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : ''; |
| 2101 | const idAttr = id ? ` id="${escapeAttr(id)}"` : ''; |
| 2102 | const alt = escapeAttr(user?.display_name || user?.username || 'Avatar'); |
| 2103 | return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr} width="${escapeAttr(size)}" height="${escapeAttr(size)}" decoding="async">`; |
| 2104 | } |
| 2105 | |
| 2106 | // A picture that does not load: the current one once (data-retry-src), then the initial. |
| 2107 | function attachAvatarFallbacks(rootEl) { |
| 2108 | rootEl?.querySelectorAll('img[data-fallback-src]').forEach((img) => { |
| 2109 | const onError = () => { |
| 2110 | const retry = img.dataset.retrySrc; |
| 2111 | if (retry) { delete img.dataset.retrySrc; img.src = retry; return; } |
| 2112 | img.removeEventListener('error', onError); |
| 2113 | const fallback = img.dataset.fallbackSrc; |
| 2114 | if (fallback && img.src !== fallback) img.src = fallback; |
| 2115 | }; |
| 2116 | img.addEventListener('error', onError); |
| 2117 | if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) onError(); |
| 2118 | }); |
| 2119 | } |
| 2120 | |
| 2121 | // Wallet balances fold into one pill and refresh when the tab returns. |
| 2122 | const WALLET_CHIPS = [ |
| 2123 | { id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'Open Wallet' }, |
| 2124 | { id: 'vibes', icon: 'fa-wallet', tone: 'green', label: 'Vibes', title: 'Open Wallet' }, |
| 2125 | { id: 'balance', icon: 'fa-wallet', label: 'Balance', title: 'Open Wallet' }, |
| 2126 | ]; |
| 2127 | let _walletFor = null, _walletAt = 0, _walletWatch = false, _walletData = null, _walletScript = null; |
| 2128 | const walletKey = (u) => `${u.subject || u.id || u.username || ''}:${_config.token || ''}`; |
| 2129 | function walletAmount(b) { |
| 2130 | const n = Number(b.amount) / Math.pow(10, b.scale || 0); |
| 2131 | return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n) : n.toLocaleString(); |
| 2132 | } |
| 2133 | function walletOrder(data) { |
| 2134 | const order = ['coins', 'vibes', 'balance']; |
| 2135 | if (!data) return order.slice(0, 1); |
| 2136 | const value = (id) => Number(data.balances[id].amount) / Math.pow(10, data.balances[id].scale) / (id === 'vibes' ? data.bits_per_usd || 100 : 1); |
| 2137 | const have = order.filter((id) => value(id) > 0).sort((a, b) => value(b) - value(a)); |
| 2138 | if (data.used_ai_this_month && BigInt(data.balances.balance.amount) === 0n) have.unshift('balance'); |
| 2139 | return have.length ? have : order.slice(0, 1); |
| 2140 | } |
| 2141 | function syncWalletChips() { |
| 2142 | if (!_config.wallet) return; |
| 2143 | const u = _config.user; |
| 2144 | let chips = (_config.chips || []).filter((c) => !(c && (c.__wallet || ['coins', 'vibes', 'balance'].includes(c.id)))); |
| 2145 | // false: unreadable, nothing kept: no chips, never a "…" that stays. |
| 2146 | const data = u && !u.is_anon && _walletFor === walletKey(u) ? _walletData : null; |
| 2147 | if (u && !u.is_anon && data !== false) { |
| 2148 | chips = walletOrder(data).map((id) => Object.assign({}, WALLET_CHIPS.find((w) => w.id === id), { |
| 2149 | __wallet: true, value: data ? walletAmount(data.balances[id]) : '…', hidden: false, onClick: openWallet, |
| 2150 | })).concat(chips); |
| 2151 | } |
| 2152 | _config.chips = chips; |
| 2153 | } |
| 2154 | async function openWallet() { |
| 2155 | if (!_config.user || _config.user.is_anon) return; |
| 2156 | if (!root.OpenVibeWallet) { |
| 2157 | if (!_walletScript) _walletScript = new Promise((resolve, reject) => { |
| 2158 | const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = siblingV('ov-wallet.css'); document.head.appendChild(css); |
| 2159 | const js = document.createElement('script'); js.src = siblingV('ov-wallet.js'); js.onload = resolve; |
| 2160 | js.onerror = () => { _walletScript = null; css.remove(); js.remove(); reject(new Error('Wallet could not load')); }; document.head.appendChild(js); |
| 2161 | }); |
| 2162 | try { await _walletScript; } catch { if (_walletScript === null) root.location.assign('https://openvibe.network/wallet'); return; } |
| 2163 | } |
| 2164 | return root.OpenVibeWallet.open({ getToken: () => _config.token || null, cookieSession: !_config.token, frameBase: _config.frameBase || 'https://openvibe.network/api/frame', |
| 2165 | onWallet: (data) => { if (!_config.user) return; _walletFor = walletKey(_config.user); _walletData = data; _walletAt = Date.now(); if (_navEl) render(); } }); |
| 2166 | } |
| 2167 | function loadWalletChips() { |
| 2168 | const u = _config.user; |
| 2169 | if (!_config.wallet || !u || u.is_anon || typeof fetch !== 'function') return; |
| 2170 | const key = walletKey(u), token = _config.token || null; |
| 2171 | if (_walletFor === key && Date.now() - _walletAt < 60000) return; |
| 2172 | if (_walletFor !== key) _walletData = null; |
| 2173 | _walletFor = key; _walletAt = Date.now(); |
| 2174 | if (!_walletWatch && typeof document !== 'undefined' && document.addEventListener) { |
| 2175 | _walletWatch = true; |
| 2176 | document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { loadWalletChips(); loadUnread(); } }); |
| 2177 | } |
| 2178 | const same = () => _config.user === u && (_config.token || null) === token; |
| 2179 | const failed = () => { if (!same() || _walletData) return; _walletData = false; if (_navEl) render(); }; |
| 2180 | fetch(`${(_config.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet/api/v1/me/wallet`, |
| 2181 | token ? { headers: { Authorization: `Bearer ${token}` }, credentials: 'omit' } : { credentials: 'include' }) |
| 2182 | .then(async (r) => { if (!r.ok) return failed(); const data = await r.json(); if (!same()) return; |
| 2183 | _walletData = data; if (_navEl) render(); |
| 2184 | (root.OpenVibePurse?.catchUp || (v => root.__ovPursePending = v))({ frameBase: _config.frameBase, token, user: u.subject || u.id, streamer: _config.purse?.streamer, valid: same }); |
| 2185 | }).catch(failed); |
| 2186 | } |
| 2187 | |
| 2188 | function render() { |
| 2189 | syncWalletChips(); |
| 2190 | // A site may put state classes on the bar (Live's transparent hero mode). A re-render must not lose them. |
| 2191 | let carried = []; try { carried = Array.from((_navEl && _navEl.classList) || []).filter(c => c !== 'openvibe-navbar'); } catch { carried = []; } |
| 2192 | if (_navEl) _navEl.remove(); |
| 2193 | if (_renderAbort) { _renderAbort.abort(); _panelQueue.length = 0; } |
| 2194 | _renderAbort = typeof AbortController === 'function' ? new AbortController() : null; |
| 2195 | |
| 2196 | const nav = document.createElement('nav'); |
| 2197 | nav.className = 'openvibe-navbar'; |
| 2198 | carried.forEach(c => { try { nav.classList.add(c); } catch { /* */ } }); |
| 2199 | const svc = _config.service; |
| 2200 | |
| 2201 | const brand = resolveBrand(); |
| 2202 | if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); } |
| 2203 | const links = currentLinks(); |
| 2204 | |
| 2205 | // With the hint saying account, the bar starts in the signed-in shape (a neutral avatar, the |
| 2206 | // account's name only when the hint carries it). refreshAuthState replaces or clears it. |
| 2207 | const hinted = !_config.user && !_hintSettled && _config.auth !== 'external' ? hintUser() : null; |
| 2208 | if (hinted) _hintPainted = true; |
| 2209 | const u = _config.user || hinted; |
| 2210 | rememberAccount(u); |
| 2211 | const accounts = getAccounts(); |
| 2212 | const isAnon = u && u.is_anon; |
| 2213 | const loginHref = resolveLoginHref(currentHost(), window.location.href); |
| 2214 | const switcher = !!root.OpenVibeAccountSwitcher; // in place on openvibe.network |
| 2215 | |
| 2216 | // The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag. |
| 2217 | if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) { |
| 2218 | try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ } |
| 2219 | } |
| 2220 | // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now, |
| 2221 | // or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing). |
| 2222 | if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers(); if (_config.forms !== false) watchForms(); |
| 2223 | if (!root.__ovShareClick) { root.__ovShareClick = true; document.addEventListener('click', shareClick); } |
| 2224 | watchOverlays(); warmLinks(); |
| 2225 | nav.innerHTML = ` |
| 2226 | ${brandHTML(brand)} |
| 2227 | <div class="openvibe-navbar-links"> |
| 2228 | ${links.filter(l => !l.more).map(l => linkHTML(l, false)).join('')}${links.some(l => l.more && !l.hidden) ? linkHTML({ id: 'ovnav-more', label: 'More', href: '#', children: links.filter(l => l.more) }, false) : ''} |
| 2229 | ${networkLinksHTML(links)} |
| 2230 | ${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''} |
| 2231 | </div> |
| 2232 | <div class="openvibe-navbar-spacer"></div> |
| 2233 | <div class="openvibe-navbar-right"> |
| 2234 | ${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''} |
| 2235 | ${featuredPhoneHTML(links)} |
| 2236 | ${links.length ? `<button type="button" class="ovnav-burger ovnav-orb" id="openvibe-burger" aria-label="Menu: this site, search and every OpenVibe site" data-ov-tip="Menu" data-ov-tip-sub="This site, search, every OpenVibe site" aria-haspopup="dialog" aria-expanded="false">${orbSVG()}</button>` : ''} |
| 2237 | ${u ? `<span class="ovnav-avatar-wrap">${avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn')}<b class="ovnav-avatar-count" hidden></b></span>` : |
| 2238 | `<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}" aria-label="Sign in" data-ov-tip="Sign in"><svg class="ovsi" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><circle class="ovsi-o" cx="12" cy="12" r="11"/><g class="ovsi-p"><circle cx="12" cy="8.8" r="3.2"/><path d="M5.900 18.400c1.100-2.700 3.300-4.100 6.100-4.100s5 1.400 6.100 4.100"/></g><path class="ovsi-a" d="M5.500 12h8.500M11 8.500l3.500 3.500-3.500 3.500M15.500 6.500h2.500v11h-2.500"/></svg></a>`} |
| 2239 | </div> |
| 2240 | ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${drawerOrder(links).map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''} |
| 2241 | `; |
| 2242 | if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c)); |
| 2243 | bindLinks(nav, links); |
| 2244 | hookSearch(nav); |
| 2245 | hookSwitcher(nav); hookPages(nav); hookSignIn(nav); hookFit(nav); |
| 2246 | tipWait(); |
| 2247 | bindChips(nav); |
| 2248 | bindChipsAll(nav); |
| 2249 | queueIcons(); |
| 2250 | queueFx(); |
| 2251 | countView(); |
| 2252 | // Pages that wire the bell themselves run right after init(); give them the first go. |
| 2253 | setTimeout(() => { mountNotifications(nav, u); mountDock(u); mountPresence(u); }, 0); |
| 2254 | |
| 2255 | // Dropdown |
| 2256 | if (u) { |
| 2257 | const dropdown = document.createElement('div'); |
| 2258 | dropdown.className = 'openvibe-navbar-dropdown'; |
| 2259 | dropdown.id = 'openvibe-user-dropdown'; |
| 2260 | |
| 2261 | const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id)); |
| 2262 | // The avatar and name lead to the person's profile (an anonymous visitor's account page): people press them. |
| 2263 | const meHref = !isAnon && u.username ? `https://openvibe.network/@${encodeURIComponent(u.username)}` : 'https://openvibe.network/my'; |
| 2264 | const menuCfg = _config.menu || {}; |
| 2265 | const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before); |
| 2266 | const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after); |
| 2267 | // The bar shows the balances (a site's chips, or wallet: true): the menu does not repeat them. |
| 2268 | const barWallet = !!_config.wallet || (Array.isArray(_config.chips) && _config.chips.some(c => c && (c.id === 'coins' || c.id === 'vibes'))); |
| 2269 | const admin = u.role === 'admin' || !!(u.capabilities && (u.capabilities.is_owner || u.capabilities.admin_panel)); |
| 2270 | |
| 2271 | const bell = _config.notifications !== false && !!_config.token && !isAnon; |
| 2272 | const places = before.concat(after).filter((it) => it && !it.sep).concat(menuCfg.defaults === false ? [] : [ |
| 2273 | !isAnon && u.username ? { id: 'ov-channel', label: 'My channel', short: 'Channel', drawn: navIcon('fa-tower-broadcast'), href: `https://openvibe.live/@${encodeURIComponent(u.username)}` } : null, |
| 2274 | { id: 'ov-history', label: 'History', drawn: navIcon('fa-clock-rotate-left'), href: 'https://openvibe.network/my#history' }, |
| 2275 | bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' }, |
| 2276 | !isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null, |
| 2277 | !isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null, |
| 2278 | admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null, |
| 2279 | !isAnon ? { id: 'ov-wallet', label: 'Wallet', drawn: navIcon('fa-wallet'), href: 'https://openvibe.network/my#wallet' } : null, |
| 2280 | !isAnon && u.username ? { id: 'ov-profile', label: 'Profile', drawn: navIcon('fa-id-card'), href: `https://openvibe.network/@${encodeURIComponent(u.username)}` } : null, |
| 2281 | ].filter(Boolean)); |
| 2282 | _own = places.filter((p) => !/^ov-(hi|no|as|wo|do|wa|pr)/.test(p.id || '')); |
| 2283 | const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8); |
| 2284 | const others = otherAccounts.filter(a => switcher || !a.is_anon); |
| 2285 | const iconBtn = (cls, drawn, label, extra) => `<button type="button" class="ud-ib${cls}" title="${label}" aria-label="${label}"${extra || ''}><span class="icon">${drawn}</span></button>`; |
| 2286 | |
| 2287 | dropdown.innerHTML = ` |
| 2288 | <div class="openvibe-navbar-dropdown-header"> |
| 2289 | <a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a> |
| 2290 | <div class="info"> |
| 2291 | <a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a> |
| 2292 | <div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div> |
| 2293 | ${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'} |
| 2294 | </div> |
| 2295 | <div class="ud-hacts"> |
| 2296 | ${_config.accounts === false ? '' : `<a class="ud-hbtn add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}" title="Add another account" aria-label="Add another account"><span class="icon">${navIcon('fa-plus')}</span></a>`} |
| 2297 | <a class="ud-hbtn" href="https://openvibe.network/my" title="Account & settings" aria-label="Account & settings"><span class="icon">${navIcon('fa-gear')}</span></a> |
| 2298 | <button id="openvibe-logout-btn" type="button" class="ud-hbtn ud-out" title="Sign out" aria-label="Sign out"><span class="icon">${navIcon('fa-right-from-bracket')}</span></button> |
| 2299 | </div> |
| 2300 | </div> |
| 2301 | ${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account"> |
| 2302 | ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')} |
| 2303 | ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Anonymous</span></div>` : ''} |
| 2304 | </div>`} |
| 2305 | <div class="openvibe-navbar-dropdown-menu"> |
| 2306 | ${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''} |
| 2307 | ${more.length ? `<details class="ud-more"><summary><span class="icon">${navIcon('fa-chevron-down')}</span> More <span class="ud-n">${more.length}</span></summary>${more.map(menuItemHTML).join('')}</details>` : ''} |
| 2308 | ${sectionsHTML(menuCfg.sections)} |
| 2309 | <div class="ud-foot"> |
| 2310 | <div class="ud-scheme" role="group" aria-label="Light or dark" hidden>${iconBtn('', navIcon('fa-sun'), 'Light', ' data-ov-scheme="light" aria-pressed="false"')}${iconBtn('', navIcon('fa-moon'), 'Dark', ' data-ov-scheme="dark" aria-pressed="false"')}${iconBtn('', navIcon('fa-circle-half-stroke'), 'Auto: follow this device', ' data-ov-scheme="auto" aria-pressed="false"')}</div> |
| 2311 | <a class="ud-ib ud-disp" href="https://openvibe.design/look" data-ov-look-open aria-expanded="false" title="Theme, text size, motion and icons" aria-label="Display settings"><span class="icon">${navIcon('fa-palette')}</span><span>Display</span></a> |
| 2312 | </div> |
| 2313 | <div class="ud-look" hidden></div> |
| 2314 | </div> |
| 2315 | `; |
| 2316 | dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); }); |
| 2317 | // 4.188.0: not a popup under the picture. It rests in the bar, out of sight (so a site that looks for its |
| 2318 | // sign-out button or its own item still finds it), and the menu's You tab hosts it while the menu is open |
| 2319 | // (openSearch's account(); onClose brings it back). |
| 2320 | dropdown.classList.add('ovnav-account'); |
| 2321 | nav.appendChild(dropdown); |
| 2322 | _accountEl = dropdown; |
| 2323 | bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections))); |
| 2324 | bindChips(dropdown); |
| 2325 | if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; const path = sitePath(a); if (path != null) { if (_config.onNavigate(path, e) === false) e.preventDefault(); dropdown.classList.remove('open'); } }); |
| 2326 | bindDisplayRows(dropdown); |
| 2327 | bindLook(dropdown); |
| 2328 | |
| 2329 | // The picture opens the menu on You, where the account's things are (the selection for Ask Vibe is read on |
| 2330 | // pointerdown: the press can clear it). |
| 2331 | const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null; |
| 2332 | avatarBtn.addEventListener('pointerdown', () => { sel = selectedText(); }); |
| 2333 | avatarBtn.addEventListener('click', () => { |
| 2334 | const ask = dropdown.querySelector('[data-ov-ask]'); if (ask) ask.href = askVibeHref(sel ?? selectedText()); sel = null; |
| 2335 | if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; } |
| 2336 | const active = document.querySelector('.ovs.is-open'); |
| 2337 | if (active && active.querySelector('.ovs-starttabs [data-tab="you"][aria-selected="true"]')) root.OpenVibeSearch.close(); |
| 2338 | else openSearch('', 'you', avatarBtn, 'notifications'); |
| 2339 | }); |
| 2340 | |
| 2341 | |
| 2342 | dropdown.querySelectorAll('[data-account-id]').forEach(el => { |
| 2343 | el.addEventListener('click', () => { |
| 2344 | const id = el.dataset.accountId; |
| 2345 | document.dispatchEvent(new CustomEvent('openvibe-switch-account', { detail: { accountId: id } })); |
| 2346 | dropdown.classList.remove('open'); |
| 2347 | }); |
| 2348 | }); |
| 2349 | |
| 2350 | dropdown.querySelector('#openvibe-logout-btn')?.addEventListener('click', () => { |
| 2351 | dropdown.classList.remove('open'); |
| 2352 | if (_config.onLogout) _config.onLogout(); |
| 2353 | else { |
| 2354 | clearAuthState(); |
| 2355 | _config.user = null; |
| 2356 | _config.token = null; |
| 2357 | try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ } |
| 2358 | try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ } |
| 2359 | if (_config.logoutUrl) { |
| 2360 | // The site ends its own server-side session too, then sends us back. |
| 2361 | window.location.href = fillUrl(_config.logoutUrl, window.location.href); |
| 2362 | } else if (onToolsDomain()) { |
| 2363 | // The gateway also clears the Domain=.openvibe.tools cookie |
| 2364 | // and the httpOnly refresh cookie, then sends us back here. |
| 2365 | window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`; |
| 2366 | } else { |
| 2367 | window.location.reload(); |
| 2368 | } |
| 2369 | } |
| 2370 | }); |
| 2371 | } else { |
| 2372 | nav.querySelector('#openvibe-login-btn')?.addEventListener('click', (event) => { |
| 2373 | if (!_config.onLogin) return; |
| 2374 | if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; |
| 2375 | event.preventDefault(); |
| 2376 | _config.onLogin(); |
| 2377 | }); |
| 2378 | } |
| 2379 | |
| 2380 | // Insert into page — use navbar-mount placeholder if available, otherwise prepend to body |
| 2381 | const mount = document.getElementById('navbar-mount'); |
| 2382 | if (mount) { |
| 2383 | // Replace the crawlable server nav atomically; never leave duplicate landmarks. |
| 2384 | if (mount.replaceChildren) mount.replaceChildren(nav); |
| 2385 | else { while (mount.firstChild) mount.removeChild(mount.firstChild); mount.appendChild(nav); } |
| 2386 | } else { |
| 2387 | document.body.prepend(nav); |
| 2388 | } |
| 2389 | _navEl = nav; |
| 2390 | paintSlots(); |
| 2391 | watchKeyboard(); |
| 2392 | loadBottom(); |
| 2393 | attachAvatarFallbacks(nav); |
| 2394 | hookTop(); |
| 2395 | loadWalletChips(); |
| 2396 | loadUnread(); |
| 2397 | renderAppBar(); |
| 2398 | return nav; |
| 2399 | } |
| 2400 | |
| 2401 | // bottom.js: the visible viewport for the bar, the small player, the Dock's pill and the composer. |
| 2402 | let _bottomLoading = false; |
| 2403 | function loadBottom() { |
| 2404 | if (_bottomLoading || root.OpenVibeBottom) return; |
| 2405 | _bottomLoading = true; |
| 2406 | try { const sc = document.createElement('script'); sc.id = 'ovb-loader'; sc.src = siblingV('bottom.js'); sc.async = true; document.head.appendChild(sc); } catch { _bottomLoading = false; } |
| 2407 | } |
| 2408 | |
| 2409 | // ── The phone app bar (appbar.js, loaded only when the site sets appBar): its main places as a bottom tab bar ── |
| 2410 | let _appBarLoading = false; |
| 2411 | const rawLinks = () => _runtimeLinks || _config.links || linksFor(_config.service); |
| 2412 | |
| 2413 | // Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }. |
| 2414 | let _searchLoading = false; |
| 2415 | // Warm the menu (4.137.0): search.js by its hashed URL is cached for good, so fetching it early costs one download per |
| 2416 | // release. At idle after the page settles (not on Save-Data or 2G), and at once when a finger or pointer reaches the |
| 2417 | // burger or the orb; the first open then has nothing to wait for. |
| 2418 | let _menuWarm = false; |
| 2419 | function warmMenu() { |
| 2420 | if (_menuWarm || root.OpenVibeSearch || typeof document === 'undefined') return; |
| 2421 | _menuWarm = true; |
| 2422 | try { const l = document.createElement('link'); l.rel = 'prefetch'; l.as = 'script'; l.href = siblingV('search.js'); document.head.appendChild(l); } catch { /* */ } |
| 2423 | } |
| 2424 | function warmMenuLater() { |
| 2425 | try { const c = root.navigator && root.navigator.connection; if (c && (c.saveData || /(^|-)2g$/.test(c.effectiveType || ''))) return; } catch { /* */ } |
| 2426 | const go = () => warmMenu(); |
| 2427 | if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(go, { timeout: 8000 }); else setTimeout(go, 6000); |
| 2428 | } |
| 2429 | /** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */ |
| 2430 | function openSearch(q, tab, fromEl, section, door, extra) { |
| 2431 | // The orb that waves while the menu loads: the one pressed (the phone's menu button is an orb too, 4.148.0). |
| 2432 | const orb = (fromEl && fromEl.classList && fromEl.classList.contains('ovnav-orb') ? fromEl : null) || (_navEl && _navEl.querySelector('#openvibe-search-btn')); |
| 2433 | // The menu drops from the button that opened it (the orb, or the burger), under the bar. |
| 2434 | const from = fromEl || orb; |
| 2435 | const anchor = () => { |
| 2436 | if (!_navEl || !from || !from.getBoundingClientRect) return null; |
| 2437 | const bar = _navEl.getBoundingClientRect(), at = from.getBoundingClientRect(); |
| 2438 | return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6, cx: at.left + at.width / 2, cy: at.top + at.height / 2 } : null; |
| 2439 | }; |
| 2440 | const go = () => { |
| 2441 | if (orb) orb.classList.remove('is-busy'); |
| 2442 | if (!root.OpenVibeSearch) return; |
| 2443 | if (_navEl) _navEl.classList.remove('ovnav-hide'); |
| 2444 | root.OpenVibeSearch.open({ ...(extra || {}), service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door, |
| 2445 | frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV, |
| 2446 | // Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had. |
| 2447 | display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); }, |
| 2448 | look: bindLook, |
| 2449 | dock: _config.dock !== false && _config.user && !_config.user.is_anon && _config.token ? (t) => openDock(t) : null, |
| 2450 | dockMore: (action) => { if (root.OpenVibeDock && root.OpenVibeDock.more) root.OpenVibeDock.more(action); }, |
| 2451 | ask: (text) => { loadDock(() => { root.OpenVibeDock.mount(dockOpts()); root.OpenVibeDock.ask({ text }); }); }, |
| 2452 | me: _accountEl ? { add: _config.accounts === false ? '' : addAccountHref(), out: () => { const b = _accountEl.querySelector('#openvibe-logout-btn'); if (b) b.click(); }, own: _own } : null, |
| 2453 | onClose: () => { if (_accountEl && _navEl && _accountEl.parentNode !== _navEl) _navEl.appendChild(_accountEl); const b = _navEl && _navEl.querySelector('#openvibe-search-btn'); if (b) b.setAttribute('aria-expanded', 'false'); const g = _navEl && _navEl.querySelector('#openvibe-burger'); if (g) g.setAttribute('aria-expanded', 'false'); } }); |
| 2454 | const b = fromEl || (_navEl && _navEl.querySelector('#openvibe-search-btn')); if (b) b.setAttribute('aria-expanded', 'true'); |
| 2455 | }; |
| 2456 | if (root.OpenVibeSearch) return go(); |
| 2457 | if (_searchLoading) return; |
| 2458 | _searchLoading = true; |
| 2459 | if (orb) orb.classList.add('is-busy'); |
| 2460 | try { const sc = document.createElement('script'); sc.src = siblingV('search.js'); sc.async = true; sc.onload = go; sc.onerror = () => { _searchLoading = false; if (orb) orb.classList.remove('is-busy'); }; document.head.appendChild(sc); } catch { _searchLoading = false; } |
| 2461 | } |
| 2462 | const menuOpen = () => !!(typeof document !== 'undefined' && document.querySelector && document.querySelector('.ovs.is-open')); |
| 2463 | |
| 2464 | // Every site: the menu on its first tab, scrolled to the sites, out of the menu button on screen. |
| 2465 | function openSwitcher() { |
| 2466 | if (menuOpen() && root.OpenVibeSearch) { root.OpenVibeSearch.close(); return; } |
| 2467 | if (_menuStatus && _menuStatus.tone === 'waiting') { openMenuOn('agent'); return; } |
| 2468 | const orbs = _navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-burger.ovnav-orb, #openvibe-search-btn')] : []; |
| 2469 | openSearch('', 'sites', orbs.find((o) => o.getBoundingClientRect && o.getBoundingClientRect().width > 0) || null); |
| 2470 | } |
| 2471 | // A signed-in person's visit moves this site to the front of their Recent, at most once in 30 minutes, and never |
| 2472 | // under Global Privacy Control or Do Not Track (as the page-view beacon). |
| 2473 | let _visitSent = false; |
| 2474 | function recordVisit() { |
| 2475 | if (_visitSent || !_config.token || !_config.service || typeof fetch === 'undefined' || typeof navigator === 'undefined') return; |
| 2476 | if (navigator.globalPrivacyControl || navigator.doNotTrack === '1') return; |
| 2477 | _visitSent = true; |
| 2478 | const key = 'ov_sw_visit_' + _config.service; |
| 2479 | try { if (Date.now() - Number(localStorage.getItem(key) || 0) < 30 * 60000) return; localStorage.setItem(key, String(Date.now())); } catch { /* storage off: still record */ } |
| 2480 | fetch('https://openvibe.network/api/themes/me/sites/visit', { method: 'POST', headers: { Authorization: `Bearer ${_config.token}`, 'Content-Type': 'application/json' }, credentials: 'include', keepalive: true, body: JSON.stringify({ site: _config.service }) }).catch(() => {}); |
| 2481 | } |
| 2482 | // The site's name opens its pages (ov-drop.js); with a modifier key it is a link. |
| 2483 | function hookPages(nav) { |
| 2484 | const a = nav.querySelector('#openvibe-brand-btn'); |
| 2485 | if (!a) return; |
| 2486 | a.addEventListener('click', (e) => { |
| 2487 | e.preventDefault(); |
| 2488 | const go = () => root.OVDrop && root.OVDrop.pages(a, flatLinks(rawLinks()), navIcon, _config.search === false ? null : { label: 'All OpenVibe sites', onClick: openSwitcher }); |
| 2489 | if (root.OVDrop) return go(); |
| 2490 | if (document.getElementById('ov-drop-loader')) return; |
| 2491 | const sc = document.createElement('script'); sc.id = 'ov-drop-loader'; sc.src = siblingV('ov-drop.js'); sc.async = true; sc.onload = go; document.head.appendChild(sc); |
| 2492 | }); |
| 2493 | } |
| 2494 | function hookSignIn(nav) { |
| 2495 | const b = nav.querySelector('#openvibe-login-btn'); |
| 2496 | if (!b || !b.classList || typeof b.addEventListener !== 'function') return; |
| 2497 | b.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; b.classList.add('is-going'); setTimeout(() => b.classList.remove('is-going'), 6000); }); |
| 2498 | } |
| 2499 | // Links leave from the end until the bar holds one row (they stay in the menu); the site's "More" leaves last. |
| 2500 | function fitLinks() { |
| 2501 | const nav = (this && this.nodeType === 1 ? this : null) || _navEl, box = nav && nav.querySelector && nav.querySelector('.openvibe-navbar-links'); |
| 2502 | if (!box || !box.children || !nav.clientWidth) return; |
| 2503 | const kids = Array.from(box.children); |
| 2504 | kids.forEach((k) => k.classList.remove('ovnav-out')); |
| 2505 | const more = (k) => !!(k.querySelector && k.querySelector('[data-link-id="ovnav-more"]')); |
| 2506 | const order = kids.filter((k) => !more(k)).reverse().concat(kids.filter(more)); |
| 2507 | for (let i = 0; i < order.length && nav.scrollWidth > nav.clientWidth + 1; i += 1) order[i].classList.add('ovnav-out'); |
| 2508 | } |
| 2509 | let _fitWatch = null, _fitSeen = null; |
| 2510 | function hookFit(nav) { |
| 2511 | fitLinks.call(nav); |
| 2512 | if (typeof ResizeObserver !== 'function' || typeof MutationObserver !== 'function') return; |
| 2513 | if (_fitWatch) { _fitWatch.disconnect(); _fitSeen.disconnect(); } |
| 2514 | let t = 0; |
| 2515 | const soon = () => { clearTimeout(t); t = setTimeout(() => fitLinks.call(nav), 60); }; |
| 2516 | _fitWatch = new ResizeObserver(soon); _fitWatch.observe(nav); |
| 2517 | _fitSeen = new MutationObserver((list) => { if (list.some((m) => !(m.target.closest && m.target.closest('.openvibe-navbar-links')))) soon(); }); |
| 2518 | _fitSeen.observe(nav, { childList: true, subtree: true, characterData: true }); |
| 2519 | if (document.fonts && document.fonts.ready && document.fonts.ready.then) document.fonts.ready.then(soon, () => {}); |
| 2520 | } |
| 2521 | function hookSwitcher(nav) { |
| 2522 | const b = nav.querySelector('#openvibe-switch-btn, #openvibe-brand-btn'); |
| 2523 | if (!b) return; |
| 2524 | if (b.id === 'openvibe-switch-btn') b.addEventListener('click', () => openSwitcher()); |
| 2525 | for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); |
| 2526 | // Once the page is idle: the visit never competes with the first paint or the sign-in check. |
| 2527 | if (typeof root.requestIdleCallback === 'function') root.requestIdleCallback(() => recordVisit(), { timeout: 8000 }); |
| 2528 | else setTimeout(recordVisit, 3000); |
| 2529 | } |
| 2530 | let _orbIntro = false; |
| 2531 | let _searchKeys = false; |
| 2532 | function hookSearch(nav) { |
| 2533 | const b = nav.querySelector('#openvibe-search-btn'); |
| 2534 | if (b) { |
| 2535 | // An agent that needs the person opens the menu on Agents (the Dock's pill did, 4.220.0). |
| 2536 | b.addEventListener('click', () => { if (menuOpen() && root.OpenVibeSearch) root.OpenVibeSearch.close(); else if (_menuStatus && _menuStatus.tone === 'waiting') openMenuOn('agent'); else openSearch(); }); |
| 2537 | for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); |
| 2538 | if (menuOpen()) b.setAttribute('aria-expanded', 'true'); |
| 2539 | // One ripple a tab, a moment after the first paint: here is the menu. |
| 2540 | let seen = _orbIntro; try { seen = seen || sessionStorage.getItem('ov-orb-intro') === '1'; sessionStorage.setItem('ov-orb-intro', '1'); } catch { /* */ } |
| 2541 | // The timers look the orb up when they fire: holding this one would keep a swapped-out bar alive (boost). |
| 2542 | const orbsNow = () => (_navEl && _navEl.querySelectorAll ? [..._navEl.querySelectorAll('#openvibe-search-btn, #openvibe-burger.ovnav-orb')] : []); |
| 2543 | if (!seen && b.classList) { _orbIntro = true; setTimeout(() => { orbsNow().forEach((o) => o.classList.add('ovo-intro')); setTimeout(() => { orbsNow().forEach((o) => o.classList.remove('ovo-intro')); }, 1100); }, 700); } |
| 2544 | } |
| 2545 | if (_searchKeys || _config.search === false || typeof document === 'undefined') return; |
| 2546 | _searchKeys = true; |
| 2547 | document.addEventListener('keydown', (e) => { |
| 2548 | const t = e.target, typing = t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); |
| 2549 | if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey && !e.altKey) || ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K'))) { e.preventDefault(); openSearch(); } |
| 2550 | }); |
| 2551 | } |
| 2552 | function renderAppBar() { |
| 2553 | if (typeof document === 'undefined' || !document.body) return; |
| 2554 | const ab = root.OpenVibeAppBar; |
| 2555 | if (ab) { ab.render({ config: _config, links: rawLinks(), activePage: _activePage, nav: _navEl, navIcon, escapeAttr, escapeHtml, flatLinks }); return; } |
| 2556 | if (!_config.appBar || _appBarLoading) return; |
| 2557 | _appBarLoading = true; |
| 2558 | try { const sc = document.createElement('script'); sc.src = siblingV('appbar.js'); sc.async = true; sc.onload = () => renderAppBar(); document.head.appendChild(sc); } catch { /* no bar */ } |
| 2559 | } |
| 2560 | |
| 2561 | // The bar's at-the-top state (ovnav-top, see injectStyles), on every render and scroll. One window listener pair for |
| 2562 | // the page's life, a frame at most per update; --ovnav-real-h is the bar's height for overlay pages. |
| 2563 | let _topHooked = false, _lastY = 0; |
| 2564 | function syncTop() { |
| 2565 | if (!_navEl) return; |
| 2566 | const y = root.scrollY || (document.documentElement && document.documentElement.scrollTop) || 0; |
| 2567 | _navEl.classList.toggle('ovnav-top', y <= 4); |
| 2568 | // Phones: away while reading down, back on the first scroll up; never with one of its menus open or focus in it. |
| 2569 | if (_config.autoHide !== false) { |
| 2570 | const phone = !!(root.matchMedia && root.matchMedia('(max-width: 760px)').matches); |
| 2571 | const busy = !!(_navEl.querySelector('.open, [aria-expanded="true"]') || (document.body && document.body.classList.contains('ov-panel-open')) || menuOpen() |
| 2572 | || (document.activeElement && document.activeElement !== document.body && _navEl.contains(document.activeElement))); |
| 2573 | const dy = y - _lastY; |
| 2574 | if (!phone || y < 90 || busy) _navEl.classList.remove('ovnav-hide'); |
| 2575 | else if (dy > 10) _navEl.classList.add('ovnav-hide'); |
| 2576 | else if (dy < -6) _navEl.classList.remove('ovnav-hide'); |
| 2577 | } |
| 2578 | _lastY = y; |
| 2579 | // A fixed bar (a site that positions it itself, like Live) takes no room, so an overlay page needs no pull-up. |
| 2580 | const fixed = root.getComputedStyle && root.getComputedStyle(_navEl).position === 'fixed'; |
| 2581 | const de = document.documentElement; |
| 2582 | if (de && de.style) de.style.setProperty('--ovnav-real-h', `${fixed ? 0 : (_navEl.offsetHeight || 52)}px`); |
| 2583 | } |
| 2584 | function hookTop() { |
| 2585 | syncTop(); |
| 2586 | if (_topHooked || typeof root.addEventListener !== 'function') return; |
| 2587 | _topHooked = true; |
| 2588 | let raf = 0; |
| 2589 | const later = () => { if (!raf) raf = (root.requestAnimationFrame || setTimeout)(() => { raf = 0; syncTop(); }); }; |
| 2590 | root.addEventListener('scroll', later, { passive: true }); |
| 2591 | root.addEventListener('resize', later, { passive: true }); |
| 2592 | } |
| 2593 | |
| 2594 | // The page a link leads to is this page, or this page sits under it (/tasks marks /tasks/42; / marks only /). |
| 2595 | const leadsHere = (href, path) => { |
| 2596 | let p = null; |
| 2597 | try { p = new URL(href, location.href); } catch { return false; } |
| 2598 | if (p.origin !== location.origin) return false; |
| 2599 | return p.pathname === path || (p.pathname !== '/' && path.startsWith(`${p.pathname.replace(/\/+$/, '')}/`)); |
| 2600 | }; |
| 2601 | const remark = (list, path) => list.map((l) => { |
| 2602 | const out = l.active === undefined ? l : Object.assign({}, l, { active: leadsHere(l.href || '', path) }); |
| 2603 | return Array.isArray(l.children) ? Object.assign({}, out, { children: remark(l.children, path) }) : out; |
| 2604 | }); |
| 2605 | |
| 2606 | // openvibe-shared/boost swapped the page in place: this is a new page for the history and the |
| 2607 | // active-link state, and a login from here returns here. |
| 2608 | let _boostHooked = false; |
| 2609 | function hookBoost() { |
| 2610 | if (_boostHooked || typeof document === 'undefined') return; |
| 2611 | _boostHooked = true; |
| 2612 | document.addEventListener('ov:boost:load', () => { |
| 2613 | if (_config.history) _config.history = Object.assign({}, _config.history, { title: document.title, url: location.href }); |
| 2614 | // A site that marked its links on the server marked them for the page it served; after a swap the mark moves |
| 2615 | // to the new page, or the old page's link stays lit beside the new one (Codes, 2026-10-30). |
| 2616 | if (Array.isArray(_config.links)) _config.links = remark(_config.links, location.pathname || '/'); |
| 2617 | try { render(); } catch { /* keep the old bar */ } |
| 2618 | if (_config.user) recordHistory(); |
| 2619 | }); |
| 2620 | } |
| 2621 | |
| 2622 | // ── Moving between pages (4.70.0, plan L52; the owner, 2026-11-01: "make navigation feel better… the logo changing |
| 2623 | // while it loads… smarter loading") ── |
| 2624 | // The mark is the loading indicator: it works from the tap until the next page is in (OVMark.navStart/navEnd; a |
| 2625 | // single-page app calls OpenVibeNavbar.busy). Same-site pages are fetched ahead on hover or press (speculation rules, |
| 2626 | // where the browser has them), never sign-in, sign-out, the API or a download. |
| 2627 | let _movesHooked = false; |
| 2628 | function hookMoves() { |
| 2629 | if (_movesHooked || typeof document === 'undefined' || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return; |
| 2630 | _movesHooked = true; |
| 2631 | // A boosted site prefetches through boost.js already (and Chrome's prefetch beside it kept parsed pages alive). |
| 2632 | if (_config.speculate !== false && !root.OVBoost && !document.querySelector('meta[name="ov-boost"]') && typeof HTMLScriptElement !== 'undefined' && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules') && !document.querySelector('script[type="speculationrules"]')) { |
| 2633 | try { |
| 2634 | const sr = document.createElement('script'); |
| 2635 | sr.type = 'speculationrules'; |
| 2636 | // A page whose policy names a nonce (Chat, Search…) runs inline speculation rules only with it: Chrome 156 |
| 2637 | // ignores 'inline-speculation-rules' beside a nonce (2026-11-02). The page's own nonce is read from any of |
| 2638 | // its nonced scripts (the attribute is blanked once parsed; the property keeps it). |
| 2639 | const nonce = [].slice.call(document.scripts || []).map((x) => x.nonce).find(Boolean); |
| 2640 | if (nonce) sr.nonce = nonce; |
| 2641 | // The rules (the owner, 2026-11-09: "how come when you change sites it doesn't load more asynchronously and |
| 2642 | // dynamically, so it's faster across sites?"): a same-site page is prefetched, and — where the browser has |
| 2643 | // prerendering — rendered in the background before the click lands; a link to another OpenVibe site is |
| 2644 | // prefetched too, so the next site is already warm when the person arrives. |
| 2645 | const sameSite = { and: [{ href_matches: '/*' }, { not: { href_matches: ['/auth/*', '/api/*', '/logout*', '/*logout*', '/signout*', '/internal/*', '/*.zip', '/*.pdf'] } }, { not: { selector_matches: '[data-no-prefetch], [download], [rel~=nofollow]' } }] }; |
| 2646 | const rules = { prefetch: [{ where: sameSite, eagerness: 'moderate' }] }; |
| 2647 | // Prerendering a page the site's own router draws would be work thrown away: a site says no with |
| 2648 | // prerender: false, and marks the links it draws itself with data-no-prefetch. |
| 2649 | if (_config.prerender !== false) rules.prerender = [{ where: sameSite, eagerness: 'moderate' }]; |
| 2650 | // Another OpenVibe site: the browser fetches it when the pointer arrives (Chrome leaves a site alone when |
| 2651 | // the person has cookies on it, which is right: that page is theirs, not a public copy). |
| 2652 | rules.prefetch.push({ where: { selector_matches: '[data-ov-net-link]' }, eagerness: 'moderate' }); |
| 2653 | sr.textContent = JSON.stringify(rules); |
| 2654 | document.head.appendChild(sr); |
| 2655 | } catch { /* an old browser: links load as they always did */ } |
| 2656 | } |
| 2657 | // 4.229.0 (the owner, 11-12: "when you click links and pages and other domains within the menu or navigation it |
| 2658 | // loads async dynamically and is highly optimized"): a link to another OpenVibe site warms up as a finger lands on |
| 2659 | // it or a pointer rests on it (its connection opens: DNS and TLS while the press is still down). This site's |
| 2660 | // pages are already fetched ahead by the speculation rules above; browsers do not prefetch another site's page |
| 2661 | // for a person signed in there. Anywhere on the page: the bar, the menu, a footer, a card. At most 24 origins. |
| 2662 | const warmed = new Set(); |
| 2663 | const OV_HOST = /(^|\.)(openvibe\.[a-z]+|openre\.stream|open\.shoes)$/; |
| 2664 | const warm = (a) => { |
| 2665 | let u; try { u = new URL(a.getAttribute('href'), location.href); } catch { return; } |
| 2666 | if (u.protocol !== 'https:' || u.origin === location.origin || warmed.has(u.origin) || warmed.size >= 24 || !OV_HOST.test(u.hostname)) return; |
| 2667 | warmed.add(u.origin); |
| 2668 | try { const l = document.createElement('link'); l.rel = 'preconnect'; l.href = u.origin; document.head.appendChild(l); } catch { /* the click connects */ } |
| 2669 | }; |
| 2670 | document.addEventListener('pointerdown', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href]'); if (a) warm(a); }, { capture: true, passive: true }); |
| 2671 | let hoverTimer = 0; |
| 2672 | document.addEventListener('pointerover', (e) => { |
| 2673 | if (e.pointerType === 'touch') return; |
| 2674 | const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return; |
| 2675 | clearTimeout(hoverTimer); hoverTimer = setTimeout(() => warm(a), 90); |
| 2676 | }, { passive: true }); |
| 2677 | // A plain same-site link on a server-rendered page: the mark starts working at once; the next page's mark takes over. |
| 2678 | document.addEventListener('click', (e) => { |
| 2679 | if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 2680 | const a = e.target && e.target.closest && e.target.closest('a[href]'); |
| 2681 | if (!a || a.target === '_blank' || a.hasAttribute('download')) return; |
| 2682 | let u; try { u = new URL(a.href, location.href); } catch { return; } |
| 2683 | if (u.origin !== location.origin || (u.pathname === location.pathname && u.search === location.search)) return; |
| 2684 | setTimeout(() => { if (!e.defaultPrevented) busy(true); }, 0); // after the page's own handler (an SPA's router) had its say |
| 2685 | }, true); |
| 2686 | // Back from the browser's page cache: whatever was loading is done. |
| 2687 | window.addEventListener('pageshow', (ev) => { if (ev && ev.persisted) { busy(false); restoreMenu(ev); } }); |
| 2688 | document.addEventListener('openvibe-menu-restore', () => restoreMenu({ persisted: true })); |
| 2689 | setTimeout(() => restoreMenu(null), 0); |
| 2690 | setTimeout(menuFromHash, 0); |
| 2691 | window.addEventListener('hashchange', menuFromHash); |
| 2692 | document.addEventListener('click', (e) => { |
| 2693 | const t = e.target && e.target.closest ? e.target.closest('[data-ov-menu]') : null; |
| 2694 | if (!t || e.defaultPrevented || e.button !== 0) return; |
| 2695 | // A link that also has an address keeps the browser's own ways to open it elsewhere (a new tab, a window). |
| 2696 | if (t.matches('a[href]') && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) return; |
| 2697 | e.preventDefault(); |
| 2698 | openMenuAt(t.getAttribute('data-ov-menu'), t); |
| 2699 | }); |
| 2700 | } |
| 2701 | // ── The shared worker (openvibe-sw.js): the shell's cache, and the push notifications it always carried ── |
| 2702 | // A site opts in: OpenVibeNavbar.init({ sw: true }). The worker is the same file the notification bell registers for |
| 2703 | // push, so there is one registration at scope "/" and nothing is unregistered by accident. |
| 2704 | let _workerHooked = false; |
| 2705 | function hookWorker() { |
| 2706 | const spec = _config.sw; |
| 2707 | if (_workerHooked || !spec) return; |
| 2708 | if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; |
| 2709 | if (typeof window !== 'undefined' && window.isSecureContext === false) return; |
| 2710 | _workerHooked = true; |
| 2711 | const opts = (spec === true) ? {} : (spec || {}); |
| 2712 | const url = opts.path || '/openvibe-sw.js'; |
| 2713 | const scope = opts.scope || '/'; |
| 2714 | // After the load: registering early would compete with the page's own files for the network. |
| 2715 | const go = () => { try { navigator.serviceWorker.register(url, { scope }).catch(() => { /* no worker, no cache */ }); } catch { /* */ } }; |
| 2716 | if (document.readyState === 'complete') go(); |
| 2717 | else window.addEventListener('load', go, { once: true }); |
| 2718 | } |
| 2719 | let _busyTimer = 0, _busyOn = false; |
| 2720 | function busy(on) { |
| 2721 | clearTimeout(_busyTimer); |
| 2722 | if (!on && !_busyOn) return; // only ends what it started (the mark keeps nothing it was not asked to) |
| 2723 | _busyOn = !!on; |
| 2724 | // A link that turns out to be a download never leaves the page: the mark gives up after ten seconds. |
| 2725 | if (on) _busyTimer = setTimeout(() => busy(false), 10000); |
| 2726 | try { if (window.OVMark) { if (on) window.OVMark.navStart(); else window.OVMark.navEnd(); } } catch { /* */ } |
| 2727 | } |
| 2728 | |
| 2729 | const OpenVibeNavbar = { |
| 2730 | /** 4.228.0: the person's Network token the site gave init() (or null), for Shared's own calls to the Network |
| 2731 | * (the composer's settings sync and Caption it). The site already put it in the page; this only reads it. */ |
| 2732 | token() { return _config.token || null; }, |
| 2733 | /** The mark works while something loads (an SPA's route, a long action): busy(true), then busy(false). */ |
| 2734 | busy(on) { busy(!!on); }, |
| 2735 | /** A floating card (overlay.js): OpenVibeNavbar.overlay({ id, src, title, href, live, side }) → Promise<handle|null>. */ |
| 2736 | overlay(desc) { return new Promise((ok) => loadOverlay((O) => ok(O.open(desc)))); }, |
| 2737 | /** The overlay host itself, loaded (for declare / close / list): Promise<OVOverlay>. */ |
| 2738 | overlays() { return new Promise((ok) => loadOverlay(ok)); }, |
| 2739 | |
| 2740 | init(opts = {}) { |
| 2741 | Object.assign(_config, opts); |
| 2742 | // Balances and Messages on every site unless it says false or brings its own (4.130.0). |
| 2743 | if (opts.wallet === undefined) _config.wallet = true; |
| 2744 | if (opts.messages === undefined) _config.messages = { __default: true }; |
| 2745 | injectStyles(); |
| 2746 | if (!root.OVSurfaces && !document.getElementById('ov-surfaces-loader')) { |
| 2747 | const sc = document.createElement('script'); sc.id = 'ov-surfaces-loader'; sc.src = siblingV('surfaces.js'); sc.async = true; document.head.appendChild(sc); |
| 2748 | } |
| 2749 | hookBoost(); |
| 2750 | hookMoves(); |
| 2751 | hookWorker(); |
| 2752 | const el = render(); |
| 2753 | upgradeIconsWhenFontsReady(); |
| 2754 | if (typeof document !== 'undefined') setTimeout(loadReleaseWatch, 4000); |
| 2755 | if (typeof document !== 'undefined') setTimeout(warmMenuLater, 3000); |
| 2756 | // A page that passes a user keeps control; otherwise the user comes from the shared SSO state, then a re-render. |
| 2757 | // auth: 'external' — the site signs people in itself (Live) and tells us with setUser(). |
| 2758 | if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } } |
| 2759 | else if (!_config.user) refreshAuthState(); |
| 2760 | else { recordHistory(); enableHandoff(); } |
| 2761 | startQuestGuide(); |
| 2762 | return el; |
| 2763 | }, |
| 2764 | |
| 2765 | /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */ |
| 2766 | record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; }, |
| 2767 | |
| 2768 | /** Re-resolve the signed-in user from the shared SSO state. */ |
| 2769 | refreshAuth() { return refreshAuthState(); }, |
| 2770 | |
| 2771 | /** Update user (after account switch). */ |
| 2772 | setUser(user) { |
| 2773 | _config.user = user; |
| 2774 | render(); |
| 2775 | }, |
| 2776 | |
| 2777 | /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */ |
| 2778 | setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); }, |
| 2779 | |
| 2780 | /** Open the search panel (search.js), with an optional query already in it. */ |
| 2781 | openSearch(q, tab) { openSearch(q, tab); }, |
| 2782 | openSwitcher() { openSwitcher(); }, |
| 2783 | |
| 2784 | /** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */ |
| 2785 | setAppBar(spec) { _config.appBar = spec; renderAppBar(); }, |
| 2786 | |
| 2787 | /** New messages: the count joins the bell's (there is no Messages button); a spec keeps `messages` on or off. */ |
| 2788 | setMessages(count, spec) { |
| 2789 | if (spec !== undefined) _config.messages = spec; |
| 2790 | _msgCount = Math.max(0, Number(count) || 0); |
| 2791 | const N = root.OpenVibeNotifications; |
| 2792 | if (N && typeof N.setMessages === 'function') N.setMessages(_msgCount); |
| 2793 | }, |
| 2794 | |
| 2795 | /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */ |
| 2796 | addMenuItem(item) { |
| 2797 | if (!item || !item.label) return; |
| 2798 | const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item); |
| 2799 | (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it); |
| 2800 | if (_navEl && _config.user) render(); |
| 2801 | return it.id; |
| 2802 | }, |
| 2803 | |
| 2804 | removeMenuItem(id) { |
| 2805 | for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id); |
| 2806 | if (_navEl && _config.user) render(); |
| 2807 | }, |
| 2808 | |
| 2809 | /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */ |
| 2810 | checkNetworkSession, |
| 2811 | |
| 2812 | /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */ |
| 2813 | brand() { return resolveBrand(); }, |
| 2814 | |
| 2815 | /** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */ |
| 2816 | openWallet, |
| 2817 | refreshWallet() { _walletAt = 0; loadWalletChips(); }, |
| 2818 | setUser(user, token) { if (root.OpenVibeWallet && (_config.user !== user || (token !== undefined && token !== _config.token))) root.OpenVibeWallet.close(); _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; const result = render(); startQuestGuide(); return result; }, |
| 2819 | /** Single-page apps: mark the link whose `page` matches as active. */ |
| 2820 | setActive(page) { _activePage = page || null; renderAppBar(); if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); }, |
| 2821 | /** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */ |
| 2822 | setChip(id, value, patch) { |
| 2823 | const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []]; |
| 2824 | lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } })); |
| 2825 | document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; }); |
| 2826 | document.querySelectorAll(`[data-chip-mini="${id}"] .ovnav-chip-mv`).forEach(el => { el.textContent = shortValue(value); }); |
| 2827 | if (root.OpenVibePurse) root.OpenVibePurse.set(id, value); |
| 2828 | document.querySelectorAll(`[data-chip-row="${id}"] b`).forEach(el => { el.textContent = value == null ? '' : String(value); }); |
| 2829 | const all = document.getElementById('openvibe-chips-all'); |
| 2830 | if (all) all.setAttribute('aria-label', `Your balances: ${(_config.chips || []).filter((c) => c && !c.hidden).map((c) => `${chipLabel(c)} ${c.value == null ? '' : c.value}`).join(', ')}`); |
| 2831 | }, |
| 2832 | /** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */ |
| 2833 | updateLink(id, patch) { |
| 2834 | const list = _runtimeLinks || _config.links || []; |
| 2835 | const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {}); |
| 2836 | if (!_navEl) return; |
| 2837 | _navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => { |
| 2838 | if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; } |
| 2839 | if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; } |
| 2840 | if ('href' in patch) a.setAttribute('href', patch.href); |
| 2841 | if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; } |
| 2842 | }); |
| 2843 | }, |
| 2844 | /** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */ |
| 2845 | updateMenuItem(id, patch) { |
| 2846 | const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {}); |
| 2847 | document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } }); |
| 2848 | }, |
| 2849 | |
| 2850 | /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */ |
| 2851 | get activity() { return root.OpenVibeIsland || null; }, |
| 2852 | set activity(_v) { /* island.js binds itself; nothing to store */ }, |
| 2853 | |
| 2854 | setToken(token) { _config.token = token; }, |
| 2855 | /** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */ |
| 2856 | get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; }, |
| 2857 | |
| 2858 | /** Give every box that scrolls with nothing focusable inside it tabindex="0" now (the bar does it when the page |
| 2859 | * settles; a page that draws such a box later may call this). Returns how many it marked. */ |
| 2860 | focusScrollers() { try { return focusScrollers(); } catch { return 0; } }, |
| 2861 | |
| 2862 | /** A button before the brand: { icon | path, label, controls, sidebar, phoneOnly, pressed, href, onClick }; null removes it. README: Navbar slots. */ |
| 2863 | lead(spec) { |
| 2864 | _lead = spec ? { ...spec } : null; |
| 2865 | paintSlots(); |
| 2866 | return { set(patch) { if (_lead) { Object.assign(_lead, patch || {}); paintSlots(); } }, remove() { _lead = null; paintSlots(); } }; |
| 2867 | }, |
| 2868 | /** An icon button before the bell: lead()'s spec plus id and badge. */ |
| 2869 | action(spec) { |
| 2870 | if (!spec || !spec.id || !/^[a-z0-9-]{1,40}$/.test(spec.id)) throw new Error('OpenVibeNavbar.action needs an id: a-z, 0-9 and -'); |
| 2871 | _actions.set(spec.id, { ...spec }); |
| 2872 | paintSlots(); |
| 2873 | return { set(patch) { const a = _actions.get(spec.id); if (a) { Object.assign(a, patch || {}); paintSlots(); } }, remove() { _actions.delete(spec.id); paintSlots(); } }; |
| 2874 | }, |
| 2875 | |
| 2876 | /** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */ |
| 2877 | openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); }, |
| 2878 | /** The menu at an address: a place, or place/part ('you/notifications', 'you/apps', 'vibe'; 4.223.0, parts 4.229.0). |
| 2879 | * The same address works as data-ov-menu="…" on any element and as #ov-menu=… at the end of a page's address. */ |
| 2880 | openMenu(p, fromEl) { openMenuAt(p || 'you', fromEl); }, |
| 2881 | /** What an address opens: { door } or { tab, part } (for a site's own button that wants to show where it goes). */ |
| 2882 | menuAddress, |
| 2883 | pop: popFrom, |
| 2884 | unpop, |
| 2885 | askAgent(text) { askAgent(text); }, |
| 2886 | |
| 2887 | /** Get the bell mount point for OpenVibeNotifications. */ |
| 2888 | getBellMount() { |
| 2889 | return _navEl?.querySelector('#openvibe-bell-mount') || null; |
| 2890 | }, |
| 2891 | |
| 2892 | getElement() { return _navEl; }, |
| 2893 | |
| 2894 | destroy() { |
| 2895 | _navEl?.remove(); |
| 2896 | _navEl = null; |
| 2897 | }, |
| 2898 | |
| 2899 | /** Internal auth helpers — exposed for tests, not a public API. */ |
| 2900 | _auth: { |
| 2901 | parseCookieToken, |
| 2902 | onToolsDomain, |
| 2903 | onNetworkDomain, |
| 2904 | resolveLoginHref, |
| 2905 | resolveToken, |
| 2906 | resolveSessionUser, |
| 2907 | clearAuthState, |
| 2908 | persistToken, |
| 2909 | fillUrl, |
| 2910 | }, |
| 2911 | _ask: { selectedText, askVibeHref }, |
| 2912 | _wallet: { loadWallet, walletOrder, syncWalletChips, loadWalletChips, configure: (c) => { Object.assign(_config, c); _walletLoaded = false; } }, |
| 2913 | _defaults: { linksFor }, |
| 2914 | }; |
| 2915 | |
| 2916 | if (typeof module !== 'undefined' && module.exports) module.exports = OpenVibeNavbar; |
| 2917 | else root.OpenVibeNavbar = OpenVibeNavbar; |
| 2918 | |
| 2919 | })(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this); |
| 2920 |