openvibers/openvibe.shared Commits
4.23.0: one OpenVibe menu (search + every site + tools, your history, tabs), a compact account menu, calm balances (#149)
Changes
@@@ -6,6 +6,60 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.23.0 — 2026-10-28++++The owner, 2026-10-28, on the phone:++- the launcher and the magnifier "kind of accomplish the same exact thing… combine… a better hierarchy… use this user's++ history… autocomplete… maybe use tabs";++- the account menu: "a lot of this is just not even really needed… a plus button… better use of icons… compactness…++ data-driven";++- the balances "look a little weird… polish… use the space".++++- **One OpenVibe menu** (`search.js`). The magnifier, `/` or Ctrl+K opens search, every site and every tool together.++ - **Before typing**, three tabs:++ - *For you*: jump back in from the person's Network history, this site's pages, recent searches, and their sites++ (or the most used across OpenVibe);++ - *Sites*: every site in the Frame's groups;++ - *Tools*: your tools, the most used tools from the Frame's analytics, and the families.++ - **Typing** makes one ranked list: go to (name matches; tools you used first), this site's results and pages, your++ history, the rest of OpenVibe (the sites you use first), tools for it, your searches, then search everywhere and++ Ask Vibe.++ - **Completion:** the box completes from your searches, history, the sites and this site's pages; Tab or → takes it.++ - Results carry their site's icon.++ - `OpenVibeNavbar.openMenu(tab)` opens it on a tab.++ - The drawer's "All sites" opens the Sites tab.++- **The launcher button is gone.** Its panel, catalog and site lists moved into `search.js`, which loads on first use,++ so `navbar.js` is 15 KB smaller (161 → 146 KB raw, 37.4 → 33.8 KB brotli). Guests' display controls moved to the++ foot of the phone drawer.++- **A compact account menu.**++ - **Header:** + (add an account), the gear and sign out sit beside the name; other accounts are small pills.++ - **Your places:** a grid of icons, four across, the site's own first, eight shown and the rest under More. The order++ follows what this person opens: counted on this device only, a place opened twice moves up.++ - **Foot:** one row for light, dark, auto, text size, motion and themes.++ - **Gone from it:** Notifications (the bell has them; the row stays where there is no bell), "Recently used" (the++ menu's Jump back in) and the long list.++ - Items take `short` for the grid's label.++- **Calm balances.**++ - **The pill:** one pill on the bar's own surface; each balance a coloured icon and a plain number, a hairline between++ them.++ - **On phones:** the pill shows the first balance up to 440 px, so the site's name always reads whole, with Apple's++ wider system font too; a tap lists both. Wider screens show both. The pill is compact at every phone width.++ - **Computers:** one chip per balance in the same style.++ - **Room:** on phones the bar drops the empty spacer's gap.++- **The showcase hero on a phone** (the owner on openvibe.quest: "why is there empty space at the top… why is++ openvibe.quest twice… better visuals… icon usage… reduce wasted space… the same for all the other sites"). Every site++ built on `showcase` gets:++ - **No repeated name:** an eyebrow that is only the site's name ("OpenVibe.Quest") is left out, since the navbar shows++ it; any other eyebrow stays, and `{ icon, text }` gives it an icon.++ - **Less space and text:** the hero starts 14 px under the bar (was 36), and the title and lede tighten.++ - **Buttons:** they sit side by side.++ - **Numbers:** they go three across, and `stats` items take an `icon`.++- The mark's seasonal leaves fall under the menus, not over them.++- `docs/kit.md`: the OpenVibe menu, the account menu, balances.++- Tests: `navbar-launcher` covers the Sites tab and "All sites"; `search-loading-chrome` covers the tabs and the++ completion; `navbar-touch-hover` checks the menu's hover-only rows; `navbar-phone-fit-chrome` checks the bar++ from 320 to 430 px; `navbar-brand` covers the new header and grid.++## 4.22.2 — 2026-10-28- **In-page links scroll instead of reloading** (`boost.js`). A link to a part of the page (`href="#the-quests"`) made@@@ -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.22.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.23.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.22.2",++ "version": "4.23.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)",@@@ -256,34 +256,96 @@ OpenVibeNavbar.setAppBar(spec | false)Pick the places people go every visit: the home or feed, search, what is theirs (messages, saved, their page), andthe site's main verb (go live, post, ask). Settings and the rest live in More.--## Search, on every site++## The OpenVibe menu: search, every site, every tool--The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The first time someone asks, the navbar loads--`search.js` (about 17 KB), so pages that are never searched pay nothing.++One control on every site: the navbar's magnifier (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two++panels doing the same job, the launcher's dots (every site) and the magnifier (search). The owner, 2026-10-28:++"combine… a better hierarchy… use this user's history… autocomplete… maybe use tabs". The navbar loads `search.js` the++first time someone asks, so a page nobody searches pays nothing (and the navbar itself got 15 KB lighter).--What it does:--- **Before typing:** the site's own pages, and what you searched before on this device.--- **While typing:** suggestions from OpenVibe.Search for every site, this site's results first. They are anonymous, and-- the older request is cancelled when you type on.--- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".--- **Keys:** arrows move, Enter opens, Escape closes.--- **Layout:** a phone gets the whole screen; a computer gets a panel near the top.++**Before typing**, three tabs:++++| Tab | What it shows |++| --- | --- |++| For you | **Jump back in**: the places this person went lately anywhere on OpenVibe (their Network history, signed in). **On this site**: the site's own pages as chips. **Recent searches** (this device, can be cleared). **Your sites**: the sites they use most, or, for a guest, the most used across OpenVibe (the Frame's analytics ranking). |++| Sites | Every open site by what people come to do (the Frame's groups), the one you are on marked. The phone drawer's "All sites" opens this tab. |++| Tools | **Your tools** (from their history), **Most used on OpenVibe** (the Frame's analytics), the tool families, and every tool. |++++**Typing** makes one ranked list:++1. **Go to**: sites, tool families and tools whose *name* matches. Tools the person used before come first.++2. **On this site**: OpenVibe.Search's results for this site, and its matching pages.++3. **Your history**: pages they visited whose title matches.++4. **Across OpenVibe**: the rest of Search's suggestions, the sites they use most first.++5. **Tools for it**: tools whose description mentions it.++6. **Your searches**, then **Search**: this site's own search page, all of OpenVibe, and Ask Vibe.++++The box also completes what you type, from your searches, your history, the sites and this site's pages. The++completion shows faintly behind the text; Tab or → takes it.++++**Keys:** arrows move, Enter opens, Escape closes.++++**Layout:** a phone gets the whole screen; a computer gets a panel near the top, with the keys listed at its foot.```jsOpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false--OpenVibeNavbar.openSearch('seattle') // open it from anything on the page++OpenVibeNavbar.openSearch('seattle') // open it on a search++OpenVibeNavbar.openMenu('sites') // or on a tab: 'you' | 'sites' | 'tools'OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box```--The suggestions come through OpenVibe.Network: `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`--asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers for 30 seconds. A site's Content-Security-Policy--already lets the navbar reach `openvibe.network`, so a site needs nothing new in its `connect-src`.++**Where the data comes from:**++++| Data | Source |++| --- | --- |++| Suggestions | OpenVibe.Network's `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`, which asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers 30 s. |++| History | `GET https://openvibe.network/api/history?limit=60` with the person's own token, read once per opening and kept two minutes. Anonymous sessions keep none. |++| Sites and the ranking | The Frame (`/api/frame`, which the navbar already holds). |++| Tools | `https://openvibe.network/api/catalog.json`, kept 30 minutes. |--Both paths answer any origin (anonymous, never with credentials). So the same suggestions work from anyone's page:--pass `{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The--SDK has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and++Every one of those is on `openvibe.network`, which every site's Content-Security-Policy already allows, so a site++needs nothing new in its `connect-src`.++++Search answers any origin (anonymous, never with credentials), so the same suggestions work from anyone's page. Pass++`{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The SDK++has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and`search.query(text, { owner, type })`.++## The account menu++++Pressing your picture opens a compact menu (4.23.0; the owner: "a lot of this is just not even really needed… a plus++button… better use of icons… compactness… data-driven"):++++- **The header:** your picture and name (both open your profile). Beside them, **+** (add another account), the++ **gear** (account and settings) and **sign out**. Other accounts, and Anonymous where the switcher runs, are a row of++ small pills under the name.++- **Your places:** a grid of icons, four across, with the site's own items first (`menu.before` / `menu.after`) and then++ My channel, History, Ask Vibe, Work together and Admin. Notifications appear only where the bar has no bell. The++ grid shows eight; the rest are under **More**.++- **The order follows use:** a place opened twice moves up. It is counted on this device only, in `ov-menu-use`, and++ never sent anywhere.++- **One row at the foot:** Light, Dark, Auto, text size, animations and themes. Guests find the same controls at the++ foot of the phone drawer.++++A site's items can carry `short` (the grid's label: `{ label: 'Streamer setup', short: 'Setup' }`). `menu.sections`++still render as their own rows.++++## Home page heroes (showcase)++++`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since++4.23.0):++- it starts right under the bar;++- the two buttons sit side by side;++- the numbers after it (`showcase.stats`) go three across, each with an optional `icon`.++++Don't pass the site's own name as the eyebrow: it is left out, because the navbar's brand already says it. Use the++eyebrow for something worth reading first ("Free, nothing to install"), with `{ icon, text }` for an icon.++++## Balances++++`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance++is a coloured icon and a plain number, with a hairline between them. Up to 440 px only the first balance shows, and a++tap lists them all. Computers get one chip per balance in the same style.++## Loading`loading.js` is the network's one loading look, on Live's player and anywhere something takes a moment. It covers@@@ -57,7 +57,7 @@.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-core { padding-right: 0; }.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-tld { padding-left: 0; }-- .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }/* A finger needs about 44px: on touch screens the wordmark's links reach the navbar's height and the markgets a clear ring, without moving anything (they were 22px and 30px tall). */@media (pointer: coarse) {@@@ -65,27 +65,15 @@.openvibe-navbar .openvibe-navbar-brand a.flame { position: relative; }.openvibe-navbar .openvibe-navbar-brand a.flame::after { content: ''; position: absolute; inset: -7px; }}-- .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }-- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }-- .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }-- .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }-- .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }-- .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }-- @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }-- .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; height: 1em; font-size: 1em; vertical-align: -.125em; overflow: visible; }-- .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }-- .openvibe-navbar-links::-webkit-scrollbar { display: none; }-- .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }-- .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }-- .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }-- .openvibe-navbar .ovnav-dot[hidden] { display: none; }-- @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }-- .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }.openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }.openvibe-navbar .ovnav-dd-menu { display: none; position: fixed; top: 56px; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }.openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }.openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }++ /* A single balance chip (computers): the same calm surface, the icon in its colour, the number in the text's. */++ .openvibe-navbar .ovnav-chip[data-tone] { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }++ .openvibe-navbar .ovnav-chip[data-tone] .ovnav-ic { color: var(--chip-c); width: 15px; height: 15px; }++ .openvibe-navbar .ovnav-chip[data-tone]:hover { background: color-mix(in srgb, var(--chip-c) 12%, transparent); border-color: color-mix(in srgb, var(--chip-c) 35%, transparent); }.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }.openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; }@@@ -118,19 +106,31 @@.ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; }.ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }.ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; }++ .ovnav-display { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px; padding: 10px 6px 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }++ .ovnav-display[hidden] { display: none; }++ .ovnav-display .ovnav-dl { flex-basis: 100%; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; }++ .ovnav-display .ovnav-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }++ .ovnav-display .ovnav-seg button { all: unset; cursor: pointer; padding: 6px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++ .ovnav-display .ovnav-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .ovnav-display .ovnav-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++ .ovnav-drawer .ovnav-display a { display: inline; padding: 6px 4px; margin-left: auto; font-size: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }.ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }.ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }/* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */-- :is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }-- .ovnav-launcher .ovl-app > .ov-icon { width: 28px; height: 28px; }++ .ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }.ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }-- :is(.ovnav-launcher, .ovnav-drawer) .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }++ .ovnav-drawer .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }/* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the siteswitcher next to the logo); a tap lists each in full. */-- .openvibe-navbar .ovnav-chip--all { display: none; gap: 8px; padding: 0 10px; --chip-c: var(--text-secondary, #a8b3c4); }-- .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; color: var(--chip-c, inherit); }++ /* The balances (owner, 2026-10-28: "the coins and vibes badges look a little weird… polish… use the space"): one++ calm pill on the bar's own surface, each balance a coloured icon and a plain number, a hairline between. */++ .openvibe-navbar .ovnav-chip.ovnav-chip--all { display: none; gap: 0; padding: 0 1px; font-size: 12.5px; --chip-c: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }++ .openvibe-navbar .ovnav-chip--all:hover, .openvibe-navbar .ovnav-chip--all[aria-expanded="true"] { background: color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent); }++ .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; padding: 0 6px; color: var(--text-primary, #e6edf7); }++ .ovnav-chip-mini + .ovnav-chip-mini { border-left: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }++ .ovnav-chip-mini .ovnav-ic { color: var(--chip-c, inherit); }.ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }-- .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; }++ .ovnav-chip-mini .ovnav-ic { width: 14px; height: 14px; }.ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }.ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }@@@ -143,56 +143,16 @@.openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }.openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }}-- @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }++ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed-- in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis; up to 440px the-- folded balances show the first one only (a tap lists them all), and a signed-in bar (balances, bell, picture) lets-- the launcher step into the menu, whose "All sites" opens it (everyone's under 375px). */++ in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px++ the folded balances show the first one only (a tap lists them all). */.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }@media (max-width: 440px) { .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini ~ .ovnav-chip-mini { display: none; } }-- @media (max-width: 440px) { .openvibe-navbar:has(#openvibe-avatar-btn) .ovnav-launch { display: none; } }@media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }-- @media (max-width: 374px) { .openvibe-navbar .ovnav-launch { display: none; } }-- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }-- .ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }-- .ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }-- .ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }-- .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }-- .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }-- .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }-- .ovnav-launcher .ovl-apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 2px 4px; }-- .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 4px; }-- .ovnav-launcher .ovl-app, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }-- .ovnav-launcher .ovl-app { padding: 7px 8px; border: 1px solid transparent; }-- .ovnav-launcher :is(.ovl-app, .ovl-fam) > span:last-child { min-width: 0; }-- .ovnav-launcher .ovl-app small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; }-- .ovnav-launcher .ovl-app.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: var(--accent-glow, rgba(59,130,246,.14)); }-- /* Hover only where a pointer hovers: on a phone a tap left its row lit beside the site you are on (the owner, 2026-10-25). */-- .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }-- @media (hover: hover) { .ovnav-launcher :is(.ovl-app, .ovl-fam):hover { background: var(--accent-glow, rgba(59,130,246,.14)); } }-- .ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-- .ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }-- .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }-- .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }-- .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }-- .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }-- .ovnav-launcher .ovl-display[hidden] { display: none; }-- .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }-- .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }-- .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }-- .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }-- .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }-- .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }-- .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }-- .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }-- .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }-- .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }-- .ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }-- @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher :is(.ovl-apps, .ovl-fams) { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ovnav-launcher .ovl-app small { -webkit-line-clamp: 1; } }-- @media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */@@@ -221,15 +181,6 @@.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }}.openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }-- .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }-- .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }-- .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #60a5fa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }-- .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }-- .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }-- .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }-- .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }-- .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }-- .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }.openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }.openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }.openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }@@@ -274,7 +225,7 @@.openvibe-navbar-dropdown {position: absolute; top: 48px; right: 8px;-- width: 260px; background: var(--bg-card, #22222c);++ width: min(320px, calc(100vw - 16px)); background: var(--bg-card, #22222c);border: 1px solid var(--border, #333340); border-radius: 10px;box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));display: none; flex-direction: column; overflow: hidden;@@@ -287,7 +238,7 @@.openvibe-navbar-dropdown > * { flex-shrink: 0; }/* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the addressbar), the panel scrolls inside itself, and it is never wider than the viewport. */-- .openvibe-navbar-dropdown, .ovnav-launcher {++ .openvibe-navbar-dropdown {max-width: calc(100vw - 16px);max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;@@@ -295,34 +246,34 @@@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }.openvibe-navbar-dropdown-header {-- padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);-- display: flex; align-items: center; gap: 10px;++ padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340);++ display: flex; align-items: center; gap: 11px;}-- .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }++ .openvibe-navbar-dropdown-header img { width: 40px; height: 40px; border-radius: 50%; }++ .openvibe-navbar-dropdown-header .ud-hacts { display: flex; gap: 2px; align-self: flex-start; }++ .openvibe-navbar-dropdown .ud-hbtn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); text-decoration: none; }++ .openvibe-navbar-dropdown .ud-hbtn:hover, .openvibe-navbar-dropdown .ud-hbtn:focus-visible { background: var(--bg-hover, rgba(255,255,255,.08)); color: var(--text-primary, #e6edf7); }++ .openvibe-navbar-dropdown .ud-hbtn .icon { font-size: 15px; }.openvibe-navbar-dropdown-header .info { line-height: 1.3; }.openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }.openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }.openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }++ /* Other accounts: a row of small pills under the name (one tap switches); Anonymous is one of them. */.openvibe-navbar-dropdown-accounts {-- padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);-- max-height: 140px; overflow-y: auto;++ display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border, #333340);++ overflow-x: auto; scrollbar-width: none;}.openvibe-navbar-dropdown-accounts .account-item {-- display: flex; align-items: center; gap: 8px; padding: 8px;-- border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;-- color: var(--text-primary, #e0e0e0); transition: background .12s;++ display: inline-flex; align-items: center; gap: 6px; flex: none; max-width: 150px; height: 32px; padding: 0 10px 0 4px;++ border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); cursor: pointer; font-size: 12.5px; font-weight: 600;++ color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s;}-- .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); }.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }-- .openvibe-navbar-dropdown-accounts .add-account {-- display: flex; align-items: center; gap: 8px; padding: 8px;-- border-radius: 6px; cursor: pointer; font-size: 12px;-- color: var(--text-muted, #707080); transition: background .12s;-- text-decoration: none;-- }-- .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }.openvibe-navbar-dropdown-menu { padding: 6px 8px; }.openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {@@@ -335,11 +286,36 @@.openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }.openvibe-navbar-dropdown-menu .danger { color: var(--live-red-text, var(--live-red, #e74c3c)); }/* Light / Dark / Auto (theme-loader.js scheme): three equal segments, the one in use filled. */-- .openvibe-navbar-dropdown-menu .ud-scheme { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; margin: 2px 0 6px; padding: 3px; border-radius: 9px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }-- .openvibe-navbar-dropdown-menu .ud-scheme button { justify-content: center; gap: 6px; padding: 7px 4px; border-radius: 7px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }++ /* Your places: a grid of icons over short names, four across (the menu was 13 rows and a display block). */++ .openvibe-navbar-dropdown-menu .ud-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 4px; }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { flex-direction: column; justify-content: flex-start; gap: 6px; min-height: 66px; padding: 9px 2px 7px; text-align: center; border-radius: 12px; font-size: 11.5px; font-weight: 600; line-height: 1.2; }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile[hidden] { display: none; }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .icon { width: 38px; height: 30px; border-radius: 10px; font-size: 16px; background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile:hover .icon { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-navbar-dropdown-menu .ud-tl { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; }++ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .ud-val { margin: 0; font-size: 10.5px; }++ .openvibe-navbar-dropdown-menu .ud-more { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin: 2px -8px 0; padding: 2px 8px; }++ .openvibe-navbar-dropdown-menu .ud-more summary { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 600; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-menu .ud-more summary::-webkit-details-marker { display: none; }++ .openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); }++ .openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); }++ .openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); }++ /* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */++ .openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib[aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-navbar-dropdown-menu .ud-foot .ud-scheme .ud-ib[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .openvibe-navbar-dropdown .ud-hbtn.ud-out { all: unset; box-sizing: border-box; cursor: pointer; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown .ud-hbtn.ud-out:hover, .openvibe-navbar-dropdown .ud-hbtn.ud-out:focus-visible { color: var(--live-red-text, var(--live-red, #f87171)); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); }++ .openvibe-navbar-dropdown-menu .ud-scheme { display: inline-flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }++ .openvibe-navbar-dropdown-menu .ud-scheme[hidden] { display: none; }++ .openvibe-navbar-dropdown-menu .ud-scheme button { width: 32px; min-width: 0; height: 30px; padding: 0; justify-content: center; border-radius: 9px; color: var(--text-secondary, #a8b3c4); }.openvibe-navbar-dropdown-menu .ud-scheme button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }.openvibe-navbar-dropdown-menu .ud-scheme button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }-- .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 13px; }++ .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 14px; }/* One box for every icon in the menu: a glyph keeps its height and is centred, whatever its width (an id cardis wider than a bell), so the rows' icons line up and read as one set. */.openvibe-navbar-dropdown-menu .icon { width: 20px; height: 16px; font-size: 15px; color: var(--text-secondary, #a8b3c4); }@@@ -366,13 +342,13 @@instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zerospecificity, so the Frame's own :focus-visible rules and a host page's rules still win.\`scale\` is its own property, so it composes with any transform already in use. */-- .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }-- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }-- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {++ .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px;}@media (prefers-reduced-motion: reduce) {-- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }++ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }}`;document.head.appendChild(s);@@@ -398,6 +374,7 @@'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],++ 'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 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'],'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],@@@ -409,7 +386,7 @@320: 'fa-chevron-right',384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',-- 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',++ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',};@@@ -622,8 +599,7 @@return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}"><a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a><span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>-- </div>-- ${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;++ </div>`;}@@@ -662,115 +638,10 @@return { get, refresh };})());-- // ── Network launcher ─────────────────────────────────────-- const LAUNCHER_SITES = [-- { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },-- { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },-- { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },-- { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },-- { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },-- { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },-- ];-- const LAUNCHER_FAMILIES = [-- { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },-- { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },-- { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },-- { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },-- { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },-- { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },-- { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },-- ];-- const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP-- const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';-- const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };---- async function launcherCatalog() {-- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }-- try {-- const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;-- const j = await r.json();-- const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),-- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,-- hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };-- try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }-- return data;-- } catch { return null; }-- }--// A render's document/window listeners and panels; the next render (each boost page move) aborts them, so the old bar can go.let _renderAbort = null;const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);-- function bindLauncher(nav) {-- const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;-- for (const ev of ['pointerenter', 'touchstart', 'focus']) btn.addEventListener(ev, () => loadOvIcons(), { once: true, passive: true });-- let panel = null;-- const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;-- const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);-- // This page's site: the longest matching host (play.openvibe.network is Play, not Network).-- const hereOf = (list) => { const h = currentHost().toLowerCase(); let best = null, len = 0; for (const x of list) { let sh = ''; try { sh = new URL(x.url).hostname; } catch { continue; } if ((h === sh || h.endsWith('.' + sh)) && sh.length > len) { best = x; len = sh.length; } } return best; };-- const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };-- function paint(cat, q) {-- const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);-- const body = panel.querySelector('.ovl-body');-- if (q) {-- // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),-- // the last row hands the query to the Tools search page, so typing never does nothing.-- const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);-- const siteList = siteItems(OVFrame.get()).filter(hit);-- const famList = fams.filter(hit);-- const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];-- const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;-- body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-apps">${siteList.map(x => tile(x, 'ovl-app')).join('')}</div>` : '')-- + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')-- + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;-- return;-- }-- const frame = OVFrame.get();-- const sites = siteItems(frame), hereSite = hereOf(sites);-- // The Frame's groups in order; a site in no known group goes in the last one.-- const groups = frame && frame.groups && frame.groups.length ? frame.groups : [{ id: '', name: 'Sites' }];-- const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some(x => x.id === s.group));-- const siteHtml = groups.map((g, i) => { const list = sites.filter(s => inGroup(s, g, i)); return list.length ? `<div class="ovl-h">${escapeAttr(g.name)}</div><div class="ovl-apps">${list.map(x => tile(x, 'ovl-app', x === hereSite)).join('')}</div>` : ''; }).join('');-- const soon = frame ? frame.soon : [];-- // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people-- // can pick the one they will remember.-- const cur = currentHost().toLowerCase();-- const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;-- const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';-- body.innerHTML = addresses + siteHtml + `-- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>-- ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;-- }-- btn.addEventListener('click', async (e) => {-- e.stopPropagation();-- if (panel && panel.classList.contains('open')) return close();-- if (!panel) {-- panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');-- panel.innerHTML = '<div class="ovl-top"><input type="search" class="ovl-q" placeholder="Find a site or tool" aria-label="Find a site or tool"></div><div class="ovl-body"></div><div class="ovl-display" hidden></div>';-- bindDisplayControls(panel.querySelector('.ovl-display'));-- nav.appendChild(panel);-- regPanel(panel, 'launcher', close);-- loadOvIcons();-- let cat = null; paint(null, '');-- const input = panel.querySelector('.ovl-q');-- input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));-- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });-- panel.addEventListener('keydown', (ev) => {-- if (ev.key === 'Escape') { close(); btn.focus(); return; }-- if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;-- const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;-- ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();-- });-- panel.addEventListener('click', (ev) => ev.stopPropagation());-- document.addEventListener('click', close, onDocument());-- launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });-- }-- panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');-- try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }-- });-- }--/** One anonymous page-view count per page load (host only), which is what orders the network's* navigation. Skipped when the browser asks not to be tracked. */let _counted = false;@@@ -884,11 +755,12 @@const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);if (burger && drawer) {++ if (!_config.user) bindDisplayControls(drawer.querySelector('.ovnav-display')); // guests: members have them in the account menuburger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });drawer.addEventListener('click', (e) => {const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;e.preventDefault(); e.stopPropagation(); closeDrawer();-- const lb = nav.querySelector('#openvibe-launcher-btn'); if (lb) lb.click();++ openSearch('', 'sites');});document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());regPanel(drawer, 'nav-drawer', closeDrawer);@@@ -925,8 +797,9 @@bindScheme(dropdown);const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }-- const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };-- const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };++ const LABEL = { text: { 100: '', 112: '+', 125: '++' }, motion: { auto: '', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };++ const SAY = { text: { 100: 'default', 112: 'large', 125: 'largest' }, motion: { auto: 'on', reduced: 'calm' } };++ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; const say = `${k === 'text' ? 'Text size' : 'Animations'}: ${(SAY[k] || {})[d[k]] || d[k]}`; r.title = say; r.setAttribute('aria-label', say); r.setAttribute('aria-pressed', String(k === 'motion' ? d[k] === 'reduced' : d[k] !== 100 && d[k] !== '100')); }); };rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));root.addEventListener('ov:display', paint, onDocument()); paint();}@@@ -969,7 +842,7 @@return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');}-- /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */++ /** Display settings for guests, at the foot of the phone menu (members have them in the account menu); theme-loader.js owns the state. */function bindDisplayControls(box) {const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;const paint = () => {@@@ -977,16 +850,15 @@const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;const sch = L.scheme ? L.scheme.get() : null;const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;-- box.innerHTML = `<span class="ovl-dl">Display</span>${sch ? `<span class="ovl-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>-- <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovl-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;++ box.innerHTML = `<span class="ovnav-dl">Display</span>${sch ? `<span class="ovnav-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovnav-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>++ <span class="ovnav-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovnav-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;box.hidden = false;};box.addEventListener('click', (e) => {const s2 = e.target.closest('button[data-s]'); if (s2 && L.scheme) { L.scheme.set(s2.dataset.s); paint(); return; }const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint();});-- root.addEventListener('ov:theme', paint, onDocument());-- root.addEventListener('ov:display', paint, onDocument());++ if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); }paint();}@@@ -1010,12 +882,32 @@}const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);++ // What this person opens from the menu, counted on this device (no request, nothing leaves the browser): the grid++ // puts their most used first once a place has been opened twice; until then the site's own order stands.++ const USE_KEY = 'ov-menu-use';++ const useKey = (item) => String(item.id || item.href || item.label || '').slice(0, 80);++ function useCounts() { try { return JSON.parse(localStorage.getItem(USE_KEY) || '{}') || {}; } catch { return {}; } }++ function countUse(key) {++ if (!key) return;++ try { const c = useCounts(); c[key] = Math.min(9999, (Number(c[key]) || 0) + 1); const keys = Object.keys(c); if (keys.length > 60) keys.sort((x, y) => c[x] - c[y]).slice(0, keys.length - 60).forEach((k) => delete c[k]); localStorage.setItem(USE_KEY, JSON.stringify(c)); } catch { /* private mode */ }++ }++ function byUse(items) {++ const c = useCounts(), n = (it) => { const v = Number(c[useKey(it)]) || 0; return v >= 2 ? v : 0; };++ return items.map((it, i) => [it, i]).sort((x, y) => (x[0].hidden ? 1 : 0) - (y[0].hidden ? 1 : 0) || n(y[0]) - n(x[0]) || x[1] - y[1]).map(([it]) => it);++ }++ /** One place in the account menu's grid: an icon over a short name (item.short, else its label). */++ function tileHTML(item) {++ const attrs = ` class="ud-tile${item.danger ? ' danger' : ''}" data-use="${escapeAttr(useKey(item))}" title="${escapeAttr(item.label)}"${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}${item.mark ? ` ${item.mark}` : ''}`;++ const inner = `<span class="icon">${item.drawn || navIcon(item.icon || 'fa-circle-info')}</span><span class="ud-tl">${escapeAttr(item.short || item.label)}</span>${item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : ''}`;++ return item.href ? `<a href="${escapeAttr(item.href)}"${attrs}${item.external ? ' target="_blank" rel="noopener"' : ''}>${inner}</a>` : `<button type="button"${attrs}>${inner}</button>`;++ }++function menuItemHTML(item) {if (!item) return '';if (item.sep) return '<div class="sep"></div>';-- const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';++ const icon = item.drawn ? `<span class="icon">${item.drawn}</span>` : item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';const cls = item.danger ? ' class="danger"' : '';-- const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;++ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''} data-use="${escapeAttr(useKey(item))}"${item.mark ? ` ${item.mark}` : ''}`;const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</button>`;@@@ -1545,27 +1437,6 @@});}-- /**-- * "Recently used" rows in the dropdown: the last few things this account touched anywhere-- * on the network, from the shared history module (loaded lazily from the Network).-- */-- function renderRecent(el) {-- if (!el || !_config.user || _config.user.is_anon) return;-- const draw = (items) => {-- if (!items || !items.length) { el.hidden = true; return; }-- el.hidden = false;-- el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +-- items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');-- };-- const H = root.OpenVibeHistory;-- if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }-- if (document.getElementById('ov-history-loader')) return;-- const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;-- sc.src = sibling('history.js');-- sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };-- document.head.appendChild(sc);-- }--/*** wallet: true — OpenCoins and Vibes as chips in the bar, on any site (owner, 2026-10-27: "make that as a feature in the* shared nav bar … maybe we'll put it on games too"). A site that sets chips of its own with those ids (Live) keeps@@@ -1664,14 +1535,14 @@</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">-- ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search (/)" title="Search (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}++ ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search and every OpenVibe site (/)" title="Search and every OpenVibe site (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}<div id="openvibe-bell-mount"></div>${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}</div>-- ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}++ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}`;if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));bindLinks(nav, links);@@@ -1679,7 +1550,6 @@bindChips(nav);bindChipsAll(nav);queueIcons();-- bindLauncher(nav);countView();// Pages that wire the bell themselves run right after init(); give them the first go.setTimeout(() => mountBell(nav, u), 0);@@@ -1700,60 +1570,61 @@const barWallet = !!_config.wallet || (Array.isArray(_config.chips) && _config.chips.some(c => c && (c.id === 'coins' || c.id === 'vibes')));const admin = u.role === 'admin' || !!(u.capabilities && (u.capabilities.is_owner || u.capabilities.admin_panel));++ // The menu (4.23.0; the owner, 2026-10-28: "a lot of this is just not even really needed… could be a button in++ // the corner… a plus button… better use of icons… more compactness… data-driven"): who you are, with + (add an++ // account) and the gear beside the name; your places as a grid of icons in the order you use them (eight, the++ // rest under More); the site's own sections; and one row for light/dark, text size, motion, themes and sign++ // out. Notifications are the bell's, and the places you went and Ask Vibe are the search menu's.++ const bell = _config.notifications !== false && !!_config.token && !isAnon;++ const places = before.concat(after).filter((it) => it && !it.sep).concat(menuCfg.defaults === false ? [] : [++ !isAnon && u.username ? { id: 'ov-channel', label: 'My channel', short: 'Channel', drawn: navIcon('fa-tower-broadcast'), href: `https://openvibe.live/@${encodeURIComponent(u.username)}` } : null,++ { id: 'ov-history', label: 'History', drawn: navIcon('fa-clock-rotate-left'), href: 'https://openvibe.network/my#history' },++ bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' },++ !isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null,++ !isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,++ admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,++ ].filter(Boolean));++ const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);++ const others = otherAccounts.filter(a => switcher || !a.is_anon);++ const iconBtn = (cls, drawn, label, extra) => `<button type="button" class="ud-ib${cls}" title="${label}" aria-label="${label}"${extra || ''}><span class="icon">${drawn}</span></button>`;++dropdown.innerHTML = `<div class="openvibe-navbar-dropdown-header"><a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a><div class="info"><a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>-- <div class="email">${isAnon ? '' : '@' + escapeHtml(u.username || '')}</div>++ <div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div>${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}-- ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}++ </div>++ <div class="ud-hacts">++ ${_config.accounts === false ? '' : `<a class="ud-hbtn add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}" title="Add another account" aria-label="Add another account"><span class="icon">${navIcon('fa-plus')}</span></a>`}++ <a class="ud-hbtn" href="https://openvibe.network/my" title="Account & settings" aria-label="Account & settings"><span class="icon">${navIcon('fa-gear')}</span></a>++ <button id="openvibe-logout-btn" type="button" class="ud-hbtn ud-out" title="Sign out" aria-label="Sign out"><span class="icon">${navIcon('fa-right-from-bracket')}</span></button></div></div>-- <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>-- ${otherAccounts.filter(a => switcher || !a.is_anon).map(a => { const t = switcher ? 'div' : 'a'; return `-- <${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}"` : `href="${escapeAttr(switchHref(a))}"`}>-- ${avatarImg(a, 48, '', '')}-- <span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span>-- </${t}>`; }).join('')}-- ${switcher ? `<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">-- <span style="width:18px;text-align:center">${navIcon('fa-user-secret')}</span>-- <span>Switch to Anonymous</span>-- </div>` : ''}-- <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}">-- <span style="width:18px;text-align:center">${navIcon('fa-plus')}</span>-- <span>Add another account</span>-- </a>-- </div>-- <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>++ ${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">++ ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}++ ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span></div>` : ''}++ </div>`}<div class="openvibe-navbar-dropdown-menu">-- ${before.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}++ ${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''}++ ${more.length ? `<details class="ud-more"><summary><span class="icon">${navIcon('fa-chevron-down')}</span> More <span class="ud-n">${more.length}</span></summary>${more.map(menuItemHTML).join('')}</details>` : ''}${sectionsHTML(menuCfg.sections)}-- ${after.length ? `${after.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}-- ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div>-- <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-gear')}</span> Account & settings</a>-- <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>-- ${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My channel</a>` : ''}-- <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>-- ${!isAnon ? `<a href="https://openvibe.chat/vibe" data-ov-ask><span class="icon">${navIcon('fa-brain')}</span> Ask Vibe</a><a href="https://openvibe.chat/work" data-ov-work><span class="icon">${navIcon('fa-people-group')}</span> Work together</a>` : ''}-- ${admin ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin</a>` : ''}-- <div class="sep"></div>`}-- <div class="ud-label">Display</div>-- <div class="ud-scheme" role="group" aria-label="Light or dark" hidden><button type="button" data-ov-scheme="light" aria-pressed="false"><span class="icon">${navIcon('fa-sun')}</span> Light</button><button type="button" data-ov-scheme="dark" aria-pressed="false"><span class="icon">${navIcon('fa-moon')}</span> Dark</button><button type="button" data-ov-scheme="auto" aria-pressed="false" title="Follow this device's light or dark setting"><span class="icon">${navIcon('fa-circle-half-stroke')}</span> Auto</button></div>-- <button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>-- <button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>-- <a href="https://openvibe.network/themes" title="Every theme, and the season's own"><span class="icon">${navIcon('fa-palette')}</span> Themes <b class="ud-val ud-go" aria-hidden="true">›</b></a>-- <div class="sep"></div>-- <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>++ <div class="ud-foot">++ <div class="ud-scheme" role="group" aria-label="Light or dark" hidden>${iconBtn('', navIcon('fa-sun'), 'Light', ' data-ov-scheme="light" aria-pressed="false"')}${iconBtn('', navIcon('fa-moon'), 'Dark', ' data-ov-scheme="dark" aria-pressed="false"')}${iconBtn('', navIcon('fa-circle-half-stroke'), 'Auto: follow this device', ' data-ov-scheme="auto" aria-pressed="false"')}</div>++ <button type="button" class="ud-ib ud-txt" data-ov-display="text" title="Text size" aria-label="Text size"><span class="icon">${navIcon('fa-text-height')}</span><b class="ud-val"></b></button>++ <button type="button" class="ud-ib" data-ov-display="motion" title="Animations" aria-label="Animations"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span><b class="ud-val"></b></button>++ <a class="ud-ib" href="https://openvibe.network/themes" title="Themes: every theme, and the season's own" aria-label="Themes"><span class="icon">${navIcon('fa-palette')}</span></a>++ </div></div>`;++ dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });nav.appendChild(dropdown);bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));bindChips(dropdown);if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });bindDisplayRows(dropdown);regPanel(dropdown, 'user-menu');-- if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;@@@ -1828,8 +1699,10 @@// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.let _searchLoading = false;-- function openSearch(q) {-- const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q });++ /** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */++ function openSearch(q, tab) {++ const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,++ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope) });if (root.OpenVibeSearch) return go();if (_searchLoading) return;_searchLoading = true;@@@ -1838,7 +1711,7 @@let _searchKeys = false;function hookSearch(nav) {const b = nav.querySelector('#openvibe-search-btn');-- if (b) b.addEventListener('click', () => openSearch());++ if (b) { b.addEventListener('click', () => openSearch()); for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); }if (_searchKeys || _config.search === false || typeof document === 'undefined') return;_searchKeys = true;document.addEventListener('keydown', (e) => {@@@ -1925,7 +1798,8 @@setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },/** Open the search panel (search.js), with an optional query already in it. */-- openSearch(q) { openSearch(q); },++ openSearch(q, tab) { openSearch(q, tab); },++ openMenu(tab) { openSearch('', tab || 'you'); },/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */setAppBar(spec) { _config.appBar = spec; renderAppBar(); },@@@ -90,7 +90,7 @@ html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}--.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:2147483000}++.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.22.2",++ "version": "4.23.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -1,34 +1,63 @@/**-- * openvibe-shared/search.js — search everything on OpenVibe from any site's navbar: the magnifier, "/" or Ctrl+K. The-- * navbar loads this file the first time someone asks (the owner, 2026-10-28: "a magnifying glass button that's more-- * compact, shared across the sites… more intelligent… asynchronous loading, lazy loading and autocomplete… reused in-- * the SDK and API… intelligent and aware").++ * openvibe-shared/search.js — the OpenVibe menu: search, every site and every tool in one panel, opened by the navbar's++ * magnifier, "/" or Ctrl+K (4.23.0). The navbar loads this file the first time someone asks.*-- * What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched-- * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (anonymous,-- * one request in flight, the older one cancelled). The suggestions come through OpenVibe.Network's-- * GET /api/frame/suggest (OpenVibe.Search's /api/v1/suggest, kept 30 s): every site's Content-Security-Policy already-- * lets the navbar reach openvibe.network, and most refuse search.openvibe.network. Every row is a link: a result, a-- * page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets-- * the whole screen; a computer a panel near the top.++ * It used to be two panels side by side, the launcher (the dots: every site) and search (the magnifier), which "kind++ * of accomplish the same exact thing" (the owner, 2026-10-28: "combine… what are the most important things… a better++ * hierarchy… use this user's history… input history and autocomplete… to give them better results… maybe use tabs").*-- * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, suggestUrl }) navbar.js calls it++ * Before typing, three tabs:++ * For you the places this person went lately anywhere on OpenVibe (their Network history, signed in), this site's++ * pages, their recent searches, and their sites (most used by them, else most used by everyone: the Frame's++ * analytics ranking).++ * Sites every OpenVibe site by what people come to do (the Frame's groups), the one you are on marked.++ * Tools the tool families and the most used tools (OpenVibe.Tools' catalog).++ * Typing makes one list: sites and tools to go to, this site's results and pages, the person's own history, the rest of++ * OpenVibe.Search (sites they use most first), their recent searches, and "search everywhere" / "Ask Vibe". The box++ * completes what they type from their own searches, history and the site names (Tab or → takes it).++ *++ * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, tab, frame, token, apiBase, user,++ * suggestUrl, icons }) navbar.js calls it* OpenVibeSearch.close()* OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box* (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the* page's connect-src allows it; both answer any origin)*-- * Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage-- * (ov_search_recent, eight at most) and can be cleared from the panel.++ * Suggestions come through OpenVibe.Network's GET /api/frame/suggest (OpenVibe.Search, kept 30 s): every site's++ * Content-Security-Policy lets the navbar reach openvibe.network. History is GET /api/history (the person's own token,++ * read once a panel opening, kept two minutes). Text is always set with textContent; links are https or same-site paths++ * only. Recent searches live in localStorage (ov_search_recent, eight at most) and can be cleared from the panel.*/(function (root) {'use strict';const API = 'https://search.openvibe.network';const SUGGEST = 'https://openvibe.network/api/frame/suggest';++ const CATALOG_URL = 'https://openvibe.network/api/catalog.json';++ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';const RECENT_KEY = 'ov_search_recent';const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };-- let el = null, input = null, listEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;++ // Until the Frame answers (or when it cannot): the network's main sites and the tool families.++ const FALLBACK_SITES = [++ { id: 'live', name: 'Live', desc: 'Go live, watch streams', icon: 'live', url: 'https://openvibe.live/' },++ { id: 'chat', name: 'Chat', desc: 'Rooms, servers and messages', icon: 'chat', url: 'https://openvibe.chat/' },++ { id: 'games', name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },++ { id: 'tools', name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },++ { id: 'community', name: 'Community', desc: 'Forum, pastes and posts', icon: 'community', url: 'https://openvibe.community/' },++ { id: 'network', name: 'Network', desc: 'Your account and themes', icon: 'network', url: 'https://openvibe.network/' },++ ];++ const FALLBACK_FAMILIES = [++ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },++ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },++ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },++ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },++ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },++ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },++ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },++ ];++ const TABS = [['you', 'For you'], ['sites', 'Sites'], ['tools', 'Tools']];++ let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;++ let tab = 'you', mine = null, catalog = null, completion = '';const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };function siteOf(url) {@@@ -40,6 +69,7 @@return h;} catch { return ''; }}++ const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };const recent = {get() { try { return (JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') || []).filter((x) => typeof x === 'string').slice(0, 8); } catch { return []; } },add(q) { try { const list = [q, ...recent.get().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } },@@@ -57,6 +87,64 @@return (d.suggestions || []).map((s) => ({ owner: s.owner, type: s.type, id: s.id, title: String(s.title || '').slice(0, 160), url: safeUrl(s.canonical_url), site: siteOf(s.canonical_url) })).filter((s) => s.url && s.title);}++ // ── What the panel knows ─────────────────────────────────++ /** Every open site from the Frame (most used across OpenVibe first), else the main ones. */++ function sites() {++ const f = ctx.frame;++ if (f && Array.isArray(f.nav) && f.nav.length) return f.nav.map((n) => ({ id: n.id, name: n.name, desc: String(n.tagline || '').replace(/\.\s*$/, ''), icon: n.icon || n.id, url: safeUrl(n.url), group: n.group })).filter((s) => s.url);++ return FALLBACK_SITES;++ }++ /** The site this page belongs to: the longest matching host (play.openvibe.network is Play, not Network). */++ function hereSite(list) {++ const h = location.hostname.toLowerCase(); let best = null, len = 0;++ for (const s of list) { const sh = hostOf(s.url); if (sh && (h === sh || h.endsWith(`.${sh}`)) && sh.length > len) { best = s; len = sh.length; } }++ return best;++ }++ /** The person's own use of each site (their history's counts), for "your sites" and for ranking results. */++ const useOf = (id) => { const s = mine && mine.services.find((x) => x.service === id); return s ? s.count : 0; };++++ /** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */++ async function loadHistory() {++ if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;++ const key = `ov_hist:${ctx.user.id || ctx.user.username || ''}`;++ try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }++ try {++ const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });++ if (!r.ok) return null;++ const j = await r.json();++ const data = {++ items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: h.updated_at })).filter((h) => h.url && h.title),++ services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),++ };++ try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ return data;++ } catch { return null; }++ }++ /** OpenVibe.Tools' catalog (families and tools), kept 30 minutes; the same copy the navbar used to keep. */++ async function loadCatalog() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try {++ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;++ const j = await r.json();++ const ok = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };++ const data = {++ families: (j.families || []).slice(0, 12).map((f) => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: ok(f.url) })).filter((f) => f.name && f.url),++ tools: (j.tools || []).slice(0, 400).map((t) => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: ok(t.url), go: t.hosts && t.hosts.short ? ok(`https://${t.hosts.short}/`) : null,++ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter((h) => typeof h === 'string' && h), id: String(t.id || ''),++ desc: String(t.tagline || '').slice(0, 120), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter((t) => t.name && t.url),++ };++ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ return data;++ } catch { return null; }++ }++++ function ago(iso) {++ const t = Date.parse(iso || ''); if (!isFinite(t)) return '';++ const m = Math.max(0, Math.round((Date.now() - t) / 60000));++ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;++ }++++ // ── Drawing ──────────────────────────────────────────────function styles() {if (document.getElementById('openvibe-search-styles')) return;const st = document.createElement('style');@@@ -64,29 +152,61 @@st.textContent = `.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }--.ovs-panel { position: absolute; left: 50%; top: 10vh; width: min(640px, calc(100vw - 32px)); max-height: 76vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }--.ovs-bar { display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }--.ovs-bar svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }--.ovs-input { flex: 1; min-width: 0; height: 48px; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 17px; }++.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }++.ovs-bar { display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 16px; }++.ovs-bar > svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }++.ovs-field { position: relative; flex: 1; min-width: 0; display: flex; }++.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }++.ovs-input { position: relative; z-index: 1; flex: 1; min-width: 0; border: 0; outline: 0; padding: 0; background: transparent; color: inherit; }.ovs-input::-webkit-search-cancel-button { -webkit-appearance: none; }++.ovs-ghost { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; overflow: hidden; pointer-events: none; color: var(--text-muted, #93a0b4); opacity: .75; }++.ovs-ghost b { color: transparent; font-weight: inherit; }.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }--.ovs-x:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: inherit; }++.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }++.ovs-tabs[hidden] { display: none; }++.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }++.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }++.ovs-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.ovs-typing .ovs-bar { border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }--.ovs-group { padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }--.ovs-group button { float: right; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11px; text-transform: none; letter-spacing: 0; cursor: pointer; }--.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 6px 10px; border-radius: 12px; color: inherit; text-decoration: none; }--.ovs-item.is-active a, .ovs-item a:hover { background: var(--bg-hover, rgba(255,255,255,.07)); }++.ovs-group { display: flex; align-items: center; gap: 8px; padding: 12px 10px 5px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }++.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }++.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }++.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }++/* A tap leaves :hover on what it touched: rows light on hover only where a pointer really hovers. */++@media (hover: hover) { .ovs-item a:hover, .ovs-chip:hover, .ovs-tile:hover, .ovs-x:hover, .ovs-tab:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } }.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }--.ovs-ic svg { width: 16px; height: 16px; }++.ovs-ic > svg { width: 16px; height: 16px; }++.ovs-ic.ovs-site { background: none; }++.ovs .ov-icon:empty { border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }.ovs-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #93a0b4); }.ovs-key { flex: none; font-size: 11px; color: var(--text-muted, #93a0b4); }++.ovs-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 8px 6px; }++.ovs-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; height: 34px; padding: 0 13px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 600; box-sizing: border-box; }++.ovs-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovs-chip svg { width: 14px; height: 14px; flex: none; color: var(--text-muted, #93a0b4); }++.ovs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; padding: 2px 4px 6px; }++.ovs-grid.is-sites { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }++.ovs-tile { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 8px 10px; border-radius: 12px; border: 1px solid transparent; color: inherit; text-decoration: none; }++.ovs-tile > span:last-child { min-width: 0; }++.ovs-tile b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 650; }++.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); }++.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; }@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }@keyframes ovs-scan { to { background-position: 140% 100%; } }--@media (max-width: 640px) { .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; } .ovs-key { display: none; } }++@media (hover: none), (max-width: 640px) { .ovs-foot, .ovs-key { display: none; } }++@media (max-width: 640px) {++ .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; }++}@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;document.head.appendChild(st);}@@@ -96,6 +216,8 @@clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',++ back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>',++ grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',};function build() {@@@ -104,17 +226,24 @@el.className = 'ovs';el.setAttribute('role', 'dialog');el.setAttribute('aria-modal', 'true');-- el.setAttribute('aria-label', 'Search OpenVibe');-- el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<input class="ovs-input" type="search" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"><button type="button" class="ovs-x">Close</button></div><ul class="ovs-list" id="ovs-list" role="listbox"></ul></div>`;++ 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>`++ + '<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');++ ghost = el.querySelector('.ovs-ghost');listEl = el.querySelector('.ovs-list');++ tabsEl = el.querySelector('.ovs-tabs');el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });el.querySelector('.ovs-x').addEventListener('click', close);-- input.addEventListener('input', () => schedule(input.value));++ tabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-tab]'); if (b) { tab = b.getAttribute('data-tab'); render(''); input.focus(); } });++ input.addEventListener('input', () => { paintGhost(); schedule(input.value); });input.addEventListener('keydown', onKey);listEl.addEventListener('click', (e) => {const clear = e.target.closest && e.target.closest('[data-ovs-clear]');-- if (clear) { e.preventDefault(); recent.clear(); render(''); 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 a = e.target.closest && e.target.closest('a[data-i]');if (a) remember(items[Number(a.getAttribute('data-i'))]);});@@@ -123,6 +252,9 @@function onKey(e) {if (e.key === 'Escape') { e.preventDefault(); close(); return; }++ if ((e.key === 'Tab' && !e.shiftKey || e.key === 'ArrowRight' && input.selectionStart === input.value.length) && completion) {++ e.preventDefault(); input.value = completion; paintGhost(); schedule(input.value); return;++ }if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {e.preventDefault();if (!items.length) return;@@@ -141,15 +273,52 @@if (q && it && it.kind !== 'recent') recent.add(q);}function paintActive() {-- Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-item'), (li, i) => {++ Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-opt'), (a, i) => {const on = i === active;-- li.classList.toggle('is-active', on);-- li.setAttribute('aria-selected', on ? 'true' : 'false');-- if (on) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }++ a.classList.toggle('is-active', on);++ a.setAttribute('aria-selected', on ? 'true' : 'false');++ if (on) { input.setAttribute('aria-activedescendant', a.id); a.scrollIntoView({ block: 'nearest' }); }});}-- /** The rows: groups of items { title, meta, url, icon, kind }; rendered with textContent only. */++ /** What the box would complete to: the person's searches, then their history, then sites and this site's pages. */++ function paintGhost() {++ const q = input.value;++ completion = '';++ if (q.trim().length >= 2 && !/\s$/.test(q)) {++ const n = q.toLowerCase();++ const pool = recent.get().concat(mine ? mine.items.map((h) => h.title) : [], sites().map((s) => s.name), (ctx.links || []).map((l) => l && l.label).filter(Boolean));++ completion = pool.find((x) => x && x.length > q.length && x.toLowerCase().startsWith(n)) || '';++ if (completion) completion = q + completion.slice(q.length);++ }++ ghost.textContent = '';++ if (!completion) return;++ const typed = document.createElement('b'); typed.textContent = q;++ ghost.append(typed, document.createTextNode(completion.slice(q.length)));++ }++++ function icon(it) {++ const ic = document.createElement('span');++ ic.className = 'ovs-ic';++ if (it.site) { ic.classList.add('ovs-site'); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }++ else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];++ else ic.textContent = (it.icon || '•').slice(0, 2);++ return ic;++ }++ function option(tag, cls, it) {++ const i = items.length;++ items.push(it);++ const a = document.createElement(tag);++ a.className = `ovs-opt ${cls}`;++ a.id = `ovs-o${i}`;++ a.setAttribute('role', 'option');++ a.href = it.url;++ a.setAttribute('data-i', String(i));++ a.tabIndex = -1;++ 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. */function paint(groups, note) {items = [];listEl.textContent = '';@@@ -157,44 +326,56 @@const h = document.createElement('li');h.className = 'ovs-group';h.setAttribute('role', 'presentation');-- h.textContent = 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.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); }listEl.appendChild(h);++ const as = g.as || 'rows';++ if (as === 'rows') {++ g.items.forEach((it) => {++ const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');++ 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.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }++ a.append(icon(it), main);++ if (items.length === 1 && input.value.trim()) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }++ li.appendChild(a); listEl.appendChild(li);++ });++ return;++ }++ 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) => {-- const i = items.length;-- items.push(it);-- const li = document.createElement('li');-- li.className = 'ovs-item';-- li.id = `ovs-o${i}`;-- li.setAttribute('role', 'option');-- const a = document.createElement('a');-- a.href = it.url;-- a.setAttribute('data-i', String(i));-- a.tabIndex = -1;-- const ic = document.createElement('span');-- ic.className = 'ovs-ic';-- if (ICON[it.icon]) ic.innerHTML = ICON[it.icon]; else ic.textContent = (it.icon || '•').slice(0, 2);-- 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.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }-- a.append(ic, main);-- if (i === 0) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }-- li.appendChild(a);-- listEl.appendChild(li);++ if (as === 'chips') {++ const a = option('a', 'ovs-chip', it);++ if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);++ const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);++ 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 (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }++ a.append(icon(it), txt);++ box.appendChild(a);});++ listEl.appendChild(box);});if (note) { const n = document.createElement('li'); n.className = 'ovs-note'; n.setAttribute('role', 'presentation'); n.textContent = note; listEl.appendChild(n); }-- active = items.length ? 0 : -1;++ active = items.length && input.value.trim() ? 0 : -1;paintActive();++ if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }}const here = () => (ctx.scope && ctx.scope.label) || document.title.split(/[·|–-]/).pop().trim() || location.hostname;++ const siteItem = (s, all) => ({ title: s.name, meta: s.desc, url: s.url, site: s.icon || s.id, kind: 'site', here: all && s === hereSite(all) });function pagesOf(q) {const n = q.toLowerCase();return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))-- .slice(0, n ? 4 : 6).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);++ .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);}function actions(q) {const out = [];@@@ -206,34 +387,118 @@out.push({ title: `Ask Vibe: “${q}”`, meta: 'OpenVibe\'s assistant searches the web and OpenVibe', url: `https://openvibe.chat/vibe?ask=${encodeURIComponent(q)}`, icon: 'spark', kind: 'action' });return out;}++ // How well a name answers what was typed: its start (3), inside it (2), or only the words behind it (1).++ const scoreOf = (n, name, words) => { const nm = name.toLowerCase(); return nm.startsWith(n) ? 3 : nm.includes(n) ? 2 : words && words.includes(n) ? 1 : 0; };++ const toolRow = (t) => ({ title: t.name, meta: t.desc ? `Tool · ${t.desc}` : 'Tool', url: t.go || t.url, site: t.icon, kind: 'tool' });++ /** How often this person opened a tool (their history's visits to any of its addresses). */++ function toolUse(t) {++ if (!mine || !t.hosts || !t.hosts.length) return 0;++ return mine.items.reduce((n, h) => n + (t.hosts.indexOf(hostOf(h.url)) >= 0 ? 1 : 0), 0);++ }++ const toolByHost = (url) => { const h = hostOf(url); return catalog ? catalog.tools.find((t) => t.hosts.indexOf(h) >= 0 || hostOf(t.url) === h) : null; };++ /** The tools this person opened lately, most first (their history), and the most used across OpenVibe (the Frame's analytics). */++ function yourTools() {++ if (!mine || !catalog) return [];++ return catalog.tools.map((t) => [toolUse(t), t]).filter(([n]) => n > 0).sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, t]) => toolRow(t));++ }++ function popularTools() {++ const list = ctx.frame && ctx.frame.footer && Array.isArray(ctx.frame.footer.popular) ? ctx.frame.footer.popular : [];++ const rows = list.map((p) => { const t = toolByHost(p.url); return t ? toolRow(t) : { title: String(p.name || ''), meta: 'Tool', url: safeUrl(p.url), site: 'tools', kind: 'tool' }; }).filter((x) => x.url && x.title);++ return rows.length ? rows : catalog ? catalog.tools.slice(0, 8).map(toolRow) : [];++ }++ /** Sites, tool families and tools whose NAME matches (the start first, then what this person uses most): the places to go. */++ function goTo(q) {++ const n = q.toLowerCase();++ const all = sites();++ const s = all.map((x) => [scoreOf(n, x.name) * 100 + Math.min(99, useOf(x.id)), x]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).map(([, x]) => siteItem(x, all));++ const fams = ((catalog && catalog.families.length) ? catalog.families : FALLBACK_FAMILIES).filter((f) => scoreOf(n, f.name) >= 2).map((f) => ({ title: f.name, meta: 'Tools', url: f.url, site: f.icon, kind: 'family' }));++ const tools = catalog ? catalog.tools.map((t) => [scoreOf(n, t.name) * 100 + Math.min(99, toolUse(t)), t]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).slice(0, 4).map(([, t]) => toolRow(t)) : [];++ // What this person already uses goes first: a tool they opened before outranks a site that merely matches.++ return tools.filter((t) => toolUse(toolByHost(t.url) || {}) > 0).concat(s, fams, tools.filter((t) => !(toolUse(toolByHost(t.url) || {}) > 0))).slice(0, 6);++ }++ /** Tools whose description or keywords mention it (but not their name): useful, never the first answer. */++ function toolsAbout(q) {++ const n = q.toLowerCase();++ if (!catalog || n.length < 3) return [];++ return catalog.tools.filter((t) => scoreOf(n, t.name, t.k) === 1).slice(0, 3).map(toolRow);++ }++ /** A result's site icon: the Frame's site for its address (openvibe.video is Video), else its first letters. */++ function siteIcon(url) {++ const h = hostOf(url); let best = null, len = 0;++ for (const s of sites()) { const sh = hostOf(s.url); if (sh && (h === sh || h.endsWith(`.${sh}`)) && sh.length > len) { best = s; len = sh.length; } }++ return best ? best.icon || best.id : null;++ }-- function render(q) {-- q = String(q || '').trim();-- if (!q) {-- const r = recent.get();++ 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;++ }++ 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: 'Recent', clear: true, items: r.map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },-- { title: `On ${here()}`, items: pagesOf('') },-- ], r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');++ { 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;}-- paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], 'Looking…');++ // For you: what this person goes back to, this site's places, their searches, and their sites.++ 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));++ 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: '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.');++ }++++ function render(q) {++ q = String(q || '').trim();++ el.classList.toggle('ovs-typing', !!q);++ tabsEl.hidden = !!q;++ 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' }));++ const history = mine ? mine.items.filter((h) => h.title.toLowerCase().includes(n)).slice(0, 4).map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];++ const first = (found) => {++ const owner = ctx.scope && ctx.scope.owner;++ const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, site: siteIcon(s.url), icon: (s.site || '?').slice(0, 2), kind: 'result' });++ const minehere = owner ? found.filter((s) => s.owner === owner) : [];++ // The rest of OpenVibe: the sites this person uses most come first, Search's own order otherwise.++ const rest = found.filter((s) => !minehere.includes(s)).map((s, i) => [useOf(s.owner) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);++ return [++ { title: 'Go to', items: goTo(q) },++ { title: `On ${here()}`, items: [...minehere.map(row), ...pagesOf(q)] },++ { title: 'Your history', items: history },++ { title: 'Across OpenVibe', items: rest.map(row) },++ { title: 'Tools for it', items: toolsAbout(q) },++ { title: 'Your searches', items: recentHits },++ { title: 'Search', items: actions(q) },++ ];++ };++ paint(first([]), 'Looking…');if (inflight) inflight.abort();const ac = new AbortController();inflight = ac;el.classList.add('ovs-busy');suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {if (ac.signal.aborted) return;-- const owner = ctx.scope && ctx.scope.owner;-- const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });-- const mine = owner ? found.filter((s) => s.owner === owner) : [];-- const rest = found.filter((s) => !mine.includes(s));-- paint([-- { title: `On ${here()}`, items: [...mine.map(row), ...pagesOf(q)] },-- { title: 'Across OpenVibe', items: rest.map(row) },-- { title: 'Search', items: actions(q) },-- ], found.length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');-- }).catch(() => { if (!ac.signal.aborted) paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], ''); })++ paint(first(found), found.length || goTo(q).length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');++ }).catch(() => { if (!ac.signal.aborted) paint(first([]), ''); }).finally(() => { if (inflight === ac) { inflight = null; if (el) el.classList.remove('ovs-busy'); } });}function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }@@@ -243,12 +508,18 @@ctx = opts || {};if (!el) build();lastFocus = document.activeElement;++ tab = TABS.some(([id]) => id === opts.tab) ? opts.tab : 'you';el.classList.add('is-open');document.documentElement.style.overflow = 'hidden';-- input.placeholder = `Search ${here()} and all of OpenVibe`;++ input.placeholder = `Search ${here()}, or jump to any site`;input.value = opts.q || '';++ paintGhost();render(input.value);setTimeout(() => input.focus(), 0);++ // What makes it the person's own arrives after the first paint: their history, and the tools catalog.++ 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); } });}function close() {if (!el) return;@@@ -110,6 +110,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}++.sc-stat dd{display:flex;align-items:center;gap:8px}.sc-stat-ic{display:inline-flex;color:var(--accent-light,var(--accent,#60a5fa))}++@media (max-width:640px){++.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}++.sc-hero{padding-top:14px}.sc-hero::before{inset:-60px 0 auto;height:280px}++.sc-eyebrow{margin-bottom:10px;font-size:11.5px}++.sc-hero h1{font-size:clamp(27px,7.6vw,34px);line-height:1.1;letter-spacing:-.8px}++.sc-hero-lede{margin-top:10px;font-size:15.5px;line-height:1.5}++.sc-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:8px;margin-top:16px}++.sc-actions .sc-btn{justify-content:center;min-height:46px;padding:10px 12px;font-size:14.5px;text-align:center}++.sc-note{margin-top:10px;font-size:12px;line-height:1.45}++.sc-stats-sec{margin-top:14px}++.sc-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}++.sc-stat{padding:10px 10px 9px;border-radius:13px;gap:1px;justify-content:flex-end}++.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}++.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++}@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}@@@ -6,7 +6,8 @@** const sc = require('openvibe-shared/showcase');* `<link rel="stylesheet" href="${serve.url('showcase.css')}">` (cached; carries the ring icons' CSS too) or-- * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside })++ * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside }) (an eyebrow that is++ * only the site's name is left out: the navbar shows it; { icon, text } gives it an icon)* + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })* + sc.steps({ title, lede, items: [{ title, text, href }] })* + sc.code({ title, lede, samples: [{ label, lang, code }] })@@@ -15,7 +16,7 @@* + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })* + sc.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits* + sc.stories({ title, items: [{ who, text, outcome }] })-- * + sc.stats({ title, items: [{ value, label, href }] }) a strip of real numbers++ * + sc.stats({ title, items: [{ value, label, href, icon }] }) a strip of real numbers (three across on a phone)* + sc.cards({ title, lede, items: [{ href, title, text, image, badge, meta, live }], layout: 'grid'|'rail',* ratio: '16/9'|'4/3'|'1/1', more: { label, href } }) rich content: videos, streams, posts* + sc.spotlight({ title, lede, items: [{ title, text, image, video, alt, href, actions }] }) features, shown@@@ -52,9 +53,19 @@ function actions(list, cls = 'sc-actions') {return `<div class="${cls}">${list.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}"${/^https?:/.test(a.href || '') && a.external ? ' rel="noopener"' : ''}>${a.icon ? icon(a.icon, 18) : ''}${esc(a.label)}</a>`).join('')}</div>`;}++// A site's own name as the eyebrow repeats the navbar's brand right above it ("OpenVibe.Quest" twice, the owner++// 2026-10-28): such an eyebrow is left out. An eyebrow that says something else stays ({ icon, text } adds an icon).++const SITE_NAME_RE = /^(?:OpenVibe\.[A-Za-z]+|OpenRestream|[A-Za-z]+\.OpenVibe\.[A-Za-z]+)$/;++function eyebrowHTML(eyebrow) {++ const e = eyebrow && typeof eyebrow === 'object' ? eyebrow : { text: eyebrow };++ const text = String(e.text || '').trim();++ if (!text || SITE_NAME_RE.test(text)) return '';++ return `<p class="sc-eyebrow">${e.icon ? icon(e.icon, 16) : ''}${esc(text)}</p>`;++}++/** The top of the page: what the product is, in one line, and where to start. */function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html } = {}) {-- return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrow ? `<p class="sc-eyebrow">${esc(eyebrow)}</p>` : ''}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;++ return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrowHTML(eyebrow)}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;}/** A grid of what the product does; each card may link to where it starts. */@@@ -175,7 +186,7 @@ function stats({ id: sid, title, items } = {}) {if (!usable.length) return '';const h = `sc-${slug(title || 'numbers')}`;return `<section class="sc-sec sc-stats-sec"${id(sid)}${labelled(title, h)}>${title ? `<h2 id="${h}" class="sr-only">${esc(title)}</h2>` : ''}<dl class="sc-stats">${usable.map((x) => {-- const inner = `<dt>${esc(x.label)}</dt><dd>${esc(x.value)}</dd>`;++ const inner = `<dt>${esc(x.label)}</dt><dd>${x.icon ? `<span class="sc-stat-ic">${icon(x.icon, 18)}</span>` : ''}${esc(x.value)}</dd>`;const href = safeHref(x.href);return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;}).join('')}</dl></section>`;@@@ -396,6 +407,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}++.sc-stat dd{display:flex;align-items:center;gap:8px}.sc-stat-ic{display:inline-flex;color:var(--accent-light,var(--accent,#60a5fa))}++@media (max-width:640px){++.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}++.sc-hero{padding-top:14px}.sc-hero::before{inset:-60px 0 auto;height:280px}++.sc-eyebrow{margin-bottom:10px;font-size:11.5px}++.sc-hero h1{font-size:clamp(27px,7.6vw,34px);line-height:1.1;letter-spacing:-.8px}++.sc-hero-lede{margin-top:10px;font-size:15.5px;line-height:1.5}++.sc-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:8px;margin-top:16px}++.sc-actions .sc-btn{justify-content:center;min-height:46px;padding:10px 12px;font-size:14.5px;text-align:center}++.sc-note{margin-top:10px;font-size:12px;line-height:1.45}++.sc-stats-sec{margin-top:14px}++.sc-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}++.sc-stat{padding:10px 10px 9px;border-radius:13px;gap:1px;justify-content:flex-end}++.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}++.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++}@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}`;@@@ -74,6 +74,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };for (const name of own) {if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs++ if (name === 'fa-chevron-down') continue; // the account menu's More: only past eight places (4.23.0)++ if (name === 'fa-bell') continue; // the Notifications place: only where the bar has no bell (4.23.0)assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);}assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');@@@ -210,7 +210,7 @@ function load(hostname, opts = {}) {const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');-- assert.ok(src.includes(':is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');++ assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');}@@@ -234,7 +234,12 @@ function load(hostname, opts = {}) {// The account menu, reviewed (the owner, 2026-10-28: "duplicated things … so many icons sizes that aren't consistent"):// no row says what another says, the seasonal switch is on the Themes page, and every icon sits in one box.['Seasonal themes', 'Linked Services', 'Switch account'].forEach((gone) => assert.ok(!dd.innerHTML.includes(gone), `no "${gone}" row`));-- assert.ok(dd.innerHTML.includes('Account & settings') && dd.innerHTML.includes(' Themes <b class="ud-val ud-go"'), 'one row for the account, one for the themes');++ // 4.23.0 (the owner: "a plus button… better use of icons… compactness"): + and the gear beside the name, your places++ // as a grid of icons, and one row at the foot for light/dark, text size, motion and themes; Sign out by the gear.++ assert.ok(/class="ud-hbtn add-account" id="openvibe-add-account"[^>]*aria-label="Add another account"/.test(dd.innerHTML), 'Add another account is a + beside the name');++ assert.ok(dd.innerHTML.includes('aria-label="Account & settings"') && dd.innerHTML.includes('aria-label="Themes"') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Themes and Sign out are icon buttons');++ assert.ok(/<div class="ud-grid">/.test(dd.innerHTML) && /class="ud-tile"[^>]*data-menu-id="mp"/.test(dd.innerHTML), 'the places are a grid of tiles, the site\'s own first');++ assert.ok(!dd.innerHTML.includes('Recently used'), 'the places you went are the search menu\'s');assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');assert.ok(/\.openvibe-navbar-dropdown-menu \.icon \.ovnav-ic \{ width: 1\.25em !important; height: 1em; \}/.test(require('fs').readFileSync(require('path').join(__dirname, '..', 'navbar.js'), 'utf8')), 'every menu icon in one fixed-width box');assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');@@@ -1,8 +1,9 @@'use strict';--// The launcher (the grid button beside the brand) lists every open site of the network, in the Frame's groups, most used--// first within each: Network's /api/frame sends 40 entries (the navbar used to keep 12, so 28 sites were missing from the--// menu, owner 2026-10-17). Runs in a real headless Chrome (skipped without Chrome or with OV_SKIP_BROWSER=1), with the--// Frame answer served through a stubbed fetch so the navbar's own cleaning of it is what is tested.++// Every open site of the network, in the Frame's groups, most used first within each: Network's /api/frame sends 40++// entries (the navbar used to keep 12, so 28 sites were missing from the menu, owner 2026-10-17). Since 4.23.0 they are++// the OpenVibe menu's Sites tab (search.js: the launcher and search became one menu, the owner 2026-10-28), opened by++// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with++// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -30,7 +31,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : realFetch(u, o));</script><script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>--<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [], countViews: false, notifications: false }); });</script>++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script></body></html>`;const server = http.createServer((req, res) => {const u = new URL(req.url, 'http://x');@@@ -50,13 +51,21 @@ const server = http.createServer((req, res) => {await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });// 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(`document.getElementById('openvibe-launcher-btn').click(), true`);-- const read = () => p.evaluate(`(() => { const b = document.querySelector('.ovnav-launcher .ovl-body'); return {-- heads: [...b.querySelectorAll('.ovl-h')].map((e) => e.firstChild.textContent.trim()),-- groups: [...b.querySelectorAll('.ovl-apps')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),-- here: [...b.querySelectorAll('.ovl-app.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),-- small: (b.querySelector('.ovl-app small') || {}).textContent } })()`);++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ // The drawer's "All sites" opens the one menu on its Sites tab.++ 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); })`);++ const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {++ tab: (document.querySelector('.ovs-tab[aria-selected="true"]') || {}).textContent,++ 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,++ 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.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');@@@ -64,11 +73,12 @@ const server = http.createServer((req, res) => {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');-- // Typing filters the sites (and the search box stays on top of the scrolling panel).-- await p.evaluate(`(() => { const q = document.querySelector('.ovnav-launcher .ovl-q'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);++ // Typing goes to a site by its name, first.++ await p.evaluate(`(() => { const q = document.querySelector('.ovs-input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);++ await wait(500);const f = await read();-- assert.deepStrictEqual(f.groups[0], ['Pics'], JSON.stringify(f));-- assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovnav-launcher .ovl-top')).position`), 'sticky');++ assert.strictEqual(f.heads[0], 'Go to', JSON.stringify(f));++ assert.strictEqual(f.rows[0], 'Pics', 'the site whose name starts so is the first answer');console.log('navbar-launcher: all checks passed');} finally {await browser.close();@@@ -70,12 +70,8 @@ const MEASURE = `(() => {const last = v.parts[v.parts.length - 1];assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);-- if (who === 'in') {-- assert.strictEqual(v.minis, 1, `${at}: the folded balances show the first one`);-- assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: a signed-in phone keeps the launcher in the menu`);-- } else if (width >= 375) {-- assert.ok(v.parts.some(([k]) => k === 'launcher'), `${at}: signed out, the launcher stays by the name`);-- }++ if (who === 'in') assert.strictEqual(v.minis, width <= 440 ? 1 : 2, `${at}: the folded balances show ${width <= 440 ? 'the first one' : 'both'}`);++ assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);await p.close();}}@@@ -1,20 +1,21 @@'use strict';// On a phone a tap leaves :hover on whatever it touched, so the launcher showed two lit rows: the site you are on and--// the last one your finger crossed (the owner, 2026-10-25, on openre.stream). The launcher's and the drawer's hover--// backgrounds apply only where a pointer really hovers; the site you are on (is-here, active) and keyboard focus--// stay lit everywhere. On phones a launcher row's description is one line, so the list is short and scannable.++// the last one your finger crossed (the owner, 2026-10-25, on openre.stream). The OpenVibe menu's rows (search.js since++// 4.23.0) and the drawer's light on hover only where a pointer really hovers; the site you are on (is-here, active),++// the keyboard's row and focus stay lit everywhere. On phones a site's description is one line, so the list is short.const assert = require('assert');const fs = require('fs');const path = require('path');--const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');--const hoverBlocks = [...src.matchAll(/@media \(hover: hover\) \{([^\n]*)\}/g)].map((m) => m[1]).join('\n');--const outside = src.replace(/@media \(hover: hover\) \{[^\n]*\}/g, '');++const hoverOnly = (src) => [...src.matchAll(/@media \(hover: hover\) \{([^\n]*)\}/g)].map((m) => m[1]).join('\n');++const outsideOf = (src) => src.replace(/@media \(hover: hover\) \{[^\n]*\}/g, '');++const nav = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');++const menu = fs.readFileSync(path.join(__dirname, '..', 'search.js'), 'utf8');--assert.ok(/\.ovnav-launcher :is\(\.ovl-app, \.ovl-fam\):hover/.test(hoverBlocks), 'launcher rows light on hover only where hover exists');--assert.ok(/\.ovnav-drawer a:hover/.test(hoverBlocks), 'drawer links too');--assert.ok(!/\.ovl-(app|fam):hover|:is\(\.ovl-app, \.ovl-fam\):hover/.test(outside), 'no launcher hover rule outside (hover: hover)');--assert.ok(!/\.ovnav-drawer a:hover/.test(outside), 'no drawer hover rule outside (hover: hover)');--assert.ok(/\.ovnav-launcher \.ovl-app\.is-here \{[^}]*background/.test(outside) && /\.ovnav-launcher a:focus-visible \{[^}]*background/.test(outside), 'the site you are on and keyboard focus are lit on every device');--assert.ok(/@media \(max-width: 480px\) \{[^\n]*\.ovnav-launcher \.ovl-app small \{ -webkit-line-clamp: 1; \}/.test(src), 'one-line descriptions on phones');++assert.ok(/\.ovs-item a:hover, \.ovs-chip:hover, \.ovs-tile:hover/.test(hoverOnly(menu)), 'menu rows, chips and tiles light on hover only where hover exists');++assert.ok(!/\.ovs-(item a|chip|tile|tab|x):hover/.test(outsideOf(menu)), 'no menu hover rule outside (hover: hover)');++assert.ok(/\.ovs-tile\.is-here \{[^}]*background/.test(outsideOf(menu)) && /\.ovs-opt\.is-active, \.ovs-opt:focus-visible \{[^}]*background/.test(outsideOf(menu)), 'the site you are on, the keyboard\'s row and focus are lit on every device');++assert.ok(/@media \(max-width: 640px\) \{[\s\S]*?\.ovs-tile small \{ -webkit-line-clamp: 1; \}/.test(menu), 'one-line descriptions on phones');++assert.ok(/\.ovnav-drawer a:hover/.test(hoverOnly(nav)), 'drawer links too');++assert.ok(!/\.ovnav-drawer a:hover/.test(outsideOf(nav)), 'no drawer hover rule outside (hover: hover)');console.log('navbar-touch-hover: ok');@@@ -29,6 +29,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>const url = new URL(String(u), location.href);if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');++ if (url.hostname === 'openvibe.network') return new Response('{}', { status: 404 }); // the Frame and the catalog: offline herereturn window.__realFetch(u, init);};OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],@@@ -70,16 +71,28 @@ const server = http.createServer((req, res) => {await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');await wait(400);let v = await p.evaluate(`({ open: !!document.querySelector('.ovs.is-open'), focused: document.activeElement && document.activeElement.className,-- groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-title')].map((t) => t.textContent),-- placeholder: document.querySelector('.ovs-input').placeholder })`);++ tabs: [...document.querySelectorAll('.ovs-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 })`);assert.ok(v.open && v.focused === 'ovs-input', '"/" opened the panel and focused the box');-- assert.deepStrictEqual([v.groups, v.rows], [['On OpenVibe.Chat'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages');-- assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat and all of OpenVibe');++ assert.deepStrictEqual(v.tabs, ['For you*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');++ assert.deepStrictEqual([v.groups, v.chips], [['On OpenVibe.Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages, then the sites');++ assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');++ assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat, or jump to any site');++ // 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; })()`);++ assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');++ await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');++ 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');// 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);v = await p.evaluate(`({ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href')]),-- bold: !!document.querySelector('.ovs-list b'), active: document.querySelector('.ovs-item.is-active .ovs-title').textContent })`);++ bold: !!document.querySelector('.ovs-list .ovs-title b'), active: document.querySelector('.ovs-opt.is-active .ovs-title').textContent })`);assert.deepStrictEqual(v.groups, ['On OpenVibe.Chat', 'Across OpenVibe', 'Search']);assert.deepStrictEqual(v.rows[0], ['night room', 'https://openvibe.chat/r/night-owls'], 'the room on this site comes first');assert.ok(v.rows.some(([t, u]) => t === 'night stream <b>bold</b>' && u === 'https://openvibe.video/v/1'), 'a title is text, never markup');@@@ -91,7 +104,7 @@ const server = http.createServer((req, res) => {assert.strictEqual(v.active, 'night room', 'the first row is ready for Enter');assert.ok(asked.includes('night'), 'Search was asked');await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');-- assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-item.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');assert.strictEqual(await p.evaluate('!!document.querySelector(".ovs.is-open")'), false, 'Escape closes');@@@ -113,4 +113,16 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(rise && !/opacity/.test(rise[0]), 'the reveal only moves: a section that fades in is measured half-transparent, so its text fails contrast checks (axe on every product home)');}++// The hero on a phone (4.23.0; the owner on openvibe.quest: "why is openvibe.quest twice… empty space at the top"): an++// eyebrow that is only the site's name is left out (the navbar shows it), any other eyebrow stays, with an icon when++// given; numbers take an icon; phones start the hero near the bar, put the buttons side by side and the numbers three across.++{++ ['OpenVibe.Quest', 'OpenRestream', 'Play.OpenVibe.Network'].forEach((name) => assert.ok(!sc.hero({ eyebrow: name, title: 'T' }).includes('sc-eyebrow'), `${name}: no eyebrow under the navbar's own brand`));++ assert.ok(/<p class="sc-eyebrow">Free, nothing to install<\/p>/.test(sc.hero({ eyebrow: 'Free, nothing to install', title: 'T' })), 'an eyebrow that says something stays');++ assert.ok(/<p class="sc-eyebrow"><(i|span)[^>]*>.*<\/(i|span)>New<\/p>/.test(sc.hero({ eyebrow: { icon: 'fa-star', text: 'New' }, title: 'T' })), 'an eyebrow can carry an icon');++ assert.ok(/<dd><span class="sc-stat-ic">.*<\/span>39<\/dd>/.test(sc.stats({ items: [{ value: 39, label: 'quests', icon: 'fa-star' }] })), 'a number can carry an icon');++ const phone = sc.CSS.slice(sc.CSS.indexOf('@media (max-width:640px){'));++ assert.ok(/\.sc-hero\{padding-top:14px\}/.test(phone) && /\.sc-actions\{display:grid;grid-template-columns:repeat\(auto-fit,minmax\(0,1fr\)\)/.test(phone) && /\.sc-stats\{grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/.test(phone), 'phones: near the bar, buttons side by side, numbers three across');++}++console.log('showcase: all checks passed');
@@ -6,6 +6,60 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,60 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.23.0 — 2026-10-28
+
+The owner, 2026-10-28, on the phone:
+- the launcher and the magnifier "kind of accomplish the same exact thing… combine… a better hierarchy… use this user's
+ history… autocomplete… maybe use tabs";
+- the account menu: "a lot of this is just not even really needed… a plus button… better use of icons… compactness…
+ data-driven";
+- the balances "look a little weird… polish… use the space".
+
+- **One OpenVibe menu** (`search.js`). The magnifier, `/` or Ctrl+K opens search, every site and every tool together.
+ - **Before typing**, three tabs:
+ - *For you*: jump back in from the person's Network history, this site's pages, recent searches, and their sites
+ (or the most used across OpenVibe);
+ - *Sites*: every site in the Frame's groups;
+ - *Tools*: your tools, the most used tools from the Frame's analytics, and the families.
+ - **Typing** makes one ranked list: go to (name matches; tools you used first), this site's results and pages, your
+ history, the rest of OpenVibe (the sites you use first), tools for it, your searches, then search everywhere and
+ Ask Vibe.
+ - **Completion:** the box completes from your searches, history, the sites and this site's pages; Tab or → takes it.
+ - Results carry their site's icon.
+ - `OpenVibeNavbar.openMenu(tab)` opens it on a tab.
+ - The drawer's "All sites" opens the Sites tab.
+- **The launcher button is gone.** Its panel, catalog and site lists moved into `search.js`, which loads on first use,
+ so `navbar.js` is 15 KB smaller (161 → 146 KB raw, 37.4 → 33.8 KB brotli). Guests' display controls moved to the
+ foot of the phone drawer.
+- **A compact account menu.**
+ - **Header:** + (add an account), the gear and sign out sit beside the name; other accounts are small pills.
+ - **Your places:** a grid of icons, four across, the site's own first, eight shown and the rest under More. The order
+ follows what this person opens: counted on this device only, a place opened twice moves up.
+ - **Foot:** one row for light, dark, auto, text size, motion and themes.
+ - **Gone from it:** Notifications (the bell has them; the row stays where there is no bell), "Recently used" (the
+ menu's Jump back in) and the long list.
+ - Items take `short` for the grid's label.
+- **Calm balances.**
+ - **The pill:** one pill on the bar's own surface; each balance a coloured icon and a plain number, a hairline between
+ them.
+ - **On phones:** the pill shows the first balance up to 440 px, so the site's name always reads whole, with Apple's
+ wider system font too; a tap lists both. Wider screens show both. The pill is compact at every phone width.
+ - **Computers:** one chip per balance in the same style.
+ - **Room:** on phones the bar drops the empty spacer's gap.
+- **The showcase hero on a phone** (the owner on openvibe.quest: "why is there empty space at the top… why is
+ openvibe.quest twice… better visuals… icon usage… reduce wasted space… the same for all the other sites"). Every site
+ built on `showcase` gets:
+ - **No repeated name:** an eyebrow that is only the site's name ("OpenVibe.Quest") is left out, since the navbar shows
+ it; any other eyebrow stays, and `{ icon, text }` gives it an icon.
+ - **Less space and text:** the hero starts 14 px under the bar (was 36), and the title and lede tighten.
+ - **Buttons:** they sit side by side.
+ - **Numbers:** they go three across, and `stats` items take an `icon`.
+- The mark's seasonal leaves fall under the menus, not over them.
+- `docs/kit.md`: the OpenVibe menu, the account menu, balances.
+- Tests: `navbar-launcher` covers the Sites tab and "All sites"; `search-loading-chrome` covers the tabs and the
+ completion; `navbar-touch-hover` checks the menu's hover-only rows; `navbar-phone-fit-chrome` checks the bar
+ from 320 to 430 px; `navbar-brand` covers the new header and grid.
+
## 4.22.2 — 2026-10-28
## 4.22.2 — 2026-10-28
- **In-page links scroll instead of reloading** (`boost.js`). A link to a part of the page (`href="#the-quests"`) made
- **In-page links scroll instead of reloading** (`boost.js`). A link to a part of the page (`href="#the-quests"`) made
@@ -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.22.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.23.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.22.2",
+ "version": "4.23.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)",
@@ -256,34 +256,96 @@ OpenVibeNavbar.setAppBar(spec | false)
@@ -256,34 +256,96 @@ OpenVibeNavbar.setAppBar(spec | false)
Pick the places people go every visit: the home or feed, search, what is theirs (messages, saved, their page), and
Pick the places people go every visit: the home or feed, search, what is theirs (messages, saved, their page), and
the site's main verb (go live, post, ask). Settings and the rest live in More.
the site's main verb (go live, post, ask). Settings and the rest live in More.
-## Search, on every site
+## The OpenVibe menu: search, every site, every tool
-The navbar has a magnifier on every site. `/` or Ctrl+K opens it too. The first time someone asks, the navbar loads
-`search.js` (about 17 KB), so pages that are never searched pay nothing.
+One control on every site: the navbar's magnifier (or `/`, or Ctrl+K) opens the OpenVibe menu. It used to be two
+panels doing the same job, the launcher's dots (every site) and the magnifier (search). The owner, 2026-10-28:
+"combine… a better hierarchy… use this user's history… autocomplete… maybe use tabs". The navbar loads `search.js` the
+first time someone asks, so a page nobody searches pays nothing (and the navbar itself got 15 KB lighter).
-What it does:
-- **Before typing:** the site's own pages, and what you searched before on this device.
-- **While typing:** suggestions from OpenVibe.Search for every site, this site's results first. They are anonymous, and
- the older request is cancelled when you type on.
-- **Always offered:** the site's own search page (when it has one), "Search all of OpenVibe" and "Ask Vibe".
-- **Keys:** arrows move, Enter opens, Escape closes.
-- **Layout:** a phone gets the whole screen; a computer gets a panel near the top.
+**Before typing**, three tabs:
+
+| Tab | What it shows |
+| --- | --- |
+| For you | **Jump back in**: the places this person went lately anywhere on OpenVibe (their Network history, signed in). **On this site**: the site's own pages as chips. **Recent searches** (this device, can be cleared). **Your sites**: the sites they use most, or, for a guest, the most used across OpenVibe (the Frame's analytics ranking). |
+| Sites | Every open site by what people come to do (the Frame's groups), the one you are on marked. The phone drawer's "All sites" opens this tab. |
+| Tools | **Your tools** (from their history), **Most used on OpenVibe** (the Frame's analytics), the tool families, and every tool. |
+
+**Typing** makes one ranked list:
+1. **Go to**: sites, tool families and tools whose *name* matches. Tools the person used before come first.
+2. **On this site**: OpenVibe.Search's results for this site, and its matching pages.
+3. **Your history**: pages they visited whose title matches.
+4. **Across OpenVibe**: the rest of Search's suggestions, the sites they use most first.
+5. **Tools for it**: tools whose description mentions it.
+6. **Your searches**, then **Search**: this site's own search page, all of OpenVibe, and Ask Vibe.
+
+The box also completes what you type, from your searches, your history, the sites and this site's pages. The
+completion shows faintly behind the text; Tab or → takes it.
+
+**Keys:** arrows move, Enter opens, Escape closes.
+
+**Layout:** a phone gets the whole screen; a computer gets a panel near the top, with the keys listed at its foot.
```js
```js
OpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false
OpenVibeNavbar.init({ …, search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' } }) // or search: false
-OpenVibeNavbar.openSearch('seattle') // open it from anything on the page
+OpenVibeNavbar.openSearch('seattle') // open it on a search
+OpenVibeNavbar.openMenu('sites') // or on a tab: 'you' | 'sites' | 'tools'
OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box
OpenVibeSearch.suggest('sea', { owner: 'live' }) // the same suggestions for a page's own box
```
```
-The suggestions come through OpenVibe.Network: `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`
-asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers for 30 seconds. A site's Content-Security-Policy
-already lets the navbar reach `openvibe.network`, so a site needs nothing new in its `connect-src`.
+**Where the data comes from:**
+
+| Data | Source |
+| --- | --- |
+| Suggestions | OpenVibe.Network's `GET https://openvibe.network/api/frame/suggest?q=&owner=&limit=`, which asks OpenVibe.Search's `GET /api/v1/suggest` and keeps answers 30 s. |
+| History | `GET https://openvibe.network/api/history?limit=60` with the person's own token, read once per opening and kept two minutes. Anonymous sessions keep none. |
+| Sites and the ranking | The Frame (`/api/frame`, which the navbar already holds). |
+| Tools | `https://openvibe.network/api/catalog.json`, kept 30 minutes. |
-Both paths answer any origin (anonymous, never with credentials). So the same suggestions work from anyone's page:
-pass `{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The
-SDK has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and
+Every one of those is on `openvibe.network`, which every site's Content-Security-Policy already allows, so a site
+needs nothing new in its `connect-src`.
+
+Search answers any origin (anonymous, never with credentials), so the same suggestions work from anyone's page. Pass
+`{ url: 'https://search.openvibe.network/api/v1/suggest' }` where your `connect-src` allows Search itself. The SDK
+has them too: `openvibe-sdk/search`'s `search.suggest(text, { owner, type, limit })` and
`search.query(text, { owner, type })`.
`search.query(text, { owner, type })`.
+## The account menu
+
+Pressing your picture opens a compact menu (4.23.0; the owner: "a lot of this is just not even really needed… a plus
+button… better use of icons… compactness… data-driven"):
+
+- **The header:** your picture and name (both open your profile). Beside them, **+** (add another account), the
+ **gear** (account and settings) and **sign out**. Other accounts, and Anonymous where the switcher runs, are a row of
+ small pills under the name.
+- **Your places:** a grid of icons, four across, with the site's own items first (`menu.before` / `menu.after`) and then
+ My channel, History, Ask Vibe, Work together and Admin. Notifications appear only where the bar has no bell. The
+ grid shows eight; the rest are under **More**.
+- **The order follows use:** a place opened twice moves up. It is counted on this device only, in `ov-menu-use`, and
+ never sent anywhere.
+- **One row at the foot:** Light, Dark, Auto, text size, animations and themes. Guests find the same controls at the
+ foot of the phone drawer.
+
+A site's items can carry `short` (the grid's label: `{ label: 'Streamer setup', short: 'Setup' }`). `menu.sections`
+still render as their own rows.
+
+## Home page heroes (showcase)
+
+`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since
+4.23.0):
+- it starts right under the bar;
+- the two buttons sit side by side;
+- the numbers after it (`showcase.stats`) go three across, each with an optional `icon`.
+
+Don't pass the site's own name as the eyebrow: it is left out, because the navbar's brand already says it. Use the
+eyebrow for something worth reading first ("Free, nothing to install"), with `{ icon, text }` for an icon.
+
+## Balances
+
+`wallet: true` (or a site's own `coins` and `vibes` chips) shows the balances as one calm pill on phones. Each balance
+is a coloured icon and a plain number, with a hairline between them. Up to 440 px only the first balance shows, and a
+tap lists them all. Computers get one chip per balance in the same style.
+
## Loading
## Loading
`loading.js` is the network's one loading look, on Live's player and anywhere something takes a moment. It covers
`loading.js` is the network's one loading look, on Live's player and anywhere something takes a moment. It covers
@@ -57,7 +57,7 @@
@@ -57,7 +57,7 @@
.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-core { padding-right: 0; }
.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-core { padding-right: 0; }
.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-tld { padding-left: 0; }
.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-tld { padding-left: 0; }
- .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ .openvibe-navbar .openvibe-navbar-brand a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
/* A finger needs about 44px: on touch screens the wordmark's links reach the navbar's height and the mark
/* A finger needs about 44px: on touch screens the wordmark's links reach the navbar's height and the mark
gets a clear ring, without moving anything (they were 22px and 30px tall). */
gets a clear ring, without moving anything (they were 22px and 30px tall). */
@media (pointer: coarse) {
@media (pointer: coarse) {
@@ -65,27 +65,15 @@
@@ -65,27 +65,15 @@
.openvibe-navbar .openvibe-navbar-brand a.flame { position: relative; }
.openvibe-navbar .openvibe-navbar-brand a.flame { position: relative; }
.openvibe-navbar .openvibe-navbar-brand a.flame::after { content: ''; position: absolute; inset: -7px; }
.openvibe-navbar .openvibe-navbar-brand a.flame::after { content: ''; position: absolute; inset: -7px; }
}
}
- .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 2px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
- .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
- .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
- .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
- .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
- @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
- .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; height: 1em; font-size: 1em; vertical-align: -.125em; overflow: visible; }
- .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }
- .openvibe-navbar-links::-webkit-scrollbar { display: none; }
- .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }
- .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }
- .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }
- .openvibe-navbar .ovnav-dot[hidden] { display: none; }
- @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }
- .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }
.openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }
.openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }
.openvibe-navbar .ovnav-dd-menu { display: none; position: fixed; top: 56px; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }
.openvibe-navbar .ovnav-dd-menu { display: none; position: fixed; top: 56px; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }
.openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }
.openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }
.openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }
.openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }
.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
.ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
+ /* A single balance chip (computers): the same calm surface, the icon in its colour, the number in the text's. */
+ .openvibe-navbar .ovnav-chip[data-tone] { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }
+ .openvibe-navbar .ovnav-chip[data-tone] .ovnav-ic { color: var(--chip-c); width: 15px; height: 15px; }
+ .openvibe-navbar .ovnav-chip[data-tone]:hover { background: color-mix(in srgb, var(--chip-c) 12%, transparent); border-color: color-mix(in srgb, var(--chip-c) 35%, transparent); }
.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
.openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
.openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
.openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; }
.openvibe-navbar-dropdown-header a.ud-me { color: inherit; text-decoration: none; display: block; line-height: 0; }
@@ -118,19 +106,31 @@
@@ -118,19 +106,31 @@
.ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; }
.ovnav-drawer-net .ovnav-net-all { all: unset; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); padding: 3px 6px; border-radius: 7px; }
.ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
.ovnav-drawer-net .ovnav-net-all:hover, .ovnav-drawer-net .ovnav-net-all:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); }
.ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; }
.ovnav-drawer-net .ovnav-net-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; }
+ .ovnav-display { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 6px; padding: 10px 6px 4px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }
+ .ovnav-display[hidden] { display: none; }
+ .ovnav-display .ovnav-dl { flex-basis: 100%; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; }
+ .ovnav-display .ovnav-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }
+ .ovnav-display .ovnav-seg button { all: unset; cursor: pointer; padding: 6px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+ .ovnav-display .ovnav-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .ovnav-display .ovnav-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+ .ovnav-drawer .ovnav-display a { display: inline; padding: 6px 4px; margin-left: auto; font-size: 12px; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net { gap: 9px; padding: 8px; font-size: 13.5px; min-width: 0; }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-drawer .ovnav-drawer-net a.ovnav-net > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */
/* Icons have their size and a faint ring before ov-icons.js draws them: nothing moves or pops when they arrive. */
- :is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }
- .ovnav-launcher .ovl-app > .ov-icon { width: 28px; height: 28px; }
+ .ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px; border-radius: 50%; }
.ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }
.ovnav-drawer .ov-icon[data-size="22"] { width: 22px; height: 22px; }
- :is(.ovnav-launcher, .ovnav-drawer) .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
+ .ovnav-drawer .ov-icon:empty { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
/* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site
/* Several balance chips fold into one on a phone (owner, 2026-10-27: the two currencies pushed out the site
switcher next to the logo); a tap lists each in full. */
switcher next to the logo); a tap lists each in full. */
- .openvibe-navbar .ovnav-chip--all { display: none; gap: 8px; padding: 0 10px; --chip-c: var(--text-secondary, #a8b3c4); }
- .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; color: var(--chip-c, inherit); }
+ /* The balances (owner, 2026-10-28: "the coins and vibes badges look a little weird… polish… use the space"): one
+ calm pill on the bar's own surface, each balance a coloured icon and a plain number, a hairline between. */
+ .openvibe-navbar .ovnav-chip.ovnav-chip--all { display: none; gap: 0; padding: 0 1px; font-size: 12.5px; --chip-c: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); border-color: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); }
+ .openvibe-navbar .ovnav-chip--all:hover, .openvibe-navbar .ovnav-chip--all[aria-expanded="true"] { background: color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent); }
+ .ovnav-chip-mini { display: inline-flex; align-items: center; gap: 4px; padding: 0 6px; color: var(--text-primary, #e6edf7); }
+ .ovnav-chip-mini + .ovnav-chip-mini { border-left: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); }
+ .ovnav-chip-mini .ovnav-ic { color: var(--chip-c, inherit); }
.ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }
.ovnav-chip-mini[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip-mini[data-tone="green"] { --chip-c: #4ade80; }
- .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; }
+ .ovnav-chip-mini .ovnav-ic { width: 14px; height: 14px; }
.ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;
.ovnav-chips-pop { position: absolute; top: calc(100% + 6px); right: 8px; z-index: 1002; min-width: 220px; padding: 6px; border-radius: 14px; display: none; flex-direction: column; gap: 2px;
background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }
background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 40px rgba(0,0,0,.45); }
.ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }
.ovnav-chips-pop.open { display: flex; animation: openvibe-slide-down .18s ease; }
@@ -143,56 +143,16 @@
@@ -143,56 +143,16 @@
.openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
.openvibe-navbar-right.has-chips-all .ovnav-chip[data-chip-id] { display: none; }
.openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
.openvibe-navbar-right.has-chips-all .ovnav-chip--all { display: inline-flex; }
}
}
- @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
+ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links, .openvibe-navbar .openvibe-navbar-spacer { display: none; } .openvibe-navbar .openvibe-navbar-right { margin-left: auto; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
@media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
/* A narrow phone never draws the site's name over the bar's buttons (owner, 2026-10-28: on openvibe.games, signed
- in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis; up to 440px the
- folded balances show the first one only (a tap lists them all), and a signed-in bar (balances, bell, picture) lets
- the launcher step into the menu, whose "All sites" opens it (everyone's under 375px). */
+ in, the name ran under the launcher and the magnifier). The name gives way with an ellipsis, and up to 440px
+ the folded balances show the first one only (a tap lists them all). */
.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }
.openvibe-navbar .openvibe-navbar-brand { flex: 0 1 auto; }
.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }
.openvibe-navbar .openvibe-navbar-brand a.flame { flex: none; }
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
.openvibe-navbar .openvibe-navbar-brand .name { display: block; min-width: 0; white-space: nowrap; overflow-x: clip; text-overflow: ellipsis; }
@media (max-width: 440px) { .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini ~ .ovnav-chip-mini { display: none; } }
@media (max-width: 440px) { .openvibe-navbar .ovnav-chip--all .ovnav-chip-mini ~ .ovnav-chip-mini { display: none; } }
- @media (max-width: 440px) { .openvibe-navbar:has(#openvibe-avatar-btn) .ovnav-launch { display: none; } }
@media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }
@media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 2px; } }
- @media (max-width: 374px) { .openvibe-navbar .ovnav-launch { display: none; } }
- .ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; box-sizing: border-box; width: min(760px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 0 12px 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
- .ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
- .ovnav-launcher .ovl-top { position: sticky; top: 0; z-index: 1; margin: 0 -12px; padding: 12px 12px 2px; background: inherit; }
- .ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
- .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
- .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
- .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
- .ovnav-launcher .ovl-apps { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 2px 4px; }
- .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 4px; }
- .ovnav-launcher .ovl-app, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
- .ovnav-launcher .ovl-app { padding: 7px 8px; border: 1px solid transparent; }
- .ovnav-launcher :is(.ovl-app, .ovl-fam) > span:last-child { min-width: 0; }
- .ovnav-launcher .ovl-app small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.3; }
- .ovnav-launcher .ovl-app.is-here { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: var(--accent-glow, rgba(59,130,246,.14)); }
- /* Hover only where a pointer hovers: on a phone a tap left its row lit beside the site you are on (the owner, 2026-10-25). */
- .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
- @media (hover: hover) { .ovnav-launcher :is(.ovl-app, .ovl-fam):hover { background: var(--accent-glow, rgba(59,130,246,.14)); } }
- .ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
- .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
- .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
- .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
- .ovnav-launcher .ovl-display { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; padding: 10px 4px 2px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #7d8aa0); }
- .ovnav-launcher .ovl-display[hidden] { display: none; }
- .ovnav-launcher .ovl-dl { font-weight: 700; letter-spacing: .6px; text-transform: uppercase; font-size: 11px; margin-right: 2px; }
- .ovnav-launcher .ovl-seg { display: inline-flex; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 9px; overflow: hidden; }
- .ovnav-launcher .ovl-seg button { all: unset; cursor: pointer; padding: 5px 9px; font-size: 12px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
- .ovnav-launcher .ovl-seg button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
- .ovnav-launcher .ovl-seg button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
- .ovnav-launcher .ovl-display a { margin-left: auto; color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 600; }
- .ovnav-launcher .ovl-addr a { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; }
- .ovnav-launcher .ovl-addr a.is-here { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); }
- .ovnav-launcher .ovl-all { grid-column: 1 / -1; color: var(--accent-light, var(--accent, #60a5fa)); }
- .ovnav-launcher .ovl-h span { text-transform: none; letter-spacing: 0; font-weight: 500; }
- .ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
- @media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher :is(.ovl-apps, .ovl-fams) { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ovnav-launcher .ovl-app small { -webkit-line-clamp: 1; } }
- @media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
.openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
/* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
@@ -221,15 +181,6 @@
@@ -221,15 +181,6 @@
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
}
}
.openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
.openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
- .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
- .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
- .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #60a5fa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
- .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
- .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
- .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }
- .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
- .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }
.openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
.openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
.openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
.openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
@@ -274,7 +225,7 @@
@@ -274,7 +225,7 @@
.openvibe-navbar-dropdown {
.openvibe-navbar-dropdown {
position: absolute; top: 48px; right: 8px;
position: absolute; top: 48px; right: 8px;
- width: 260px; background: var(--bg-card, #22222c);
+ width: min(320px, calc(100vw - 16px)); background: var(--bg-card, #22222c);
border: 1px solid var(--border, #333340); border-radius: 10px;
border: 1px solid var(--border, #333340); border-radius: 10px;
box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
box-shadow: var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
display: none; flex-direction: column; overflow: hidden;
display: none; flex-direction: column; overflow: hidden;
@@ -287,7 +238,7 @@
@@ -287,7 +238,7 @@
.openvibe-navbar-dropdown > * { flex-shrink: 0; }
.openvibe-navbar-dropdown > * { flex-shrink: 0; }
/* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the address
/* Panels never leave the screen: dvh is the VISIBLE height on phones (100vh reaches behind the address
bar), the panel scrolls inside itself, and it is never wider than the viewport. */
bar), the panel scrolls inside itself, and it is never wider than the viewport. */
- .openvibe-navbar-dropdown, .ovnav-launcher {
+ .openvibe-navbar-dropdown {
max-width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));
max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px - env(safe-area-inset-bottom, 0px));
overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
@@ -295,34 +246,34 @@
@@ -295,34 +246,34 @@
@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes openvibe-slide-down { from { transform: translateY(-8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.openvibe-navbar-dropdown-header {
.openvibe-navbar-dropdown-header {
- padding: 14px 16px; border-bottom: 1px solid var(--border, #333340);
- display: flex; align-items: center; gap: 10px;
+ padding: 12px 10px 12px 14px; border-bottom: 1px solid var(--border, #333340);
+ display: flex; align-items: center; gap: 11px;
}
}
- .openvibe-navbar-dropdown-header img { width: 36px; height: 36px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-header img { width: 40px; height: 40px; border-radius: 50%; }
+ .openvibe-navbar-dropdown-header .ud-hacts { display: flex; gap: 2px; align-self: flex-start; }
+ .openvibe-navbar-dropdown .ud-hbtn { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); text-decoration: none; }
+ .openvibe-navbar-dropdown .ud-hbtn:hover, .openvibe-navbar-dropdown .ud-hbtn:focus-visible { background: var(--bg-hover, rgba(255,255,255,.08)); color: var(--text-primary, #e6edf7); }
+ .openvibe-navbar-dropdown .ud-hbtn .icon { font-size: 15px; }
.openvibe-navbar-dropdown-header .info { line-height: 1.3; }
.openvibe-navbar-dropdown-header .info { line-height: 1.3; }
.openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
.openvibe-navbar-dropdown-header .info .name { font-size: 14px; font-weight: 600; }
.openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
.openvibe-navbar-dropdown-header .info .email { font-size: 11px; color: var(--text-muted, #707080); }
.openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }
.openvibe-navbar-dropdown-header .info .anon-tag { font-size: 10px; color: var(--accent-light, #60a5fa); }
+ /* Other accounts: a row of small pills under the name (one tap switches); Anonymous is one of them. */
.openvibe-navbar-dropdown-accounts {
.openvibe-navbar-dropdown-accounts {
- padding: 6px 8px; border-bottom: 1px solid var(--border, #333340);
- max-height: 140px; overflow-y: auto;
+ display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border, #333340);
+ overflow-x: auto; scrollbar-width: none;
}
}
.openvibe-navbar-dropdown-accounts .account-item {
.openvibe-navbar-dropdown-accounts .account-item {
- display: flex; align-items: center; gap: 8px; padding: 8px;
- border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
- color: var(--text-primary, #e0e0e0); transition: background .12s;
+ display: inline-flex; align-items: center; gap: 6px; flex: none; max-width: 150px; height: 32px; padding: 0 10px 0 4px;
+ border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); cursor: pointer; font-size: 12.5px; font-weight: 600;
+ color: var(--text-primary, #e0e0e0); text-decoration: none; transition: background .12s;
}
}
- .openvibe-navbar-dropdown-accounts .account-item:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-accounts .account-item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-navbar-dropdown-accounts .account-item.ud-anon { padding-left: 9px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-accounts .account-item:hover, .openvibe-navbar-dropdown-accounts .account-item:focus-visible { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
.openvibe-navbar-dropdown-accounts .account-item img { width: 24px; height: 24px; border-radius: 50%; }
.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
.openvibe-navbar-dropdown-accounts .account-item.active { color: var(--accent-light, #60a5fa); font-weight: 600; }
- .openvibe-navbar-dropdown-accounts .add-account {
- display: flex; align-items: center; gap: 8px; padding: 8px;
- border-radius: 6px; cursor: pointer; font-size: 12px;
- color: var(--text-muted, #707080); transition: background .12s;
- text-decoration: none;
- }
- .openvibe-navbar-dropdown-accounts .add-account:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
.openvibe-navbar-dropdown-menu { padding: 6px 8px; }
.openvibe-navbar-dropdown-menu { padding: 6px 8px; }
.openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {
.openvibe-navbar-dropdown-menu a, .openvibe-navbar-dropdown-menu button {
@@ -335,11 +286,36 @@
@@ -335,11 +286,36 @@
.openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-menu .danger { color: var(--live-red-text, var(--live-red, #e74c3c)); }
.openvibe-navbar-dropdown-menu .danger { color: var(--live-red-text, var(--live-red, #e74c3c)); }
/* Light / Dark / Auto (theme-loader.js scheme): three equal segments, the one in use filled. */
/* Light / Dark / Auto (theme-loader.js scheme): three equal segments, the one in use filled. */
- .openvibe-navbar-dropdown-menu .ud-scheme { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; margin: 2px 0 6px; padding: 3px; border-radius: 9px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }
- .openvibe-navbar-dropdown-menu .ud-scheme button { justify-content: center; gap: 6px; padding: 7px 4px; border-radius: 7px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
+ /* Your places: a grid of icons over short names, four across (the menu was 13 rows and a display block). */
+ .openvibe-navbar-dropdown-menu .ud-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 2px; padding: 2px 0 4px; }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile { flex-direction: column; justify-content: flex-start; gap: 6px; min-height: 66px; padding: 9px 2px 7px; text-align: center; border-radius: 12px; font-size: 11.5px; font-weight: 600; line-height: 1.2; }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile[hidden] { display: none; }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .icon { width: 38px; height: 30px; border-radius: 10px; font-size: 16px; background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile:hover .icon { background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-navbar-dropdown-menu .ud-tl { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; max-width: 100%; }
+ .openvibe-navbar-dropdown-menu .ud-grid .ud-tile .ud-val { margin: 0; font-size: 10.5px; }
+ .openvibe-navbar-dropdown-menu .ud-more { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin: 2px -8px 0; padding: 2px 8px; }
+ .openvibe-navbar-dropdown-menu .ud-more summary { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; cursor: pointer; list-style: none; font-size: 13px; font-weight: 600; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-menu .ud-more summary::-webkit-details-marker { display: none; }
+ .openvibe-navbar-dropdown-menu .ud-more summary:hover { background: var(--bg-hover, #2f2f3d); }
+ .openvibe-navbar-dropdown-menu .ud-more[open] summary .icon { transform: rotate(180deg); }
+ .openvibe-navbar-dropdown-menu .ud-more .ud-n { margin-left: auto; font-size: 11.5px; color: var(--text-muted, #7d8aa0); }
+ /* One row at the foot: Light, Dark, Auto, text size, motion, themes, and Sign out. */
+ .openvibe-navbar-dropdown-menu .ud-foot { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 4px -8px -6px; padding: 8px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib { width: auto; min-width: 34px; height: 34px; padding: 0 8px; justify-content: center; gap: 3px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .icon { width: 18px; color: inherit; }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val { margin: 0; font-size: 11px; font-weight: 700; color: inherit; }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib .ud-val:empty { display: none; }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-ib[aria-pressed="true"] { color: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-navbar-dropdown-menu .ud-foot .ud-scheme .ud-ib[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .openvibe-navbar-dropdown .ud-hbtn.ud-out { all: unset; box-sizing: border-box; cursor: pointer; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown .ud-hbtn.ud-out:hover, .openvibe-navbar-dropdown .ud-hbtn.ud-out:focus-visible { color: var(--live-red-text, var(--live-red, #f87171)); background: color-mix(in srgb, var(--live-red, #ef4444) 14%, transparent); }
+ .openvibe-navbar-dropdown-menu .ud-scheme { display: inline-flex; gap: 2px; padding: 2px; border-radius: 11px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }
+ .openvibe-navbar-dropdown-menu .ud-scheme[hidden] { display: none; }
+ .openvibe-navbar-dropdown-menu .ud-scheme button { width: 32px; min-width: 0; height: 30px; padding: 0; justify-content: center; border-radius: 9px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .ud-scheme button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
.openvibe-navbar-dropdown-menu .ud-scheme button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
.openvibe-navbar-dropdown-menu .ud-scheme button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.openvibe-navbar-dropdown-menu .ud-scheme button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
- .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 13px; }
+ .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 14px; }
/* One box for every icon in the menu: a glyph keeps its height and is centred, whatever its width (an id card
/* One box for every icon in the menu: a glyph keeps its height and is centred, whatever its width (an id card
is wider than a bell), so the rows' icons line up and read as one set. */
is wider than a bell), so the rows' icons line up and read as one set. */
.openvibe-navbar-dropdown-menu .icon { width: 20px; height: 16px; font-size: 15px; color: var(--text-secondary, #a8b3c4); }
.openvibe-navbar-dropdown-menu .icon { width: 20px; height: 16px; font-size: 15px; color: var(--text-secondary, #a8b3c4); }
@@ -366,13 +342,13 @@
@@ -366,13 +342,13 @@
instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zero
instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zero
specificity, so the Frame's own :focus-visible rules and a host page's rules still win.
specificity, so the Frame's own :focus-visible rules and a host page's rules still win.
\`scale\` is its own property, so it composes with any transform already in use. */
\`scale\` is its own property, so it composes with any transform already in use. */
- .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }
- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }
- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
+ .openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px;
outline: 2px solid var(--accent-light, var(--accent, #3b82f6)); outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
- :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-launcher, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }
+ :where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: none; opacity: .82; }
}
}
`;
`;
document.head.appendChild(s);
document.head.appendChild(s);
@@ -398,6 +374,7 @@
@@ -398,6 +374,7 @@
'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
'fa-tower-broadcast': [576, 'M80 55Q64 94 64 139Q64 184 80 223Q85 236 80 247Q75 259 63 265Q50 270 38 265Q26 259 21 247Q0 197 0 139Q0 81 21 31Q26 19 38 14Q50 9 63 13Q75 19 80 30Q85 42 80 55ZM555 31Q576 81 576 139Q576 197 555 247Q550 259 538 264Q526 269 513 265Q501 259 496 248Q491 236 496 223Q512 184 512 139Q512 94 496 55Q491 42 496 31Q501 19 513 13Q526 8 538 13Q550 19 555 31ZM352 139Q350 176 320 194V491Q320 505 311 514Q302 523 288 523Q274 523 265 514Q256 505 256 491V194Q226 176 224 139Q225 112 243 94Q261 76 288 75Q315 76 333 94Q351 112 352 139ZM171 88Q160 111 160 139Q160 167 171 190Q176 203 171 215Q166 227 154 232Q141 237 130 233Q118 228 112 216Q96 180 96 139Q96 98 112 62Q118 50 130 45Q142 41 154 46Q166 51 171 63Q176 75 171 88ZM464 62Q480 98 480 139Q480 180 464 216Q458 228 446 233Q434 237 422 232Q410 227 405 215Q400 203 405 190Q416 167 416 139Q416 111 405 88Q400 75 405 63Q410 51 422 46Q435 41 446 45Q458 50 464 62Z'],
'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
+ 'fa-chevron-down': [512, 'M233 418Q243 427 256 427Q269 427 279 418L471 226Q480 216 480 203Q480 190 471 180Q461 171 448 171Q435 171 425 180L256 350L87 180Q77 171 64 171Q51 171 41 180Q32 190 32 203Q32 216 41 226L233 418Z'],
'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],
'fa-gear': [512, 'M496 178Q500 192 490 202L446 242Q448 254 448 267Q448 280 446 292L490 332Q500 342 496 356Q489 374 480 391L475 399Q465 415 453 430Q443 441 429 437L373 419Q353 435 329 444L317 502Q313 516 298 519Q278 523 256 523Q234 523 213 519Q199 516 195 502L183 444Q159 435 139 419L83 437Q69 441 59 430Q46 415 37 399L32 391Q23 374 16 357Q12 342 22 332L66 293Q64 280 64 267Q64 254 66 242L22 202Q12 192 16 178Q23 160 32 143L37 135Q46 119 59 104Q69 93 83 97L139 115Q159 99 183 89L195 32Q199 18 214 15Q234 11 256 11Q278 11 299 15Q313 18 317 32L329 89Q353 99 373 115L429 97Q443 93 453 104Q466 119 476 135L480 143Q489 160 496 178ZM256 347Q301 346 325 307Q347 267 325 227Q301 188 256 187Q211 188 187 227Q165 267 187 307Q211 346 256 347Z'],
'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 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'],
'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 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'],
'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
@@ -409,7 +386,7 @@
@@ -409,7 +386,7 @@
320: 'fa-chevron-right',
320: 'fa-chevron-right',
384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
448: 'fa-book fa-hand-fist fa-user fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
- 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
+ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-magnifying-glass fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
576: 'fa-house fa-id-card fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
640: 'fa-comments fa-gamepad fa-link fa-network-wired fa-people-arrows fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
};
};
@@ -622,8 +599,7 @@
@@ -622,8 +599,7 @@
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
<span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
- </div>
- ${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
+ </div>`;
}
}
@@ -662,115 +638,10 @@
@@ -662,115 +638,10 @@
return { get, refresh };
return { get, refresh };
})());
})());
- // ── Network launcher ─────────────────────────────────────
- const LAUNCHER_SITES = [
- { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
- { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
- { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
- { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
- { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },
- { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },
- ];
- const LAUNCHER_FAMILIES = [
- { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
- { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
- { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
- { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
- { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
- { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
- { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
- ];
- const CATALOG_URL = 'https://openvibe.network/api/catalog.json'; // allowed by every site's CSP
- const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
- const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
-
- async function launcherCatalog() {
- try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
- try {
- const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
- const j = await r.json();
- const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
- tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), go: t.hosts && t.hosts.short ? okUrl('https://' + t.hosts.short + '/') : null,
- hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter(h => typeof h === 'string' && h), id: String(t.id || ''), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
- try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
- return data;
- } catch { return null; }
- }
-
// A render's document/window listeners and panels; the next render (each boost page move) aborts them, so the old bar can go.
// A render's document/window listeners and panels; the next render (each boost page move) aborts them, so the old bar can go.
let _renderAbort = null;
let _renderAbort = null;
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
const onDocument = () => (_renderAbort ? { signal: _renderAbort.signal } : undefined);
- function bindLauncher(nav) {
- const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
- for (const ev of ['pointerenter', 'touchstart', 'focus']) btn.addEventListener(ev, () => loadOvIcons(), { once: true, passive: true });
- let panel = null;
- const tile = (it, cls, here) => `<a class="${cls}${here ? ' is-here' : ''}" href="${escapeAttr(it.go || it.url)}"${here ? ' aria-current="page"' : ''}${it.desc ? ` title="${escapeAttr(it.desc)}"` : ''}><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-app' ? 28 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
- const siteItems = (frame) => (frame && frame.nav.length ? frame.nav.map(n => ({ name: n.name, desc: n.tagline.replace(/\.\s*$/, ''), icon: n.icon || n.id, url: n.url, group: n.group })) : LAUNCHER_SITES);
- // This page's site: the longest matching host (play.openvibe.network is Play, not Network).
- const hereOf = (list) => { const h = currentHost().toLowerCase(); let best = null, len = 0; for (const x of list) { let sh = ''; try { sh = new URL(x.url).hostname; } catch { continue; } if ((h === sh || h.endsWith('.' + sh)) && sh.length > len) { best = x; len = sh.length; } } return best; };
- const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
- function paint(cat, q) {
- const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
- const body = panel.querySelector('.ovl-body');
- if (q) {
- // Sites and families always filter locally; tools need the catalog. Without it (offline, blocked),
- // the last row hands the query to the Tools search page, so typing never does nothing.
- const hit = (x) => (x.name + ' ' + (x.desc || '')).toLowerCase().includes(q);
- const siteList = siteItems(OVFrame.get()).filter(hit);
- const famList = fams.filter(hit);
- const tools = cat ? cat.tools.filter(t => t.k.includes(q)).slice(0, 12) : [];
- const more = `<a class="ovl-fam ovl-all" href="${escapeAttr(TOOLS_SEARCH + encodeURIComponent(q))}"><span class="ov-icon" data-icon="search" data-size="24" data-fx="none"></span><span><b>Search all tools for “${escapeAttr(q)}”</b></span></a>`;
- body.innerHTML = (siteList.length ? `<div class="ovl-h">Sites</div><div class="ovl-apps">${siteList.map(x => tile(x, 'ovl-app')).join('')}</div>` : '')
- + (famList.length ? `<div class="ovl-h">Tool families</div><div class="ovl-fams">${famList.map(x => tile(x, 'ovl-fam')).join('')}</div>` : '')
- + `<div class="ovl-h">Tools${cat ? '' : ' <span>loading…</span>'}</div><div class="ovl-fams">${tools.map(t => tile(t, 'ovl-fam')).join('')}${more}</div>`;
- return;
- }
- const frame = OVFrame.get();
- const sites = siteItems(frame), hereSite = hereOf(sites);
- // The Frame's groups in order; a site in no known group goes in the last one.
- const groups = frame && frame.groups && frame.groups.length ? frame.groups : [{ id: '', name: 'Sites' }];
- const inGroup = (s, g, i) => !g.id || s.group === g.id || (i === groups.length - 1 && !groups.some(x => x.id === s.group));
- const siteHtml = groups.map((g, i) => { const list = sites.filter(s => inGroup(s, g, i)); return list.length ? `<div class="ovl-h">${escapeAttr(g.name)}</div><div class="ovl-apps">${list.map(x => tile(x, 'ovl-app', x === hereSite)).join('')}</div>` : ''; }).join('');
- const soon = frame ? frame.soon : [];
- // On a tool: every address it answers to (short, search-friendly, mirrors, custom domains), so people
- // can pick the one they will remember.
- const cur = currentHost().toLowerCase();
- const here = cat ? cat.tools.find(t => t.hosts.indexOf(cur) >= 0) : null;
- const addresses = here && here.hosts.length > 1 ? `<div class="ovl-h">${escapeAttr(here.name)} lives at <a href="https://openvibe.tools/tool/${escapeAttr(here.id)}">About</a></div><div class="ovl-soon ovl-addr">${[...new Set(here.hosts)].filter(h => /^[a-z0-9.-]+$/.test(h)).map(h => `<a href="https://${h}/"${h === cur ? ' class="is-here" aria-current="page"' : ''}>${h}</a>`).join('')}</div>` : '';
- body.innerHTML = addresses + siteHtml + `
- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
- ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
- }
- btn.addEventListener('click', async (e) => {
- e.stopPropagation();
- if (panel && panel.classList.contains('open')) return close();
- if (!panel) {
- panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
- panel.innerHTML = '<div class="ovl-top"><input type="search" class="ovl-q" placeholder="Find a site or tool" aria-label="Find a site or tool"></div><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
- bindDisplayControls(panel.querySelector('.ovl-display'));
- nav.appendChild(panel);
- regPanel(panel, 'launcher', close);
- loadOvIcons();
- let cat = null; paint(null, '');
- const input = panel.querySelector('.ovl-q');
- input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
- input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); location.href = first ? first.href : TOOLS_SEARCH + encodeURIComponent(input.value.trim()); } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
- panel.addEventListener('keydown', (ev) => {
- if (ev.key === 'Escape') { close(); btn.focus(); return; }
- if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
- const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;
- ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
- });
- panel.addEventListener('click', (ev) => ev.stopPropagation());
- document.addEventListener('click', close, onDocument());
- launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
- }
- panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
- try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }
- });
- }
-
/** One anonymous page-view count per page load (host only), which is what orders the network's
/** One anonymous page-view count per page load (host only), which is what orders the network's
* navigation. Skipped when the browser asks not to be tracked. */
* navigation. Skipped when the browser asks not to be tracked. */
let _counted = false;
let _counted = false;
@@ -884,11 +755,12 @@
@@ -884,11 +755,12 @@
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
const place = (e) => placeDd(e.target.closest && e.target.closest('.ovnav-dd'));
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
nav.addEventListener('pointerover', place); nav.addEventListener('focusin', place);
if (burger && drawer) {
if (burger && drawer) {
+ if (!_config.user) bindDisplayControls(drawer.querySelector('.ovnav-display')); // guests: members have them in the account menu
burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });
burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); if (open) loadOvIcons(drawer); });
drawer.addEventListener('click', (e) => {
drawer.addEventListener('click', (e) => {
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
const all = e.target.closest && e.target.closest('[data-open-launcher]'); if (!all) return;
e.preventDefault(); e.stopPropagation(); closeDrawer();
e.preventDefault(); e.stopPropagation(); closeDrawer();
- const lb = nav.querySelector('#openvibe-launcher-btn'); if (lb) lb.click();
+ openSearch('', 'sites');
});
});
document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());
document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); }, onDocument());
regPanel(drawer, 'nav-drawer', closeDrawer);
regPanel(drawer, 'nav-drawer', closeDrawer);
@@ -925,8 +797,9 @@
@@ -925,8 +797,9 @@
bindScheme(dropdown);
bindScheme(dropdown);
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
- const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };
- const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
+ const LABEL = { text: { 100: '', 112: '+', 125: '++' }, motion: { auto: '', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };
+ const SAY = { text: { 100: 'default', 112: 'large', 125: 'largest' }, motion: { auto: 'on', reduced: 'calm' } };
+ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; const say = `${k === 'text' ? 'Text size' : 'Animations'}: ${(SAY[k] || {})[d[k]] || d[k]}`; r.title = say; r.setAttribute('aria-label', say); r.setAttribute('aria-pressed', String(k === 'motion' ? d[k] === 'reduced' : d[k] !== 100 && d[k] !== '100')); }); };
rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
root.addEventListener('ov:display', paint, onDocument()); paint();
root.addEventListener('ov:display', paint, onDocument()); paint();
}
}
@@ -969,7 +842,7 @@
@@ -969,7 +842,7 @@
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
}
- /** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
+ /** Display settings for guests, at the foot of the phone menu (members have them in the account menu); theme-loader.js owns the state. */
function bindDisplayControls(box) {
function bindDisplayControls(box) {
const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
const L = root.OpenVibeThemeLoader; if (!box || !L || !L.display) return;
const paint = () => {
const paint = () => {
@@ -977,16 +850,15 @@
@@ -977,16 +850,15 @@
const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
const sch = L.scheme ? L.scheme.get() : null;
const sch = L.scheme ? L.scheme.get() : null;
const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;
const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;
- box.innerHTML = `<span class="ovl-dl">Display</span>${sch ? `<span class="ovl-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
- <span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovl-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;
+ box.innerHTML = `<span class="ovnav-dl">Display</span>${sch ? `<span class="ovnav-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovnav-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
+ <span class="ovnav-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovnav-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;
box.hidden = false;
box.hidden = false;
};
};
box.addEventListener('click', (e) => {
box.addEventListener('click', (e) => {
const s2 = e.target.closest('button[data-s]'); if (s2 && L.scheme) { L.scheme.set(s2.dataset.s); paint(); return; }
const s2 = e.target.closest('button[data-s]'); if (s2 && L.scheme) { L.scheme.set(s2.dataset.s); paint(); return; }
const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint();
const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint();
});
});
- root.addEventListener('ov:theme', paint, onDocument());
- root.addEventListener('ov:display', paint, onDocument());
+ if (typeof root.addEventListener === 'function') { root.addEventListener('ov:theme', paint, onDocument()); root.addEventListener('ov:display', paint, onDocument()); }
paint();
paint();
}
}
@@ -1010,12 +882,32 @@
@@ -1010,12 +882,32 @@
}
}
const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);
const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);
+ // What this person opens from the menu, counted on this device (no request, nothing leaves the browser): the grid
+ // puts their most used first once a place has been opened twice; until then the site's own order stands.
+ const USE_KEY = 'ov-menu-use';
+ const useKey = (item) => String(item.id || item.href || item.label || '').slice(0, 80);
+ function useCounts() { try { return JSON.parse(localStorage.getItem(USE_KEY) || '{}') || {}; } catch { return {}; } }
+ function countUse(key) {
+ if (!key) return;
+ try { const c = useCounts(); c[key] = Math.min(9999, (Number(c[key]) || 0) + 1); const keys = Object.keys(c); if (keys.length > 60) keys.sort((x, y) => c[x] - c[y]).slice(0, keys.length - 60).forEach((k) => delete c[k]); localStorage.setItem(USE_KEY, JSON.stringify(c)); } catch { /* private mode */ }
+ }
+ function byUse(items) {
+ const c = useCounts(), n = (it) => { const v = Number(c[useKey(it)]) || 0; return v >= 2 ? v : 0; };
+ return items.map((it, i) => [it, i]).sort((x, y) => (x[0].hidden ? 1 : 0) - (y[0].hidden ? 1 : 0) || n(y[0]) - n(x[0]) || x[1] - y[1]).map(([it]) => it);
+ }
+ /** One place in the account menu's grid: an icon over a short name (item.short, else its label). */
+ function tileHTML(item) {
+ const attrs = ` class="ud-tile${item.danger ? ' danger' : ''}" data-use="${escapeAttr(useKey(item))}" title="${escapeAttr(item.label)}"${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}${item.mark ? ` ${item.mark}` : ''}`;
+ const inner = `<span class="icon">${item.drawn || navIcon(item.icon || 'fa-circle-info')}</span><span class="ud-tl">${escapeAttr(item.short || item.label)}</span>${item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : ''}`;
+ return item.href ? `<a href="${escapeAttr(item.href)}"${attrs}${item.external ? ' target="_blank" rel="noopener"' : ''}>${inner}</a>` : `<button type="button"${attrs}>${inner}</button>`;
+ }
+
function menuItemHTML(item) {
function menuItemHTML(item) {
if (!item) return '';
if (!item) return '';
if (item.sep) return '<div class="sep"></div>';
if (item.sep) return '<div class="sep"></div>';
- const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
+ const icon = item.drawn ? `<span class="icon">${item.drawn}</span>` : item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
const cls = item.danger ? ' class="danger"' : '';
const cls = item.danger ? ' class="danger"' : '';
- const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;
+ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''} data-use="${escapeAttr(useKey(item))}"${item.mark ? ` ${item.mark}` : ''}`;
const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';
const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';
if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;
if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;
return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</button>`;
return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</button>`;
@@ -1545,27 +1437,6 @@
@@ -1545,27 +1437,6 @@
});
});
}
}
- /**
- * "Recently used" rows in the dropdown: the last few things this account touched anywhere
- * on the network, from the shared history module (loaded lazily from the Network).
- */
- function renderRecent(el) {
- if (!el || !_config.user || _config.user.is_anon) return;
- const draw = (items) => {
- if (!items || !items.length) { el.hidden = true; return; }
- el.hidden = false;
- el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
- items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
- };
- const H = root.OpenVibeHistory;
- if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
- if (document.getElementById('ov-history-loader')) return;
- const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
- sc.src = sibling('history.js');
- sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
- document.head.appendChild(sc);
- }
-
/**
/**
* wallet: true — OpenCoins and Vibes as chips in the bar, on any site (owner, 2026-10-27: "make that as a feature in the
* wallet: true — OpenCoins and Vibes as chips in the bar, on any site (owner, 2026-10-27: "make that as a feature in the
* shared nav bar … maybe we'll put it on games too"). A site that sets chips of its own with those ids (Live) keeps
* shared nav bar … maybe we'll put it on games too"). A site that sets chips of its own with those ids (Live) keeps
@@ -1664,14 +1535,14 @@
@@ -1664,14 +1535,14 @@
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
- ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search (/)" title="Search (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}
+ ${_config.search === false ? '' : '<button type="button" class="ovnav-search" id="openvibe-search-btn" aria-label="Search and every OpenVibe site (/)" title="Search and every OpenVibe site (/)"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg></button>'}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
${u ? chipsHTML(_config.chips) + chipsAllHTML(_config.chips) : ''}
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}
${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}
</div>
</div>
- ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}
+ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div>${u ? '' : '<div class="ovnav-display" hidden></div>'}</div>` : ''}
`;
`;
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
bindLinks(nav, links);
bindLinks(nav, links);
@@ -1679,7 +1550,6 @@
@@ -1679,7 +1550,6 @@
bindChips(nav);
bindChips(nav);
bindChipsAll(nav);
bindChipsAll(nav);
queueIcons();
queueIcons();
- bindLauncher(nav);
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
setTimeout(() => mountBell(nav, u), 0);
setTimeout(() => mountBell(nav, u), 0);
@@ -1700,60 +1570,61 @@
@@ -1700,60 +1570,61 @@
const barWallet = !!_config.wallet || (Array.isArray(_config.chips) && _config.chips.some(c => c && (c.id === 'coins' || c.id === 'vibes')));
const barWallet = !!_config.wallet || (Array.isArray(_config.chips) && _config.chips.some(c => c && (c.id === 'coins' || c.id === 'vibes')));
const admin = u.role === 'admin' || !!(u.capabilities && (u.capabilities.is_owner || u.capabilities.admin_panel));
const admin = u.role === 'admin' || !!(u.capabilities && (u.capabilities.is_owner || u.capabilities.admin_panel));
+ // The menu (4.23.0; the owner, 2026-10-28: "a lot of this is just not even really needed… could be a button in
+ // the corner… a plus button… better use of icons… more compactness… data-driven"): who you are, with + (add an
+ // account) and the gear beside the name; your places as a grid of icons in the order you use them (eight, the
+ // rest under More); the site's own sections; and one row for light/dark, text size, motion, themes and sign
+ // out. Notifications are the bell's, and the places you went and Ask Vibe are the search menu's.
+ const bell = _config.notifications !== false && !!_config.token && !isAnon;
+ const places = before.concat(after).filter((it) => it && !it.sep).concat(menuCfg.defaults === false ? [] : [
+ !isAnon && u.username ? { id: 'ov-channel', label: 'My channel', short: 'Channel', drawn: navIcon('fa-tower-broadcast'), href: `https://openvibe.live/@${encodeURIComponent(u.username)}` } : null,
+ { id: 'ov-history', label: 'History', drawn: navIcon('fa-clock-rotate-left'), href: 'https://openvibe.network/my#history' },
+ bell ? null : { id: 'ov-notifications', label: 'Notifications', short: 'Alerts', drawn: navIcon('fa-bell'), href: 'https://openvibe.network/my#notifications' },
+ !isAnon ? { id: 'ov-ask', label: 'Ask Vibe', drawn: navIcon('fa-brain'), href: 'https://openvibe.chat/vibe', mark: 'data-ov-ask' } : null,
+ !isAnon ? { id: 'ov-work', label: 'Work together', short: 'Work', drawn: navIcon('fa-people-group'), href: 'https://openvibe.chat/work', mark: 'data-ov-work' } : null,
+ admin ? { id: 'ov-admin', label: 'Admin', drawn: navIcon('fa-shield-halved'), href: 'https://openvibe.network/admin' } : null,
+ ].filter(Boolean));
+ const ranked = byUse(places), grid = ranked.slice(0, 8), more = ranked.slice(8);
+ const others = otherAccounts.filter(a => switcher || !a.is_anon);
+ const iconBtn = (cls, drawn, label, extra) => `<button type="button" class="ud-ib${cls}" title="${label}" aria-label="${label}"${extra || ''}><span class="icon">${drawn}</span></button>`;
+
dropdown.innerHTML = `
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<a class="ud-me" href="${escapeAttr(meHref)}" tabindex="-1" aria-hidden="true">${avatarImg(u, 72, '', '')}</a>
<div class="info">
<div class="info">
<a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>
<a class="name ud-me" href="${escapeAttr(meHref)}" title="${isAnon ? 'Your account' : 'Your profile'}">${escapeHtml(u.display_name || u.username)}</a>
- <div class="email">${isAnon ? '' : '@' + escapeHtml(u.username || '')}</div>
+ <div class="email">${isAnon ? `Anonymous #${u.anon_number || '?'}` : '@' + escapeHtml(u.username || '')}</div>
${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
${barWallet ? '' : menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div><div class="ud-wallet ud-vibes" id="openvibe-vibes" hidden></div>'}
- ${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
+ </div>
+ <div class="ud-hacts">
+ ${_config.accounts === false ? '' : `<a class="ud-hbtn add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}" title="Add another account" aria-label="Add another account"><span class="icon">${navIcon('fa-plus')}</span></a>`}
+ <a class="ud-hbtn" href="https://openvibe.network/my" title="Account & settings" aria-label="Account & settings"><span class="icon">${navIcon('fa-gear')}</span></a>
+ <button id="openvibe-logout-btn" type="button" class="ud-hbtn ud-out" title="Sign out" aria-label="Sign out"><span class="icon">${navIcon('fa-right-from-bracket')}</span></button>
</div>
</div>
</div>
</div>
- <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>
- ${otherAccounts.filter(a => switcher || !a.is_anon).map(a => { const t = switcher ? 'div' : 'a'; return `
- <${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}"` : `href="${escapeAttr(switchHref(a))}"`}>
- ${avatarImg(a, 48, '', '')}
- <span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span>
- </${t}>`; }).join('')}
- ${switcher ? `<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
- <span style="width:18px;text-align:center">${navIcon('fa-user-secret')}</span>
- <span>Switch to Anonymous</span>
- </div>` : ''}
- <a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref())}">
- <span style="width:18px;text-align:center">${navIcon('fa-plus')}</span>
- <span>Add another account</span>
- </a>
- </div>
- <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
+ ${_config.accounts === false || (!others.length && !switcher) ? '' : `<div class="openvibe-navbar-dropdown-accounts" role="group" aria-label="Switch account">
+ ${others.map(a => { const t = switcher ? 'div' : 'a'; return `<${t} class="account-item" ${switcher ? `data-account-id="${escapeAttr(String(a.id))}" role="button" tabindex="0"` : `href="${escapeAttr(switchHref(a))}"`} title="Switch to ${escapeAttr(a.display_name || a.username || '')}">${avatarImg(a, 48, '', '')}<span>${escapeHtml(a.display_name || a.username || '')}${a.is_anon ? ' (anon)' : ''}</span></${t}>`; }).join('')}
+ ${switcher && !isAnon ? `<div class="account-item ud-anon" data-account-id="anon" role="button" tabindex="0" title="Browse without your name"><span class="icon">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span></div>` : ''}
+ </div>`}
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
- ${before.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
+ ${grid.length ? `<div class="ud-grid">${grid.map(tileHTML).join('')}</div>` : ''}
+ ${more.length ? `<details class="ud-more"><summary><span class="icon">${navIcon('fa-chevron-down')}</span> More <span class="ud-n">${more.length}</span></summary>${more.map(menuItemHTML).join('')}</details>` : ''}
${sectionsHTML(menuCfg.sections)}
${sectionsHTML(menuCfg.sections)}
- ${after.length ? `${after.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
- ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div>
- <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-gear')}</span> Account & settings</a>
- <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
- ${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My channel</a>` : ''}
- <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
- ${!isAnon ? `<a href="https://openvibe.chat/vibe" data-ov-ask><span class="icon">${navIcon('fa-brain')}</span> Ask Vibe</a><a href="https://openvibe.chat/work" data-ov-work><span class="icon">${navIcon('fa-people-group')}</span> Work together</a>` : ''}
- ${admin ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin</a>` : ''}
- <div class="sep"></div>`}
- <div class="ud-label">Display</div>
- <div class="ud-scheme" role="group" aria-label="Light or dark" hidden><button type="button" data-ov-scheme="light" aria-pressed="false"><span class="icon">${navIcon('fa-sun')}</span> Light</button><button type="button" data-ov-scheme="dark" aria-pressed="false"><span class="icon">${navIcon('fa-moon')}</span> Dark</button><button type="button" data-ov-scheme="auto" aria-pressed="false" title="Follow this device's light or dark setting"><span class="icon">${navIcon('fa-circle-half-stroke')}</span> Auto</button></div>
- <button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
- <button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
- <a href="https://openvibe.network/themes" title="Every theme, and the season's own"><span class="icon">${navIcon('fa-palette')}</span> Themes <b class="ud-val ud-go" aria-hidden="true">›</b></a>
- <div class="sep"></div>
- <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
+ <div class="ud-foot">
+ <div class="ud-scheme" role="group" aria-label="Light or dark" hidden>${iconBtn('', navIcon('fa-sun'), 'Light', ' data-ov-scheme="light" aria-pressed="false"')}${iconBtn('', navIcon('fa-moon'), 'Dark', ' data-ov-scheme="dark" aria-pressed="false"')}${iconBtn('', navIcon('fa-circle-half-stroke'), 'Auto: follow this device', ' data-ov-scheme="auto" aria-pressed="false"')}</div>
+ <button type="button" class="ud-ib ud-txt" data-ov-display="text" title="Text size" aria-label="Text size"><span class="icon">${navIcon('fa-text-height')}</span><b class="ud-val"></b></button>
+ <button type="button" class="ud-ib" data-ov-display="motion" title="Animations" aria-label="Animations"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span><b class="ud-val"></b></button>
+ <a class="ud-ib" href="https://openvibe.network/themes" title="Themes: every theme, and the season's own" aria-label="Themes"><span class="icon">${navIcon('fa-palette')}</span></a>
+ </div>
</div>
</div>
`;
`;
+ dropdown.addEventListener('click', (e) => { const t = e.target.closest && e.target.closest('[data-use]'); if (t) countUse(t.getAttribute('data-use')); });
nav.appendChild(dropdown);
nav.appendChild(dropdown);
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
bindChips(dropdown);
bindChips(dropdown);
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
bindDisplayRows(dropdown);
regPanel(dropdown, 'user-menu');
regPanel(dropdown, 'user-menu');
- if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)
// Avatar click toggles dropdown (the selection for Ask Vibe is read on pointerdown: the press can clear it)
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
const avatarBtn = nav.querySelector('#openvibe-avatar-btn'); let sel = null;
@@ -1828,8 +1699,10 @@
@@ -1828,8 +1699,10 @@
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
// Search (search.js, loaded on first use). _config.search: false, or { owner, label, page }.
let _searchLoading = false;
let _searchLoading = false;
- function openSearch(q) {
- const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q });
+ /** The OpenVibe menu (search.js, loaded on first use): search, every site and every tool. tab: 'you' | 'sites' | 'tools'. */
+ function openSearch(q, tab) {
+ const go = () => root.OpenVibeSearch && root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab,
+ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope) });
if (root.OpenVibeSearch) return go();
if (root.OpenVibeSearch) return go();
if (_searchLoading) return;
if (_searchLoading) return;
_searchLoading = true;
_searchLoading = true;
@@ -1838,7 +1711,7 @@
@@ -1838,7 +1711,7 @@
let _searchKeys = false;
let _searchKeys = false;
function hookSearch(nav) {
function hookSearch(nav) {
const b = nav.querySelector('#openvibe-search-btn');
const b = nav.querySelector('#openvibe-search-btn');
- if (b) b.addEventListener('click', () => openSearch());
+ if (b) { b.addEventListener('click', () => openSearch()); for (const ev of ['pointerenter', 'touchstart', 'focus']) b.addEventListener(ev, () => { loadOvIcons(); OVFrame.get(); }, { once: true, passive: true }); }
if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
if (_searchKeys || _config.search === false || typeof document === 'undefined') return;
_searchKeys = true;
_searchKeys = true;
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
@@ -1925,7 +1798,8 @@
@@ -1925,7 +1798,8 @@
setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
/** Open the search panel (search.js), with an optional query already in it. */
/** Open the search panel (search.js), with an optional query already in it. */
- openSearch(q) { openSearch(q); },
+ openSearch(q, tab) { openSearch(q, tab); },
+ openMenu(tab) { openSearch('', tab || 'you'); },
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
/** The phone app bar: true (from the links that have icons), a list of { label, href, icon, id, page, dot }, or false. */
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
setAppBar(spec) { _config.appBar = spec; renderAppBar(); },
@@ -90,7 +90,7 @@ html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic
@@ -90,7 +90,7 @@ html[data-ov-season-mark] .ov-mark[data-ovm-run] .sl{animation:ovmLeaf .9s cubic
.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}
.ov-mark[data-ovm-flourish] .sr{animation:ovmFlicker 1.6s linear 1}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
-.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:2147483000}
+.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
@keyframes ovmLeaf{from{transform:scale(0) rotate(-50deg)}to{transform:none}}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.22.2",
+ "version": "4.23.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": {
@@ -1,34 +1,63 @@
@@ -1,34 +1,63 @@
/**
/**
- * openvibe-shared/search.js — search everything on OpenVibe from any site's navbar: the magnifier, "/" or Ctrl+K. The
- * navbar loads this file the first time someone asks (the owner, 2026-10-28: "a magnifying glass button that's more
- * compact, shared across the sites… more intelligent… asynchronous loading, lazy loading and autocomplete… reused in
- * the SDK and API… intelligent and aware").
+ * openvibe-shared/search.js — the OpenVibe menu: search, every site and every tool in one panel, opened by the navbar's
+ * magnifier, "/" or Ctrl+K (4.23.0). The navbar loads this file the first time someone asks.
*
*
- * What it knows: the site it is on (its pages come first, its own search page when it has one), what you searched
- * before (on this device), and everything OpenVibe.Search holds across the sites, suggested as you type (anonymous,
- * one request in flight, the older one cancelled). The suggestions come through OpenVibe.Network's
- * GET /api/frame/suggest (OpenVibe.Search's /api/v1/suggest, kept 30 s): every site's Content-Security-Policy already
- * lets the navbar reach openvibe.network, and most refuse search.openvibe.network. Every row is a link: a result, a
- * page of this site, "Search all of OpenVibe" and "Ask Vibe". Arrows move, Enter opens, Escape closes. A phone gets
- * the whole screen; a computer a panel near the top.
+ * It used to be two panels side by side, the launcher (the dots: every site) and search (the magnifier), which "kind
+ * of accomplish the same exact thing" (the owner, 2026-10-28: "combine… what are the most important things… a better
+ * hierarchy… use this user's history… input history and autocomplete… to give them better results… maybe use tabs").
*
*
- * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, suggestUrl }) navbar.js calls it
+ * Before typing, three tabs:
+ * For you the places this person went lately anywhere on OpenVibe (their Network history, signed in), this site's
+ * pages, their recent searches, and their sites (most used by them, else most used by everyone: the Frame's
+ * analytics ranking).
+ * Sites every OpenVibe site by what people come to do (the Frame's groups), the one you are on marked.
+ * Tools the tool families and the most used tools (OpenVibe.Tools' catalog).
+ * Typing makes one list: sites and tools to go to, this site's results and pages, the person's own history, the rest of
+ * OpenVibe.Search (sites they use most first), their recent searches, and "search everywhere" / "Ask Vibe". The box
+ * completes what they type from their own searches, history and the site names (Tab or → takes it).
+ *
+ * OpenVibeSearch.open({ service, scope: { owner, label, page }, links, q, tab, frame, token, apiBase, user,
+ * suggestUrl, icons }) navbar.js calls it
* OpenVibeSearch.close()
* OpenVibeSearch.close()
* OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box
* OpenVibeSearch.suggest(q, { owner, limit, signal, url }) → [{ owner, type, id, title, url, site }] for any page's own box
* (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the
* (url: https://openvibe.network/api/frame/suggest, or https://search.openvibe.network/api/v1/suggest where the
* page's connect-src allows it; both answer any origin)
* page's connect-src allows it; both answer any origin)
*
*
- * Text is always set with textContent; links are https or same-site paths only. Recent searches live in localStorage
- * (ov_search_recent, eight at most) and can be cleared from the panel.
+ * Suggestions come through OpenVibe.Network's GET /api/frame/suggest (OpenVibe.Search, kept 30 s): every site's
+ * Content-Security-Policy lets the navbar reach openvibe.network. History is GET /api/history (the person's own token,
+ * read once a panel opening, kept two minutes). Text is always set with textContent; links are https or same-site paths
+ * only. Recent searches live in localStorage (ov_search_recent, eight at most) and can be cleared from the panel.
*/
*/
(function (root) {
(function (root) {
'use strict';
'use strict';
const API = 'https://search.openvibe.network';
const API = 'https://search.openvibe.network';
const SUGGEST = 'https://openvibe.network/api/frame/suggest';
const SUGGEST = 'https://openvibe.network/api/frame/suggest';
+ const CATALOG_URL = 'https://openvibe.network/api/catalog.json';
+ const TOOLS_SEARCH = 'https://openvibe.tools/search?q=';
const RECENT_KEY = 'ov_search_recent';
const RECENT_KEY = 'ov_search_recent';
const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
const SITE = { 'openre.stream': 'Restream', 'search.openvibe.network': 'Search', 'ai.openvibe.services': 'AI' };
const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
const TYPE = { vod: 'Recording', clip: 'Clip', job: 'Job', story: 'Story', page: 'Page', thread: 'Thread', paste: 'Paste', post: 'Post', stream: 'Live', channel: 'Channel', deal: 'Deal', coupon: 'Code', review: 'Review', entity: 'Reviews', recipe: 'Recipe', game: 'Game', tool: 'Tool', listing: 'Listing', wiki: 'Wiki' };
- let el = null, input = null, listEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
+ // Until the Frame answers (or when it cannot): the network's main sites and the tool families.
+ const FALLBACK_SITES = [
+ { id: 'live', name: 'Live', desc: 'Go live, watch streams', icon: 'live', url: 'https://openvibe.live/' },
+ { id: 'chat', name: 'Chat', desc: 'Rooms, servers and messages', icon: 'chat', url: 'https://openvibe.chat/' },
+ { id: 'games', name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
+ { id: 'tools', name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
+ { id: 'community', name: 'Community', desc: 'Forum, pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
+ { id: 'network', name: 'Network', desc: 'Your account and themes', icon: 'network', url: 'https://openvibe.network/' },
+ ];
+ const FALLBACK_FAMILIES = [
+ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
+ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
+ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
+ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
+ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
+ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
+ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
+ ];
+ const TABS = [['you', 'For you'], ['sites', 'Sites'], ['tools', 'Tools']];
+ let el = null, input = null, ghost = null, listEl = null, tabsEl = null, ctx = {}, items = [], active = -1, inflight = null, timer = null, lastFocus = null;
+ let tab = 'you', mine = null, catalog = null, completion = '';
const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
function siteOf(url) {
function siteOf(url) {
@@ -40,6 +69,7 @@
@@ -40,6 +69,7 @@
return h;
return h;
} catch { return ''; }
} catch { return ''; }
}
}
+ const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
const recent = {
const recent = {
get() { try { return (JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') || []).filter((x) => typeof x === 'string').slice(0, 8); } catch { return []; } },
get() { try { return (JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') || []).filter((x) => typeof x === 'string').slice(0, 8); } catch { return []; } },
add(q) { try { const list = [q, ...recent.get().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } },
add(q) { try { const list = [q, ...recent.get().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 8); localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } },
@@ -57,6 +87,64 @@
@@ -57,6 +87,64 @@
return (d.suggestions || []).map((s) => ({ owner: s.owner, type: s.type, id: s.id, title: String(s.title || '').slice(0, 160), url: safeUrl(s.canonical_url), site: siteOf(s.canonical_url) })).filter((s) => s.url && s.title);
return (d.suggestions || []).map((s) => ({ owner: s.owner, type: s.type, id: s.id, title: String(s.title || '').slice(0, 160), url: safeUrl(s.canonical_url), site: siteOf(s.canonical_url) })).filter((s) => s.url && s.title);
}
}
+ // ── What the panel knows ─────────────────────────────────
+ /** Every open site from the Frame (most used across OpenVibe first), else the main ones. */
+ function sites() {
+ const f = ctx.frame;
+ if (f && Array.isArray(f.nav) && f.nav.length) return f.nav.map((n) => ({ id: n.id, name: n.name, desc: String(n.tagline || '').replace(/\.\s*$/, ''), icon: n.icon || n.id, url: safeUrl(n.url), group: n.group })).filter((s) => s.url);
+ return FALLBACK_SITES;
+ }
+ /** The site this page belongs to: the longest matching host (play.openvibe.network is Play, not Network). */
+ function hereSite(list) {
+ const h = location.hostname.toLowerCase(); let best = null, len = 0;
+ for (const s of list) { const sh = hostOf(s.url); if (sh && (h === sh || h.endsWith(`.${sh}`)) && sh.length > len) { best = s; len = sh.length; } }
+ return best;
+ }
+ /** The person's own use of each site (their history's counts), for "your sites" and for ranking results. */
+ const useOf = (id) => { const s = mine && mine.services.find((x) => x.service === id); return s ? s.count : 0; };
+
+ /** The person's history: one request per opening, kept two minutes (signed in only; anonymous sessions keep none). */
+ async function loadHistory() {
+ if (!ctx.token || !ctx.user || ctx.user.is_anon) return null;
+ const key = `ov_hist:${ctx.user.id || ctx.user.username || ''}`;
+ try { const c = JSON.parse(sessionStorage.getItem(key) || 'null'); if (c && Date.now() - c.at < 120000) return c.data; } catch { /* */ }
+ try {
+ const r = await fetch(`${ctx.apiBase || 'https://openvibe.network'}/api/history?limit=60`, { headers: { Authorization: `Bearer ${ctx.token}` }, credentials: 'omit' });
+ if (!r.ok) return null;
+ const j = await r.json();
+ const data = {
+ items: (j.items || []).map((h) => ({ title: String(h.title || '').slice(0, 160), url: safeUrl(h.url), site: h.service_label || siteOf(h.url), service: h.service, at: h.updated_at })).filter((h) => h.url && h.title),
+ services: (j.services || []).map((s) => ({ service: String(s.service || ''), count: Number(s.count) || 0 })),
+ };
+ try { sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ return data;
+ } catch { return null; }
+ }
+ /** OpenVibe.Tools' catalog (families and tools), kept 30 minutes; the same copy the navbar used to keep. */
+ async function loadCatalog() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog2') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try {
+ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
+ const j = await r.json();
+ const ok = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
+ const data = {
+ families: (j.families || []).slice(0, 12).map((f) => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: ok(f.url) })).filter((f) => f.name && f.url),
+ tools: (j.tools || []).slice(0, 400).map((t) => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: ok(t.url), go: t.hosts && t.hosts.short ? ok(`https://${t.hosts.short}/`) : null,
+ hosts: [t.hosts && t.hosts.short, t.hosts && t.hosts.canonical].concat((t.hosts && t.hosts.mirrors) || [], (t.hosts && t.hosts.aliases) || []).filter((h) => typeof h === 'string' && h), id: String(t.id || ''),
+ desc: String(t.tagline || '').slice(0, 120), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter((t) => t.name && t.url),
+ };
+ try { sessionStorage.setItem('ov_catalog2', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ return data;
+ } catch { return null; }
+ }
+
+ function ago(iso) {
+ const t = Date.parse(iso || ''); if (!isFinite(t)) return '';
+ const m = Math.max(0, Math.round((Date.now() - t) / 60000));
+ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
+ }
+
+ // ── Drawing ──────────────────────────────────────────────
function styles() {
function styles() {
if (document.getElementById('openvibe-search-styles')) return;
if (document.getElementById('openvibe-search-styles')) return;
const st = document.createElement('style');
const st = document.createElement('style');
@@ -64,29 +152,61 @@
@@ -64,29 +152,61 @@
st.textContent = `
st.textContent = `
.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
.ovs { position: fixed; inset: 0; z-index: 10020; display: none; background: rgba(3, 6, 12, .55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; color: var(--text-primary, #e8ecf3); }
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
.ovs.is-open { display: block; animation: ovs-in .14s ease-out; }
-.ovs-panel { position: absolute; left: 50%; top: 10vh; width: min(640px, calc(100vw - 32px)); max-height: 76vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
-.ovs-bar { display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 16px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
-.ovs-bar svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }
-.ovs-input { flex: 1; min-width: 0; height: 48px; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 17px; }
+.ovs-panel { position: absolute; left: 50%; top: 8vh; width: min(720px, calc(100vw - 32px)); max-height: 80vh; transform: translateX(-50%); display: flex; flex-direction: column; border-radius: 18px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #121826); box-shadow: 0 30px 80px rgba(0,0,0,.55); overflow: hidden; }
+.ovs-bar { display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 16px; }
+.ovs-bar > svg { width: 18px; height: 18px; flex: none; color: var(--text-muted, #93a0b4); }
+.ovs-field { position: relative; flex: 1; min-width: 0; display: flex; }
+.ovs-input, .ovs-ghost { height: 48px; font: inherit; font-size: 17px; letter-spacing: normal; }
+.ovs-input { position: relative; z-index: 1; flex: 1; min-width: 0; border: 0; outline: 0; padding: 0; background: transparent; color: inherit; }
.ovs-input::-webkit-search-cancel-button { -webkit-appearance: none; }
.ovs-input::-webkit-search-cancel-button { -webkit-appearance: none; }
+.ovs-ghost { position: absolute; inset: 0; display: flex; align-items: center; white-space: pre; overflow: hidden; pointer-events: none; color: var(--text-muted, #93a0b4); opacity: .75; }
+.ovs-ghost b { color: transparent; font-weight: inherit; }
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
.ovs-x { flex: none; height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #93a0b4); font: inherit; font-size: 13px; cursor: pointer; }
-.ovs-x:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: inherit; }
+.ovs-tabs { display: flex; gap: 4px; padding: 0 12px 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
+.ovs-tabs[hidden] { display: none; }
+.ovs-tab { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 999px; background: transparent; color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer; }
+.ovs-tab[aria-selected="true"] { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #8ab4ff)); }
+.ovs-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.ovs-typing .ovs-bar { border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
-.ovs-group { padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
-.ovs-group button { float: right; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11px; text-transform: none; letter-spacing: 0; cursor: pointer; }
-.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 6px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
-.ovs-item.is-active a, .ovs-item a:hover { background: var(--bg-hover, rgba(255,255,255,.07)); }
+.ovs-group { display: flex; align-items: center; gap: 8px; padding: 12px 10px 5px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
+.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
+.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
+.ovs-opt.is-active, .ovs-opt:focus-visible { background: var(--bg-hover, rgba(255,255,255,.07)); outline: none; }
+/* A tap leaves :hover on what it touched: rows light on hover only where a pointer really hovers. */
+@media (hover: hover) { .ovs-item a:hover, .ovs-chip:hover, .ovs-tile:hover, .ovs-x:hover, .ovs-tab:hover { background: var(--bg-hover, rgba(255,255,255,.07)); color: inherit; } }
.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }
.ovs-ic { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); color: var(--accent-light, #8ab4ff); font-size: 12px; font-weight: 800; }
-.ovs-ic svg { width: 16px; height: 16px; }
+.ovs-ic > svg { width: 16px; height: 16px; }
+.ovs-ic.ovs-site { background: none; }
+.ovs .ov-icon:empty { border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
.ovs-main { flex: 1; min-width: 0; display: grid; gap: 1px; }
.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }
.ovs-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 600; }
.ovs-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovs-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
.ovs-key { flex: none; font-size: 11px; color: var(--text-muted, #93a0b4); }
.ovs-key { flex: none; font-size: 11px; color: var(--text-muted, #93a0b4); }
+.ovs-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 8px 6px; }
+.ovs-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; height: 34px; padding: 0 13px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12)); color: inherit; text-decoration: none; font-size: 13.5px; font-weight: 600; box-sizing: border-box; }
+.ovs-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovs-chip svg { width: 14px; height: 14px; flex: none; color: var(--text-muted, #93a0b4); }
+.ovs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; padding: 2px 4px 6px; }
+.ovs-grid.is-sites { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
+.ovs-tile { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 8px 10px; border-radius: 12px; border: 1px solid transparent; color: inherit; text-decoration: none; }
+.ovs-tile > span:last-child { min-width: 0; }
+.ovs-tile b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; font-weight: 650; }
+.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); }
.ovs-note { padding: 14px 12px; font-size: 13px; 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-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; }
@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ovs-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ovs-scan { to { background-position: 140% 100%; } }
@keyframes ovs-scan { to { background-position: 140% 100%; } }
-@media (max-width: 640px) { .ovs-panel { top: 0; left: 0; width: 100vw; height: 100dvh; max-height: none; transform: none; border-radius: 0; border: 0; } .ovs-key { display: none; } }
+@media (hover: none), (max-width: 640px) { .ovs-foot, .ovs-key { display: none; } }
+@media (max-width: 640px) {
+ .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; }
+}
@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;
@media (prefers-reduced-motion: reduce) { .ovs.is-open, .ovs-busy .ovs-bar { animation: none; } }`;
document.head.appendChild(st);
document.head.appendChild(st);
}
}
@@ -96,6 +216,8 @@
@@ -96,6 +216,8 @@
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',
go: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></svg>',
spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',
spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z"/></svg>',
+ back: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>',
+ grid: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></svg>',
};
};
function build() {
function build() {
@@ -104,17 +226,24 @@
@@ -104,17 +226,24 @@
el.className = 'ovs';
el.className = 'ovs';
el.setAttribute('role', 'dialog');
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-modal', 'true');
- el.setAttribute('aria-label', 'Search OpenVibe');
- el.innerHTML = `<div class="ovs-panel"><div class="ovs-bar">${ICON.search}<input class="ovs-input" type="search" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="ovs-list" autocomplete="off" spellcheck="false" enterkeyhint="search" maxlength="100"><button type="button" class="ovs-x">Close</button></div><ul class="ovs-list" id="ovs-list" role="listbox"></ul></div>`;
+ 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>`
+ + '<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');
+ ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
listEl = el.querySelector('.ovs-list');
+ tabsEl = el.querySelector('.ovs-tabs');
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.addEventListener('mousedown', (e) => { if (e.target === el) close(); });
el.querySelector('.ovs-x').addEventListener('click', close);
el.querySelector('.ovs-x').addEventListener('click', close);
- input.addEventListener('input', () => schedule(input.value));
+ tabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-tab]'); if (b) { tab = b.getAttribute('data-tab'); render(''); input.focus(); } });
+ input.addEventListener('input', () => { paintGhost(); schedule(input.value); });
input.addEventListener('keydown', onKey);
input.addEventListener('keydown', onKey);
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.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 a = e.target.closest && e.target.closest('a[data-i]');
const a = e.target.closest && e.target.closest('a[data-i]');
if (a) remember(items[Number(a.getAttribute('data-i'))]);
if (a) remember(items[Number(a.getAttribute('data-i'))]);
});
});
@@ -123,6 +252,9 @@
@@ -123,6 +252,9 @@
function onKey(e) {
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
+ if ((e.key === 'Tab' && !e.shiftKey || e.key === 'ArrowRight' && input.selectionStart === input.value.length) && completion) {
+ e.preventDefault(); input.value = completion; paintGhost(); schedule(input.value); return;
+ }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.preventDefault();
if (!items.length) return;
if (!items.length) return;
@@ -141,15 +273,52 @@
@@ -141,15 +273,52 @@
if (q && it && it.kind !== 'recent') recent.add(q);
if (q && it && it.kind !== 'recent') recent.add(q);
}
}
function paintActive() {
function paintActive() {
- Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-item'), (li, i) => {
+ Array.prototype.forEach.call(listEl.querySelectorAll('.ovs-opt'), (a, i) => {
const on = i === active;
const on = i === active;
- li.classList.toggle('is-active', on);
- li.setAttribute('aria-selected', on ? 'true' : 'false');
- if (on) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }
+ a.classList.toggle('is-active', on);
+ a.setAttribute('aria-selected', on ? 'true' : 'false');
+ if (on) { input.setAttribute('aria-activedescendant', a.id); a.scrollIntoView({ block: 'nearest' }); }
});
});
}
}
- /** The rows: groups of items { title, meta, url, icon, kind }; rendered with textContent only. */
+ /** What the box would complete to: the person's searches, then their history, then sites and this site's pages. */
+ function paintGhost() {
+ const q = input.value;
+ completion = '';
+ if (q.trim().length >= 2 && !/\s$/.test(q)) {
+ const n = q.toLowerCase();
+ const pool = recent.get().concat(mine ? mine.items.map((h) => h.title) : [], sites().map((s) => s.name), (ctx.links || []).map((l) => l && l.label).filter(Boolean));
+ completion = pool.find((x) => x && x.length > q.length && x.toLowerCase().startsWith(n)) || '';
+ if (completion) completion = q + completion.slice(q.length);
+ }
+ ghost.textContent = '';
+ if (!completion) return;
+ const typed = document.createElement('b'); typed.textContent = q;
+ ghost.append(typed, document.createTextNode(completion.slice(q.length)));
+ }
+
+ function icon(it) {
+ const ic = document.createElement('span');
+ ic.className = 'ovs-ic';
+ if (it.site) { ic.classList.add('ovs-site'); const s = document.createElement('span'); s.className = 'ov-icon'; s.setAttribute('data-icon', it.site); s.setAttribute('data-size', '28'); s.setAttribute('data-fx', 'none'); ic.appendChild(s); }
+ else if (ICON[it.icon]) ic.innerHTML = ICON[it.icon];
+ else ic.textContent = (it.icon || '•').slice(0, 2);
+ return ic;
+ }
+ function option(tag, cls, it) {
+ const i = items.length;
+ items.push(it);
+ const a = document.createElement(tag);
+ a.className = `ovs-opt ${cls}`;
+ a.id = `ovs-o${i}`;
+ a.setAttribute('role', 'option');
+ a.href = it.url;
+ a.setAttribute('data-i', String(i));
+ a.tabIndex = -1;
+ 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. */
function paint(groups, note) {
function paint(groups, note) {
items = [];
items = [];
listEl.textContent = '';
listEl.textContent = '';
@@ -157,44 +326,56 @@
@@ -157,44 +326,56 @@
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');
- h.textContent = 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); }
listEl.appendChild(h);
listEl.appendChild(h);
+ const as = g.as || 'rows';
+ if (as === 'rows') {
+ g.items.forEach((it) => {
+ const li = document.createElement('li'); li.className = 'ovs-item'; li.setAttribute('role', 'presentation');
+ 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.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
+ a.append(icon(it), main);
+ if (items.length === 1 && input.value.trim()) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
+ li.appendChild(a); listEl.appendChild(li);
+ });
+ return;
+ }
+ 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) => {
g.items.forEach((it) => {
- const i = items.length;
- items.push(it);
- const li = document.createElement('li');
- li.className = 'ovs-item';
- li.id = `ovs-o${i}`;
- li.setAttribute('role', 'option');
- const a = document.createElement('a');
- a.href = it.url;
- a.setAttribute('data-i', String(i));
- a.tabIndex = -1;
- const ic = document.createElement('span');
- ic.className = 'ovs-ic';
- if (ICON[it.icon]) ic.innerHTML = ICON[it.icon]; else ic.textContent = (it.icon || '•').slice(0, 2);
- 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.meta) { const m = document.createElement('span'); m.className = 'ovs-meta'; m.textContent = it.meta; main.appendChild(m); }
- a.append(ic, main);
- if (i === 0) { const k = document.createElement('span'); k.className = 'ovs-key'; k.textContent = 'Enter'; a.appendChild(k); }
- li.appendChild(a);
- listEl.appendChild(li);
+ if (as === 'chips') {
+ const a = option('a', 'ovs-chip', it);
+ if (ICON[it.icon]) a.insertAdjacentHTML('afterbegin', ICON[it.icon]);
+ const s = document.createElement('span'); s.textContent = it.title; a.appendChild(s);
+ 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 (as === 'sites' && it.meta) { const sm = document.createElement('small'); sm.textContent = it.meta; txt.appendChild(sm); }
+ a.append(icon(it), txt);
+ box.appendChild(a);
});
});
+ listEl.appendChild(box);
});
});
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 ? 0 : -1;
+ active = items.length && input.value.trim() ? 0 : -1;
paintActive();
paintActive();
+ if (typeof ctx.icons === 'function') { try { ctx.icons(listEl); } catch { /* the icons are optional */ } }
}
}
const here = () => (ctx.scope && ctx.scope.label) || document.title.split(/[·|–-]/).pop().trim() || location.hostname;
const here = () => (ctx.scope && ctx.scope.label) || document.title.split(/[·|–-]/).pop().trim() || location.hostname;
+ const siteItem = (s, all) => ({ title: s.name, meta: s.desc, url: s.url, site: s.icon || s.id, kind: 'site', here: all && s === hereSite(all) });
function pagesOf(q) {
function pagesOf(q) {
const n = q.toLowerCase();
const n = q.toLowerCase();
return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
return (ctx.links || []).filter((l) => l && l.label && typeof l.href === 'string' && !l.hidden && (!n || l.label.toLowerCase().includes(n)))
- .slice(0, n ? 4 : 6).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);
+ .slice(0, n ? 4 : 8).map((l) => ({ title: l.label, meta: `Go to · ${here()}`, url: safeUrl(l.href), icon: 'go', kind: 'page' })).filter((x) => x.url);
}
}
function actions(q) {
function actions(q) {
const out = [];
const out = [];
@@ -206,34 +387,118 @@
@@ -206,34 +387,118 @@
out.push({ title: `Ask Vibe: “${q}”`, meta: 'OpenVibe\'s assistant searches the web and OpenVibe', url: `https://openvibe.chat/vibe?ask=${encodeURIComponent(q)}`, icon: 'spark', kind: 'action' });
out.push({ title: `Ask Vibe: “${q}”`, meta: 'OpenVibe\'s assistant searches the web and OpenVibe', url: `https://openvibe.chat/vibe?ask=${encodeURIComponent(q)}`, icon: 'spark', kind: 'action' });
return out;
return out;
}
}
+ // How well a name answers what was typed: its start (3), inside it (2), or only the words behind it (1).
+ const scoreOf = (n, name, words) => { const nm = name.toLowerCase(); return nm.startsWith(n) ? 3 : nm.includes(n) ? 2 : words && words.includes(n) ? 1 : 0; };
+ const toolRow = (t) => ({ title: t.name, meta: t.desc ? `Tool · ${t.desc}` : 'Tool', url: t.go || t.url, site: t.icon, kind: 'tool' });
+ /** How often this person opened a tool (their history's visits to any of its addresses). */
+ function toolUse(t) {
+ if (!mine || !t.hosts || !t.hosts.length) return 0;
+ return mine.items.reduce((n, h) => n + (t.hosts.indexOf(hostOf(h.url)) >= 0 ? 1 : 0), 0);
+ }
+ const toolByHost = (url) => { const h = hostOf(url); return catalog ? catalog.tools.find((t) => t.hosts.indexOf(h) >= 0 || hostOf(t.url) === h) : null; };
+ /** The tools this person opened lately, most first (their history), and the most used across OpenVibe (the Frame's analytics). */
+ function yourTools() {
+ if (!mine || !catalog) return [];
+ return catalog.tools.map((t) => [toolUse(t), t]).filter(([n]) => n > 0).sort((a, b) => b[0] - a[0]).slice(0, 6).map(([, t]) => toolRow(t));
+ }
+ function popularTools() {
+ const list = ctx.frame && ctx.frame.footer && Array.isArray(ctx.frame.footer.popular) ? ctx.frame.footer.popular : [];
+ const rows = list.map((p) => { const t = toolByHost(p.url); return t ? toolRow(t) : { title: String(p.name || ''), meta: 'Tool', url: safeUrl(p.url), site: 'tools', kind: 'tool' }; }).filter((x) => x.url && x.title);
+ return rows.length ? rows : catalog ? catalog.tools.slice(0, 8).map(toolRow) : [];
+ }
+ /** Sites, tool families and tools whose NAME matches (the start first, then what this person uses most): the places to go. */
+ function goTo(q) {
+ const n = q.toLowerCase();
+ const all = sites();
+ const s = all.map((x) => [scoreOf(n, x.name) * 100 + Math.min(99, useOf(x.id)), x]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).map(([, x]) => siteItem(x, all));
+ const fams = ((catalog && catalog.families.length) ? catalog.families : FALLBACK_FAMILIES).filter((f) => scoreOf(n, f.name) >= 2).map((f) => ({ title: f.name, meta: 'Tools', url: f.url, site: f.icon, kind: 'family' }));
+ const tools = catalog ? catalog.tools.map((t) => [scoreOf(n, t.name) * 100 + Math.min(99, toolUse(t)), t]).filter(([v]) => v >= 200).sort((a, b) => b[0] - a[0]).slice(0, 4).map(([, t]) => toolRow(t)) : [];
+ // What this person already uses goes first: a tool they opened before outranks a site that merely matches.
+ return tools.filter((t) => toolUse(toolByHost(t.url) || {}) > 0).concat(s, fams, tools.filter((t) => !(toolUse(toolByHost(t.url) || {}) > 0))).slice(0, 6);
+ }
+ /** Tools whose description or keywords mention it (but not their name): useful, never the first answer. */
+ function toolsAbout(q) {
+ const n = q.toLowerCase();
+ if (!catalog || n.length < 3) return [];
+ return catalog.tools.filter((t) => scoreOf(n, t.name, t.k) === 1).slice(0, 3).map(toolRow);
+ }
+ /** A result's site icon: the Frame's site for its address (openvibe.video is Video), else its first letters. */
+ function siteIcon(url) {
+ const h = hostOf(url); let best = null, len = 0;
+ for (const s of sites()) { const sh = hostOf(s.url); if (sh && (h === sh || h.endsWith(`.${sh}`)) && sh.length > len) { best = s; len = sh.length; } }
+ return best ? best.icon || best.id : null;
+ }
- function render(q) {
- q = String(q || '').trim();
- if (!q) {
- const r = recent.get();
+ 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;
+ }
+ 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([
paint([
- { title: 'Recent', clear: true, items: r.map((x) => ({ title: x, meta: 'Search again', url: `${API}/?q=${encodeURIComponent(x)}`, icon: 'clock', kind: 'recent' })) },
- { title: `On ${here()}`, items: pagesOf('') },
- ], r.length ? '' : 'Search streams, recordings, jobs, posts, deals, wikis and more, on every OpenVibe site.');
+ { 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;
return;
}
}
- paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], 'Looking…');
+ // For you: what this person goes back to, this site's places, their searches, and their sites.
+ 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));
+ 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: '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.');
+ }
+
+ function render(q) {
+ q = String(q || '').trim();
+ el.classList.toggle('ovs-typing', !!q);
+ tabsEl.hidden = !!q;
+ 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' }));
+ const history = mine ? mine.items.filter((h) => h.title.toLowerCase().includes(n)).slice(0, 4).map((h) => ({ title: h.title, meta: [h.site, ago(h.at)].filter(Boolean).join(' · '), url: h.url, icon: 'back', kind: 'history' })) : [];
+ const first = (found) => {
+ const owner = ctx.scope && ctx.scope.owner;
+ const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, site: siteIcon(s.url), icon: (s.site || '?').slice(0, 2), kind: 'result' });
+ const minehere = owner ? found.filter((s) => s.owner === owner) : [];
+ // The rest of OpenVibe: the sites this person uses most come first, Search's own order otherwise.
+ const rest = found.filter((s) => !minehere.includes(s)).map((s, i) => [useOf(s.owner) * 1000 - i, s]).sort((a, b) => b[0] - a[0]).map(([, s]) => s);
+ return [
+ { title: 'Go to', items: goTo(q) },
+ { title: `On ${here()}`, items: [...minehere.map(row), ...pagesOf(q)] },
+ { title: 'Your history', items: history },
+ { title: 'Across OpenVibe', items: rest.map(row) },
+ { title: 'Tools for it', items: toolsAbout(q) },
+ { title: 'Your searches', items: recentHits },
+ { title: 'Search', items: actions(q) },
+ ];
+ };
+ paint(first([]), 'Looking…');
if (inflight) inflight.abort();
if (inflight) inflight.abort();
const ac = new AbortController();
const ac = new AbortController();
inflight = ac;
inflight = ac;
el.classList.add('ovs-busy');
el.classList.add('ovs-busy');
suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {
suggest(q, { limit: 10, signal: ac.signal, url: ctx.suggestUrl }).then((found) => {
if (ac.signal.aborted) return;
if (ac.signal.aborted) return;
- const owner = ctx.scope && ctx.scope.owner;
- const row = (s) => ({ title: s.title, meta: [s.site, TYPE[s.type] || s.type].filter(Boolean).join(' · '), url: s.url, icon: (s.site || '?').slice(0, 2), kind: 'result' });
- const mine = owner ? found.filter((s) => s.owner === owner) : [];
- const rest = found.filter((s) => !mine.includes(s));
- paint([
- { title: `On ${here()}`, items: [...mine.map(row), ...pagesOf(q)] },
- { title: 'Across OpenVibe', items: rest.map(row) },
- { title: 'Search', items: actions(q) },
- ], found.length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');
- }).catch(() => { if (!ac.signal.aborted) paint([{ title: `On ${here()}`, items: pagesOf(q) }, { title: 'Search', items: actions(q) }], ''); })
+ paint(first(found), found.length || goTo(q).length ? '' : 'Nothing starts with that yet: search everywhere, or ask Vibe.');
+ }).catch(() => { if (!ac.signal.aborted) paint(first([]), ''); })
.finally(() => { if (inflight === ac) { inflight = null; if (el) el.classList.remove('ovs-busy'); } });
.finally(() => { if (inflight === ac) { inflight = null; if (el) el.classList.remove('ovs-busy'); } });
}
}
function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }
function schedule(q) { clearTimeout(timer); timer = setTimeout(() => render(q), String(q).trim() ? 140 : 0); }
@@ -243,12 +508,18 @@
@@ -243,12 +508,18 @@
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';
el.classList.add('is-open');
el.classList.add('is-open');
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
- input.placeholder = `Search ${here()} and all of OpenVibe`;
+ input.placeholder = `Search ${here()}, or jump to any site`;
input.value = opts.q || '';
input.value = opts.q || '';
+ paintGhost();
render(input.value);
render(input.value);
setTimeout(() => input.focus(), 0);
setTimeout(() => input.focus(), 0);
+ // What makes it the person's own arrives after the first paint: their history, and the tools catalog.
+ 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); } });
}
}
function close() {
function close() {
if (!el) return;
if (!el) return;
@@ -110,6 +110,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -110,6 +110,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
+.sc-stat dd{display:flex;align-items:center;gap:8px}.sc-stat-ic{display:inline-flex;color:var(--accent-light,var(--accent,#60a5fa))}
+@media (max-width:640px){
+.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}
+.sc-hero{padding-top:14px}.sc-hero::before{inset:-60px 0 auto;height:280px}
+.sc-eyebrow{margin-bottom:10px;font-size:11.5px}
+.sc-hero h1{font-size:clamp(27px,7.6vw,34px);line-height:1.1;letter-spacing:-.8px}
+.sc-hero-lede{margin-top:10px;font-size:15.5px;line-height:1.5}
+.sc-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:8px;margin-top:16px}
+.sc-actions .sc-btn{justify-content:center;min-height:46px;padding:10px 12px;font-size:14.5px;text-align:center}
+.sc-note{margin-top:10px;font-size:12px;line-height:1.45}
+.sc-stats-sec{margin-top:14px}
+.sc-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
+.sc-stat{padding:10px 10px 9px;border-radius:13px;gap:1px;justify-content:flex-end}
+.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
+.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.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)}
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
*
*
* const sc = require('openvibe-shared/showcase');
* const sc = require('openvibe-shared/showcase');
* `<link rel="stylesheet" href="${serve.url('showcase.css')}">` (cached; carries the ring icons' CSS too) or
* `<link rel="stylesheet" href="${serve.url('showcase.css')}">` (cached; carries the ring icons' CSS too) or
- * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside })
+ * `<style>${sc.CSS}</style>`, then sc.hero({ eyebrow, title, accent, lede, actions, note, aside }) (an eyebrow that is
+ * only the site's name is left out: the navbar shows it; { icon, text } gives it an icon)
* + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })
* + sc.features({ id, title, lede, items: [{ icon, title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.steps({ title, lede, items: [{ title, text, href }] })
* + sc.code({ title, lede, samples: [{ label, lang, code }] })
* + sc.code({ title, lede, samples: [{ label, lang, code }] })
@@ -15,7 +16,7 @@
@@ -15,7 +16,7 @@
* + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })
* + sc.pricing({ title, lede, tiers: [{ name, price, per, items, action, highlight }], note })
* + sc.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits
* + sc.limits({ title, lede, columns, rows: [{ label, values }], source }) pricing that equals the API's limits
* + sc.stories({ title, items: [{ who, text, outcome }] })
* + sc.stories({ title, items: [{ who, text, outcome }] })
- * + sc.stats({ title, items: [{ value, label, href }] }) a strip of real numbers
+ * + sc.stats({ title, items: [{ value, label, href, icon }] }) a strip of real numbers (three across on a phone)
* + sc.cards({ title, lede, items: [{ href, title, text, image, badge, meta, live }], layout: 'grid'|'rail',
* + sc.cards({ title, lede, items: [{ href, title, text, image, badge, meta, live }], layout: 'grid'|'rail',
* ratio: '16/9'|'4/3'|'1/1', more: { label, href } }) rich content: videos, streams, posts
* ratio: '16/9'|'4/3'|'1/1', more: { label, href } }) rich content: videos, streams, posts
* + sc.spotlight({ title, lede, items: [{ title, text, image, video, alt, href, actions }] }) features, shown
* + sc.spotlight({ title, lede, items: [{ title, text, image, video, alt, href, actions }] }) features, shown
@@ -52,9 +53,19 @@ function actions(list, cls = 'sc-actions') {
@@ -52,9 +53,19 @@ function actions(list, cls = 'sc-actions') {
return `<div class="${cls}">${list.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}"${/^https?:/.test(a.href || '') && a.external ? ' rel="noopener"' : ''}>${a.icon ? icon(a.icon, 18) : ''}${esc(a.label)}</a>`).join('')}</div>`;
return `<div class="${cls}">${list.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}"${/^https?:/.test(a.href || '') && a.external ? ' rel="noopener"' : ''}>${a.icon ? icon(a.icon, 18) : ''}${esc(a.label)}</a>`).join('')}</div>`;
}
}
+// A site's own name as the eyebrow repeats the navbar's brand right above it ("OpenVibe.Quest" twice, the owner
+// 2026-10-28): such an eyebrow is left out. An eyebrow that says something else stays ({ icon, text } adds an icon).
+const SITE_NAME_RE = /^(?:OpenVibe\.[A-Za-z]+|OpenRestream|[A-Za-z]+\.OpenVibe\.[A-Za-z]+)$/;
+function eyebrowHTML(eyebrow) {
+ const e = eyebrow && typeof eyebrow === 'object' ? eyebrow : { text: eyebrow };
+ const text = String(e.text || '').trim();
+ if (!text || SITE_NAME_RE.test(text)) return '';
+ return `<p class="sc-eyebrow">${e.icon ? icon(e.icon, 16) : ''}${esc(text)}</p>`;
+}
+
/** The top of the page: what the product is, in one line, and where to start. */
/** The top of the page: what the product is, in one line, and where to start. */
function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html } = {}) {
function hero({ eyebrow, title, accent, lede, actions: acts, note, aside, html } = {}) {
- return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrow ? `<p class="sc-eyebrow">${esc(eyebrow)}</p>` : ''}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;
+ return `<section class="sc-hero${aside || html ? ' sc-split' : ''}"><div class="sc-hero-main">${eyebrowHTML(eyebrow)}<h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-hero-lede">${esc(lede)}</p>` : ''}${actions(acts)}${note ? `<p class="sc-note">${esc(note)}</p>` : ''}</div>${aside ? `<div class="sc-hero-aside">${aside.html != null ? aside.html : esc(aside)}</div>` : html != null ? `<div class="sc-hero-aside">${html}</div>` : ''}</section>`;
}
}
/** A grid of what the product does; each card may link to where it starts. */
/** A grid of what the product does; each card may link to where it starts. */
@@ -175,7 +186,7 @@ function stats({ id: sid, title, items } = {}) {
@@ -175,7 +186,7 @@ function stats({ id: sid, title, items } = {}) {
if (!usable.length) return '';
if (!usable.length) return '';
const h = `sc-${slug(title || 'numbers')}`;
const h = `sc-${slug(title || 'numbers')}`;
return `<section class="sc-sec sc-stats-sec"${id(sid)}${labelled(title, h)}>${title ? `<h2 id="${h}" class="sr-only">${esc(title)}</h2>` : ''}<dl class="sc-stats">${usable.map((x) => {
return `<section class="sc-sec sc-stats-sec"${id(sid)}${labelled(title, h)}>${title ? `<h2 id="${h}" class="sr-only">${esc(title)}</h2>` : ''}<dl class="sc-stats">${usable.map((x) => {
- const inner = `<dt>${esc(x.label)}</dt><dd>${esc(x.value)}</dd>`;
+ const inner = `<dt>${esc(x.label)}</dt><dd>${x.icon ? `<span class="sc-stat-ic">${icon(x.icon, 18)}</span>` : ''}${esc(x.value)}</dd>`;
const href = safeHref(x.href);
const href = safeHref(x.href);
return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;
return href ? `<a class="sc-stat" href="${esc(href)}">${inner}</a>` : `<div class="sc-stat">${inner}</div>`;
}).join('')}</dl></section>`;
}).join('')}</dl></section>`;
@@ -396,6 +407,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -396,6 +407,22 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:860px){.sc-hero.sc-split{grid-template-columns:1fr}.sc-hero{padding-top:36px}.sc-spot{grid-template-columns:1fr;gap:14px}.sc-spot:nth-child(even) .sc-spot-media{order:0}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
+.sc-stat dd{display:flex;align-items:center;gap:8px}.sc-stat-ic{display:inline-flex;color:var(--accent-light,var(--accent,#60a5fa))}
+@media (max-width:640px){
+.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}
+.sc-hero{padding-top:14px}.sc-hero::before{inset:-60px 0 auto;height:280px}
+.sc-eyebrow{margin-bottom:10px;font-size:11.5px}
+.sc-hero h1{font-size:clamp(27px,7.6vw,34px);line-height:1.1;letter-spacing:-.8px}
+.sc-hero-lede{margin-top:10px;font-size:15.5px;line-height:1.5}
+.sc-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:8px;margin-top:16px}
+.sc-actions .sc-btn{justify-content:center;min-height:46px;padding:10px 12px;font-size:14.5px;text-align:center}
+.sc-note{margin-top:10px;font-size:12px;line-height:1.45}
+.sc-stats-sec{margin-top:14px}
+.sc-stats{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
+.sc-stat{padding:10px 10px 9px;border-radius:13px;gap:1px;justify-content:flex-end}
+.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
+.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go,.sc-tile,.sc-thumb img,.sc-net a{transition:none}.sc-badge.sc-live::before{animation:none}}
`;
`;
@@ -74,6 +74,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
@@ -74,6 +74,8 @@ const admin = { id: 1, username: 'alex', display_name: 'Alex', role: 'admin' };
for (const name of own) {
for (const name of own) {
if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)
if (name === 'fa-coins') continue; // wallet chip, drawn when the menu first opens (below)
if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs
if (name === 'fa-user-secret') continue; // "Switch to Anonymous": only where openvibe.network's account switcher runs
+ if (name === 'fa-chevron-down') continue; // the account menu's More: only past eight places (4.23.0)
+ if (name === 'fa-bell') continue; // the Notifications place: only where the bar has no bell (4.23.0)
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
assert.ok(out.includes(glyph(name)), `${name} drawn inline by the default navbar`);
}
}
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
assert.ok(!/data-ovnav-ic|fa-solid/.test(out), 'no lazy slot or icon-font fallback in the default navbar');
@@ -210,7 +210,7 @@ function load(hostname, opts = {}) {
@@ -210,7 +210,7 @@ function load(hostname, opts = {}) {
const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
const src = require('fs').readFileSync(NAVBAR_PATH, 'utf8');
assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
assert.ok(src.includes('<span class="ud-label">Across OpenVibe</span>') && src.includes('data-open-launcher>All sites</button>'), 'a header with All sites');
assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
assert.ok(/class="ovnav-net-grid">\$\{pick\.map\(n => `<a class="ovnav-net" href="\$\{escapeAttr\(n\.url\)\}" title="\$\{escapeAttr\(n\.tagline\)\}"><span class="ov-icon" data-icon=/.test(src), 'each site with its mark');
- assert.ok(src.includes(':is(.ovnav-launcher, .ovnav-drawer) .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
+ assert.ok(src.includes('.ovnav-drawer .ov-icon { display: inline-block; flex: none; width: 24px; height: 24px;'), 'icon slots are sized before they are drawn');
assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
assert.strictEqual((src.match(/function loadIcons\(/g) || []).length, 1, 'one loadIcons (the nav glyphs); the ov-icons loader has its own name');
assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
assert.ok(/function queueIcons\(\)[\s\S]*?requestIdleCallback\(go/.test(src), 'ov-icons.js loads when the page settles');
}
}
@@ -234,7 +234,12 @@ function load(hostname, opts = {}) {
@@ -234,7 +234,12 @@ function load(hostname, opts = {}) {
// The account menu, reviewed (the owner, 2026-10-28: "duplicated things … so many icons sizes that aren't consistent"):
// The account menu, reviewed (the owner, 2026-10-28: "duplicated things … so many icons sizes that aren't consistent"):
// no row says what another says, the seasonal switch is on the Themes page, and every icon sits in one box.
// no row says what another says, the seasonal switch is on the Themes page, and every icon sits in one box.
['Seasonal themes', 'Linked Services', 'Switch account'].forEach((gone) => assert.ok(!dd.innerHTML.includes(gone), `no "${gone}" row`));
['Seasonal themes', 'Linked Services', 'Switch account'].forEach((gone) => assert.ok(!dd.innerHTML.includes(gone), `no "${gone}" row`));
- assert.ok(dd.innerHTML.includes('Account & settings') && dd.innerHTML.includes(' Themes <b class="ud-val ud-go"'), 'one row for the account, one for the themes');
+ // 4.23.0 (the owner: "a plus button… better use of icons… compactness"): + and the gear beside the name, your places
+ // as a grid of icons, and one row at the foot for light/dark, text size, motion and themes; Sign out by the gear.
+ assert.ok(/class="ud-hbtn add-account" id="openvibe-add-account"[^>]*aria-label="Add another account"/.test(dd.innerHTML), 'Add another account is a + beside the name');
+ assert.ok(dd.innerHTML.includes('aria-label="Account & settings"') && dd.innerHTML.includes('aria-label="Themes"') && /id="openvibe-logout-btn"[^>]*aria-label="Sign out"/.test(dd.innerHTML), 'the gear, Themes and Sign out are icon buttons');
+ assert.ok(/<div class="ud-grid">/.test(dd.innerHTML) && /class="ud-tile"[^>]*data-menu-id="mp"/.test(dd.innerHTML), 'the places are a grid of tiles, the site\'s own first');
+ assert.ok(!dd.innerHTML.includes('Recently used'), 'the places you went are the search menu\'s');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
assert.ok(!dd.innerHTML.includes('Across OpenVibe'), 'the other sites are the launcher\'s and the drawer\'s, not the account menu\'s');
assert.ok(/\.openvibe-navbar-dropdown-menu \.icon \.ovnav-ic \{ width: 1\.25em !important; height: 1em; \}/.test(require('fs').readFileSync(require('path').join(__dirname, '..', 'navbar.js'), 'utf8')), 'every menu icon in one fixed-width box');
assert.ok(/\.openvibe-navbar-dropdown-menu \.icon \.ovnav-ic \{ width: 1\.25em !important; height: 1em; \}/.test(require('fs').readFileSync(require('path').join(__dirname, '..', 'navbar.js'), 'utf8')), 'every menu icon in one fixed-width box');
assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');
assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
-// The launcher (the grid button beside the brand) lists every open site of the network, in the Frame's groups, most used
-// first within each: Network's /api/frame sends 40 entries (the navbar used to keep 12, so 28 sites were missing from the
-// menu, owner 2026-10-17). Runs in a real headless Chrome (skipped without Chrome or with OV_SKIP_BROWSER=1), with the
-// Frame answer served through a stubbed fetch so the navbar's own cleaning of it is what is tested.
+// Every open site of the network, in the Frame's groups, most used first within each: Network's /api/frame sends 40
+// entries (the navbar used to keep 12, so 28 sites were missing from the menu, owner 2026-10-17). Since 4.23.0 they are
+// the OpenVibe menu's Sites tab (search.js: the launcher and search became one menu, the owner 2026-10-28), opened by
+// the magnifier or the drawer's "All sites". Runs in a real headless Chrome (skipped without Chrome or with
+// OV_SKIP_BROWSER=1), with the Frame answer served through a stubbed fetch.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -30,7 +31,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
@@ -30,7 +31,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : realFetch(u, o));
window.fetch = (u, o) => (String(u).startsWith('https://openvibe.network/api/frame') ? Promise.resolve({ ok: true, json: async () => (${JSON.stringify(FRAME)}) }) : realFetch(u, o));
</script>
</script>
<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
<script src="/shared/navbar.js" defer></script></head><body><div id="navbar-mount"></div>
-<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [], countViews: false, notifications: false }); });</script>
+<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }], countViews: false, notifications: false }); });</script>
</body></html>`;
</body></html>`;
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
@@ -50,13 +51,21 @@ const server = http.createServer((req, res) => {
@@ -50,13 +51,21 @@ const server = http.createServer((req, res) => {
await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
// 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); })`);
- await p.evaluate(`document.getElementById('openvibe-launcher-btn').click(), true`);
- const read = () => p.evaluate(`(() => { const b = document.querySelector('.ovnav-launcher .ovl-body'); return {
- heads: [...b.querySelectorAll('.ovl-h')].map((e) => e.firstChild.textContent.trim()),
- groups: [...b.querySelectorAll('.ovl-apps')].map((g) => [...g.querySelectorAll('b')].map((x) => x.textContent)),
- here: [...b.querySelectorAll('.ovl-app.is-here')].map((a) => a.querySelector('b').textContent + '|' + a.getAttribute('aria-current')),
- small: (b.querySelector('.ovl-app small') || {}).textContent } })()`);
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ // The drawer's "All sites" opens the one menu on its Sites tab.
+ 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); })`);
+ const read = () => p.evaluate(`(() => { const l = document.querySelector('.ovs-list'); return {
+ tab: (document.querySelector('.ovs-tab[aria-selected="true"]') || {}).textContent,
+ 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,
+ 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.deepStrictEqual(r.heads.slice(0, 3), ['Watch & stream', 'Talk & learn', 'Your OpenVibe'], '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.deepStrictEqual(r.groups[0].slice(0, 2), ['Live', 'Watch 0'], 'most used first within a group');
@@ -64,11 +73,12 @@ const server = http.createServer((req, res) => {
@@ -64,11 +73,12 @@ const server = http.createServer((req, res) => {
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');
- // Typing filters the sites (and the search box stays on top of the scrolling panel).
- await p.evaluate(`(() => { const q = document.querySelector('.ovnav-launcher .ovl-q'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
+ // Typing goes to a site by its name, first.
+ await p.evaluate(`(() => { const q = document.querySelector('.ovs-input'); q.value = 'pic'; q.dispatchEvent(new Event('input')); return true; })()`);
+ await wait(500);
const f = await read();
const f = await read();
- assert.deepStrictEqual(f.groups[0], ['Pics'], JSON.stringify(f));
- assert.strictEqual(await p.evaluate(`getComputedStyle(document.querySelector('.ovnav-launcher .ovl-top')).position`), 'sticky');
+ assert.strictEqual(f.heads[0], 'Go to', JSON.stringify(f));
+ assert.strictEqual(f.rows[0], 'Pics', 'the site whose name starts so is the first answer');
console.log('navbar-launcher: all checks passed');
console.log('navbar-launcher: all checks passed');
} finally {
} finally {
await browser.close();
await browser.close();
@@ -70,12 +70,8 @@ const MEASURE = `(() => {
@@ -70,12 +70,8 @@ const MEASURE = `(() => {
const last = v.parts[v.parts.length - 1];
const last = v.parts[v.parts.length - 1];
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
assert.ok(last[1][1] <= width, `${at}: ${last[0]} runs off the screen`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
if (width >= 360) assert.ok(!v.clipped && /Games/.test(v.visibleText), `${at}: the site's word reads whole (${v.visibleText})`);
- if (who === 'in') {
- assert.strictEqual(v.minis, 1, `${at}: the folded balances show the first one`);
- assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: a signed-in phone keeps the launcher in the menu`);
- } else if (width >= 375) {
- assert.ok(v.parts.some(([k]) => k === 'launcher'), `${at}: signed out, the launcher stays by the name`);
- }
+ if (who === 'in') assert.strictEqual(v.minis, width <= 440 ? 1 : 2, `${at}: the folded balances show ${width <= 440 ? 'the first one' : 'both'}`);
+ assert.ok(!v.parts.some(([k]) => k === 'launcher'), `${at}: no launcher button: the magnifier opens the one menu (4.23.0)`);
await p.close();
await p.close();
}
}
}
}
@@ -1,20 +1,21 @@
@@ -1,20 +1,21 @@
'use strict';
'use strict';
// On a phone a tap leaves :hover on whatever it touched, so the launcher showed two lit rows: the site you are on and
// On a phone a tap leaves :hover on whatever it touched, so the launcher showed two lit rows: the site you are on and
-// the last one your finger crossed (the owner, 2026-10-25, on openre.stream). The launcher's and the drawer's hover
-// backgrounds apply only where a pointer really hovers; the site you are on (is-here, active) and keyboard focus
-// stay lit everywhere. On phones a launcher row's description is one line, so the list is short and scannable.
+// the last one your finger crossed (the owner, 2026-10-25, on openre.stream). The OpenVibe menu's rows (search.js since
+// 4.23.0) and the drawer's light on hover only where a pointer really hovers; the site you are on (is-here, active),
+// the keyboard's row and focus stay lit everywhere. On phones a site's description is one line, so the list is short.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
-const hoverBlocks = [...src.matchAll(/@media \(hover: hover\) \{([^\n]*)\}/g)].map((m) => m[1]).join('\n');
-const outside = src.replace(/@media \(hover: hover\) \{[^\n]*\}/g, '');
+const hoverOnly = (src) => [...src.matchAll(/@media \(hover: hover\) \{([^\n]*)\}/g)].map((m) => m[1]).join('\n');
+const outsideOf = (src) => src.replace(/@media \(hover: hover\) \{[^\n]*\}/g, '');
+const nav = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
+const menu = fs.readFileSync(path.join(__dirname, '..', 'search.js'), 'utf8');
-assert.ok(/\.ovnav-launcher :is\(\.ovl-app, \.ovl-fam\):hover/.test(hoverBlocks), 'launcher rows light on hover only where hover exists');
-assert.ok(/\.ovnav-drawer a:hover/.test(hoverBlocks), 'drawer links too');
-assert.ok(!/\.ovl-(app|fam):hover|:is\(\.ovl-app, \.ovl-fam\):hover/.test(outside), 'no launcher hover rule outside (hover: hover)');
-assert.ok(!/\.ovnav-drawer a:hover/.test(outside), 'no drawer hover rule outside (hover: hover)');
-assert.ok(/\.ovnav-launcher \.ovl-app\.is-here \{[^}]*background/.test(outside) && /\.ovnav-launcher a:focus-visible \{[^}]*background/.test(outside), 'the site you are on and keyboard focus are lit on every device');
-assert.ok(/@media \(max-width: 480px\) \{[^\n]*\.ovnav-launcher \.ovl-app small \{ -webkit-line-clamp: 1; \}/.test(src), 'one-line descriptions on phones');
+assert.ok(/\.ovs-item a:hover, \.ovs-chip:hover, \.ovs-tile:hover/.test(hoverOnly(menu)), 'menu rows, chips and tiles light on hover only where hover exists');
+assert.ok(!/\.ovs-(item a|chip|tile|tab|x):hover/.test(outsideOf(menu)), 'no menu hover rule outside (hover: hover)');
+assert.ok(/\.ovs-tile\.is-here \{[^}]*background/.test(outsideOf(menu)) && /\.ovs-opt\.is-active, \.ovs-opt:focus-visible \{[^}]*background/.test(outsideOf(menu)), 'the site you are on, the keyboard\'s row and focus are lit on every device');
+assert.ok(/@media \(max-width: 640px\) \{[\s\S]*?\.ovs-tile small \{ -webkit-line-clamp: 1; \}/.test(menu), 'one-line descriptions on phones');
+assert.ok(/\.ovnav-drawer a:hover/.test(hoverOnly(nav)), 'drawer links too');
+assert.ok(!/\.ovnav-drawer a:hover/.test(outsideOf(nav)), 'no drawer hover rule outside (hover: hover)');
console.log('navbar-touch-hover: ok');
console.log('navbar-touch-hover: ok');
@@ -29,6 +29,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
@@ -29,6 +29,7 @@ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>
const url = new URL(String(u), location.href);
const url = new URL(String(u), location.href);
if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);
if (url.hostname === 'openvibe.network' && url.pathname === '/api/frame/suggest') return window.__realFetch('/suggest' + url.search, init);
if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');
if (url.hostname === 'search.openvibe.network') throw new TypeError('refused by connect-src');
+ if (url.hostname === 'openvibe.network') return new Response('{}', { status: 404 }); // the Frame and the catalog: offline here
return window.__realFetch(u, init);
return window.__realFetch(u, init);
};
};
OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
OpenVibeNavbar.init({ service: 'chat', user: null, links: [{ label: 'Rooms', href: '/rooms', icon: 'fa-people-group' }, { label: 'Servers', href: '/servers', icon: 'fa-server' }],
@@ -70,16 +71,28 @@ const server = http.createServer((req, res) => {
@@ -70,16 +71,28 @@ const server = http.createServer((req, res) => {
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
await p.evaluate('document.dispatchEvent(new KeyboardEvent("keydown", { key: "/", bubbles: true })); 1');
await wait(400);
await wait(400);
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,
- groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-title')].map((t) => t.textContent),
- placeholder: document.querySelector('.ovs-input').placeholder })`);
+ tabs: [...document.querySelectorAll('.ovs-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 })`);
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.groups, v.rows], [['On OpenVibe.Chat'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages');
- assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat and all of OpenVibe');
+ assert.deepStrictEqual(v.tabs, ['For you*', 'Sites', 'Tools'], 'one menu, three tabs (the launcher and search together, 4.23.0)');
+ assert.deepStrictEqual([v.groups, v.chips], [['On OpenVibe.Chat', 'Popular on OpenVibe'], ['Rooms', 'Servers']], 'nothing typed: this site\'s pages, then the sites');
+ assert.ok(v.sites.includes('Live') && v.sites.includes('Tools'), 'the main sites even before the Frame answers');
+ assert.strictEqual(v.placeholder, 'Search OpenVibe.Chat, or jump to any site');
+ // 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; })()`);
+ assert.strictEqual(await p.evaluate('document.querySelector(".ovs-ghost").textContent'), 'Rooms', 'the completion shows behind the text');
+ await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); 1');
+ 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');
// 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);
v = await p.evaluate(`({ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href')]),
v = await p.evaluate(`({ groups: [...document.querySelectorAll('.ovs-group')].map((g) => g.firstChild.textContent), rows: [...document.querySelectorAll('.ovs-item a')].map((a) => [a.querySelector('.ovs-title').textContent, a.getAttribute('href')]),
- bold: !!document.querySelector('.ovs-list b'), active: document.querySelector('.ovs-item.is-active .ovs-title').textContent })`);
+ bold: !!document.querySelector('.ovs-list .ovs-title b'), active: document.querySelector('.ovs-opt.is-active .ovs-title').textContent })`);
assert.deepStrictEqual(v.groups, ['On OpenVibe.Chat', 'Across OpenVibe', 'Search']);
assert.deepStrictEqual(v.groups, ['On OpenVibe.Chat', 'Across OpenVibe', 'Search']);
assert.deepStrictEqual(v.rows[0], ['night room', 'https://openvibe.chat/r/night-owls'], 'the room on this site comes first');
assert.deepStrictEqual(v.rows[0], ['night room', 'https://openvibe.chat/r/night-owls'], 'the room on this site comes first');
assert.ok(v.rows.some(([t, u]) => t === 'night stream <b>bold</b>' && u === 'https://openvibe.video/v/1'), 'a title is text, never markup');
assert.ok(v.rows.some(([t, u]) => t === 'night stream <b>bold</b>' && u === 'https://openvibe.video/v/1'), 'a title is text, never markup');
@@ -91,7 +104,7 @@ const server = http.createServer((req, res) => {
@@ -91,7 +104,7 @@ const server = http.createServer((req, res) => {
assert.strictEqual(v.active, 'night room', 'the first row is ready for Enter');
assert.strictEqual(v.active, 'night room', 'the first row is ready for Enter');
assert.ok(asked.includes('night'), 'Search was asked');
assert.ok(asked.includes('night'), 'Search was asked');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
await p.evaluate('document.querySelector(".ovs-input").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); 1');
- assert.notStrictEqual(await p.evaluate('document.querySelector(".ovs-item.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');
@@ -113,4 +113,16 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -113,4 +113,16 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(rise && !/opacity/.test(rise[0]), 'the reveal only moves: a section that fades in is measured half-transparent, so its text fails contrast checks (axe on every product home)');
assert.ok(rise && !/opacity/.test(rise[0]), 'the reveal only moves: a section that fades in is measured half-transparent, so its text fails contrast checks (axe on every product home)');
}
}
+// The hero on a phone (4.23.0; the owner on openvibe.quest: "why is openvibe.quest twice… empty space at the top"): an
+// eyebrow that is only the site's name is left out (the navbar shows it), any other eyebrow stays, with an icon when
+// given; numbers take an icon; phones start the hero near the bar, put the buttons side by side and the numbers three across.
+{
+ ['OpenVibe.Quest', 'OpenRestream', 'Play.OpenVibe.Network'].forEach((name) => assert.ok(!sc.hero({ eyebrow: name, title: 'T' }).includes('sc-eyebrow'), `${name}: no eyebrow under the navbar's own brand`));
+ assert.ok(/<p class="sc-eyebrow">Free, nothing to install<\/p>/.test(sc.hero({ eyebrow: 'Free, nothing to install', title: 'T' })), 'an eyebrow that says something stays');
+ assert.ok(/<p class="sc-eyebrow"><(i|span)[^>]*>.*<\/(i|span)>New<\/p>/.test(sc.hero({ eyebrow: { icon: 'fa-star', text: 'New' }, title: 'T' })), 'an eyebrow can carry an icon');
+ assert.ok(/<dd><span class="sc-stat-ic">.*<\/span>39<\/dd>/.test(sc.stats({ items: [{ value: 39, label: 'quests', icon: 'fa-star' }] })), 'a number can carry an icon');
+ const phone = sc.CSS.slice(sc.CSS.indexOf('@media (max-width:640px){'));
+ assert.ok(/\.sc-hero\{padding-top:14px\}/.test(phone) && /\.sc-actions\{display:grid;grid-template-columns:repeat\(auto-fit,minmax\(0,1fr\)\)/.test(phone) && /\.sc-stats\{grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/.test(phone), 'phones: near the bar, buttons side by side, numbers three across');
+}
+
console.log('showcase: all checks passed');
console.log('showcase: all checks passed');