openvibers/openvibe.shared Commits
4.128.0: the menu rethought around real use (#316)
Changes
@@@ -1,5 +1,55 @@# Changelog++## 4.128.0 — 2026-11-07++++- **The menu, rethought around real use** (the owner, 11-07: Jump back in came first, people opened Sites every time just to++ reach Live, and it "doesn't spread traffic … not smartly done based on what's popular"). Network's data that day: Video,++ Live and Community take nearly all the page views, 48 of 67 signed-in people used one site, and people move Live ↔++ Community, Live ↔ Tools, Chat → Live, Live → Bot and Live → Video. One calm scroll, no tabs, in this order:++ - **Who is live**: "3 streams live now · 41 watching" and the live channels when Network names them (`pulse.live`).++ - **Your places**: a dock of eight big icons, the places this person uses most (else the network's most used, without++ developer services). One tap to Live from any site.++ - **On <this site>**: the bar's own links, two to a row, the rest behind "More on <site> (N)".++ - **Next from <this site>**: where people really go next (Network's moves; the Frame's `next` when it sends one).++ - **New to you**: two places this person never used, picked fairly from the ranked network so every place gets its++ turn; developer services only for people who use one. The pick holds for the whole opening.++ - **You** (signed in): your agents and Vibe (they open in the Dock), account, wallet, themes, machines.++ - **Tools**, **Recent searches**, then **Every OpenVibe site (N)** as one counted fold (this site's group first), and++ **Jump back in** last.++- `open({ tab: 'sites' })` opens the list of every site and `tab: 'tools'` scrolls to the tools, so old callers keep++ working.++- **A click beacon, only when Network asks for it** (`frame.menu.beacon`): the section, the position, where it led and++ the kind; no person and no page, so the menu can learn what helps and the ranking can spread people across the network.++- Touch screens get 44 px chips and header links.++- **Sites put their own content in the menu** (the owner, 11-07: "content aware … sites can interact and create their own++ content in the bar"), two ways:++ - **On their own pages, a card:** a site pushes `{ id, title, render(el, api) }` onto `window.OpenVibeMenuCards`++ (a queue that works before `search.js` loads) or calls `OpenVibeSearch.registerCard()`. The card draws itself++ under the workspace: Download's drop zone, Live's go-live status, Codes' new task. It is the site's own code on++ its own page. A card that throws is left out.++ - **On every site, from Network:** the Frame's `cards` (Network collects each site's++ `/.well-known/openvibe-menu.json`). The menu draws at most two, as a rail, rows or chips: Live's channels with++ their viewers, Games' new worlds, Video's trending. They are text and links only, re-checked here, and never++ another site's code.++- **Back closes the menu first** (the owner: Back on a phone left the page). Opening adds one history entry; Back pops++ it and only closes the menu. Choosing a link leaves cleanly.++- **The whole screen on a phone,** opened from the bar's orb as a circle that grows from the orb, under the safe area.++ No motion when reduced motion is asked for.++- **Your workspace** (signed in), right under your places: Agents and Vibe (open in the Dock), Codes, Files, Machines,++ Wallet, Account and Themes, one tap from every site (the owner: "access codes through this menu easily, and talk to++ your agent … less having to hop between sites").++- **What's new**: the newest real change on each site from the network changelog (library bumps left out), newest first,++ marked **New** when it shipped after your last visit there, each linking to that site's `/updates`.++- **The full list of sites**: the flagships first (Live, Video, Codes, Design, Space, Games, Chat, Tools), then every group;++ each site with its icon, a two-line description (no more cut-off words on a phone) and its own domain.++- **Every site has its own icon** (`ov-icons.js`, regenerated from Font Awesome Free 6 by `scripts/build-nav-icons.py`):++ Cam a camera, Casino dice, Surf waves, Fun masks, Meme a laughing face, Tours a compass, Vision binoculars, Shoes, Today,++ Report, Design, Domains, Exchange and Bench. Until now they borrowed a neighbour's (Cam showed Video's, Casino and Surf++ the gamepad, Fun Quest's trophy).++- **No spinning rings anywhere** (`ov-icons.js`; the owner, 10-31 and again 11-07 on Surf's and Space's cards: "things only++ spin if they're active"). The ring icons' comet spun forever on every icon on every site. Now the comet, the pulse and++ the bounce run only while `data-state="busy"`; at rest an icon is its ring and glyph with a small lift on hover.++ `data-fx` now says what *busy* looks like. Every site loses the idle spin with this release.## 4.127.0 — 2026-11-07- **The Dock's Agent panel** (design 2026-11-07, one agent behind the Dock): Now says what the agent is doing this moment (the mode, the step in plain words, how long, and Stop/Resume), Needs you lists every approval and question from any mode with its buttons in place, and the timeline below shows one entry per step — the mode, the title, where it ran and why, the outcome, the time, the cost and a link to the step's own page. A mode change is a line of its own.@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.127.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.128.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.127.0",++ "version": "4.128.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -21,6 +21,7 @@"share cards for every site and for its big pages (openvibe-shared/social)","the shared video player (player.js, player.css): one look for every <video>","the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",++ "the menu rethought around real use: who is live, your places dock, this site's pages, next from here, two places new to you, you and your agents, tools, every site behind a count, jump back in; a click beacon when Network asks (4.128.0)","items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site","shipped pill, recent list and /updates log","server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",@@@ -2043,7 +2043,7 @@const anchor = () => {if (!_navEl || !orb || !orb.getBoundingClientRect) return null;const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();-- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;++ 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;};const go = () => {if (orb) orb.classList.remove('is-busy');@@@ -2070,7 +2070,7 @@const anchor = () => {if (!_navEl || !btn || !btn.getBoundingClientRect) return null;const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();-- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;++ 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;};const go = () => {const S = root.OpenVibeSiteSwitcher;@@@ -15,7 +15,10 @@* OpenVibeIcons.set(el, { progress, state, icon })* OpenVibeIcons.svg(name, size) markup string, for server-side / templates*-- * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the++ * Nothing moves at rest (the owner, 10-31 and 11-07: "things only spin if they're active"; the comet spun forever on++ * every icon until 4.128.0). The comet, the pulse and the bounce run only while data-state="busy"; at rest an icon is++ * its ring and glyph, with a small lift on hover where a pointer hovers. "draw" plays once when an icon appears.++ * Effects (data-fx, what busy looks like): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the* SVG and transform on the <svg> element only: no CSS transform ever touches an element inside the SVG* (transform-box/transform-origin on SVG children left glyphs and whole icons unpainted on phones while* scrolling), and hover effects apply only where a pointer can hover, so a thumb scrolling over a link@@@ -91,6 +94,22 @@run: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/>' + P('M7.5 10l2.5 2-2.5 2M12 14.5h4'),billing: P('M4.5 7.5h13a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2zM4.5 7.5l10-3v3M15.5 13h2'),feeds: '<circle cx="6.5" cy="17.5" r="1.4"/>' + P('M5.5 11a7.5 7.5 0 0 1 7.5 7.5M5.5 6A12.5 12.5 0 0 1 18 18.5'),++ // Their own glyphs (11-07; until then each borrowed a neighbour's: Cam showed Video's, Casino and Surf the gamepad,++ // Fun Quest's trophy), so every site in the menu reads at a glance.++ cam: '<rect x="3.5" y="7.5" width="17" height="12" rx="2.5"/>' + P('M8.6 7.5 10 5h4l1.4 2.5') + '<circle cx="12" cy="13.5" r="3.3"/><circle cx="17.3" cy="10.2" r=".6"/>',++ casino: '<rect x="5" y="5" width="14" height="14" rx="3"/><circle cx="9" cy="9" r=".7"/><circle cx="15" cy="9" r=".7"/><circle cx="12" cy="12" r=".7"/><circle cx="9" cy="15" r=".7"/><circle cx="15" cy="15" r=".7"/>',++ surf: P('M3.5 16c2 0 2.5-1.4 4.2-1.4S10 16 12 16s2.5-1.4 4.2-1.4S18.5 16 20.5 16M3.5 19.5c2 0 2.5-1.3 4.2-1.3s2.3 1.3 4.3 1.3 2.5-1.3 4.2-1.3 2.3 1.3 4.3 1.3M5.5 12.8C6.6 8 10.3 4.8 15.8 4.8c-2.4 1.4-3.5 3.7-3.2 6.6'),++ fun: P('M4.8 19.2 8.6 8.6l6.8 6.8zM8.6 8.6l6.8 6.8M13.5 4.5v1.8M17.2 6.8l1.3-1.3M19.5 10.5h-1.8M16 9.5c.9-.9 2.2-1.1 3.1-.4'),++ meme: '<circle cx="12" cy="12" r="7.5"/>' + P('M8.6 13.3a3.4 3.4 0 0 0 6.8 0zM8.7 9.6l1.6 1M15.3 9.6l-1.6 1'),++ tours: '<circle cx="12" cy="12" r="7.5"/>' + P('M15 9l-1.8 4.2L9 15l1.8-4.2z'),++ vision: P('M3.5 12S6.5 6.5 12 6.5 20.5 12 20.5 12 17.5 17.5 12 17.5 3.5 12 3.5 12z') + '<circle cx="12" cy="12" r="2.6"/>',++ shoes: P('M3.5 16.5V12l3-1.4 2 1.4 2.3-2.9 4.3 3.7c1.7 1.4 3.3 1.9 5.4 2.1v1.6zM3.5 16.5h17M9.3 12.9l1 1M11.3 11.6l1 1'),++ today: '<rect x="4.5" y="5.5" width="15" height="14" rx="2.5"/>' + P('M4.5 9.5h15M8.5 3.5v3.2M15.5 3.5v3.2') + '<circle cx="12" cy="14.5" r="1.8"/>',++ report: P('M5 19.5h14M7.5 16.5v-4M11 16.5V8.5M14.5 16.5v-6M18 16.5V5.5'),++ design: P('M12 3.8l5 7.2-5 9.2-5-9.2zM12 3.8v7.4') + '<circle cx="12" cy="12.6" r="1.3"/>',++ domains: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.2 2.1 3.3 4.6 3.3 7.5S14.2 17.4 12 19.5c-2.2-2.1-3.3-4.6-3.3-7.5S9.8 6.6 12 4.5z'),++ exchange: P('M5.5 9h13l-3.2-3.2M18.5 15h-13l3.2 3.2'),++ bench: P('M4.5 19.5h15M9 19.5v-7.5h6v7.5M4.5 19.5v-4.5H9M15 15h4.5v4.5') + '<circle cx="12" cy="8.2" r=".7"/>',pics: '<rect x="6.5" y="4.5" width="13" height="11" rx="1.5"/>' + P('M4.5 8v9.5a2 2 0 0 0 2 2H16M8.5 13.5l3-3 2.5 2.5 1.5-1.5 2.5 2.5'),homes: P('M4.5 11 12 5l7.5 6M6.5 9.5V19h11V9.5M10 19v-5h4v5'),website: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="6.5" cy="7" r=".7"/><circle cx="8.8" cy="7" r=".7"/>' + P('M3.5 9h17'),@@@ -120,7 +139,7 @@.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}--.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;display:none}.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}@@@ -130,12 +149,13 @@@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}.ov-icon[data-fx=none] .ovi-comet{display:none}.ov-icon[data-fx=pulse] .ovi-comet{display:none}--.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}++.ov-icon[data-fx=pulse][data-state=busy] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}--.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-icon[data-fx=bounce][data-state=busy] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}.ov-icon[data-progress] .ovi-prog{opacity:1}.ov-icon[data-progress] .ovi-comet{opacity:0}--.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}++.ov-icon[data-state=busy] .ovi-comet{display:inline;animation:oviSpin 1.1s linear infinite;opacity:1;stroke-dasharray:40 85.66}++.ov-icon[data-state=busy][data-fx=none] .ovi-comet,.ov-icon[data-state=busy][data-fx=pulse] .ovi-comet{display:none}.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}@@@ -224,6 +244,20 @@link: [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],shield: [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],plug: [384, 'M96 11Q82 11 73 20Q64 29 64 43V139H128V43Q128 29 119 20Q110 11 96 11ZM288 11Q274 11 265 20Q256 29 256 43V139H320V43Q320 29 311 20Q302 11 288 11ZM32 171Q18 171 9 180Q0 189 0 203Q0 217 9 226Q18 235 32 235V267Q33 326 69 369Q104 412 160 424V491Q160 505 169 514Q178 523 192 523Q206 523 215 514Q224 505 224 491V424Q280 412 315 369Q351 326 352 267V235Q366 235 375 226Q384 217 384 203Q384 189 375 180Q366 171 352 171H32Z'],++ cam: [512, 'M149 76 139 107 149 76 139 107H64Q37 108 19 126Q1 144 0 171V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V171Q511 144 493 126Q475 108 448 107H373L363 76Q351 45 317 43H195Q161 45 149 76ZM256 203Q282 203 304 216Q326 229 339 251Q352 274 352 299Q352 324 339 347Q326 369 304 382Q282 395 256 395Q230 395 208 382Q186 369 173 347Q160 324 160 299Q160 274 173 251Q186 229 208 216Q230 203 256 203Z'],++ casino: [640, 'M275 45Q253 24 224 24Q195 24 173 45L34 184Q13 206 13 235Q13 264 34 286L173 425Q195 446 224 446Q253 446 275 425L414 286Q435 264 435 235Q435 206 414 184L275 45ZM200 235Q202 213 224 211Q246 213 248 235Q246 257 224 259Q202 257 200 235ZM96 211Q118 213 120 235Q118 257 96 259Q74 257 72 235Q74 213 96 211ZM224 387Q202 385 200 363Q202 341 224 339Q246 341 248 363Q246 385 224 387ZM352 211Q374 213 376 235Q374 257 352 259Q330 257 328 235Q330 213 352 211ZM224 131Q202 129 200 107Q202 85 224 83Q246 85 248 107Q246 129 224 131ZM320 459Q321 486 339 504Q357 522 384 523H576Q603 522 621 504Q639 486 640 459V267Q639 240 621 222Q603 204 576 203H462Q470 231 464 259Q458 287 436 309L320 425V459ZM480 339Q502 341 504 363Q502 385 480 387Q458 385 456 363Q458 341 480 339Z'],++ surf: [576, 'M270 81Q288 69 307 81Q342 106 384 107Q426 105 461 81Q482 68 501 83Q523 101 551 108Q564 111 571 122Q578 133 575 146Q572 159 561 166Q550 173 537 170Q499 160 479 145Q434 170 384 171Q335 168 304 152Q295 148 288 144Q281 148 272 152Q241 168 192 171Q142 170 98 145Q77 160 39 170Q26 173 15 166Q4 159 1 146Q-2 133 5 122Q12 111 25 108Q53 101 75 83Q94 68 115 81Q150 105 192 107Q235 106 270 81ZM307 369Q342 394 384 395Q426 393 461 369Q482 356 501 371Q523 389 551 396Q564 399 571 410Q578 421 575 434Q572 447 561 454Q550 461 537 458Q499 448 479 433Q434 458 384 459Q335 456 304 440Q295 436 288 432Q281 436 272 440Q241 456 192 459Q142 458 98 433Q77 448 39 458Q26 461 15 454Q4 447 1 434Q-2 421 5 410Q12 399 25 396Q53 389 75 371Q94 357 115 369Q150 393 192 395Q235 394 270 369Q288 357 307 369ZM307 225Q342 250 384 251Q426 249 461 225Q482 212 501 227Q523 245 551 252Q564 255 571 266Q578 277 575 290Q572 303 561 310Q550 317 537 314Q499 304 479 289Q434 314 384 315Q335 312 304 296Q295 292 288 288Q281 292 272 296Q241 312 192 315Q142 314 98 289Q77 304 39 314Q26 317 15 310Q4 303 1 290Q-2 277 5 266Q12 255 25 252Q53 245 75 227Q94 212 115 225Q150 249 192 251Q235 250 270 225Q288 213 307 225Z'],++ fun: [640, 'M75 384Q107 413 152 438Q196 462 241 458Q250 456 259 453Q245 435 235 418Q203 364 209 304Q177 311 154 330Q149 334 144 331Q138 329 139 323Q144 297 162 279Q181 261 207 257Q211 256 215 256L235 124Q236 101 260 79Q273 67 299 57Q324 47 363 43Q363 43 363 43Q362 42 361 41Q347 28 303 17Q259 5 176 17Q93 31 54 54Q15 78 6 95Q-2 107 1 123L25 287Q33 345 75 384ZM162 165Q171 160 174 168Q174 168 174 169Q174 169 174 170Q176 186 167 199Q158 212 141 215Q125 217 112 207Q99 198 97 181Q97 181 97 181Q96 180 96 180Q97 171 107 173Q120 178 135 175Q150 173 162 165ZM262 401Q284 438 320 475Q355 512 399 521Q444 525 488 501Q533 476 565 447Q607 408 615 350L639 185Q642 170 634 158Q624 141 586 117Q547 93 464 79Q381 68 337 79Q293 90 279 104Q268 114 266 129L242 293Q233 351 262 401ZM405 246Q393 238 378 236Q363 233 349 238Q340 240 339 231Q339 231 339 230Q339 230 339 230Q342 213 354 203Q367 194 384 196Q400 199 409 212Q419 225 417 241Q417 242 417 242Q416 243 416 243Q413 251 405 246ZM541 262Q537 270 529 265Q517 257 502 254Q487 252 473 257Q464 259 463 250Q463 250 463 249Q463 249 463 248Q466 232 479 222Q491 213 508 215Q524 218 534 231Q543 243 541 260Q541 261 541 261Q541 261 541 262ZM530 361Q515 395 483 413Q451 431 413 426Q375 420 350 393Q325 367 320 329Q320 323 325 321Q330 319 335 323Q372 355 422 363Q473 370 517 350Q523 348 528 351Q532 355 530 361Z'],++ meme: [512, 'M427 25Q442 11 462 11Q482 11 497 26Q512 41 512 61Q512 81 498 96Q486 106 460 113Q434 120 414 122Q413 122 412 122Q411 123 410 123Q400 123 400 113Q402 94 409 66Q409 65 409 64Q410 63 410 62Q416 37 427 25ZM382 44Q380 51 378 59Q371 89 368 109Q366 130 380 143Q393 157 414 155Q434 152 464 145Q471 143 479 141Q520 216 510 300Q499 383 437 448Q372 510 289 521Q205 531 130 490Q132 483 134 476Q141 445 143 425Q146 404 132 391Q119 377 98 379Q78 382 47 389Q40 391 33 393Q-8 318 2 234Q13 151 75 86Q140 24 223 13Q307 3 382 44ZM52 421Q82 414 102 411Q112 411 112 421Q109 441 102 472Q95 497 85 509Q70 523 50 523Q30 523 15 508Q0 493 0 473Q0 453 14 438Q26 428 52 421ZM416 213Q412 204 403 205Q395 205 390 214Q360 269 310 320Q259 371 203 400Q194 405 194 414Q194 423 203 427Q248 445 296 436Q343 427 381 391Q416 354 425 307Q434 259 416 213ZM87 296Q90 299 94 300L151 305L156 361Q156 365 159 368Q164 373 170 371Q176 370 179 363L209 266Q211 257 205 251Q199 245 190 247L92 276Q85 279 84 285Q82 291 87 296ZM265 103 236 200 265 103 236 200Q233 210 239 216Q245 222 255 219L352 190Q359 187 360 181Q362 175 357 170Q354 167 350 167L294 161L288 105Q288 101 285 98Q280 93 274 95Q268 96 265 103Z'],++ tours: [512, 'M256 523Q326 522 384 489Q442 455 478 395Q512 334 512 267Q512 200 478 139Q442 79 384 45Q326 12 256 11Q186 12 128 45Q70 79 34 139Q0 200 0 267Q0 334 34 395Q70 455 128 489Q186 522 256 523ZM307 336 162 392 307 336 162 392Q147 396 137 386Q127 376 131 361L187 216Q192 203 205 198L350 142Q365 138 375 148Q385 158 381 173L325 318Q320 331 307 336ZM288 267Q288 253 279 244Q270 235 256 235Q242 235 233 244Q224 253 224 267Q224 281 233 290Q242 299 256 299Q270 299 279 290Q288 281 288 267Z'],++ vision: [512, 'M128 43H160H128H160Q174 43 183 52Q192 61 192 75V107H96V75Q96 61 105 52Q114 43 128 43ZM192 139V459V139V459Q192 473 183 482Q174 491 160 491H32Q18 491 9 482Q0 473 0 459V400Q0 347 27 302Q48 267 53 227L61 167Q66 141 92 139H192ZM420 139Q446 141 452 167L459 227Q464 267 485 302Q512 347 512 400V459Q512 473 503 482Q494 491 480 491H352Q338 491 329 482Q320 473 320 459V139H420ZM320 75Q320 61 329 52Q338 43 352 43H384Q398 43 407 52Q416 61 416 75V107H320V75ZM288 139V299V139V299H224V139H288Z'],++ shoes: [640, 'M416 11Q383 12 346 19Q309 27 283 35Q257 42 256 43V171Q291 172 316 181Q340 191 360 203Q380 216 405 225Q429 235 464 235Q494 235 535 228Q576 220 607 199Q638 179 640 139Q639 99 603 70Q568 42 517 26Q465 11 416 11ZM128 107Q129 134 147 152Q165 170 192 171H224V43H192Q165 44 147 62Q129 80 128 107ZM288 523Q337 523 389 508Q440 492 475 464Q511 435 512 395Q510 355 479 335Q448 314 407 306Q366 299 336 299Q301 300 277 309Q252 319 232 331Q212 344 188 353Q163 363 128 363V491Q129 492 155 499Q181 507 218 515Q255 522 288 523ZM0 427Q1 454 19 472Q37 490 64 491H96V363H64Q37 364 19 382Q1 400 0 427Z'],++ today: [448, 'M128 11Q142 11 151 20Q160 29 160 43V75H288V43Q288 29 297 20Q306 11 320 11Q334 11 343 20Q352 29 352 43V75H400Q420 76 434 89Q447 103 448 123V171H0V123Q1 103 14 89Q28 76 48 75H96V43Q96 29 105 20Q114 11 128 11ZM0 203H448H0H448V475Q447 495 434 509Q420 522 400 523H48Q28 522 14 509Q1 495 0 475V203ZM80 267Q65 268 64 283V379Q65 394 80 395H176Q191 394 192 379V283Q191 268 176 267H80Z'],++ report: [448, 'M160 91Q161 71 174 57Q188 44 208 43H240Q260 44 274 57Q287 71 288 91V443Q287 463 274 477Q260 490 240 491H208Q188 490 174 477Q161 463 160 443V91ZM0 283Q1 263 14 249Q28 236 48 235H80Q100 236 114 249Q127 263 128 283V443Q127 463 114 477Q100 490 80 491H48Q28 490 14 477Q1 463 0 443V283ZM368 107H400H368H400Q420 108 434 121Q447 135 448 155V443Q447 463 434 477Q420 490 400 491H368Q348 490 334 477Q321 463 320 443V155Q321 135 334 121Q348 108 368 107Z'],++ design: [512, 'M469 30 493 54 469 30 493 54Q511 73 511 99Q511 124 493 144L436 201L322 87L379 30Q398 12 424 12Q449 12 469 30ZM45 364 300 109 45 364 300 109 414 223 159 478Q148 488 135 492L31 522Q17 525 7 516Q-2 506 1 492L31 388Q35 374 45 364ZM249 114 103 260 249 114 103 260 16 173Q2 158 2 139Q2 120 16 105L94 27Q109 13 128 13Q147 13 162 27L182 47Q182 47 181 47Q181 47 181 48L117 112Q107 123 117 134Q128 144 139 134L203 70Q204 70 204 70Q204 70 204 69L249 114ZM409 274 454 319 409 274 454 319Q453 319 453 319Q453 319 453 320L389 384Q379 395 389 406Q400 415 411 406L475 342Q476 342 476 342Q476 341 476 341L496 361Q510 376 510 395Q510 414 496 429L418 507Q403 521 384 521Q365 521 350 507L263 420L409 274Z'],++ domains: [512, 'M58 204 67 220 58 204 67 220Q80 243 105 250L163 267Q190 276 192 305V345Q193 363 208 371Q223 379 224 397V436Q224 448 233 455Q242 461 254 459Q279 450 287 425L289 414Q296 387 320 374L328 369Q351 355 352 327V319Q352 299 338 285L334 281Q320 267 300 267H257Q240 267 225 259L191 239Q184 235 182 228Q177 212 192 203L198 200Q208 195 219 199L242 206Q256 210 264 199Q271 187 263 176L249 160Q234 139 249 119L265 100Q279 83 269 63L266 59Q261 59 256 59Q185 60 132 100Q79 140 58 204ZM464 267Q463 211 438 166L412 176Q400 181 395 192Q390 203 394 216L410 266Q416 283 433 287L462 295Q464 281 464 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],++ exchange: [512, 'M32 107H352H32H352V43Q353 22 372 13Q391 6 407 20L503 116Q512 126 512 139Q512 152 503 162L407 258Q391 272 372 264Q353 256 352 235V171H32Q18 171 9 162Q0 153 0 139Q0 125 9 116Q18 107 32 107ZM480 363Q494 363 503 372Q512 381 512 395Q512 409 503 418Q494 427 480 427H160V491Q159 512 140 521Q121 528 105 514L9 418Q0 408 0 395Q0 382 9 373L105 277Q121 262 140 270Q159 278 160 299V363H480Z'],++ bench: [448, 'M32 11Q46 11 55 20Q64 29 64 43V59L133 42Q192 28 247 54Q282 72 320 72Q358 72 394 54L403 50Q420 42 433 51Q447 59 448 77V357Q447 378 427 387L393 400Q320 426 251 392Q192 364 128 379L64 395V491Q64 505 55 514Q46 523 32 523Q18 523 9 514Q0 505 0 491V411V345V75V43Q0 29 9 20Q18 11 32 11ZM64 198 128 184 64 198 128 184V250L64 264V329L113 317Q120 315 128 314V250L167 241Q179 239 192 239V175Q213 176 232 182L256 188V255L214 243Q203 240 192 239V310Q225 313 256 325V255L279 262Q299 268 320 269V205Q308 204 297 201L256 189V127Q236 121 218 112Q205 105 192 103V175Q172 175 153 179L128 184V109L64 125V198ZM320 347Q346 349 370 340L384 335V263L376 265Q348 271 320 269V347ZM384 197V126V197V126Q353 136 320 136V205Q341 207 362 202L384 197Z'],};// END generated solid glyphs// A glyph drawn as a solid Font Awesome path, fitted to the 24-box the stroke glyphs use.@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.127.0",++ "version": "4.128.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -52,6 +52,12 @@ OV_SOLID = {'fund': 'fa-hand-holding-dollar', 'play': 'fa-ranking-star',# Page furniture the product homes name (2.21.1).'gauge': 'fa-gauge-high', 'link': 'fa-link', 'shield': 'fa-shield-halved', 'plug': 'fa-plug',++ # Sites that borrowed a neighbour's glyph until 2026-11-07 (Cam showed Video's, Casino and Surf the gamepad, Fun++ # Quest's trophy): each its own now, so the menu reads at a glance.++ 'cam': 'fa-camera', 'casino': 'fa-dice', 'surf': 'fa-water', 'fun': 'fa-masks-theater', 'meme': 'fa-face-grin-squint-tears',++ 'tours': 'fa-compass', 'vision': 'fa-binoculars', 'shoes': 'fa-shoe-prints', 'today': 'fa-calendar-day',++ 'report': 'fa-chart-simple', 'design': 'fa-pen-ruler', 'domains': 'fa-earth-americas', 'exchange': 'fa-right-left',++ 'bench': 'fa-flag-checkered',}OVICONS = HERE.parent / 'ov-icons.js'OV_START, OV_END = ' // BEGIN generated solid glyphs (scripts/build-nav-icons.py)', ' // END generated solid glyphs'@@@ -214,6 +214,36 @@.ovs-tile small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11.5px; line-height: 1.3; color: var(--text-muted, #93a0b4); }.ovs-tile.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }.ovs-note { padding: 14px 12px; font-size: 13px; color: var(--text-muted, #93a0b4); }++/* The menu before typing (4.128.0): the places dock, this site's pages, the live bar, counted folds. */++.ovs-apps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; padding: 2px 2px 6px; }++.ovs-app { display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; padding: 10px 2px 8px; border-radius: 16px; color: inherit; text-decoration: none; }++.ovs .ovs-appic { width: 54px; height: 54px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1), box-shadow .18s; }++.ovs-appname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++.ovs-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 6px; padding: 0 2px 2px; }++.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }++.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }++.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }++.ovs-livebar::-webkit-scrollbar { display: none; }++.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }++.ovs-live.is-count { border-color: color-mix(in srgb, var(--danger, #ef4444) 45%, transparent); background: color-mix(in srgb, var(--danger, #ef4444) 10%, transparent); }++.ovs-live small { color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 600; }++.ovs-livedot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--danger, #ef4444); animation: ovs-livedot 1.6s ease-in-out infinite; }++@keyframes ovs-livedot { 50% { opacity: .4; transform: scale(.75); } }++.ovs-foldrow { padding: 6px 4px 2px; }++.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }++.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }++.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }++.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }++.ovs-card { padding: 2px 6px 6px; }++.ovs-cardbody { border-radius: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); padding: 12px 14px; }++.ovs-host { display: block; margin-top: 2px; font-size: 11px; font-weight: 650; letter-spacing: .01em; color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--text-muted, #93a0b4)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovs-group .ovs-more { margin-left: auto; color: var(--accent-light, #8ab4ff); font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; text-decoration: none; }++@media (hover: hover) { .ovs-app:hover, .ovs-link:hover, .ovs-live:hover, .ovs-fold:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } .ovs-app:hover .ovs-appic { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); } }++@media (max-width: 640px) { .ovs-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); } }++@media (pointer: coarse) { .ovs-chip { height: 44px; } .ovs-group button, .ovs-group .ovs-more { min-height: 44px; display: inline-flex; align-items: center; } }++@media (prefers-reduced-motion: reduce) { .ovs-livedot { animation: none; } .ovs .ovs-appic { transition: none; } }.ovs-foot { display: flex; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: 11.5px; color: var(--text-muted, #93a0b4); }.ovs-foot kbd { font: inherit; font-weight: 700; padding: 1px 5px; border-radius: 5px; border: 1px solid var(--border, rgba(255,255,255,.14)); }.ovs-busy .ovs-bar { background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent), transparent); background-size: 40% 2px; background-repeat: no-repeat; background-position: -40% 100%; animation: ovs-scan 1s linear infinite; }@@@ -234,13 +264,19 @@.ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; }.ovs-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }-- .ovs-tile small { -webkit-line-clamp: 1; }++ .ovs-grid:not(.is-sites) .ovs-tile small { -webkit-line-clamp: 1; }}/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }--@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }++@media (max-width: 640px) {++ /* The whole screen on a phone (the owner, 11-07: "make full use of the height, not compensate for the navbar"),++ opening as a circle that grows from the button that opened it. */++ .ovs.ovs-anchored { top: 0; }++ .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }++}++@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;document.head.appendChild(st);}@@@ -262,7 +298,7 @@el.setAttribute('aria-modal', 'true');el.setAttribute('aria-label', 'Search and every OpenVibe site');el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x">Close</button></div>`-- + `<div class="ovs-tabs" role="tablist" aria-label="Show">${TABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-tab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`++ + '<div class="ovs-tabs ovs-starttabs" hidden></div>'+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';input = el.querySelector('.ovs-input');@@@ -279,10 +315,19 @@listEl.addEventListener('click', (e) => {const clear = e.target.closest && e.target.closest('[data-ovs-clear]');if (clear) { e.preventDefault(); recent.clear(); render(input.value); input.focus(); return; }-- const to = e.target.closest && e.target.closest('[data-ovs-tab]');-- if (to) { e.preventDefault(); tab = to.getAttribute('data-ovs-tab'); render(''); input.focus(); return; }++ const fold = e.target.closest && e.target.closest('[data-ovs-fold]');++ if (fold) {++ e.preventDefault();++ const key = fold.getAttribute('data-ovs-fold');++ if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen;++ render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;++ }const a = e.target.closest && e.target.closest('a[data-i]');const it = a && items[Number(a.getAttribute('data-i'))];++ if (it && !input.value.trim()) beacon(it, a);++ if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {++ e.preventDefault(); close(); try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } return;++ }if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }if (it) remember(it);@@@ -316,6 +361,7 @@}const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();function remember(it) {++ if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }const q = input && input.value.trim();if (q && it && it.kind !== 'recent') recent.add(q);}@@@ -365,21 +411,44 @@if (it.here) { a.classList.add('is-here'); a.setAttribute('aria-current', 'page'); }return a;}-- /** groups: [{ title, items, as: 'rows' | 'chips' | 'tiles' | 'sites', clear, more: { label, tab } }]; text only through textContent. */++ /** groups: [{ title, items, as: 'rows' | 'chips' | 'tiles' | 'sites' | 'apps' | 'links' | 'livebar' | 'fold', section,++ * clear, more: { label, tab | href }, fold: { open, label, key } }]; text only through textContent. */++ function foldButton(f, sec) {++ const li = document.createElement('li'); li.setAttribute('role', 'presentation'); li.className = 'ovs-foldrow';++ if (sec) li.setAttribute('data-sec', sec);++ const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-fold';++ b.setAttribute('data-ovs-fold', f.key); b.setAttribute('aria-expanded', f.open ? 'true' : 'false');++ b.insertAdjacentHTML('beforeend', ICON.go); const t = document.createElement('span'); t.textContent = f.label; b.appendChild(t);++ li.appendChild(b); return li;++ }function paint(groups, note) {items = [];listEl.textContent = '';-- groups.filter((g) => (g.items && g.items.length) || g.as === 'note' || g.as === 'loading' || g.as === 'route').forEach((g) => {++ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card'].includes(g.as)).forEach((g) => {if (g.as === 'route') {const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);return;}++ if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }++ if (g.as === 'card') {++ const body = document.createElement('div'); body.className = 'ovs-cardbody';++ try {++ g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.token && ctx.user && !ctx.user.is_anon) });++ } catch { return; }++ if (!body.childNodes.length) return;++ if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }++ const li = document.createElement('li'); li.className = 'ovs-card'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', g.section);++ li.appendChild(body); listEl.appendChild(li);++ return;++ }const h = document.createElement('li');h.className = 'ovs-group';h.setAttribute('role', 'presentation');++ if (g.section) h.setAttribute('data-sec', g.section);h.appendChild(document.createTextNode(g.title));if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }-- if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }++ if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }++ else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }if (g.title) listEl.appendChild(h);const as = g.as || 'rows';if (as === 'note' || as === 'loading') {@@@ -392,9 +461,11 @@if (as === 'rows') {g.items.forEach((it) => {const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');++ if (g.section) li.setAttribute('data-sec', g.section);const a = option('a', '', it);const main = document.createElement('span'); main.className = 'ovs-main';const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);++ if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; t.appendChild(tg); }if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }a.append(icon(it), main);@@@ -406,8 +477,9 @@}const box = document.createElement('li');box.setAttribute('role', 'presentation');-- box.className = as === 'chips' ? 'ovs-chips' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;-- g.items.forEach((it) => {++ if (g.section) box.setAttribute('data-sec', g.section);++ box.className = as === 'chips' ? 'ovs-chips' : as === 'apps' ? 'ovs-apps' : as === 'links' ? 'ovs-links' : as === 'livebar' ? 'ovs-livebar' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;++ g.items.forEach((it, k) => {if (as === 'chips') {const a = option('a', 'ovs-chip', it);// The place's own icon (the navbar's drawn set, never page text), else the menu's.@@@ -416,14 +488,39 @@box.appendChild(a);return;}++ if (as === 'apps') {++ // A dock of big icons: the name under each, one tap to the place.++ const a = option('a', 'ovs-app', it);++ const ic = icon(it); ic.classList.add('ovs-appic');++ const span = ic.querySelector('.ov-icon'); if (span) span.setAttribute('data-size', '40');++ const t = document.createElement('span'); t.className = 'ovs-appname'; t.textContent = it.title;++ a.append(ic, t); box.appendChild(a); return;++ }++ if (as === 'links') {++ const a = option('a', 'ovs-link', it);++ if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else a.insertAdjacentHTML('afterbegin', ICON.go);++ const t = document.createElement('span'); t.textContent = it.title; a.appendChild(t);++ box.appendChild(a); return;++ }++ if (as === 'livebar') {++ // The first is the count ("3 streams live now"), then the live channels when Network names them.++ const a = option('a', k === 0 ? 'ovs-live is-count' : 'ovs-live', it);++ if (k === 0) { const dot = document.createElement('span'); dot.className = 'ovs-livedot'; a.appendChild(dot); }++ const t = document.createElement('span'); t.className = 'ovs-livename'; t.textContent = it.title; a.appendChild(t);++ if (it.meta) { const m = document.createElement('small'); m.textContent = it.meta; a.appendChild(m); }++ box.appendChild(a); return;++ }const a = option('a', 'ovs-tile', it);const txt = document.createElement('span');const b = document.createElement('b'); b.textContent = it.title; txt.appendChild(b);++ if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }++ if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }a.append(icon(it), txt);box.appendChild(a);});listEl.appendChild(box);++ if (g.fold) listEl.appendChild(foldButton(g.fold, g.section));});if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }active = items.length && input.value.trim() && view !== 'results' ? 0 : -1;@@@ -506,48 +603,221 @@return best ? best.icon || best.id : null;}++ // ── The menu before typing (the owner, 2026-11-07: "rethink the whole idea of this whole menu … not spreading++ // traffic good enough … not smartly done based on what's popular"). One calm scroll, no tabs, in the order people++ // need it: who is live, the places they use (one tap to Live from anywhere), this site's own pages, where people++ // really go next from here (Network's cross-site moves, 11-07), two places new to them (fair rotation through the++ // long tail), their own corner (agents, Vibe, account), tools, every site behind a counted fold, and the pages they++ // were on last. Network's Frame may send `next`, `discover`, `you` and `live`; until it does the menu works them out.++ const NEXT_OF = {++ live: ['media-hub', 'community', 'chat', 'tools', 'bot', 'vip'],++ 'media-hub': ['live', 'pics', 'community', 'download'],++ community: ['live', 'chat', 'tools', 'space'],++ chat: ['live', 'community', 'media-hub', 'games'],++ tools: ['live', 'community', 'download', 'chat'],++ network: ['live', 'media-hub', 'community', 'design'],++ games: ['live', 'casino', 'quest', 'chat'],++ bot: ['live', 'actor', 'codes', 'run'],++ pics: ['media-hub', 'download', 'live', 'design'],++ download: ['pics', 'media-hub', 'tools', 'zone'],++ design: ['space', 'website', 'pics', 'network'],++ space: ['website', 'design', 'community', 'domains'],++ };++ const NEXT_WHY = {++ live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',++ tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',++ };++ let allOpen = false, hereOpen = false, discoverPick = null, pulseData = null;++ /** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */++ function loadPulse() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }++ return fetch('https://openvibe.network/api/frame/pulse', { credentials: 'omit' }).then((r) => (r.ok ? r.json() : null)).then((d) => {++ if (d) { pulseData = d; try { sessionStorage.setItem('ov_pulse', JSON.stringify({ at: Date.now(), data: d })); } catch { /* */ } }++ return d;++ }).catch(() => null);++ }++ /** What shipped lately, every site (Network's GET /api/v1/changelog, the feed OpenVibe.Blog keeps; kept 5 minutes):++ * the newest real change per site, library bumps left out (the same rule as shipped.js). */++ let changesData = null;++ const HOUSEKEEPING = /^(?:openvibe-(?:shared|sdk|contracts|publishing)|openvibe-codes-cli)\s+v?\d|^(?:bump|pin)\b|\(was v\d/i;++ function loadChanges() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_changes') || 'null'); if (c && Date.now() - c.at < 300000) { changesData = c.data; return Promise.resolve(c.data); } } catch { /* */ }++ return fetch('https://openvibe.network/api/v1/changelog?limit=100', { credentials: 'omit', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : null)).then((d) => {++ if (d && Array.isArray(d.entries)) { changesData = d.entries; try { sessionStorage.setItem('ov_changes', JSON.stringify({ at: Date.now(), data: d.entries })); } catch { /* */ } }++ return changesData;++ }).catch(() => null);++ }++ /** A commit subject in plain words: no PR number, no stage tag ("A6: "), no trailing period. */++ const plainChange = (t) => String(t || '').replace(/\s*\(#\d+\)\s*$/, '').replace(/^[A-Z]{1,3}\d{0,3}(?:-\d+)?:\s+/, '').replace(/\.$/, '').slice(0, 110);++ /** When this person was last on a site (their history), for "new since your last visit". */++ function lastVisit(id) {++ if (!mine) return 0;++ let t = 0;++ for (const h of mine.items) if (h.service === id || (id === 'media-hub' && h.service === 'media')) { const v = Date.parse(h.at || ''); if (v > t) t = v; }++ return t;++ }++++ /** Two places this person has never used, picked fairly from the network's ranked list: better-used places come up++ * more often, but every place gets its turn (the pick changes each time the menu opens). */++ function pickDiscover(pool) {++ // The same two for the whole opening (history arriving later must not reshuffle them); one that has since moved++ // up into the person's places is replaced, so there are always two.++ const kept = discoverPick ? discoverPick.map((id) => pool.find((s) => s.id === id)).filter(Boolean) : [];++ if (discoverPick && kept.length >= 2) return kept;++ const fresh = pool.filter((s) => !useOf(s.id) && !kept.includes(s));++ const picked = kept.slice();++ const bag = fresh.map((s, i) => [s, 1 / Math.sqrt(1 + i)]);++ while (picked.length < 2 && bag.length) {++ const total = bag.reduce((n, [, w]) => n + w, 0);++ let r = Math.random() * total, k = 0;++ for (; k < bag.length - 1 && r > bag[k][1]; k++) r -= bag[k][1];++ picked.push(bag[k][0]); bag.splice(k, 1);++ }++ discoverPick = picked.map((s) => s.id);++ return picked;++ }++ /** The person's workspace (signed in): their agents and Vibe (the Dock opens in place), Codes, their files, their++ * machines, wallet, account and themes, one tap from every site (the owner, 11-07: "access codes through this menu++ * easily, and talk to your agent … less having to hop between sites"). */++ function youItems() {++ const f = ctx.frame;++ if (f && Array.isArray(f.you) && f.you.length) return f.you.map((y) => ({ title: String(y.name || ''), url: safeUrl(y.url), site: y.icon || 'ov', kind: y.dock ? 'dock' : 'you', dock: y.dock || null })).filter((x) => x.url && x.title);++ return [++ { title: 'Agents', url: 'https://openvibe.actor/', site: 'actor', kind: 'dock', dock: 'agent' },++ { title: 'Vibe', url: 'https://openvibe.chat/vibe', site: 'ov', kind: 'dock', dock: 'vibe' },++ { title: 'Codes', url: 'https://openvibe.codes/', site: 'codes', kind: 'you' },++ { title: 'Files', url: 'https://openvibe.download/files', site: 'drive', kind: 'you' },++ { title: 'Machines', url: 'https://node.openvibe.network', site: 'plug', kind: 'you' },++ { title: 'Wallet', url: 'https://openvibe.network/wallet', site: 'billing', kind: 'you' },++ { title: 'Account', url: 'https://openvibe.network/my', site: 'account', kind: 'you' },++ { title: 'Themes', url: 'https://openvibe.network/themes', site: 'theme', kind: 'you' },++ ];++ }++ // ── Site cards: a site's own live piece of the menu (the owner, 11-07: "content aware … on Download, a little++ // download thing … or create custom elements"). A page pushes { id, title, render(el, api) } onto++ // window.OpenVibeMenuCards (a queue, so it works before search.js loads) or calls OpenVibeSearch.registerCard().++ // render() gets an empty element and { close, here, user, signedIn } and draws with DOM nodes; it runs on the++ // site's own page, so it is the site's code and its own responsibility. A card that throws is left out.++ const cards = [];++ function registerCard(c) {++ if (!c || typeof c.render !== 'function' || !c.id || cards.some((x) => x.id === c.id)) return () => {};++ cards.push(c);++ return () => { const i = cards.indexOf(c); if (i >= 0) cards.splice(i, 1); };++ }++ function drainCards() {++ const q = root.OpenVibeMenuCards;++ if (Array.isArray(q)) { while (q.length) registerCard(q.shift()); }++ // Later pushes go straight in.++ root.OpenVibeMenuCards = { push: (c) => { registerCard(c); return cards.length; } };++ }++++ // The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").++ const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];++function renderTab() {-- const all = sites(), mineSite = hereSite(all);-- tabsEl.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.getAttribute('data-tab') === tab)));-- if (tab === 'sites') {-- const groups = ctx.frame && ctx.frame.groups && ctx.frame.groups.length ? ctx.frame.groups : [{ id: '', name: 'OpenVibe' }];-- const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some((x) => x.id === s.group));-- const soon = ctx.frame && Array.isArray(ctx.frame.soon) ? ctx.frame.soon : [];-- paint(groups.map((g, i) => ({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g, i)).map((s) => siteItem(s, all)) }))-- .concat(soon.length ? [{ title: 'Opening soon', as: 'chips', items: soon.map((x) => ({ title: x.name, url: safeUrl(x.url), kind: 'site' })).filter((x) => x.url) }] : []));-- return;++ // This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).++ const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;++ const byId = new Map(all.map((s) => [s.id, s]));++ const shown = new Set(mineSite ? [mineSite.id] : []);++ const take = (list, n) => { const out = []; for (const s of list) { if (out.length >= n) break; if (s && !shown.has(s.id)) { out.push(s); shown.add(s.id); } } return out; };++ const groups = [];++ const f0 = ctx.frame;++ // 1. Who is live (Network's pulse): the network's heart, one tap from every site.++ const p = pulseData;++ const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;++ if (liveNow) {++ const chans = Array.isArray(p.live) ? p.live.slice(0, 8).map((c) => ({ title: String(c.name || ''), meta: c.viewers ? `${c.viewers} watching` : 'Live', url: safeUrl(c.url), site: 'live', kind: 'live' })).filter((x) => x.url && x.title) : [];++ groups.push({ title: '', as: 'livebar', items: [{ title: liveNow === 1 ? '1 stream live now' : `${liveNow} streams live now`, meta: p.watching ? `${p.watching} watching` : 'Watch on Live', url: 'https://openvibe.live/', site: 'live', kind: 'live' }, ...chans] });}-- if (tab === 'tools') {-- const fams = catalog && catalog.families.length ? catalog.families : FALLBACK_FAMILIES;-- const tile = (r) => ({ title: r.title, url: r.url, site: r.site, kind: 'tool' });-- paint([-- { title: 'Your tools', as: 'tiles', items: yourTools().map(tile) },-- { title: 'Most used on OpenVibe', as: 'tiles', items: popularTools().map(tile) },-- { title: 'Tool families', as: 'tiles', items: fams.map((f) => ({ title: f.name, url: f.url, site: f.icon, kind: 'family' })) },-- { title: 'Every tool', items: [{ title: 'Browse every tool on OpenVibe.Tools', meta: 'Hundreds of free tools, nothing to install', url: 'https://openvibe.tools/', icon: 'grid', kind: 'action' }] },-- ], catalog ? '' : 'Loading the tools…');-- return;++ // 1b. The network's cards: what sites publish for every menu (Network collects each site's++ // /.well-known/openvibe-menu.json into the Frame's `cards`): Live's channels, Games' new worlds, Video's trending.++ // Text and links only (never another site's code), checked here again; at most two, Network's order.++ const netCards = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 2) : [];++ netCards.forEach((c) => {++ const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';++ const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url), site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);++ if (rows.length) groups.push({ title: String(c.title).slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });++ });++ // 2. The places this person uses most (else the network's most used): a dock of big icons.++ const yours = mine && mine.services.some((x) => x.count > 0);++ const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);++ // Without their own history, developer services (Media's API, Events, Zone…) are not places to visit: they stay in++ // the full list and come up here only once the person uses them.++ const dockPool = yours ? ranked : ranked.filter((s) => s.group !== 'build' || useOf(s.id));++ groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((s) => siteItem(s)), section: 'places' });++ // 2b. Their workspace, right under their places: agents and Vibe, Codes, files, machines, wallet, account, themes.++ if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });++ // 2c. This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).++ for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });++ // 3. This site's own pages: the bar's links, two to a row; more behind a count.++ const allPages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);++ if (allPages.length) {++ const head = hereOpen ? allPages : allPages.slice(0, 6);++ groups.push({ title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null });}-- // For you: what this person goes back to, this site's places, their searches, and their sites.++ // 4. Where people go next from here: the Frame's list, else the moves Network saw (11-07) and this site's group.++ const f = ctx.frame;++ const nextIds = mineSite ? ((f && f.next && Array.isArray(f.next[mineSite.id]) && f.next[mineSite.id]) || NEXT_OF[mineSite.id] || all.filter((s) => s.group === mineSite.group).map((s) => s.id)) : [];++ const next = take(nextIds.map((id) => byId.get(id)), 4);++ if (next.length) groups.push({ title: `Next from ${here()}`, as: 'sites', items: next.map((s) => Object.assign(siteItem(s), { meta: NEXT_WHY[s.id] || s.desc })), section: 'next' });++ // 5. Discover: two places new to this person, rotating fairly through the whole network.++ // Developer services are offered to people who already use one; everyone else discovers places to visit.++ const builder = all.some((s) => s.group === 'build' && useOf(s.id));++ const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)++ .filter((s) => !shown.has(s.id) && (builder || s.group !== 'build'));++ const disc = pickDiscover(discoverPool);++ disc.forEach((s) => shown.add(s.id));++ if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((s) => Object.assign(siteItem(s), { tag: 'New' })), section: 'discover' });++ // 5b. What's new (the owner, 11-07: "check which of the sites got updates recently, and if the user has been on++ // since … sort them by last update … bring you to the /updates page"): the newest real change per site,++ // newest first, "New" when it shipped after this person's last visit there.++ if (Array.isArray(changesData) && changesData.length) {++ const newest = new Map();++ for (const e of changesData) {++ if (!e || !e.service || HOUSEKEEPING.test(String(e.subject || '')) || newest.has(e.service)) continue;++ const site = byId.get(e.service) || (e.service === 'media' ? byId.get('media-hub') : null);++ if (!site || (site.group === 'build' && !useOf(site.id))) continue;++ newest.set(e.service, { e, site });++ }++ const rows = [...newest.values()].sort((a, b) => Date.parse(b.e.deployed_at || b.e.committed_at || 0) - Date.parse(a.e.deployed_at || a.e.committed_at || 0)).slice(0, 4).map(({ e, site }) => {++ const at = e.deployed_at || e.committed_at;++ const seen = lastVisit(site.id);++ const fresh = seen && Date.parse(at) > seen;++ return { title: site.name, meta: `${plainChange(e.subject)} · ${ago(at)}`, url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };++ }).filter((x) => x.url);++ if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });++ }++ // 7. Tools: the ones they use, then the everyday ones.++ const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);++ groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });++ // 8. Their searches.const r = recent.get();-- const yours = mine && mine.services.some((x) => x.count > 0);-- const ranked = all.filter((s) => s !== mineSite).map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).slice(0, 8).map(([, s]) => siteItem(s));++ groups.push({ title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })), section: 'recent' });++ // 9. Every site, grouped, behind one count (this site's group first).++ const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];++ if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));++ groups.push({ title: '', as: 'fold', items: [], fold: { open: allOpen, label: allOpen ? 'Hide the list of sites' : `Every OpenVibe site (${all.length})`, key: 'all' }, section: 'all' });++ if (allOpen) {++ const inGroup = (s, g) => !g.id || s.group === g.id || (!fgroups.some((x) => x.id === s.group) && g === fgroups[fgroups.length - 1]);++ const full = (s) => Object.assign(siteItem(s, all), { host: hostOf(s.url).replace(/^www\./, '') });++ const flag = FLAGSHIPS.map((id) => byId.get(id)).filter(Boolean);++ if (flag.length) groups.push({ title: 'Flagships', as: 'sites', items: flag.map(full), section: 'all' });++ fgroups.forEach((g) => groups.push({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g)).map(full), section: 'all' }));++ const soon = f && Array.isArray(f.soon) ? f.soon : [];++ if (soon.length) groups.push({ title: 'Opening soon', as: 'chips', items: soon.map((x) => ({ title: x.name, url: safeUrl(x.url), kind: 'site' })).filter((x) => x.url), section: 'all' });++ }++ // 10. Last: the pages they were on.const seen = new Set();const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5).map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];-- paint([-- { title: 'Jump back in', items: back },-- { title: `On ${here()}`, as: 'chips', items: pagesOf('') },-- { title: 'Quick tools', as: 'chips', more: { label: 'All tools', tab: 'tools' }, items: quickTools() },-- { title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },-- { title: yours ? 'Your sites' : 'Popular on OpenVibe', as: 'tiles', more: { label: 'All sites', tab: 'sites' }, items: ranked },-- ], back.length || r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');++ groups.push({ title: 'Jump back in', items: back, section: 'back' });++ paint(groups);}function render(q) {q = String(q || '').trim();el.classList.toggle('ovs-typing', !!q);-- tabsEl.hidden = !!q;++ tabsEl.hidden = true;if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); renderTab(); return; }const n = q.toLowerCase();const recentHits = recent.get().filter((x) => x.toLowerCase().startsWith(n) && x.toLowerCase() !== n).slice(0, 3).map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' }));@@@ -687,17 +957,39 @@return parts.length ? `Searched ${parts.join(' · ')}` : '';}++ /** What people pick from the menu (section, position, where it leads), when Network asks for it: no person, no++ * page, summed per day there, so the menu can learn what helps and spread people across the network. */++ function beacon(it, a) {++ const b = ctx.frame && ctx.frame.menu && typeof ctx.frame.menu.beacon === 'string' ? safeUrl(ctx.frame.menu.beacon) : null;++ if (!b || !navigator.sendBeacon) return;++ const li = a.closest('[data-sec]');++ const sec = li ? li.getAttribute('data-sec') : '';++ const peers = li ? Array.prototype.indexOf.call(li.querySelectorAll('a[data-i]'), a) : -1;++ try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }++ }++function open(opts = {}) {if (typeof document === 'undefined') return;ctx = opts || {};if (!el) build();lastFocus = document.activeElement;tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';++ // The old tabs, kept as a way in: "sites" opens the list of every site, "tools" scrolls to the tools.++ allOpen = tab === 'sites'; hereOpen = false; discoverPick = null;++ drainCards();el.classList.add('is-open');++ // Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one++ // history entry; Back pops it and only closes the menu.++ try { if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; } } catch { pushed = false; }// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.const at = opts.anchor;el.classList.toggle('ovs-anchored', !!at);-- if (at) { el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`); }++ if (at) {++ el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`);++ const cx = Number.isFinite(at.cx) ? at.cx : (Number.isFinite(at.left) ? at.left + 20 : 40);++ const cy = Number.isFinite(at.cy) ? at.cy : (Number.isFinite(at.top) ? Math.max(20, at.top - 28) : 28);++ el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);++ }try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }document.documentElement.style.overflow = 'hidden';input.placeholder = `Search ${here()}, or ask Vibe`;@@@ -709,10 +1001,17 @@mine = null;loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });++ loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });++ loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });++ if (tab === 'tools' || tab === 'sites') setTimeout(() => { const t = listEl.querySelector(`[data-sec="${tab === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);}++ let pushed = false, popping = false;++ if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });function close() {if (view === 'results' && rtabsEl) leaveResults();if (!el) return;++ // Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.++ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) history.back(); } catch { /* */ } }el.classList.remove('is-open');document.documentElement.style.overflow = '';if (inflight) { inflight.abort(); inflight = null; }@@@ -721,7 +1020,7 @@if (lastFocus && lastFocus.focus) lastFocus.focus();}-- const api = { open, close, suggest, siteOf };++ const api = { open, close, suggest, siteOf, registerCard };root.OpenVibeSearch = api;if (typeof module === 'object' && module.exports) module.exports = api;})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -217,7 +217,7 @@ html{overflow-x:clip}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}--.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;display:none}.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}@@@ -227,12 +227,13 @@ html{overflow-x:clip}@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}.ov-icon[data-fx=none] .ovi-comet{display:none}.ov-icon[data-fx=pulse] .ovi-comet{display:none}--.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}++.ov-icon[data-fx=pulse][data-state=busy] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}--.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-icon[data-fx=bounce][data-state=busy] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}.ov-icon[data-progress] .ovi-prog{opacity:1}.ov-icon[data-progress] .ovi-comet{opacity:0}--.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}++.ov-icon[data-state=busy] .ovi-comet{display:inline;animation:oviSpin 1.1s linear infinite;opacity:1;stroke-dasharray:40 85.66}++.ov-icon[data-state=busy][data-fx=none] .ovi-comet,.ov-icon[data-state=busy][data-fx=pulse] .ovi-comet{display:none}.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}@@@ -52,24 +52,25 @@ const server = http.createServer((req, res) => {// The navbar asks for the Frame at render; the launcher paints from what it kept.await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);const wait = (ms) => new Promise((r) => setTimeout(r, ms));-- // The drawer's "All sites" opens the one menu on its Sites tab.++ // The drawer's "All sites" opens the one menu with the list of every site open (4.128.0: one scroll, no tabs).await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);await wait(150);await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);-- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites')) { clearInterval(t); r(true); } }, 20); })`);++ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {-- tab: (document.querySelector('.ovs-tab[aria-selected="true"]') || {}).textContent,++ open: (l.querySelector('[data-ovs-fold="all"]') || { getAttribute: () => null }).getAttribute('aria-expanded'),heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),-- groups: [...l.querySelectorAll('.ovs-grid.is-sites')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),-- here: [...l.querySelectorAll('.ovs-tile.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),-- small: (l.querySelector('.ovs-tile small') || {}).textContent,++ listHeads: [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()).filter((h) => h !== 'Flagships'),++ groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),++ here: [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),++ small: (l.querySelector('.ovs-grid.is-sites[data-sec="all"] .ovs-tile small') || {}).textContent,rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);const r = await read();-- assert.strictEqual(r.tab, 'Sites', 'the Sites tab');-- assert.deepStrictEqual(r.heads.slice(0, 3), ['Watch & stream', 'Talk & learn', 'Your OpenVibe'], 'the Frame\'s groups, in its order');++ assert.strictEqual(r.open, 'true', 'the list of every site is open');++ assert.deepStrictEqual(r.listHeads.slice(0, 3), ['Talk & learn', 'Watch & stream', 'Your OpenVibe'], 'this site\'s group first, then the Frame\'s groups in its order');assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');-- assert.deepStrictEqual(r.groups[0].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');-- assert.ok(r.groups[0].includes('Pics'), 'a product on its own domain sits in its group');++ assert.deepStrictEqual(r.groups[1].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');++ assert.ok(r.groups[1].includes('Pics'), 'a product on its own domain sits in its group');assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');@@@ -87,13 +87,15 @@ const server = http.createServer((req, res) => {for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);await wait(150);let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,-- tabs: [...document.querySelectorAll('.ovs-tab[data-tab]')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),-- groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),-- sites: [...document.querySelectorAll('.ovs-tile b')].map((t) => t.textContent), placeholder: document.querySelector('.ovs-input').placeholder })`);++ tabs: document.querySelectorAll('.ovs-tab[data-tab]').length,++ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), links: [...document.querySelectorAll('.ovs-link span')].map((t) => t.textContent),++ sites: [...document.querySelectorAll('.ovs-appname')].map((t) => t.textContent), fold: (document.querySelector('[data-ovs-fold="all"]') || {}).textContent,++ placeholder: document.querySelector('.ovs-input').placeholder })`);assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');-- assert.deepStrictEqual(v.tabs, ['Start*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');-- assert.deepStrictEqual([v.groups, v.chips], [['On Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages (by its short name, 4.48.0), then the sites');++ assert.strictEqual(v.tabs, 0, 'one calm scroll, no tabs (4.128.0, the owner 11-07: "rethink the whole idea of this whole menu")');++ assert.deepStrictEqual([v.groups, v.links], [['Popular on OpenVibe', 'On Chat'], ['Rooms', 'Servers']], 'nothing typed: the places first (one tap to Live), then this site\'s own pages');assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');++ assert.strictEqual(v.fold, 'Every OpenVibe site (6)', 'every site behind one counted fold');assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);@@@ -102,8 +104,8 @@ const server = http.createServer((req, res) => {assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);await wait(100);-- await p.evaluate('document.querySelector(".ovs-tab[data-tab=sites]").click(); 1');-- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile")'), 'the Sites tab lists the sites');++ await p.evaluate('document.querySelector("[data-ovs-fold=all]").click(); 1');++ assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile") && document.querySelector("[data-ovs-fold=all]").getAttribute("aria-expanded") === "true"'), 'the fold opens the list of every site in place');// Typing: one request for the latest text (the earlier one cancelled), this site's results first.await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);await wait(1200);@@@ -143,6 +145,15 @@ const server = http.createServer((req, res) => {assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');++ // Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).++ const before = await p.evaluate('location.href');++ await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');++ for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);++ assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry');++ await p.evaluate('history.back(); 1');++ await wait(400);++ assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');++ assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');// ── Loading ──v = await p.evaluate(`new Promise((done) => {
@@ -1,5 +1,55 @@
@@ -1,5 +1,55 @@
# Changelog
# Changelog
+## 4.128.0 — 2026-11-07
+
+- **The menu, rethought around real use** (the owner, 11-07: Jump back in came first, people opened Sites every time just to
+ reach Live, and it "doesn't spread traffic … not smartly done based on what's popular"). Network's data that day: Video,
+ Live and Community take nearly all the page views, 48 of 67 signed-in people used one site, and people move Live ↔
+ Community, Live ↔ Tools, Chat → Live, Live → Bot and Live → Video. One calm scroll, no tabs, in this order:
+ - **Who is live**: "3 streams live now · 41 watching" and the live channels when Network names them (`pulse.live`).
+ - **Your places**: a dock of eight big icons, the places this person uses most (else the network's most used, without
+ developer services). One tap to Live from any site.
+ - **On <this site>**: the bar's own links, two to a row, the rest behind "More on <site> (N)".
+ - **Next from <this site>**: where people really go next (Network's moves; the Frame's `next` when it sends one).
+ - **New to you**: two places this person never used, picked fairly from the ranked network so every place gets its
+ turn; developer services only for people who use one. The pick holds for the whole opening.
+ - **You** (signed in): your agents and Vibe (they open in the Dock), account, wallet, themes, machines.
+ - **Tools**, **Recent searches**, then **Every OpenVibe site (N)** as one counted fold (this site's group first), and
+ **Jump back in** last.
+- `open({ tab: 'sites' })` opens the list of every site and `tab: 'tools'` scrolls to the tools, so old callers keep
+ working.
+- **A click beacon, only when Network asks for it** (`frame.menu.beacon`): the section, the position, where it led and
+ the kind; no person and no page, so the menu can learn what helps and the ranking can spread people across the network.
+- Touch screens get 44 px chips and header links.
+- **Sites put their own content in the menu** (the owner, 11-07: "content aware … sites can interact and create their own
+ content in the bar"), two ways:
+ - **On their own pages, a card:** a site pushes `{ id, title, render(el, api) }` onto `window.OpenVibeMenuCards`
+ (a queue that works before `search.js` loads) or calls `OpenVibeSearch.registerCard()`. The card draws itself
+ under the workspace: Download's drop zone, Live's go-live status, Codes' new task. It is the site's own code on
+ its own page. A card that throws is left out.
+ - **On every site, from Network:** the Frame's `cards` (Network collects each site's
+ `/.well-known/openvibe-menu.json`). The menu draws at most two, as a rail, rows or chips: Live's channels with
+ their viewers, Games' new worlds, Video's trending. They are text and links only, re-checked here, and never
+ another site's code.
+- **Back closes the menu first** (the owner: Back on a phone left the page). Opening adds one history entry; Back pops
+ it and only closes the menu. Choosing a link leaves cleanly.
+- **The whole screen on a phone,** opened from the bar's orb as a circle that grows from the orb, under the safe area.
+ No motion when reduced motion is asked for.
+- **Your workspace** (signed in), right under your places: Agents and Vibe (open in the Dock), Codes, Files, Machines,
+ Wallet, Account and Themes, one tap from every site (the owner: "access codes through this menu easily, and talk to
+ your agent … less having to hop between sites").
+- **What's new**: the newest real change on each site from the network changelog (library bumps left out), newest first,
+ marked **New** when it shipped after your last visit there, each linking to that site's `/updates`.
+- **The full list of sites**: the flagships first (Live, Video, Codes, Design, Space, Games, Chat, Tools), then every group;
+ each site with its icon, a two-line description (no more cut-off words on a phone) and its own domain.
+- **Every site has its own icon** (`ov-icons.js`, regenerated from Font Awesome Free 6 by `scripts/build-nav-icons.py`):
+ Cam a camera, Casino dice, Surf waves, Fun masks, Meme a laughing face, Tours a compass, Vision binoculars, Shoes, Today,
+ Report, Design, Domains, Exchange and Bench. Until now they borrowed a neighbour's (Cam showed Video's, Casino and Surf
+ the gamepad, Fun Quest's trophy).
+- **No spinning rings anywhere** (`ov-icons.js`; the owner, 10-31 and again 11-07 on Surf's and Space's cards: "things only
+ spin if they're active"). The ring icons' comet spun forever on every icon on every site. Now the comet, the pulse and
+ the bounce run only while `data-state="busy"`; at rest an icon is its ring and glyph with a small lift on hover.
+ `data-fx` now says what *busy* looks like. Every site loses the idle spin with this release.
## 4.127.0 — 2026-11-07
## 4.127.0 — 2026-11-07
- **The Dock's Agent panel** (design 2026-11-07, one agent behind the Dock): Now says what the agent is doing this moment (the mode, the step in plain words, how long, and Stop/Resume), Needs you lists every approval and question from any mode with its buttons in place, and the timeline below shows one entry per step — the mode, the title, where it ran and why, the outcome, the time, the cost and a link to the step's own page. A mode change is a line of its own.
- **The Dock's Agent panel** (design 2026-11-07, one agent behind the Dock): Now says what the agent is doing this moment (the mode, the step in plain words, how long, and Stop/Resume), Needs you lists every approval and question from any mode with its buttons in place, and the timeline below shows one entry per step — the mode, the title, where it ran and why, the outcome, the time, the cost and a link to the step's own page. A mode change is a line of its own.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.127.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.128.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.127.0",
+ "version": "4.128.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"share cards for every site and for its big pages (openvibe-shared/social)",
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the shared video player (player.js, player.css): one look for every <video>",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
"the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
+ "the menu rethought around real use: who is live, your places dock, this site's pages, next from here, two places new to you, you and your agents, tools, every site behind a count, jump back in; a click beacon when Network asks (4.128.0)",
"items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site",
"items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site",
"shipped pill, recent list and /updates log",
"shipped pill, recent list and /updates log",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
@@ -2043,7 +2043,7 @@
@@ -2043,7 +2043,7 @@
const anchor = () => {
const anchor = () => {
if (!_navEl || !orb || !orb.getBoundingClientRect) return null;
if (!_navEl || !orb || !orb.getBoundingClientRect) return null;
const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();
const bar = _navEl.getBoundingClientRect(), at = orb.getBoundingClientRect();
- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;
+ 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;
};
};
const go = () => {
const go = () => {
if (orb) orb.classList.remove('is-busy');
if (orb) orb.classList.remove('is-busy');
@@ -2070,7 +2070,7 @@
@@ -2070,7 +2070,7 @@
const anchor = () => {
const anchor = () => {
if (!_navEl || !btn || !btn.getBoundingClientRect) return null;
if (!_navEl || !btn || !btn.getBoundingClientRect) return null;
const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();
const bar = _navEl.getBoundingClientRect(), at = btn.getBoundingClientRect();
- return bar.bottom > 0 && bar.bottom < 200 ? { top: bar.bottom, left: at.left - 6 } : null;
+ 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;
};
};
const go = () => {
const go = () => {
const S = root.OpenVibeSiteSwitcher;
const S = root.OpenVibeSiteSwitcher;
@@ -15,7 +15,10 @@
@@ -15,7 +15,10 @@
* OpenVibeIcons.set(el, { progress, state, icon })
* OpenVibeIcons.set(el, { progress, state, icon })
* OpenVibeIcons.svg(name, size) markup string, for server-side / templates
* OpenVibeIcons.svg(name, size) markup string, for server-side / templates
*
*
- * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the
+ * Nothing moves at rest (the owner, 10-31 and 11-07: "things only spin if they're active"; the comet spun forever on
+ * every icon until 4.128.0). The comet, the pulse and the bounce run only while data-state="busy"; at rest an icon is
+ * its ring and glyph, with a small lift on hover where a pointer hovers. "draw" plays once when an icon appears.
+ * Effects (data-fx, what busy looks like): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the
* SVG and transform on the <svg> element only: no CSS transform ever touches an element inside the SVG
* SVG and transform on the <svg> element only: no CSS transform ever touches an element inside the SVG
* (transform-box/transform-origin on SVG children left glyphs and whole icons unpainted on phones while
* (transform-box/transform-origin on SVG children left glyphs and whole icons unpainted on phones while
* scrolling), and hover effects apply only where a pointer can hover, so a thumb scrolling over a link
* scrolling), and hover effects apply only where a pointer can hover, so a thumb scrolling over a link
@@ -91,6 +94,22 @@
@@ -91,6 +94,22 @@
run: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/>' + P('M7.5 10l2.5 2-2.5 2M12 14.5h4'),
run: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/>' + P('M7.5 10l2.5 2-2.5 2M12 14.5h4'),
billing: P('M4.5 7.5h13a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2zM4.5 7.5l10-3v3M15.5 13h2'),
billing: P('M4.5 7.5h13a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2zM4.5 7.5l10-3v3M15.5 13h2'),
feeds: '<circle cx="6.5" cy="17.5" r="1.4"/>' + P('M5.5 11a7.5 7.5 0 0 1 7.5 7.5M5.5 6A12.5 12.5 0 0 1 18 18.5'),
feeds: '<circle cx="6.5" cy="17.5" r="1.4"/>' + P('M5.5 11a7.5 7.5 0 0 1 7.5 7.5M5.5 6A12.5 12.5 0 0 1 18 18.5'),
+ // Their own glyphs (11-07; until then each borrowed a neighbour's: Cam showed Video's, Casino and Surf the gamepad,
+ // Fun Quest's trophy), so every site in the menu reads at a glance.
+ cam: '<rect x="3.5" y="7.5" width="17" height="12" rx="2.5"/>' + P('M8.6 7.5 10 5h4l1.4 2.5') + '<circle cx="12" cy="13.5" r="3.3"/><circle cx="17.3" cy="10.2" r=".6"/>',
+ casino: '<rect x="5" y="5" width="14" height="14" rx="3"/><circle cx="9" cy="9" r=".7"/><circle cx="15" cy="9" r=".7"/><circle cx="12" cy="12" r=".7"/><circle cx="9" cy="15" r=".7"/><circle cx="15" cy="15" r=".7"/>',
+ surf: P('M3.5 16c2 0 2.5-1.4 4.2-1.4S10 16 12 16s2.5-1.4 4.2-1.4S18.5 16 20.5 16M3.5 19.5c2 0 2.5-1.3 4.2-1.3s2.3 1.3 4.3 1.3 2.5-1.3 4.2-1.3 2.3 1.3 4.3 1.3M5.5 12.8C6.6 8 10.3 4.8 15.8 4.8c-2.4 1.4-3.5 3.7-3.2 6.6'),
+ fun: P('M4.8 19.2 8.6 8.6l6.8 6.8zM8.6 8.6l6.8 6.8M13.5 4.5v1.8M17.2 6.8l1.3-1.3M19.5 10.5h-1.8M16 9.5c.9-.9 2.2-1.1 3.1-.4'),
+ meme: '<circle cx="12" cy="12" r="7.5"/>' + P('M8.6 13.3a3.4 3.4 0 0 0 6.8 0zM8.7 9.6l1.6 1M15.3 9.6l-1.6 1'),
+ tours: '<circle cx="12" cy="12" r="7.5"/>' + P('M15 9l-1.8 4.2L9 15l1.8-4.2z'),
+ vision: P('M3.5 12S6.5 6.5 12 6.5 20.5 12 20.5 12 17.5 17.5 12 17.5 3.5 12 3.5 12z') + '<circle cx="12" cy="12" r="2.6"/>',
+ shoes: P('M3.5 16.5V12l3-1.4 2 1.4 2.3-2.9 4.3 3.7c1.7 1.4 3.3 1.9 5.4 2.1v1.6zM3.5 16.5h17M9.3 12.9l1 1M11.3 11.6l1 1'),
+ today: '<rect x="4.5" y="5.5" width="15" height="14" rx="2.5"/>' + P('M4.5 9.5h15M8.5 3.5v3.2M15.5 3.5v3.2') + '<circle cx="12" cy="14.5" r="1.8"/>',
+ report: P('M5 19.5h14M7.5 16.5v-4M11 16.5V8.5M14.5 16.5v-6M18 16.5V5.5'),
+ design: P('M12 3.8l5 7.2-5 9.2-5-9.2zM12 3.8v7.4') + '<circle cx="12" cy="12.6" r="1.3"/>',
+ domains: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.2 2.1 3.3 4.6 3.3 7.5S14.2 17.4 12 19.5c-2.2-2.1-3.3-4.6-3.3-7.5S9.8 6.6 12 4.5z'),
+ exchange: P('M5.5 9h13l-3.2-3.2M18.5 15h-13l3.2 3.2'),
+ bench: P('M4.5 19.5h15M9 19.5v-7.5h6v7.5M4.5 19.5v-4.5H9M15 15h4.5v4.5') + '<circle cx="12" cy="8.2" r=".7"/>',
pics: '<rect x="6.5" y="4.5" width="13" height="11" rx="1.5"/>' + P('M4.5 8v9.5a2 2 0 0 0 2 2H16M8.5 13.5l3-3 2.5 2.5 1.5-1.5 2.5 2.5'),
pics: '<rect x="6.5" y="4.5" width="13" height="11" rx="1.5"/>' + P('M4.5 8v9.5a2 2 0 0 0 2 2H16M8.5 13.5l3-3 2.5 2.5 1.5-1.5 2.5 2.5'),
homes: P('M4.5 11 12 5l7.5 6M6.5 9.5V19h11V9.5M10 19v-5h4v5'),
homes: P('M4.5 11 12 5l7.5 6M6.5 9.5V19h11V9.5M10 19v-5h4v5'),
website: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="6.5" cy="7" r=".7"/><circle cx="8.8" cy="7" r=".7"/>' + P('M3.5 9h17'),
website: '<rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="6.5" cy="7" r=".7"/><circle cx="8.8" cy="7" r=".7"/>' + P('M3.5 9h17'),
@@ -120,7 +139,7 @@
@@ -120,7 +139,7 @@
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
-.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}
+.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;display:none}
.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
@@ -130,12 +149,13 @@
@@ -130,12 +149,13 @@
@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
-.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
+.ov-icon[data-fx=pulse][data-state=busy] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
-.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-icon[data-fx=bounce][data-state=busy] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-comet{opacity:0}
.ov-icon[data-progress] .ovi-comet{opacity:0}
-.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}
+.ov-icon[data-state=busy] .ovi-comet{display:inline;animation:oviSpin 1.1s linear infinite;opacity:1;stroke-dasharray:40 85.66}
+.ov-icon[data-state=busy][data-fx=none] .ovi-comet,.ov-icon[data-state=busy][data-fx=pulse] .ovi-comet{display:none}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
@@ -224,6 +244,20 @@
@@ -224,6 +244,20 @@
link: [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],
link: [640, 'M580 279Q622 234 622 176Q622 119 580 74Q541 37 489 32Q438 28 394 59L392 60Q381 68 379 81Q377 93 385 105Q393 115 405 117Q418 120 429 112L431 111Q456 94 484 96Q513 98 535 119Q558 144 558 176Q558 209 535 233L422 346Q397 369 365 369Q333 369 308 346Q287 324 285 295Q283 267 300 242L301 240Q308 229 306 217Q304 204 293 196Q282 188 270 190Q257 192 249 203L248 205Q217 249 221 301Q225 352 263 391Q308 433 365 433Q423 433 468 391L580 279ZM60 255Q18 300 18 358Q18 415 60 460Q99 497 151 502Q202 506 247 475L248 474Q259 466 261 453Q263 441 256 429Q247 419 235 417Q222 414 211 422L209 423Q184 440 156 438Q127 436 105 415Q82 390 82 358Q82 325 106 301L218 188Q243 165 275 165Q307 165 332 188Q353 210 355 239Q357 267 340 292L339 294Q332 305 334 318Q336 330 347 338Q358 346 370 344Q383 342 391 331L392 329Q423 285 419 233Q415 182 377 143Q332 101 275 101Q217 101 173 143L60 255Z'],
shield: [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],
shield: [512, 'M256 11Q263 11 269 14L458 94Q475 101 485 116Q496 131 496 151Q497 202 479 271Q461 340 414 405Q368 471 282 514Q256 526 230 514Q144 471 98 405Q51 340 33 271Q15 202 16 151Q16 131 27 116Q37 101 54 94L243 14Q249 11 256 11ZM256 78V456V78V456Q324 421 363 367Q401 313 417 256Q432 198 432 152L256 78Z'],
plug: [384, 'M96 11Q82 11 73 20Q64 29 64 43V139H128V43Q128 29 119 20Q110 11 96 11ZM288 11Q274 11 265 20Q256 29 256 43V139H320V43Q320 29 311 20Q302 11 288 11ZM32 171Q18 171 9 180Q0 189 0 203Q0 217 9 226Q18 235 32 235V267Q33 326 69 369Q104 412 160 424V491Q160 505 169 514Q178 523 192 523Q206 523 215 514Q224 505 224 491V424Q280 412 315 369Q351 326 352 267V235Q366 235 375 226Q384 217 384 203Q384 189 375 180Q366 171 352 171H32Z'],
plug: [384, 'M96 11Q82 11 73 20Q64 29 64 43V139H128V43Q128 29 119 20Q110 11 96 11ZM288 11Q274 11 265 20Q256 29 256 43V139H320V43Q320 29 311 20Q302 11 288 11ZM32 171Q18 171 9 180Q0 189 0 203Q0 217 9 226Q18 235 32 235V267Q33 326 69 369Q104 412 160 424V491Q160 505 169 514Q178 523 192 523Q206 523 215 514Q224 505 224 491V424Q280 412 315 369Q351 326 352 267V235Q366 235 375 226Q384 217 384 203Q384 189 375 180Q366 171 352 171H32Z'],
+ cam: [512, 'M149 76 139 107 149 76 139 107H64Q37 108 19 126Q1 144 0 171V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V171Q511 144 493 126Q475 108 448 107H373L363 76Q351 45 317 43H195Q161 45 149 76ZM256 203Q282 203 304 216Q326 229 339 251Q352 274 352 299Q352 324 339 347Q326 369 304 382Q282 395 256 395Q230 395 208 382Q186 369 173 347Q160 324 160 299Q160 274 173 251Q186 229 208 216Q230 203 256 203Z'],
+ casino: [640, 'M275 45Q253 24 224 24Q195 24 173 45L34 184Q13 206 13 235Q13 264 34 286L173 425Q195 446 224 446Q253 446 275 425L414 286Q435 264 435 235Q435 206 414 184L275 45ZM200 235Q202 213 224 211Q246 213 248 235Q246 257 224 259Q202 257 200 235ZM96 211Q118 213 120 235Q118 257 96 259Q74 257 72 235Q74 213 96 211ZM224 387Q202 385 200 363Q202 341 224 339Q246 341 248 363Q246 385 224 387ZM352 211Q374 213 376 235Q374 257 352 259Q330 257 328 235Q330 213 352 211ZM224 131Q202 129 200 107Q202 85 224 83Q246 85 248 107Q246 129 224 131ZM320 459Q321 486 339 504Q357 522 384 523H576Q603 522 621 504Q639 486 640 459V267Q639 240 621 222Q603 204 576 203H462Q470 231 464 259Q458 287 436 309L320 425V459ZM480 339Q502 341 504 363Q502 385 480 387Q458 385 456 363Q458 341 480 339Z'],
+ surf: [576, 'M270 81Q288 69 307 81Q342 106 384 107Q426 105 461 81Q482 68 501 83Q523 101 551 108Q564 111 571 122Q578 133 575 146Q572 159 561 166Q550 173 537 170Q499 160 479 145Q434 170 384 171Q335 168 304 152Q295 148 288 144Q281 148 272 152Q241 168 192 171Q142 170 98 145Q77 160 39 170Q26 173 15 166Q4 159 1 146Q-2 133 5 122Q12 111 25 108Q53 101 75 83Q94 68 115 81Q150 105 192 107Q235 106 270 81ZM307 369Q342 394 384 395Q426 393 461 369Q482 356 501 371Q523 389 551 396Q564 399 571 410Q578 421 575 434Q572 447 561 454Q550 461 537 458Q499 448 479 433Q434 458 384 459Q335 456 304 440Q295 436 288 432Q281 436 272 440Q241 456 192 459Q142 458 98 433Q77 448 39 458Q26 461 15 454Q4 447 1 434Q-2 421 5 410Q12 399 25 396Q53 389 75 371Q94 357 115 369Q150 393 192 395Q235 394 270 369Q288 357 307 369ZM307 225Q342 250 384 251Q426 249 461 225Q482 212 501 227Q523 245 551 252Q564 255 571 266Q578 277 575 290Q572 303 561 310Q550 317 537 314Q499 304 479 289Q434 314 384 315Q335 312 304 296Q295 292 288 288Q281 292 272 296Q241 312 192 315Q142 314 98 289Q77 304 39 314Q26 317 15 310Q4 303 1 290Q-2 277 5 266Q12 255 25 252Q53 245 75 227Q94 212 115 225Q150 249 192 251Q235 250 270 225Q288 213 307 225Z'],
+ fun: [640, 'M75 384Q107 413 152 438Q196 462 241 458Q250 456 259 453Q245 435 235 418Q203 364 209 304Q177 311 154 330Q149 334 144 331Q138 329 139 323Q144 297 162 279Q181 261 207 257Q211 256 215 256L235 124Q236 101 260 79Q273 67 299 57Q324 47 363 43Q363 43 363 43Q362 42 361 41Q347 28 303 17Q259 5 176 17Q93 31 54 54Q15 78 6 95Q-2 107 1 123L25 287Q33 345 75 384ZM162 165Q171 160 174 168Q174 168 174 169Q174 169 174 170Q176 186 167 199Q158 212 141 215Q125 217 112 207Q99 198 97 181Q97 181 97 181Q96 180 96 180Q97 171 107 173Q120 178 135 175Q150 173 162 165ZM262 401Q284 438 320 475Q355 512 399 521Q444 525 488 501Q533 476 565 447Q607 408 615 350L639 185Q642 170 634 158Q624 141 586 117Q547 93 464 79Q381 68 337 79Q293 90 279 104Q268 114 266 129L242 293Q233 351 262 401ZM405 246Q393 238 378 236Q363 233 349 238Q340 240 339 231Q339 231 339 230Q339 230 339 230Q342 213 354 203Q367 194 384 196Q400 199 409 212Q419 225 417 241Q417 242 417 242Q416 243 416 243Q413 251 405 246ZM541 262Q537 270 529 265Q517 257 502 254Q487 252 473 257Q464 259 463 250Q463 250 463 249Q463 249 463 248Q466 232 479 222Q491 213 508 215Q524 218 534 231Q543 243 541 260Q541 261 541 261Q541 261 541 262ZM530 361Q515 395 483 413Q451 431 413 426Q375 420 350 393Q325 367 320 329Q320 323 325 321Q330 319 335 323Q372 355 422 363Q473 370 517 350Q523 348 528 351Q532 355 530 361Z'],
+ meme: [512, 'M427 25Q442 11 462 11Q482 11 497 26Q512 41 512 61Q512 81 498 96Q486 106 460 113Q434 120 414 122Q413 122 412 122Q411 123 410 123Q400 123 400 113Q402 94 409 66Q409 65 409 64Q410 63 410 62Q416 37 427 25ZM382 44Q380 51 378 59Q371 89 368 109Q366 130 380 143Q393 157 414 155Q434 152 464 145Q471 143 479 141Q520 216 510 300Q499 383 437 448Q372 510 289 521Q205 531 130 490Q132 483 134 476Q141 445 143 425Q146 404 132 391Q119 377 98 379Q78 382 47 389Q40 391 33 393Q-8 318 2 234Q13 151 75 86Q140 24 223 13Q307 3 382 44ZM52 421Q82 414 102 411Q112 411 112 421Q109 441 102 472Q95 497 85 509Q70 523 50 523Q30 523 15 508Q0 493 0 473Q0 453 14 438Q26 428 52 421ZM416 213Q412 204 403 205Q395 205 390 214Q360 269 310 320Q259 371 203 400Q194 405 194 414Q194 423 203 427Q248 445 296 436Q343 427 381 391Q416 354 425 307Q434 259 416 213ZM87 296Q90 299 94 300L151 305L156 361Q156 365 159 368Q164 373 170 371Q176 370 179 363L209 266Q211 257 205 251Q199 245 190 247L92 276Q85 279 84 285Q82 291 87 296ZM265 103 236 200 265 103 236 200Q233 210 239 216Q245 222 255 219L352 190Q359 187 360 181Q362 175 357 170Q354 167 350 167L294 161L288 105Q288 101 285 98Q280 93 274 95Q268 96 265 103Z'],
+ tours: [512, 'M256 523Q326 522 384 489Q442 455 478 395Q512 334 512 267Q512 200 478 139Q442 79 384 45Q326 12 256 11Q186 12 128 45Q70 79 34 139Q0 200 0 267Q0 334 34 395Q70 455 128 489Q186 522 256 523ZM307 336 162 392 307 336 162 392Q147 396 137 386Q127 376 131 361L187 216Q192 203 205 198L350 142Q365 138 375 148Q385 158 381 173L325 318Q320 331 307 336ZM288 267Q288 253 279 244Q270 235 256 235Q242 235 233 244Q224 253 224 267Q224 281 233 290Q242 299 256 299Q270 299 279 290Q288 281 288 267Z'],
+ vision: [512, 'M128 43H160H128H160Q174 43 183 52Q192 61 192 75V107H96V75Q96 61 105 52Q114 43 128 43ZM192 139V459V139V459Q192 473 183 482Q174 491 160 491H32Q18 491 9 482Q0 473 0 459V400Q0 347 27 302Q48 267 53 227L61 167Q66 141 92 139H192ZM420 139Q446 141 452 167L459 227Q464 267 485 302Q512 347 512 400V459Q512 473 503 482Q494 491 480 491H352Q338 491 329 482Q320 473 320 459V139H420ZM320 75Q320 61 329 52Q338 43 352 43H384Q398 43 407 52Q416 61 416 75V107H320V75ZM288 139V299V139V299H224V139H288Z'],
+ shoes: [640, 'M416 11Q383 12 346 19Q309 27 283 35Q257 42 256 43V171Q291 172 316 181Q340 191 360 203Q380 216 405 225Q429 235 464 235Q494 235 535 228Q576 220 607 199Q638 179 640 139Q639 99 603 70Q568 42 517 26Q465 11 416 11ZM128 107Q129 134 147 152Q165 170 192 171H224V43H192Q165 44 147 62Q129 80 128 107ZM288 523Q337 523 389 508Q440 492 475 464Q511 435 512 395Q510 355 479 335Q448 314 407 306Q366 299 336 299Q301 300 277 309Q252 319 232 331Q212 344 188 353Q163 363 128 363V491Q129 492 155 499Q181 507 218 515Q255 522 288 523ZM0 427Q1 454 19 472Q37 490 64 491H96V363H64Q37 364 19 382Q1 400 0 427Z'],
+ today: [448, 'M128 11Q142 11 151 20Q160 29 160 43V75H288V43Q288 29 297 20Q306 11 320 11Q334 11 343 20Q352 29 352 43V75H400Q420 76 434 89Q447 103 448 123V171H0V123Q1 103 14 89Q28 76 48 75H96V43Q96 29 105 20Q114 11 128 11ZM0 203H448H0H448V475Q447 495 434 509Q420 522 400 523H48Q28 522 14 509Q1 495 0 475V203ZM80 267Q65 268 64 283V379Q65 394 80 395H176Q191 394 192 379V283Q191 268 176 267H80Z'],
+ report: [448, 'M160 91Q161 71 174 57Q188 44 208 43H240Q260 44 274 57Q287 71 288 91V443Q287 463 274 477Q260 490 240 491H208Q188 490 174 477Q161 463 160 443V91ZM0 283Q1 263 14 249Q28 236 48 235H80Q100 236 114 249Q127 263 128 283V443Q127 463 114 477Q100 490 80 491H48Q28 490 14 477Q1 463 0 443V283ZM368 107H400H368H400Q420 108 434 121Q447 135 448 155V443Q447 463 434 477Q420 490 400 491H368Q348 490 334 477Q321 463 320 443V155Q321 135 334 121Q348 108 368 107Z'],
+ design: [512, 'M469 30 493 54 469 30 493 54Q511 73 511 99Q511 124 493 144L436 201L322 87L379 30Q398 12 424 12Q449 12 469 30ZM45 364 300 109 45 364 300 109 414 223 159 478Q148 488 135 492L31 522Q17 525 7 516Q-2 506 1 492L31 388Q35 374 45 364ZM249 114 103 260 249 114 103 260 16 173Q2 158 2 139Q2 120 16 105L94 27Q109 13 128 13Q147 13 162 27L182 47Q182 47 181 47Q181 47 181 48L117 112Q107 123 117 134Q128 144 139 134L203 70Q204 70 204 70Q204 70 204 69L249 114ZM409 274 454 319 409 274 454 319Q453 319 453 319Q453 319 453 320L389 384Q379 395 389 406Q400 415 411 406L475 342Q476 342 476 342Q476 341 476 341L496 361Q510 376 510 395Q510 414 496 429L418 507Q403 521 384 521Q365 521 350 507L263 420L409 274Z'],
+ domains: [512, 'M58 204 67 220 58 204 67 220Q80 243 105 250L163 267Q190 276 192 305V345Q193 363 208 371Q223 379 224 397V436Q224 448 233 455Q242 461 254 459Q279 450 287 425L289 414Q296 387 320 374L328 369Q351 355 352 327V319Q352 299 338 285L334 281Q320 267 300 267H257Q240 267 225 259L191 239Q184 235 182 228Q177 212 192 203L198 200Q208 195 219 199L242 206Q256 210 264 199Q271 187 263 176L249 160Q234 139 249 119L265 100Q279 83 269 63L266 59Q261 59 256 59Q185 60 132 100Q79 140 58 204ZM464 267Q463 211 438 166L412 176Q400 181 395 192Q390 203 394 216L410 266Q416 283 433 287L462 295Q464 281 464 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],
+ exchange: [512, 'M32 107H352H32H352V43Q353 22 372 13Q391 6 407 20L503 116Q512 126 512 139Q512 152 503 162L407 258Q391 272 372 264Q353 256 352 235V171H32Q18 171 9 162Q0 153 0 139Q0 125 9 116Q18 107 32 107ZM480 363Q494 363 503 372Q512 381 512 395Q512 409 503 418Q494 427 480 427H160V491Q159 512 140 521Q121 528 105 514L9 418Q0 408 0 395Q0 382 9 373L105 277Q121 262 140 270Q159 278 160 299V363H480Z'],
+ bench: [448, 'M32 11Q46 11 55 20Q64 29 64 43V59L133 42Q192 28 247 54Q282 72 320 72Q358 72 394 54L403 50Q420 42 433 51Q447 59 448 77V357Q447 378 427 387L393 400Q320 426 251 392Q192 364 128 379L64 395V491Q64 505 55 514Q46 523 32 523Q18 523 9 514Q0 505 0 491V411V345V75V43Q0 29 9 20Q18 11 32 11ZM64 198 128 184 64 198 128 184V250L64 264V329L113 317Q120 315 128 314V250L167 241Q179 239 192 239V175Q213 176 232 182L256 188V255L214 243Q203 240 192 239V310Q225 313 256 325V255L279 262Q299 268 320 269V205Q308 204 297 201L256 189V127Q236 121 218 112Q205 105 192 103V175Q172 175 153 179L128 184V109L64 125V198ZM320 347Q346 349 370 340L384 335V263L376 265Q348 271 320 269V347ZM384 197V126V197V126Q353 136 320 136V205Q341 207 362 202L384 197Z'],
};
};
// END generated solid glyphs
// END generated solid glyphs
// A glyph drawn as a solid Font Awesome path, fitted to the 24-box the stroke glyphs use.
// A glyph drawn as a solid Font Awesome path, fitted to the 24-box the stroke glyphs use.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.127.0",
+ "version": "4.128.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -52,6 +52,12 @@ OV_SOLID = {
@@ -52,6 +52,12 @@ OV_SOLID = {
'fund': 'fa-hand-holding-dollar', 'play': 'fa-ranking-star',
'fund': 'fa-hand-holding-dollar', 'play': 'fa-ranking-star',
# Page furniture the product homes name (2.21.1).
# Page furniture the product homes name (2.21.1).
'gauge': 'fa-gauge-high', 'link': 'fa-link', 'shield': 'fa-shield-halved', 'plug': 'fa-plug',
'gauge': 'fa-gauge-high', 'link': 'fa-link', 'shield': 'fa-shield-halved', 'plug': 'fa-plug',
+ # Sites that borrowed a neighbour's glyph until 2026-11-07 (Cam showed Video's, Casino and Surf the gamepad, Fun
+ # Quest's trophy): each its own now, so the menu reads at a glance.
+ 'cam': 'fa-camera', 'casino': 'fa-dice', 'surf': 'fa-water', 'fun': 'fa-masks-theater', 'meme': 'fa-face-grin-squint-tears',
+ 'tours': 'fa-compass', 'vision': 'fa-binoculars', 'shoes': 'fa-shoe-prints', 'today': 'fa-calendar-day',
+ 'report': 'fa-chart-simple', 'design': 'fa-pen-ruler', 'domains': 'fa-earth-americas', 'exchange': 'fa-right-left',
+ 'bench': 'fa-flag-checkered',
}
}
OVICONS = HERE.parent / 'ov-icons.js'
OVICONS = HERE.parent / 'ov-icons.js'
OV_START, OV_END = ' // BEGIN generated solid glyphs (scripts/build-nav-icons.py)', ' // END generated solid glyphs'
OV_START, OV_END = ' // BEGIN generated solid glyphs (scripts/build-nav-icons.py)', ' // END generated solid glyphs'
@@ -214,6 +214,36 @@
@@ -214,6 +214,36 @@
.ovs-tile small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11.5px; line-height: 1.3; color: var(--text-muted, #93a0b4); }
.ovs-tile small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11.5px; line-height: 1.3; color: var(--text-muted, #93a0b4); }
.ovs-tile.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
.ovs-tile.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
.ovs-note { padding: 14px 12px; font-size: 13px; color: var(--text-muted, #93a0b4); }
.ovs-note { padding: 14px 12px; font-size: 13px; color: var(--text-muted, #93a0b4); }
+/* The menu before typing (4.128.0): the places dock, this site's pages, the live bar, counted folds. */
+.ovs-apps { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; padding: 2px 2px 6px; }
+.ovs-app { display: flex; flex-direction: column; align-items: center; gap: 7px; min-width: 0; padding: 10px 2px 8px; border-radius: 16px; color: inherit; text-decoration: none; }
+.ovs .ovs-appic { width: 54px; height: 54px; border-radius: 17px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.1)); transition: transform .18s cubic-bezier(.2,1.4,.4,1), box-shadow .18s; }
+.ovs-appname { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+.ovs-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 6px; padding: 0 2px 2px; }
+.ovs-link { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 44px; padding: 0 10px; border-radius: 12px; color: inherit; text-decoration: none; font-size: 14px; font-weight: 600; }
+.ovs-link svg { width: 16px; height: 16px; flex: none; color: var(--text-muted, #93a0b4); }
+.ovs-link span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovs-livebar { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px 6px 2px; }
+.ovs-livebar::-webkit-scrollbar { display: none; }
+.ovs-live { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 650; }
+.ovs-live.is-count { border-color: color-mix(in srgb, var(--danger, #ef4444) 45%, transparent); background: color-mix(in srgb, var(--danger, #ef4444) 10%, transparent); }
+.ovs-live small { color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 600; }
+.ovs-livedot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--danger, #ef4444); animation: ovs-livedot 1.6s ease-in-out infinite; }
+@keyframes ovs-livedot { 50% { opacity: .4; transform: scale(.75); } }
+.ovs-foldrow { padding: 6px 4px 2px; }
+.ovs-fold { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 0 12px; border-radius: 12px; border: 1px dashed var(--border, rgba(255,255,255,.16)); background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; text-align: left; cursor: pointer; }
+.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
+.ovs-fold[aria-expanded="true"] svg { transform: rotate(90deg); }
+.ovs-fold:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.ovs-tag { margin-left: 7px; padding: 1px 7px; border-radius: 999px; font-style: normal; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; vertical-align: 1px; color: color-mix(in srgb, var(--accent, #3b82f6) 72%, var(--text-primary, #e8ecf3)); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); }
+.ovs-card { padding: 2px 6px 6px; }
+.ovs-cardbody { border-radius: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); padding: 12px 14px; }
+.ovs-host { display: block; margin-top: 2px; font-size: 11px; font-weight: 650; letter-spacing: .01em; color: color-mix(in srgb, var(--accent, #3b82f6) 60%, var(--text-muted, #93a0b4)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovs-group .ovs-more { margin-left: auto; color: var(--accent-light, #8ab4ff); font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; text-decoration: none; }
+@media (hover: hover) { .ovs-app:hover, .ovs-link:hover, .ovs-live:hover, .ovs-fold:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } .ovs-app:hover .ovs-appic { transform: translateY(-2px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); } }
+@media (max-width: 640px) { .ovs-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+@media (pointer: coarse) { .ovs-chip { height: 44px; } .ovs-group button, .ovs-group .ovs-more { min-height: 44px; display: inline-flex; align-items: center; } }
+@media (prefers-reduced-motion: reduce) { .ovs-livedot { animation: none; } .ovs .ovs-appic { transition: none; } }
.ovs-foot { display: flex; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: 11.5px; color: var(--text-muted, #93a0b4); }
.ovs-foot { display: flex; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: 11.5px; color: var(--text-muted, #93a0b4); }
.ovs-foot kbd { font: inherit; font-weight: 700; padding: 1px 5px; border-radius: 5px; border: 1px solid var(--border, rgba(255,255,255,.14)); }
.ovs-foot kbd { font: inherit; font-weight: 700; padding: 1px 5px; border-radius: 5px; border: 1px solid var(--border, rgba(255,255,255,.14)); }
.ovs-busy .ovs-bar { background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent), transparent); background-size: 40% 2px; background-repeat: no-repeat; background-position: -40% 100%; animation: ovs-scan 1s linear infinite; }
.ovs-busy .ovs-bar { background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent, #3b82f6) 40%, transparent), transparent); background-size: 40% 2px; background-repeat: no-repeat; background-position: -40% 100%; animation: ovs-scan 1s linear infinite; }
@@ -234,13 +264,19 @@
@@ -234,13 +264,19 @@
.ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; }
.ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; }
.ovs-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ovs-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ovs-grid.is-sites { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .ovs-tile small { -webkit-line-clamp: 1; }
+ .ovs-grid:not(.is-sites) .ovs-tile small { -webkit-line-clamp: 1; }
}
}
/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */
/* Dropped from the navbar's orb (4.25.0): under the bar, from the orb's side, the bar still showing. */
.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }
.ovs.ovs-anchored { top: var(--ovs-top, 0px); background: rgba(3, 6, 12, .45); }
.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }
.ovs.ovs-anchored .ovs-panel { top: 8px; left: clamp(8px, var(--ovs-left, 12px), calc(100vw - min(720px, calc(100vw - 16px)) - 8px)); transform: none; transform-origin: 24px 0; max-height: calc(100dvh - var(--ovs-top, 0px) - 24px); animation: ovs-drop .22s cubic-bezier(.2,1.15,.3,1); }
@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
@keyframes ovs-drop { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
-@media (max-width: 640px) { .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: auto; max-height: calc(100dvh - var(--ovs-top, 0px) - 12px); border-radius: 0 0 22px 22px; border-top: 0; } }
+@media (max-width: 640px) {
+ /* The whole screen on a phone (the owner, 11-07: "make full use of the height, not compensate for the navbar"),
+ opening as a circle that grows from the button that opened it. */
+ .ovs.ovs-anchored { top: 0; }
+ .ovs.ovs-anchored .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; border-radius: 0; border: 0; padding-top: env(safe-area-inset-top, 0px); animation: ovs-reveal .38s cubic-bezier(.2,.9,.25,1) both; }
+}
+@keyframes ovs-reveal { from { clip-path: circle(28px at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } to { clip-path: circle(150% at var(--ovs-cx, 40px) var(--ovs-cy, 28px)); } }
@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;
@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar, .ovs.ovs-anchored .ovs-panel, .ovs-skel, .ovs-list > li { animation: none; } }`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -262,7 +298,7 @@
@@ -262,7 +298,7 @@
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-label', 'Search and every OpenVibe site');
el.setAttribute('aria-label', 'Search and every OpenVibe site');
el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x">Close</button></div>`
el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<div class="ovs-field"><span class="ovs-ghost" aria-hidden="true"></span><input class="ovs-input" type="search" role="combobox" aria-autocomplete="both" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"></div><button type="button" class="ovs-x">Close</button></div>`
- + `<div class="ovs-tabs" role="tablist" aria-label="Show">${TABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-tab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ + '<div class="ovs-tabs ovs-starttabs" hidden></div>'
+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ `<div class="ovs-tabs ovs-rtabs" role="tablist" aria-label="Results from" hidden>${RTABS.map(([id, label]) => `<button type="button" class="ovs-tab" role="tab" data-rtab="${id}" aria-selected="false">${label}</button>`).join('')}</div>`
+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
+ '<ul class="ovs-list" id="ovs-list" role="listbox"></ul><div class="ovs-foot" aria-hidden="true"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Tab</kbd> complete</span><span><kbd>Esc</kbd> close</span></div></div>';
input = el.querySelector('.ovs-input');
input = el.querySelector('.ovs-input');
@@ -279,10 +315,19 @@
@@ -279,10 +315,19 @@
listEl.addEventListener('click', (e) => {
listEl.addEventListener('click', (e) => {
const clear = e.target.closest && e.target.closest('[data-ovs-clear]');
const clear = e.target.closest && e.target.closest('[data-ovs-clear]');
if (clear) { e.preventDefault(); recent.clear(); render(input.value); input.focus(); return; }
if (clear) { e.preventDefault(); recent.clear(); render(input.value); input.focus(); return; }
- const to = e.target.closest && e.target.closest('[data-ovs-tab]');
- if (to) { e.preventDefault(); tab = to.getAttribute('data-ovs-tab'); render(''); input.focus(); return; }
+ const fold = e.target.closest && e.target.closest('[data-ovs-fold]');
+ if (fold) {
+ e.preventDefault();
+ const key = fold.getAttribute('data-ovs-fold');
+ if (key === 'all') allOpen = !allOpen; else if (key === 'here') hereOpen = !hereOpen;
+ render(''); const back = listEl.querySelector(`[data-ovs-fold="${key}"]`); if (back) back.focus(); return;
+ }
const a = e.target.closest && e.target.closest('a[data-i]');
const a = e.target.closest && e.target.closest('a[data-i]');
const it = a && items[Number(a.getAttribute('data-i'))];
const it = a && items[Number(a.getAttribute('data-i'))];
+ if (it && !input.value.trim()) beacon(it, a);
+ if (it && it.kind === 'dock' && root.OpenVibeNavbar && typeof root.OpenVibeNavbar.openDock === 'function' && ctx.token) {
+ e.preventDefault(); close(); try { root.OpenVibeNavbar.openDock(it.dock); } catch { location.href = it.url; } return;
+ }
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
if (it && it.kind === 'results') { e.preventDefault(); showResults(input.value.trim()); input.focus(); return; }
if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
if (it && it.kind === 'recent') { e.preventDefault(); input.value = it.title; paintGhost(); showResults(it.title); input.focus(); return; }
if (it) remember(it);
if (it) remember(it);
@@ -316,6 +361,7 @@
@@ -316,6 +361,7 @@
}
}
const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
const exact = (it, q) => !!it && ['site', 'family', 'tool', 'page'].includes(it.kind) && String(it.title).toLowerCase() === q.toLowerCase();
function remember(it) {
function remember(it) {
+ if (pushed) { pushed = false; try { history.replaceState(Object.assign({}, history.state || {}, { ovsMenu: false }), ''); } catch { /* */ } }
const q = input && input.value.trim();
const q = input && input.value.trim();
if (q && it && it.kind !== 'recent') recent.add(q);
if (q && it && it.kind !== 'recent') recent.add(q);
}
}
@@ -365,21 +411,44 @@
@@ -365,21 +411,44 @@
if (it.here) { a.classList.add('is-here'); a.setAttribute('aria-current', 'page'); }
if (it.here) { a.classList.add('is-here'); a.setAttribute('aria-current', 'page'); }
return a;
return a;
}
}
- /** groups: [{ title, items, as: 'rows' | 'chips' | 'tiles' | 'sites', clear, more: { label, tab } }]; text only through textContent. */
+ /** groups: [{ title, items, as: 'rows' | 'chips' | 'tiles' | 'sites' | 'apps' | 'links' | 'livebar' | 'fold', section,
+ * clear, more: { label, tab | href }, fold: { open, label, key } }]; text only through textContent. */
+ function foldButton(f, sec) {
+ const li = document.createElement('li'); li.setAttribute('role', 'presentation'); li.className = 'ovs-foldrow';
+ if (sec) li.setAttribute('data-sec', sec);
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'ovs-fold';
+ b.setAttribute('data-ovs-fold', f.key); b.setAttribute('aria-expanded', f.open ? 'true' : 'false');
+ b.insertAdjacentHTML('beforeend', ICON.go); const t = document.createElement('span'); t.textContent = f.label; b.appendChild(t);
+ li.appendChild(b); return li;
+ }
function paint(groups, note) {
function paint(groups, note) {
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
- groups.filter((g) => (g.items && g.items.length) || g.as === 'note' || g.as === 'loading' || g.as === 'route').forEach((g) => {
+ groups.filter((g) => (g.items && g.items.length) || ['note', 'loading', 'route', 'fold', 'card'].includes(g.as)).forEach((g) => {
if (g.as === 'route') {
if (g.as === 'route') {
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
const r = document.createElement('li'); r.className = 'ovs-route'; r.setAttribute('role', 'presentation'); r.textContent = g.text; listEl.appendChild(r);
return;
return;
}
}
+ if (g.as === 'fold') { listEl.appendChild(foldButton(g.fold, g.section)); return; }
+ if (g.as === 'card') {
+ const body = document.createElement('div'); body.className = 'ovs-cardbody';
+ try {
+ g.card.render(body, { close, here: here(), user: ctx.user || null, signedIn: !!(ctx.token && ctx.user && !ctx.user.is_anon) });
+ } catch { return; }
+ if (!body.childNodes.length) return;
+ if (g.title) { const h = document.createElement('li'); h.className = 'ovs-group'; h.setAttribute('role', 'presentation'); h.setAttribute('data-sec', g.section); h.textContent = g.title; listEl.appendChild(h); }
+ const li = document.createElement('li'); li.className = 'ovs-card'; li.setAttribute('role', 'presentation'); li.setAttribute('data-sec', g.section);
+ li.appendChild(body); listEl.appendChild(li);
+ return;
+ }
const h = document.createElement('li');
const h = document.createElement('li');
h.className = 'ovs-group';
h.className = 'ovs-group';
h.setAttribute('role', 'presentation');
h.setAttribute('role', 'presentation');
+ if (g.section) h.setAttribute('data-sec', g.section);
h.appendChild(document.createTextNode(g.title));
h.appendChild(document.createTextNode(g.title));
if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
if (g.clear) { const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Clear'; b.setAttribute('data-ovs-clear', ''); h.appendChild(b); }
- if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }
+ if (g.more && g.more.href) { const m = document.createElement('a'); m.className = 'ovs-more'; m.href = safeUrl(g.more.href) || '#'; m.textContent = g.more.label; h.appendChild(m); }
+ else if (g.more) { const b = document.createElement('button'); b.type = 'button'; b.textContent = g.more.label; b.setAttribute('data-ovs-tab', g.more.tab); h.appendChild(b); }
if (g.title) listEl.appendChild(h);
if (g.title) listEl.appendChild(h);
const as = g.as || 'rows';
const as = g.as || 'rows';
if (as === 'note' || as === 'loading') {
if (as === 'note' || as === 'loading') {
@@ -392,9 +461,11 @@
@@ -392,9 +461,11 @@
if (as === 'rows') {
if (as === 'rows') {
g.items.forEach((it) => {
g.items.forEach((it) => {
const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');
const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');
+ if (g.section) li.setAttribute('data-sec', g.section);
const a = option('a', '', it);
const a = option('a', '', it);
const main = document.createElement('span'); main.className = 'ovs-main';
const main = document.createElement('span'); main.className = 'ovs-main';
const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);
const t = document.createElement('span'); t.className = 'ovs-title'; t.textContent = it.title; main.appendChild(t);
+ if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; t.appendChild(tg); }
if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
if (it.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
if (it.snippet) { const sn = document.createElement('span'); sn.className = 'ovs-snip'; sn.textContent = it.snippet; main.appendChild(sn); }
a.append(icon(it), main);
a.append(icon(it), main);
@@ -406,8 +477,9 @@
@@ -406,8 +477,9 @@
}
}
const box = document.createElement('li');
const box = document.createElement('li');
box.setAttribute('role', 'presentation');
box.setAttribute('role', 'presentation');
- box.className = as === 'chips' ? 'ovs-chips' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;
- g.items.forEach((it) => {
+ if (g.section) box.setAttribute('data-sec', g.section);
+ box.className = as === 'chips' ? 'ovs-chips' : as === 'apps' ? 'ovs-apps' : as === 'links' ? 'ovs-links' : as === 'livebar' ? 'ovs-livebar' : `ovs-grid${as === 'sites' ? ' is-sites' : ''}`;
+ g.items.forEach((it, k) => {
if (as === 'chips') {
if (as === 'chips') {
const a = option('a', 'ovs-chip', it);
const a = option('a', 'ovs-chip', it);
// The place's own icon (the navbar's drawn set, never page text), else the menu's.
// The place's own icon (the navbar's drawn set, never page text), else the menu's.
@@ -416,14 +488,39 @@
@@ -416,14 +488,39 @@
box.appendChild(a);
box.appendChild(a);
return;
return;
}
}
+ if (as === 'apps') {
+ // A dock of big icons: the name under each, one tap to the place.
+ const a = option('a', 'ovs-app', it);
+ const ic = icon(it); ic.classList.add('ovs-appic');
+ const span = ic.querySelector('.ov-icon'); if (span) span.setAttribute('data-size', '40');
+ const t = document.createElement('span'); t.className = 'ovs-appname'; t.textContent = it.title;
+ a.append(ic, t); box.appendChild(a); return;
+ }
+ if (as === 'links') {
+ const a = option('a', 'ovs-link', it);
+ if (it.drawn && /^<svg[\s>]/.test(it.drawn)) a.insertAdjacentHTML('afterbegin', it.drawn); else a.insertAdjacentHTML('afterbegin', ICON.go);
+ const t = document.createElement('span'); t.textContent = it.title; a.appendChild(t);
+ box.appendChild(a); return;
+ }
+ if (as === 'livebar') {
+ // The first is the count ("3 streams live now"), then the live channels when Network names them.
+ const a = option('a', k === 0 ? 'ovs-live is-count' : 'ovs-live', it);
+ if (k === 0) { const dot = document.createElement('span'); dot.className = 'ovs-livedot'; a.appendChild(dot); }
+ const t = document.createElement('span'); t.className = 'ovs-livename'; t.textContent = it.title; a.appendChild(t);
+ if (it.meta) { const m = document.createElement('small'); m.textContent = it.meta; a.appendChild(m); }
+ box.appendChild(a); return;
+ }
const a = option('a', 'ovs-tile', it);
const a = option('a', 'ovs-tile', it);
const txt = document.createElement('span');
const txt = document.createElement('span');
const b = document.createElement('b'); b.textContent = it.title; txt.appendChild(b);
const b = document.createElement('b'); b.textContent = it.title; txt.appendChild(b);
+ if (it.tag) { const tg = document.createElement('i'); tg.className = 'ovs-tag'; tg.textContent = it.tag; b.appendChild(tg); }
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
if (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
+ if (it.host) { const hs = document.createElement('span'); hs.className = 'ovs-host'; hs.textContent = it.host; txt.appendChild(hs); }
a.append(icon(it), txt);
a.append(icon(it), txt);
box.appendChild(a);
box.appendChild(a);
});
});
listEl.appendChild(box);
listEl.appendChild(box);
+ if (g.fold) listEl.appendChild(foldButton(g.fold, g.section));
});
});
if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }
if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }
active = items.length && input.value.trim() && view !== 'results' ? 0 : -1;
active = items.length && input.value.trim() && view !== 'results' ? 0 : -1;
@@ -506,48 +603,221 @@
@@ -506,48 +603,221 @@
return best ? best.icon || best.id : null;
return best ? best.icon || best.id : null;
}
}
+ // ── The menu before typing (the owner, 2026-11-07: "rethink the whole idea of this whole menu … not spreading
+ // traffic good enough … not smartly done based on what's popular"). One calm scroll, no tabs, in the order people
+ // need it: who is live, the places they use (one tap to Live from anywhere), this site's own pages, where people
+ // really go next from here (Network's cross-site moves, 11-07), two places new to them (fair rotation through the
+ // long tail), their own corner (agents, Vibe, account), tools, every site behind a counted fold, and the pages they
+ // were on last. Network's Frame may send `next`, `discover`, `you` and `live`; until it does the menu works them out.
+ const NEXT_OF = {
+ live: ['media-hub', 'community', 'chat', 'tools', 'bot', 'vip'],
+ 'media-hub': ['live', 'pics', 'community', 'download'],
+ community: ['live', 'chat', 'tools', 'space'],
+ chat: ['live', 'community', 'media-hub', 'games'],
+ tools: ['live', 'community', 'download', 'chat'],
+ network: ['live', 'media-hub', 'community', 'design'],
+ games: ['live', 'casino', 'quest', 'chat'],
+ bot: ['live', 'actor', 'codes', 'run'],
+ pics: ['media-hub', 'download', 'live', 'design'],
+ download: ['pics', 'media-hub', 'tools', 'zone'],
+ design: ['space', 'website', 'pics', 'network'],
+ space: ['website', 'design', 'community', 'domains'],
+ };
+ const NEXT_WHY = {
+ live: 'Streams and calls', 'media-hub': 'Recordings and clips', community: 'Forum and pastes', chat: 'Rooms and messages',
+ tools: 'Free tools', bot: 'Robots and machines', vip: 'Back a streamer', pics: 'Pictures', download: 'Files and drive',
+ };
+ let allOpen = false, hereOpen = false, discoverPick = null, pulseData = null;
+ /** Network's live line (GET /api/frame/pulse, kept 30 s): how many are live now, and who when the Frame sends it. */
+ function loadPulse() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_pulse') || 'null'); if (c && Date.now() - c.at < 30000) { pulseData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
+ return fetch('https://openvibe.network/api/frame/pulse', { credentials: 'omit' }).then((r) => (r.ok ? r.json() : null)).then((d) => {
+ if (d) { pulseData = d; try { sessionStorage.setItem('ov_pulse', JSON.stringify({ at: Date.now(), data: d })); } catch { /* */ } }
+ return d;
+ }).catch(() => null);
+ }
+ /** What shipped lately, every site (Network's GET /api/v1/changelog, the feed OpenVibe.Blog keeps; kept 5 minutes):
+ * the newest real change per site, library bumps left out (the same rule as shipped.js). */
+ let changesData = null;
+ const HOUSEKEEPING = /^(?:openvibe-(?:shared|sdk|contracts|publishing)|openvibe-codes-cli)\s+v?\d|^(?:bump|pin)\b|\(was v\d/i;
+ function loadChanges() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_changes') || 'null'); if (c && Date.now() - c.at < 300000) { changesData = c.data; return Promise.resolve(c.data); } } catch { /* */ }
+ return fetch('https://openvibe.network/api/v1/changelog?limit=100', { credentials: 'omit', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : null)).then((d) => {
+ if (d && Array.isArray(d.entries)) { changesData = d.entries; try { sessionStorage.setItem('ov_changes', JSON.stringify({ at: Date.now(), data: d.entries })); } catch { /* */ } }
+ return changesData;
+ }).catch(() => null);
+ }
+ /** A commit subject in plain words: no PR number, no stage tag ("A6: "), no trailing period. */
+ const plainChange = (t) => String(t || '').replace(/\s*\(#\d+\)\s*$/, '').replace(/^[A-Z]{1,3}\d{0,3}(?:-\d+)?:\s+/, '').replace(/\.$/, '').slice(0, 110);
+ /** When this person was last on a site (their history), for "new since your last visit". */
+ function lastVisit(id) {
+ if (!mine) return 0;
+ let t = 0;
+ for (const h of mine.items) if (h.service === id || (id === 'media-hub' && h.service === 'media')) { const v = Date.parse(h.at || ''); if (v > t) t = v; }
+ return t;
+ }
+
+ /** Two places this person has never used, picked fairly from the network's ranked list: better-used places come up
+ * more often, but every place gets its turn (the pick changes each time the menu opens). */
+ function pickDiscover(pool) {
+ // The same two for the whole opening (history arriving later must not reshuffle them); one that has since moved
+ // up into the person's places is replaced, so there are always two.
+ const kept = discoverPick ? discoverPick.map((id) => pool.find((s) => s.id === id)).filter(Boolean) : [];
+ if (discoverPick && kept.length >= 2) return kept;
+ const fresh = pool.filter((s) => !useOf(s.id) && !kept.includes(s));
+ const picked = kept.slice();
+ const bag = fresh.map((s, i) => [s, 1 / Math.sqrt(1 + i)]);
+ while (picked.length < 2 && bag.length) {
+ const total = bag.reduce((n, [, w]) => n + w, 0);
+ let r = Math.random() * total, k = 0;
+ for (; k < bag.length - 1 && r > bag[k][1]; k++) r -= bag[k][1];
+ picked.push(bag[k][0]); bag.splice(k, 1);
+ }
+ discoverPick = picked.map((s) => s.id);
+ return picked;
+ }
+ /** The person's workspace (signed in): their agents and Vibe (the Dock opens in place), Codes, their files, their
+ * machines, wallet, account and themes, one tap from every site (the owner, 11-07: "access codes through this menu
+ * easily, and talk to your agent … less having to hop between sites"). */
+ function youItems() {
+ const f = ctx.frame;
+ if (f && Array.isArray(f.you) && f.you.length) return f.you.map((y) => ({ title: String(y.name || ''), url: safeUrl(y.url), site: y.icon || 'ov', kind: y.dock ? 'dock' : 'you', dock: y.dock || null })).filter((x) => x.url && x.title);
+ return [
+ { title: 'Agents', url: 'https://openvibe.actor/', site: 'actor', kind: 'dock', dock: 'agent' },
+ { title: 'Vibe', url: 'https://openvibe.chat/vibe', site: 'ov', kind: 'dock', dock: 'vibe' },
+ { title: 'Codes', url: 'https://openvibe.codes/', site: 'codes', kind: 'you' },
+ { title: 'Files', url: 'https://openvibe.download/files', site: 'drive', kind: 'you' },
+ { title: 'Machines', url: 'https://node.openvibe.network', site: 'plug', kind: 'you' },
+ { title: 'Wallet', url: 'https://openvibe.network/wallet', site: 'billing', kind: 'you' },
+ { title: 'Account', url: 'https://openvibe.network/my', site: 'account', kind: 'you' },
+ { title: 'Themes', url: 'https://openvibe.network/themes', site: 'theme', kind: 'you' },
+ ];
+ }
+ // ── Site cards: a site's own live piece of the menu (the owner, 11-07: "content aware … on Download, a little
+ // download thing … or create custom elements"). A page pushes { id, title, render(el, api) } onto
+ // window.OpenVibeMenuCards (a queue, so it works before search.js loads) or calls OpenVibeSearch.registerCard().
+ // render() gets an empty element and { close, here, user, signedIn } and draws with DOM nodes; it runs on the
+ // site's own page, so it is the site's code and its own responsibility. A card that throws is left out.
+ const cards = [];
+ function registerCard(c) {
+ if (!c || typeof c.render !== 'function' || !c.id || cards.some((x) => x.id === c.id)) return () => {};
+ cards.push(c);
+ return () => { const i = cards.indexOf(c); if (i >= 0) cards.splice(i, 1); };
+ }
+ function drainCards() {
+ const q = root.OpenVibeMenuCards;
+ if (Array.isArray(q)) { while (q.length) registerCard(q.shift()); }
+ // Later pushes go straight in.
+ root.OpenVibeMenuCards = { push: (c) => { registerCard(c); return cards.length; } };
+ }
+
+ // The network's flagships, first in the full list (the owner, 11-07: "show our most coolest products front facing").
+ const FLAGSHIPS = ['live', 'media-hub', 'codes', 'design', 'space', 'games', 'chat', 'tools'];
+
function renderTab() {
function renderTab() {
- const all = sites(), mineSite = hereSite(all);
- tabsEl.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.getAttribute('data-tab') === tab)));
- if (tab === 'sites') {
- const groups = ctx.frame && ctx.frame.groups && ctx.frame.groups.length ? ctx.frame.groups : [{ id: '', name: 'OpenVibe' }];
- const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some((x) => x.id === s.group));
- const soon = ctx.frame && Array.isArray(ctx.frame.soon) ? ctx.frame.soon : [];
- paint(groups.map((g, i) => ({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g, i)).map((s) => siteItem(s, all)) }))
- .concat(soon.length ? [{ title: 'Opening soon', as: 'chips', items: soon.map((x) => ({ title: x.name, url: safeUrl(x.url), kind: 'site' })).filter((x) => x.url) }] : []));
- return;
+ // This site: by its address, else by the service the bar names (www., a preview host, a stand-in page).
+ const all = sites(), mineSite = hereSite(all) || all.find((x) => x.id === ctx.service) || null;
+ const byId = new Map(all.map((s) => [s.id, s]));
+ const shown = new Set(mineSite ? [mineSite.id] : []);
+ const take = (list, n) => { const out = []; for (const s of list) { if (out.length >= n) break; if (s && !shown.has(s.id)) { out.push(s); shown.add(s.id); } } return out; };
+ const groups = [];
+ const f0 = ctx.frame;
+ // 1. Who is live (Network's pulse): the network's heart, one tap from every site.
+ const p = pulseData;
+ const liveNow = p && Number(p.live_now) > 0 ? Number(p.live_now) : 0;
+ if (liveNow) {
+ const chans = Array.isArray(p.live) ? p.live.slice(0, 8).map((c) => ({ title: String(c.name || ''), meta: c.viewers ? `${c.viewers} watching` : 'Live', url: safeUrl(c.url), site: 'live', kind: 'live' })).filter((x) => x.url && x.title) : [];
+ groups.push({ title: '', as: 'livebar', items: [{ title: liveNow === 1 ? '1 stream live now' : `${liveNow} streams live now`, meta: p.watching ? `${p.watching} watching` : 'Watch on Live', url: 'https://openvibe.live/', site: 'live', kind: 'live' }, ...chans] });
}
}
- if (tab === 'tools') {
- const fams = catalog && catalog.families.length ? catalog.families : FALLBACK_FAMILIES;
- const tile = (r) => ({ title: r.title, url: r.url, site: r.site, kind: 'tool' });
- paint([
- { title: 'Your tools', as: 'tiles', items: yourTools().map(tile) },
- { title: 'Most used on OpenVibe', as: 'tiles', items: popularTools().map(tile) },
- { title: 'Tool families', as: 'tiles', items: fams.map((f) => ({ title: f.name, url: f.url, site: f.icon, kind: 'family' })) },
- { title: 'Every tool', items: [{ title: 'Browse every tool on OpenVibe.Tools', meta: 'Hundreds of free tools, nothing to install', url: 'https://openvibe.tools/', icon: 'grid', kind: 'action' }] },
- ], catalog ? '' : 'Loading the tools…');
- return;
+ // 1b. The network's cards: what sites publish for every menu (Network collects each site's
+ // /.well-known/openvibe-menu.json into the Frame's `cards`): Live's channels, Games' new worlds, Video's trending.
+ // Text and links only (never another site's code), checked here again; at most two, Network's order.
+ const netCards = f0 && Array.isArray(f0.cards) ? f0.cards.filter((c) => c && typeof c.title === 'string' && Array.isArray(c.items) && c.items.length).slice(0, 2) : [];
+ netCards.forEach((c) => {
+ const as = c.kind === 'rail' ? 'livebar' : c.kind === 'chips' ? 'chips' : 'rows';
+ const rows = c.items.slice(0, 6).map((x) => ({ title: String(x.title || '').slice(0, 80), meta: String(x.meta || '').slice(0, 120), url: safeUrl(x.url), site: c.site && /^[a-z0-9-]{1,30}$/.test(c.site) ? c.site : null, icon: 'go', kind: 'card' })).filter((x) => x.url && x.title);
+ if (rows.length) groups.push({ title: String(c.title).slice(0, 60), as, items: rows, section: `net:${String(c.id || c.site || '').slice(0, 30)}`, more: c.more && safeUrl(c.more.url) ? { label: String(c.more.label || 'More').slice(0, 24), href: c.more.url } : null });
+ });
+ // 2. The places this person uses most (else the network's most used): a dock of big icons.
+ const yours = mine && mine.services.some((x) => x.count > 0);
+ const ranked = all.map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
+ // Without their own history, developer services (Media's API, Events, Zone…) are not places to visit: they stay in
+ // the full list and come up here only once the person uses them.
+ const dockPool = yours ? ranked : ranked.filter((s) => s.group !== 'build' || useOf(s.id));
+ groups.push({ title: yours ? 'Your places' : 'Popular on OpenVibe', as: 'apps', items: take(dockPool, 8).map((s) => siteItem(s)), section: 'places' });
+ // 2b. Their workspace, right under their places: agents and Vibe, Codes, files, machines, wallet, account, themes.
+ if (ctx.token && ctx.user && !ctx.user.is_anon) groups.push({ title: 'Your workspace', as: 'apps', items: youItems(), section: 'you' });
+ // 2c. This site's own cards (a drop zone on Download, go-live status on Live, a new task on Codes…).
+ for (const c of cards) groups.push({ title: c.title || '', as: 'card', card: c, section: `card:${c.id}`, items: [] });
+ // 3. This site's own pages: the bar's links, two to a row; more behind a count.
+ const allPages = (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden).map((l) => ({ title: l.label, url: safeUrl(l.href), icon: 'go', drawn: l.icon && typeof ctx.navIcon === 'function' ? ctx.navIcon(l.icon) : '', kind: 'page' })).filter((x) => x.url);
+ if (allPages.length) {
+ const head = hereOpen ? allPages : allPages.slice(0, 6);
+ groups.push({ title: `On ${here()}`, as: 'links', items: head, section: 'here', fold: allPages.length > 6 ? { open: hereOpen, label: hereOpen ? 'Fewer' : `More on ${here()} (${allPages.length - 6})`, key: 'here' } : null });
}
}
- // For you: what this person goes back to, this site's places, their searches, and their sites.
+ // 4. Where people go next from here: the Frame's list, else the moves Network saw (11-07) and this site's group.
+ const f = ctx.frame;
+ const nextIds = mineSite ? ((f && f.next && Array.isArray(f.next[mineSite.id]) && f.next[mineSite.id]) || NEXT_OF[mineSite.id] || all.filter((s) => s.group === mineSite.group).map((s) => s.id)) : [];
+ const next = take(nextIds.map((id) => byId.get(id)), 4);
+ if (next.length) groups.push({ title: `Next from ${here()}`, as: 'sites', items: next.map((s) => Object.assign(siteItem(s), { meta: NEXT_WHY[s.id] || s.desc })), section: 'next' });
+ // 5. Discover: two places new to this person, rotating fairly through the whole network.
+ // Developer services are offered to people who already use one; everyone else discovers places to visit.
+ const builder = all.some((s) => s.group === 'build' && useOf(s.id));
+ const discoverPool = (f && Array.isArray(f.discover_ids) && f.discover_ids.length ? f.discover_ids.map((id) => byId.get(id)).filter(Boolean) : ranked)
+ .filter((s) => !shown.has(s.id) && (builder || s.group !== 'build'));
+ const disc = pickDiscover(discoverPool);
+ disc.forEach((s) => shown.add(s.id));
+ if (disc.length) groups.push({ title: 'New to you', as: 'sites', items: disc.map((s) => Object.assign(siteItem(s), { tag: 'New' })), section: 'discover' });
+ // 5b. What's new (the owner, 11-07: "check which of the sites got updates recently, and if the user has been on
+ // since … sort them by last update … bring you to the /updates page"): the newest real change per site,
+ // newest first, "New" when it shipped after this person's last visit there.
+ if (Array.isArray(changesData) && changesData.length) {
+ const newest = new Map();
+ for (const e of changesData) {
+ if (!e || !e.service || HOUSEKEEPING.test(String(e.subject || '')) || newest.has(e.service)) continue;
+ const site = byId.get(e.service) || (e.service === 'media' ? byId.get('media-hub') : null);
+ if (!site || (site.group === 'build' && !useOf(site.id))) continue;
+ newest.set(e.service, { e, site });
+ }
+ const rows = [...newest.values()].sort((a, b) => Date.parse(b.e.deployed_at || b.e.committed_at || 0) - Date.parse(a.e.deployed_at || a.e.committed_at || 0)).slice(0, 4).map(({ e, site }) => {
+ const at = e.deployed_at || e.committed_at;
+ const seen = lastVisit(site.id);
+ const fresh = seen && Date.parse(at) > seen;
+ return { title: site.name, meta: `${plainChange(e.subject)} · ${ago(at)}`, url: safeUrl(`${site.url.replace(/\/$/, '')}/updates`), site: site.icon || site.id, kind: 'update', tag: fresh ? 'New' : '' };
+ }).filter((x) => x.url);
+ if (rows.length) groups.push({ title: "What's new", items: rows, section: 'news', more: { label: 'All updates', href: 'https://openvibe.network/updates' } });
+ }
+ // 7. Tools: the ones they use, then the everyday ones.
+ const tools = yourTools().concat(quickTools()).filter((t, i, a) => a.findIndex((x) => x.url === t.url) === i).slice(0, 6);
+ groups.push({ title: 'Tools', as: 'chips', more: { label: 'All tools', href: 'https://openvibe.tools/' }, items: tools, section: 'tools' });
+ // 8. Their searches.
const r = recent.get();
const r = recent.get();
- const yours = mine && mine.services.some((x) => x.count > 0);
- const ranked = all.filter((s) => s !== mineSite).map((s, i) => [useOf(s.id) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).slice(0, 8).map(([, s]) => siteItem(s));
+ groups.push({ title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })), section: 'recent' });
+ // 9. Every site, grouped, behind one count (this site's group first).
+ const fgroups = f && f.groups && f.groups.length ? f.groups.slice() : [{ id: '', name: 'OpenVibe' }];
+ if (mineSite) fgroups.sort((a, b) => (b.id === mineSite.group) - (a.id === mineSite.group));
+ groups.push({ title: '', as: 'fold', items: [], fold: { open: allOpen, label: allOpen ? 'Hide the list of sites' : `Every OpenVibe site (${all.length})`, key: 'all' }, section: 'all' });
+ if (allOpen) {
+ const inGroup = (s, g) => !g.id || s.group === g.id || (!fgroups.some((x) => x.id === s.group) && g === fgroups[fgroups.length - 1]);
+ const full = (s) => Object.assign(siteItem(s, all), { host: hostOf(s.url).replace(/^www\./, '') });
+ const flag = FLAGSHIPS.map((id) => byId.get(id)).filter(Boolean);
+ if (flag.length) groups.push({ title: 'Flagships', as: 'sites', items: flag.map(full), section: 'all' });
+ fgroups.forEach((g) => groups.push({ title: g.name, as: 'sites', items: all.filter((s) => inGroup(s, g)).map(full), section: 'all' }));
+ const soon = f && Array.isArray(f.soon) ? f.soon : [];
+ if (soon.length) groups.push({ title: 'Opening soon', as: 'chips', items: soon.map((x) => ({ title: x.name, url: safeUrl(x.url), kind: 'site' })).filter((x) => x.url), section: 'all' });
+ }
+ // 10. Last: the pages they were on.
const seen = new Set();
const seen = new Set();
const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
const back = mine ? mine.items.filter((h) => { if (seen.has(h.url)) return false; seen.add(h.url); return true; }).slice(0, 5)
.map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
.map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
- paint([
- { title: 'Jump back in', items: back },
- { title: `On ${here()}`, as: 'chips', items: pagesOf('') },
- { title: 'Quick tools', as: 'chips', more: { label: 'All tools', tab: 'tools' }, items: quickTools() },
- { title: 'Recent searches', as: 'chips', clear: true, items: r.map((x) => ({ title: x, url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },
- { title: yours ? 'Your sites' : 'Popular on OpenVibe', as: 'tiles', more: { label: 'All sites', tab: 'sites' }, items: ranked },
- ], back.length || r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');
+ groups.push({ title: 'Jump back in', items: back, section: 'back' });
+ paint(groups);
}
}
function render(q) {
function render(q) {
q = String(q || '').trim();
q = String(q || '').trim();
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
- tabsEl.hidden = !!q;
+ tabsEl.hidden = true;
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); renderTab(); return; }
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); renderTab(); return; }
const n = q.toLowerCase();
const n = q.toLowerCase();
const recentHits = recent.get().filter((x) => x.toLowerCase().startsWith(n) && x.toLowerCase() !== n).slice(0, 3).map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' }));
const recentHits = recent.get().filter((x) => x.toLowerCase().startsWith(n) && x.toLowerCase() !== n).slice(0, 3).map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' }));
@@ -687,17 +957,39 @@
@@ -687,17 +957,39 @@
return parts.length ? `Searched ${parts.join(' · ')}` : '';
return parts.length ? `Searched ${parts.join(' · ')}` : '';
}
}
+ /** What people pick from the menu (section, position, where it leads), when Network asks for it: no person, no
+ * page, summed per day there, so the menu can learn what helps and spread people across the network. */
+ function beacon(it, a) {
+ const b = ctx.frame && ctx.frame.menu && typeof ctx.frame.menu.beacon === 'string' ? safeUrl(ctx.frame.menu.beacon) : null;
+ if (!b || !navigator.sendBeacon) return;
+ const li = a.closest('[data-sec]');
+ const sec = li ? li.getAttribute('data-sec') : '';
+ const peers = li ? Array.prototype.indexOf.call(li.querySelectorAll('a[data-i]'), a) : -1;
+ try { navigator.sendBeacon(b, new Blob([JSON.stringify({ host: location.hostname, section: sec, pos: peers, to: hostOf(it.url), kind: it.kind })], { type: 'text/plain' })); } catch { /* */ }
+ }
+
function open(opts = {}) {
function open(opts = {}) {
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
ctx = opts || {};
ctx = opts || {};
if (!el) build();
if (!el) build();
lastFocus = document.activeElement;
lastFocus = document.activeElement;
tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';
+ // The old tabs, kept as a way in: "sites" opens the list of every site, "tools" scrolls to the tools.
+ allOpen = tab === 'sites'; hereOpen = false; discoverPick = null;
+ drainCards();
el.classList.add('is-open');
el.classList.add('is-open');
+ // Back closes the menu first (the owner, 11-07: pressing Back on a phone left the page instead): opening adds one
+ // history entry; Back pops it and only closes the menu.
+ try { if (!(history.state && history.state.ovsMenu)) { history.pushState(Object.assign({}, history.state || {}, { ovsMenu: true }), ''); pushed = true; } } catch { pushed = false; }
// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
// Opened from the navbar's orb: the menu drops from it, under the bar; otherwise centred.
const at = opts.anchor;
const at = opts.anchor;
el.classList.toggle('ovs-anchored', !!at);
el.classList.toggle('ovs-anchored', !!at);
- if (at) { el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`); }
+ if (at) {
+ el.style.setProperty('--ovs-top', `${Math.max(0, Math.round(at.top))}px`); el.style.setProperty('--ovs-left', `${Math.max(8, Math.round(at.left))}px`);
+ const cx = Number.isFinite(at.cx) ? at.cx : (Number.isFinite(at.left) ? at.left + 20 : 40);
+ const cy = Number.isFinite(at.cy) ? at.cy : (Number.isFinite(at.top) ? Math.max(20, at.top - 28) : 28);
+ el.style.setProperty('--ovs-cx', `${Math.round(cx)}px`); el.style.setProperty('--ovs-cy', `${Math.round(cy)}px`);
+ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
try { document.dispatchEvent(new CustomEvent('openvibe-menu', { detail: { open: true } })); } catch { /* */ }
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
input.placeholder = `Search ${here()}, or ask Vibe`;
input.placeholder = `Search ${here()}, or ask Vibe`;
@@ -709,10 +1001,17 @@
@@ -709,10 +1001,17 @@
mine = null;
mine = null;
loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });
loadHistory().then((h) => { if (h && el.classList.contains('is-open')) { mine = h; paintGhost(); render(input.value); } });
if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
if (!catalog) loadCatalog().then((c) => { if (c) { catalog = c; if (el.classList.contains('is-open')) render(input.value); } });
+ loadPulse().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
+ loadChanges().then((d) => { if (d && el.classList.contains('is-open') && !input.value.trim() && view !== 'results') render(''); });
+ if (tab === 'tools' || tab === 'sites') setTimeout(() => { const t = listEl.querySelector(`[data-sec="${tab === 'tools' ? 'tools' : 'all'}"]`); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'start' }); }, 60);
}
}
+ let pushed = false, popping = false;
+ if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
function close() {
function close() {
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
if (!el) return;
if (!el) return;
+ // Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
+ if (pushed && !popping) { pushed = false; try { if (history.state && history.state.ovsMenu) history.back(); } catch { /* */ } }
el.classList.remove('is-open');
el.classList.remove('is-open');
document.documentElement.style.overflow = '';
document.documentElement.style.overflow = '';
if (inflight) { inflight.abort(); inflight = null; }
if (inflight) { inflight.abort(); inflight = null; }
@@ -721,7 +1020,7 @@
@@ -721,7 +1020,7 @@
if (lastFocus && lastFocus.focus) lastFocus.focus();
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
}
- const api = { open, close, suggest, siteOf };
+ const api = { open, close, suggest, siteOf, registerCard };
root.OpenVibeSearch = api;
root.OpenVibeSearch = api;
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -217,7 +217,7 @@ html{overflow-x:clip}
@@ -217,7 +217,7 @@ html{overflow-x:clip}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
-.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}
+.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;display:none}
.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
@@ -227,12 +227,13 @@ html{overflow-x:clip}
@@ -227,12 +227,13 @@ html{overflow-x:clip}
@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
-.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
+.ov-icon[data-fx=pulse][data-state=busy] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
-.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-icon[data-fx=bounce][data-state=busy] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-comet{opacity:0}
.ov-icon[data-progress] .ovi-comet{opacity:0}
-.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}
+.ov-icon[data-state=busy] .ovi-comet{display:inline;animation:oviSpin 1.1s linear infinite;opacity:1;stroke-dasharray:40 85.66}
+.ov-icon[data-state=busy][data-fx=none] .ovi-comet,.ov-icon[data-state=busy][data-fx=pulse] .ovi-comet{display:none}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
@@ -52,24 +52,25 @@ const server = http.createServer((req, res) => {
@@ -52,24 +52,25 @@ const server = http.createServer((req, res) => {
// The navbar asks for the Frame at render; the launcher paints from what it kept.
// The navbar asks for the Frame at render; the launcher paints from what it kept.
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);
await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (localStorage.getItem('ov_frame_v2')) { clearInterval(t); r(true); } }, 20); })`);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
- // The drawer's "All sites" opens the one menu on its Sites tab.
+ // The drawer's "All sites" opens the one menu with the list of every site open (4.128.0: one scroll, no tabs).
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
await p.evaluate(`document.getElementById('openvibe-burger').click(), true`);
await wait(150);
await wait(150);
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
await p.evaluate(`document.querySelector('#openvibe-drawer [data-open-launcher]').click(), true`);
- await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites')) { clearInterval(t); r(true); } }, 20); })`);
+ await p.evaluate(`new Promise((r) => { const t = setInterval(() => { if (document.querySelector('.ovs.is-open .ovs-grid.is-sites[data-sec="all"]')) { clearInterval(t); r(true); } }, 20); })`);
const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {
const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {
- tab: (document.querySelector('.ovs-tab[aria-selected="true"]') || {}).textContent,
+ open: (l.querySelector('[data-ovs-fold="all"]') || { getAttribute: () => null }).getAttribute('aria-expanded'),
heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),
heads: [...l.querySelectorAll('.ovs-group')].map((e) => e.firstChild.textContent.trim()),
- groups: [...l.querySelectorAll('.ovs-grid.is-sites')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),
- here: [...l.querySelectorAll('.ovs-tile.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),
- small: (l.querySelector('.ovs-tile small') || {}).textContent,
+ listHeads: [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()).filter((h) => h !== 'Flagships'),
+ groups: (() => { const hs = [...l.querySelectorAll('li.ovs-group[data-sec="all"]')].map((e) => e.firstChild.textContent.trim()); return [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"]')].filter((g, i) => hs[i] !== 'Flagships').map((g) => [...g.querySelectorAll('b')].map((x) => x.firstChild.textContent)); })(),
+ here: [...l.querySelectorAll('.ovs-grid.is-sites[data-sec="all"] .ovs-tile.is-here')].map((a) => a.querySelector('b').firstChild.textContent + '|' + a.getAttribute('aria-current')).filter((v, i, a) => a.indexOf(v) === i),
+ small: (l.querySelector('.ovs-grid.is-sites[data-sec="all"] .ovs-tile small') || {}).textContent,
rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);
rows: [...l.querySelectorAll('.ovs-item .ovs-title')].map((t) => t.textContent) } })()`);
const r = await read();
const r = await read();
- assert.strictEqual(r.tab, 'Sites', 'the Sites tab');
- assert.deepStrictEqual(r.heads.slice(0, 3), ['Watch & stream', 'Talk & learn', 'Your OpenVibe'], 'the Frame\'s groups, in its order');
+ assert.strictEqual(r.open, 'true', 'the list of every site is open');
+ assert.deepStrictEqual(r.listHeads.slice(0, 3), ['Talk & learn', 'Watch & stream', 'Your OpenVibe'], 'this site\'s group first, then the Frame\'s groups in its order');
assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');
assert.strictEqual(r.groups.flat().length, 21, 'every site of the Frame (21 here; the navbar used to keep 12)');
- assert.deepStrictEqual(r.groups[0].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
- assert.ok(r.groups[0].includes('Pics'), 'a product on its own domain sits in its group');
+ assert.deepStrictEqual(r.groups[1].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
+ assert.ok(r.groups[1].includes('Pics'), 'a product on its own domain sits in its group');
assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');
assert.deepStrictEqual(r.groups[2], ['Network', 'Mystery'], 'a site in a group the navbar does not know goes in the last one');
assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');
assert.deepStrictEqual(r.here, ['Here|page'], 'the page\'s own site is marked');
assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');
assert.strictEqual(r.small, 'Live things', 'taglines without a closing period');
@@ -87,13 +87,15 @@ const server = http.createServer((req, res) => {
@@ -87,13 +87,15 @@ const server = http.createServer((req, res) => {
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
for (let i = 0; i < 50 && !(await p.evaluate('!!document.querySelector(".ovs.is-open .ovs-input")')); i++) await wait(100);
await wait(150);
await wait(150);
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,
- tabs: [...document.querySelectorAll('.ovs-tab[data-tab]')].map((t) => t.textContent + (t.getAttribute('aria-selected') === 'true' ? '*' : '')),
- groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), chips: [...document.querySelectorAll('.ovs-chip span')].map((t) => t.textContent),
- sites: [...document.querySelectorAll('.ovs-tile b')].map((t) => t.textContent), placeholder: document.querySelector('.ovs-input').placeholder })`);
+ tabs: document.querySelectorAll('.ovs-tab[data-tab]').length,
+ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), links: [...document.querySelectorAll('.ovs-link span')].map((t) => t.textContent),
+ sites: [...document.querySelectorAll('.ovs-appname')].map((t) => t.textContent), fold: (document.querySelector('[data-ovs-fold="all"]') || {}).textContent,
+ placeholder: document.querySelector('.ovs-input').placeholder })`);
assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');
- assert.deepStrictEqual(v.tabs, ['Start*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');
- assert.deepStrictEqual([v.groups, v.chips], [['On Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages (by its short name, 4.48.0), then the sites');
+ assert.strictEqual(v.tabs, 0, 'one calm scroll, no tabs (4.128.0, the owner 11-07: "rethink the whole idea of this whole menu")');
+ assert.deepStrictEqual([v.groups, v.links], [['Popular on OpenVibe', 'On Chat'], ['Rooms', 'Servers']], 'nothing typed: the places first (one tap to Live), then this site\'s own pages');
assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');
assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');
+ assert.strictEqual(v.fold, 'Every OpenVibe site (6)', 'every site behind one counted fold');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
assert.strictEqual(v.placeholder, 'Search Chat, or ask Vibe', 'the site by its short name (4.48.0)');
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
// The box completes from this site's pages (and the person's searches, history and sites); Tab takes it.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'Roo'; i.dispatchEvent(new Event('input')); return 1; })()`);
@@ -102,8 +104,8 @@ const server = http.createServer((req, res) => {
@@ -102,8 +104,8 @@ const server = http.createServer((req, res) => {
assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
assert.strictEqual(await p.evaluate('document.querySelector(".ovs-input").value'), 'Rooms', 'Tab takes it');
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = ''; i.dispatchEvent(new Event('input')); return 1; })()`);
await wait(100);
await wait(100);
- await p.evaluate('document.querySelector(".ovs-tab[data-tab=sites]").click(); 1');
- assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile")'), 'the Sites tab lists the sites');
+ await p.evaluate('document.querySelector("[data-ovs-fold=all]").click(); 1');
+ assert.ok(await p.evaluate('!!document.querySelector(".ovs-grid.is-sites .ovs-tile") && document.querySelector("[data-ovs-fold=all]").getAttribute("aria-expanded") === "true"'), 'the fold opens the list of every site in place');
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
// Typing: one request for the latest text (the earlier one cancelled), this site's results first.
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await p.evaluate(`(() => { const i = document.querySelector('.ovs-input'); i.value = 'slow'; i.dispatchEvent(new Event('input')); setTimeout(() => { i.value = 'night'; i.dispatchEvent(new Event('input')); }, 250); })(); 1`);
await wait(1200);
await wait(1200);
@@ -143,6 +145,15 @@ const server = http.createServer((req, res) => {
@@ -143,6 +145,15 @@ const server = http.createServer((req, res) => {
assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');
assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-opt.is-active .ovs-title").textContent'), 'night room', 'arrows move');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); 1');
assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');
assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');
+ // Back closes the menu and stays on the page (4.128.0, the owner 11-07: Back on a phone left the page).
+ const before = await p.evaluate('location.href');
+ await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
+ for (let i = 0; i < 30 && !(await p.evaluate('!!document.querySelector(".ovs.is-open")')); i++) await wait(100);
+ assert.strictEqual(await p.evaluate('!!(history.state && history.state.ovsMenu)'), true, 'opening adds one history entry');
+ await p.evaluate('history.back(); 1');
+ await wait(400);
+ assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Back closes the menu');
+ assert.strictEqual(await p.evaluate('location.href'), before, 'and stays on the page');
// ── Loading ──
// ── Loading ──
v = await p.evaluate(`new Promise((done) => {
v = await p.evaluate(`new Promise((done) => {