openvibers/openvibe.shared navbar.js

navbar.js

main 235.5 KB · 2920 lines · javascript

1// OpenVibe — the shared navbar (the OpenVibe Frame's top bar). Options and behaviour: README.md, "The navbar".
2
3(function (root) {
4 'use strict';
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, '&amp;').replace(/"/g, '&quot;');
2061 }
2062 function escapeHtml(value) {
2063 return String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &amp; settings" aria-label="Account &amp; 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