openvibers/openvibe.shared Commits
The network's own icons in place of Font Awesome (4.61.0) (#213)
Changes
@@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.61.0 — 2026-10-31++++- **The network's own icons: `openvibe-shared/glyphs` and `ov-glyphs.js`** (plan L32; the owner: "our own custom++ icon system… replace Font Awesome… through openvibe.design").++ - **One registry, in the mark's hand** (24-unit grid, 2-unit round strokes): the kit's 84 icons plus 71 drawn for++ the names the network asked Font Awesome for most. 155 glyphs in all, and 236 Font Awesome names mapped. That++ covers 179 of the 188 names used eight or more times; the rest are brand logos (which stay Font Awesome's) and++ a few rare amenity icons.++ - **`ov-glyphs.js`** (built by `scripts/build-glyphs.js`; 7.4 KB brotli) swaps `<i class="fa-… fa-x">` for the++ glyph in place, in the text's colour and size.++ - It follows class changes (play ↔ pause) and markup added later.++ - It leaves `fa-brands` and names it does not know to Font Awesome.++ - It turns Font Awesome's own glyph and spin off on what it swaps. A spinner becomes three dots that take turns.++ - **The navbar loads it** after first paint on any page that uses Font Awesome (a site opts out with++ `glyphs: false`). `kit.PATHS` exposes the kit's drawings to the registry.++- **The files beside the navbar load by content hash** (`SIBLING_REV`, `scripts/sibling-revs.js`): ov-mark.js,++ ov-glyphs.js and the brand face get a new URL each release. After 4.59.0 an edge served the old mark for minutes.++- navbar.js is smaller than in 4.60.0 (its longest comments tightened), so no home page grows: 40.7 KB brotli.++## 4.60.0 — 2026-10-31- **One left sidebar for every product: `kit.sidebar`** (plan L35; the owner: "the navigation makes more sense in a@@@ -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.60.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.61.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.@@@ -21,7 +21,7 @@ were reconciled first.|---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` || Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `glyphs` (the network's icons, one registry: `NAMES`, `path(name)`, `svg(name, { size, title })`, `fromFa(name)`, `FA`; the browser drop-in `ov-glyphs.js` shows them in place of Font Awesome's), `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) || Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.60.0",++ "version": "4.61.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)",@@@ -25,6 +25,7 @@ const BROWSER = Object.freeze(['account-switcher.js','user-card.js','ov-mark.js', // the OV brand mark drop-in++ 'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)'history.js', // cross-site history'sso-client.js', // link hand-off, FedCMnnew file mode 100644@@@ -0,0 +1,153 @@++/**++ * openvibe-shared/glyphs.js — the OpenVibe glyphs: one registry for every icon the network draws (plan L32; the owner,++ * 2026-10-31: "our own custom icon system… replace Font Awesome… with our own… through openvibe.design").++ *++ * One hand for all of them, the mark's: a 24-unit grid, 2-unit round strokes, nothing filled but a dot. The kit's icons++ * (kit.js) are the first of them; the drawings below fill in what the network asked Font Awesome for most, and FA++ * maps every Font Awesome name in use to the glyph that stands for it, so a page can drop Font Awesome as soon as its++ * names are covered. Brand logos (GitHub, YouTube, Discord…) are not ours to redraw and stay as they are.++ *++ * const glyphs = require('openvibe-shared/glyphs');++ * glyphs.NAMES every glyph's name++ * glyphs.path(name) its drawing (the inside of a 24-unit SVG), or ''++ * glyphs.svg(name, { size, title }) a standalone SVG (stroke currentColor), or ''++ * glyphs.fromFa('fa-tower-broadcast') → 'live' the glyph for a Font Awesome name (with or without fa-), or null++ * glyphs.FA { 'tower-broadcast': 'live', … }++ * glyphs.META[name] { by, tags } who drew it and what it is for (openvibe.design/icons)++ *++ * In a page, ov-glyphs.js (built from this file by scripts/build-glyphs.js) swaps <i class="fa-… fa-x"> for the glyph,++ * in place, and leaves a name it does not know to Font Awesome.++ */++(function (root, factory) {++ const api = factory(typeof module === 'object' && module.exports ? require('./kit') : root.OpenVibeKit);++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }++ root.OpenVibeGlyphs = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function (kit) {++ 'use strict';++ // ── The drawings that join the kit's: 24-unit grid, strokes only (round caps and joins), a dot is a 0.01 line ──++ const DRAWN = {++ scissors: '<circle cx="6" cy="7" r="2.6"/><circle cx="6" cy="17" r="2.6"/><path d="M8.2 8.6 20 18M8.2 15.4 20 6"/>',++ tree: '<path d="M12 3 6.5 10.5h3L5 17h14l-4.5-6.5h3ZM12 17v4"/>',++ pin: '<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0C18.5 15.4 12 21 12 21Z"/><circle cx="12" cy="10" r="2.4"/>',++ volume: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4Z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10"/>',++ mute: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4Z"/><path d="m16 10 4 4M20 10l-4 4"/>',++ tent: '<path d="M2.5 20h19M12 5 4 20M12 5l8 15M12 13.5 9 20M12 13.5l3 6.5"/>',++ food: '<path d="M7 3v7M5 3v4.5a2 2 0 0 0 4 0V3M7 10v11M17 21V3c-2 1.2-3.2 3.6-3.2 7v3H17"/>',++ train: '<rect x="6" y="3" width="12" height="13" rx="3"/><path d="M6 10h12M9 13h.01M15 13h.01M8.5 21l2-3.5M15.5 21l-2-3.5"/>',++ water: '<path d="M2.5 9c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2M2.5 15c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2"/>',++ dish: '<path d="M5 9.5A9.5 9.5 0 0 0 14.5 19Z"/><path d="m9.5 14.5 4.5-4.5M14.5 4.5a5 5 0 0 1 5 5M14.5 8a1.5 1.5 0 0 1 1.5 1.5"/>',++ history: '<path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1"/><path d="M12 8v4.2l3 1.8"/>',++ film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21"/>',++ circle: '<circle cx="12" cy="12" r="8.5"/>',++ sliders: '<path d="M4 7h9.5M18.5 7H20M4 17h3.5M12.5 17H20"/><circle cx="16" cy="7" r="2.3"/><circle cx="10" cy="17" r="2.3"/>',++ leaf: '<path d="M5 19c0-8.5 5-13.6 15-15-.9 9.9-6 15-15 15ZM5 19l8-8"/>',++ clipboard: '<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/>',++ gauge: '<path d="M4 18a8 8 0 1 1 16 0"/><path d="m12 14.5 4-5.5"/><circle cx="12" cy="14.5" r="1.3"/>',++ save: '<path d="M5 4h11l3 3v13H5Z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',++ plug: '<path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0ZM12 16.5V21"/>',++ compress: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',++ expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',++ care: '<path d="M3 15.5h3l4 3h5.5a2 2 0 0 0 0-4H12M7 15.5l2.2-2h3.3"/><path d="M15.5 10s-3.2-2-3.2-4.4a1.9 1.9 0 0 1 3.2-1.3 1.9 1.9 0 0 1 3.2 1.3c0 2.4-3.2 4.4-3.2 4.4Z"/>',++ city: '<path d="M3 21h18M5 21V9h5v12M10 21V4h6v17M16 21v-8h3.5v8M7.5 12h.01M7.5 15.5h.01M13 7.5h.01M13 11h.01M13 14.5h.01"/>',++ wrench: '<path d="M14.5 4a5 5 0 0 0-4.4 7L4 17.1V20h2.9l6.1-6.1a5 5 0 0 0 7-4.4l-3 1.1-2.4-2.4 1.1-3Z"/>',++ music: '<path d="M9 18V6l11-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/>',++ 'eye-off': '<path d="M3 12s3.5-6.5 9-6.5c1.6 0 3 .5 4.3 1.2M21 12s-3.5 6.5-9 6.5c-1.6 0-3-.5-4.3-1.2"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M4 4l16 16"/>',++ plane: '<path d="M20.5 3.5 3.5 10.5l6.8 3.2 3.2 6.8Z"/><path d="m10.3 13.7 10.2-10.2"/>',++ fire: '<path d="M12 21a6 6 0 0 0 6-6c0-3.8-2.6-6-3.7-9-1.8 2-2.3 4-2.3 4S10.8 8 9.8 6.2C7.6 8.7 6 11.6 6 15a6 6 0 0 0 6 6Z"/>',++ nodes: '<circle cx="12" cy="5" r="2.2"/><circle cx="5.5" cy="18" r="2.2"/><circle cx="18.5" cy="18" r="2.2"/><path d="M11 7 6.5 16M13 7l4.5 9M7.7 18h8.6"/>',++ bridge: '<path d="M2 15.5h20M4.5 15.5V10M19.5 15.5V10M2 9.5c4 0 6-3 10-3s6 3 10 3M8.5 15.5V12M15.5 15.5V12M12 15.5V8"/>',++ 'check-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m8 12.4 2.8 2.8L16 9.6"/>',++ 'x-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6M15 9l-6 6"/>',++ 'alert-circle': '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5.5M12 16.5h.01"/>',++ news: '<path d="M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z"/><path d="M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5"/>',++ camera: '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/>',++ crown: '<path d="M3 8.5 7.5 12.5 12 5.5l4.5 7 4.5-4L19 19H5Z"/>',++ wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v4M4 7.5V18a2 2 0 0 0 2 2h14V9H6.5A2.5 2.5 0 0 1 4 7.5Z"/><path d="M16 14.5h.01"/>',++ chip: '<rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4"/>',++ id: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16c.5-1.5 1.7-2.3 3-2.3s2.5.8 3 2.3M15 10h3M15 13.5h3"/>',++ brain: '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2"/>',++ snow: '<path d="M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9M9.5 4.5 12 7l2.5-2.5M9.5 19.5 12 17l2.5 2.5"/>',++ mountain: '<path d="M3 20 9.5 8.5l4 6.5 2.5-3.5L21 20Z"/>',++ 'mic-off': '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 11.6 5.3M19 11c0 .8-.1 1.6-.4 2.3M12 18v3M4 4l16 16"/>',++ database: '<ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3"/>',++ layers: '<path d="m12 3.5 9 5-9 5-9-5Z"/><path d="m3 13 9 5 9-5"/>',++ hash: '<path d="M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17"/>',++ text: '<path d="M5.5 20 11 4h2l5.5 16M7.6 14h8.8"/>',++ enter: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 8l4 4-4 4M14 12H4"/>',++ language: '<path d="M4 5h9M8.5 3v2M6 5c.5 3 2.5 6 6 7.5M11 5c-.6 3.4-2.8 6.8-6.5 8.5M12 21l4-9 4 9M13.4 18h5.2"/>',++ idea: '<path d="M9 18h6M10 21h4M8.6 14.6A6 6 0 1 1 15.4 14.6c-.7.6-1.4 1.5-1.4 2.4h-4c0-.9-.7-1.8-1.4-2.4Z"/>',++ dollar: '<path d="M16 7.5C15.3 6 13.8 5 12 5 9.5 5 8 6.3 8 8s1.7 2.6 4 3 4 1.4 4 3.3-1.6 3.2-4 3.2c-1.9 0-3.4-1-4-2.5M12 3v18"/>',++ target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>',++ 'arrow-right': '<path d="M4 12h16M14 6l6 6-6 6"/>', 'arrow-left': '<path d="M20 12H4M10 6l-6 6 6 6"/>',++ 'arrow-up': '<path d="M12 20V4M6 10l6-6 6 6"/>', 'arrow-down': '<path d="M12 4v16M6 14l6 6 6-6"/>',++ shirt: '<path d="M8 3 3 6l2 4 2-1v12h10V9l2 1 2-4-5-3c-.5 1.5-2 2.5-4 2.5S8.5 4.5 8 3Z"/>',++ route: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><path d="M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6"/>',++ reply: '<path d="M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6v1"/>',++ smile: '<circle cx="12" cy="12" r="8.5"/><path d="M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/>',++ archive: '<rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9M10 13h4"/>',++ 'user-check': '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 11l2 2 4-4"/>',++ ticket: '<path d="M3 7h18v3a2 2 0 0 0 0 4v3H3v-3a2 2 0 0 0 0-4Z"/><path d="M14 7v10"/>',++ tack: '<path d="M9 3h6l-1 5 3 3H7l3-3ZM12 11v10"/>',++ store: '<path d="M4 9 5.5 4h13L20 9M4 9h16v1.5a2.7 2.7 0 0 1-5.3 0 2.7 2.7 0 0 1-5.4 0A2.7 2.7 0 0 1 4 10.5ZM5 13v7h14v-7M10 20v-4h4v4"/>',++ keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9 10h.01M12 10h.01M15 10h.01M18 10h.01M8 14h8"/>',++ gavel: '<path d="m14 4.5 5.5 5.5M10.5 8l5.5 5.5M12.3 6.3l-6 6 5.4 5.4 6-6M3 21l6.3-6.3M14 21h7"/>',++ drop: '<path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z"/>',++ wifi: '<path d="M2.5 9a14 14 0 0 1 19 0M5.5 12.5a9.5 9.5 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01"/>',++ branch: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10M18 9c0 5.5-6.5 4-11 8.5"/>',++ // A spinner is never drawn as one (the owner, 2026-10-31: nothing spins): three dots that take turns (ov-glyphs.js).++ busy: '<path d="M6 12h.01M12 12h.01M18 12h.01" stroke-width="3.4"/>',++ };++ const BY = { kit: 'the OpenVibe Kit', opus: 'drawn by Opus for the glyph set (2026-10-31)' };++ const ALL = { ...(kit && kit.PATHS ? kit.PATHS : {}), ...DRAWN };++ const NAMES = Object.freeze(Object.keys(ALL).sort());++ const META = Object.freeze(Object.fromEntries(NAMES.map((n) => [n, { by: DRAWN[n] ? BY.opus : BY.kit, tags: [] }])));++++ // ── Font Awesome's names, as the network uses them, mapped to the glyph that stands for each ──++ const FA = Object.freeze({++ 'wand-magic-sparkles': 'spark', 'wand-magic': 'spark', sparkles: 'spark', 'star-of-life': 'spark', eye: 'eye', 'eye-slash': 'eye-off',++ 'tower-broadcast': 'live', 'broadcast-tower': 'live', 'satellite-dish': 'dish', 'tower-cell': 'live', 'signal': 'wifi', wifi: 'wifi',++ xmark: 'close', times: 'close', 'circle-xmark': 'x-circle', 'xmark-circle': 'x-circle', video: 'video', 'video-slash': 'video', camera: 'camera',++ robot: 'robot', scissors: 'scissors', cut: 'scissors', copy: 'copy', paste: 'clipboard', clipboard: 'clipboard', 'clipboard-list': 'clipboard',++ bolt: 'zap', 'bolt-lightning': 'zap', check: 'check', 'check-circle': 'check-circle', 'circle-check': 'check-circle',++ comments: 'chat', comment: 'chat', 'comment-dots': 'chat', message: 'chat', play: 'play', 'circle-play': 'play', pause: 'pause', stop: 'stop',++ tree: 'tree', spinner: 'busy', 'circle-notch': 'busy', 'map-pin': 'pin', 'location-dot': 'pin', 'location-pin': 'pin', map: 'map', route: 'route',++ globe: 'globe', 'earth-americas': 'globe', 'earth-europe': 'globe', language: 'language', code: 'code', terminal: 'terminal', 'code-branch': 'branch', 'code-fork': 'branch',++ coins: 'coin', coin: 'coin', 'hand-holding-dollar': 'coin', 'dollar-sign': 'dollar', wallet: 'wallet', clock: 'clock', 'clock-rotate-left': 'history', 'rotate-left': 'retry',++ rotate: 'retry', 'rotate-right': 'retry', 'arrows-rotate': 'retry', 'arrow-rotate-right': 'retry', 'shield-halved': 'shield', shield: 'shield', 'user-shield': 'shield',++ plus: 'plus', minus: 'minus', lock: 'lock', unlock: 'lock', key: 'key', 'arrow-right': 'arrow-right', 'arrow-left': 'arrow-left', 'arrow-up': 'arrow-up', 'arrow-down': 'arrow-down',++ 'chevron-right': 'chevron', 'chevron-left': 'back', 'chevron-down': 'down', 'chevron-up': 'up', 'angle-right': 'chevron', 'angle-down': 'down',++ star: 'star', 'volume-high': 'volume', 'volume-low': 'volume', 'volume-xmark': 'mute', 'volume-off': 'mute', 'triangle-exclamation': 'alert',++ 'circle-exclamation': 'alert-circle', 'circle-info': 'info', 'circle-question': 'help', question: 'help', link: 'link', image: 'image', images: 'image',++ 'file-image': 'image', 'photo-film': 'film', film: 'film', user: 'user', 'user-secret': 'user', 'user-astronaut': 'user', 'user-plus': 'invite',++ 'user-check': 'user-check', users: 'members', 'people-group': 'members', 'user-group': 'members', server: 'server', database: 'database',++ 'magnifying-glass': 'search', search: 'search', campground: 'tent', utensils: 'food', train: 'train', heart: 'heart', 'hand-holding-heart': 'care', water: 'water',++ 'person-swimming': 'water', house: 'home', 'house-chimney': 'home', download: 'download', upload: 'upload', 'cloud-arrow-up': 'upload', circle: 'circle',++ sliders: 'sliders', 'sliders-h': 'sliders', leaf: 'leaf', gamepad: 'game', microphone: 'mic', 'microphone-slash': 'mic-off',++ 'arrow-up-right-from-square': 'open', 'up-right-from-square': 'open', 'external-link': 'open', palette: 'palette', bell: 'bell', 'gauge-high': 'gauge', gauge: 'gauge',++ 'floppy-disk': 'save', trash: 'trash', 'trash-can': 'trash', 'chart-line': 'chart', plug: 'plug', compress: 'compress', expand: 'expand',++ 'up-right-and-down-left-from-center': 'expand', pen: 'pencil', 'pen-nib': 'pencil', pencil: 'pencil', 'pen-to-square': 'pencil', city: 'city', ban: 'ban',++ 'screwdriver-wrench': 'wrench', wrench: 'wrench', music: 'music', gear: 'gear', gears: 'gear', 'paper-plane': 'plane', fire: 'fire',++ 'circle-nodes': 'nodes', 'network-wired': 'nodes', 'diagram-project': 'nodes', sitemap: 'nodes', bridge: 'bridge', envelope: 'mail', sun: 'sun', moon: 'moon',++ 'list-check': 'steps', list: 'list', 'list-ul': 'list', bars: 'menu', display: 'computer', desktop: 'computer', laptop: 'computer', 'wave-square': 'wave',++ newspaper: 'news', 'file-lines': 'file', file: 'file', 'file-pdf': 'file', 'file-audio': 'file', folder: 'folder', book: 'book', 'book-open': 'book', crown: 'crown',++ microchip: 'chip', 'id-card': 'id', brain: 'brain', cloud: 'cloud', 'cloud-sun': 'cloud', 'cloud-rain': 'cloud', 'cloud-showers-heavy': 'cloud', snowflake: 'snow',++ mountain: 'mountain', 'mountain-sun': 'mountain', 'layer-group': 'layers', hashtag: 'hash', font: 'text', 'text-height': 'text',++ 'right-from-bracket': 'leave', 'right-to-bracket': 'enter', 'arrow-right-from-bracket': 'leave', 'arrow-right-to-bracket': 'enter',++ gift: 'gift', lightbulb: 'idea', 'cart-shopping': 'cart', cart: 'cart', bullseye: 'target', trophy: 'trophy', tag: 'tag', tags: 'tag', shirt: 'shirt', reply: 'reply',++ 'face-smile': 'smile', 'box-archive': 'archive', ticket: 'ticket', thumbtack: 'tack', store: 'store', keyboard: 'keyboard', gavel: 'gavel', droplet: 'drop',++ flag: 'flag', calendar: 'calendar', 'calendar-days': 'calendar', phone: 'phone', 'mobile-screen': 'phone', 'hand': 'hand', 'hand-pointer': 'hand', grid: 'grid',++ 'table-cells': 'grid', 'chart-simple': 'chart', 'chart-bar': 'chart', 'filter': 'filter', 'ellipsis': 'more', 'ellipsis-vertical': 'more', 'share-nodes': 'nodes',++ });++++ const path = (name) => (Object.prototype.hasOwnProperty.call(ALL, name) ? ALL[name] : '');++ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));++ function svg(name, { size = 24, title = '' } = {}) {++ const d = path(name);++ if (!d) return '';++ const label = title ? ` role="img" aria-label="${esc(title)}"><title>${esc(title)}</title>` : ' aria-hidden="true">';++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${Number(size) || 24}" height="${Number(size) || 24}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}${d}</svg>`;++ }++ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) && path(FA[n]) ? FA[n] : null; };++++ return { NAMES, META, FA, DRAWN: Object.freeze(Object.keys(DRAWN)), path, svg, fromFa };++});@@@ -108,6 +108,8 @@palette: '<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7.3" r="1.2"/><circle cx="14.5" cy="7.3" r="1.2"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',};const ICONS = Object.freeze(Object.keys(P));++ /** Every icon's drawing (the inside of a 24-unit SVG), for openvibe-shared/glyphs, the one registry built on these. */++ const PATHS = Object.freeze({ ...P });/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */function icon(name, { size = null, cls = '' } = {}) {const d = P[name];@@@ -814,7 +816,7 @@`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;return {-- esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,++ esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,@@@ -46,7 +46,7 @@body.ov-panel-open .openvibe-navbar.ovnav-top { background-color: var(--bg-secondary, #252530); border-bottom-color: var(--border, #333340); }@media (prefers-reduced-motion: reduce) { .openvibe-navbar, .openvibe-navbar::before { transition: none; } }/* Names: the brand face (ov-brand.woff2: Nunito, Latin, 700-900). */-- @font-face { font-family: 'OV Brand'; src: url(${sibling('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; }++ @font-face { font-family: 'OV Brand'; src: url(${siblingV('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; }.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }/* Brand group: mark + linked wordmark + launcher read as one control. Every property a hostpage might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */@@@ -474,14 +474,11 @@};const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated-- // Icons are solid filled paths generated from the Font Awesome Free solid font, so every OpenVibe-- // page gets the same crisp glyphs with no dependency on Font Awesome CSS. The glyphs the navbar-- // draws itself ship above; the rest of the set (nav-icons.js, ~16 KB brotli) is fetched once,-- // after first paint, the first time a site's link, chip or menu row names one. Until it lands-- // that icon is an empty <svg> of the glyph's exact size, then its path is filled in. A page that-- // loads nav-icons.js itself (before navbar.js) has every glyph from the first frame. Names not in-- // the set, and every lazy glyph if nav-icons.js cannot load, fall back to an <i class="fa-solid">-- // for pages that do load Font Awesome.++ // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.++ const SIBLING_REV = { 'ov-mark.js': 'd53438811552', 'ov-glyphs.js': '2e3590333ae6', 'ov-brand.woff2': '3b4e6a8a5878' };++ // The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest++ // (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their++ // size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';let _lazyWidth = null;let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable@@@ -505,6 +502,7 @@* A shared browser file next to this navbar.js: the same origin and the same release (a site that* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/++ const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;function sibling(name) {const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;@@@ -1197,14 +1195,9 @@}// ─── SSO auth resolution ───────────────────────────────────-- // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,-- // and Domain=.openvibe.tools on the tools apex + every tool subdomain.-- // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,-- // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,-- // img., yt., docs., food., …) do NOT mount /auth/* — they only read the-- // shared cookie — so sign-in/out always goes through the gateway apex.-- // Detection order: ov_token cookie → localStorage → page-provided token →-- // app-specific session endpoint (opt-in via config.sessionUrl).++ // ov_token is host-only on openvibe.network and Domain=.openvibe.tools on the tools (CONTRACTS.md). The tools'++ // apex owns the OAuth client (/auth/*); its subdomains only read the cookie, so sign-in/out goes through the apex.++ // Order: the cookie, localStorage, the page's token, then config.sessionUrl.const TOOLS_APEX = 'openvibe.tools';const TOKEN_KEY = 'ov_token';@@@ -1418,13 +1411,9 @@}/**-- * Two ways to find out that this browser is signed in to the network without a session here:-- * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the-- * silent sign-in (one quick redirect, back where you were);-- * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no-- * redirect unless the answer is yes. Browsers that partition third-party cookies answer-- * no and nothing happens, which is the same as before.-- * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.++ * Signed in to the network but not here: the 'account' hint cookie goes straight to the silent sign-in (one quick++ * redirect); otherwise a hidden iframe asks GET /sso/check and redirects only on yes (partitioned cookies: no).++ * At most once per tab per 10 minutes; never after a sign-out ('guest'); never for bots.*/function maybeSilentLogin() {if (!_config.silentLogin || typeof location === 'undefined') return false;@@@ -1578,11 +1567,9 @@}/**-- * 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-- * them; elsewhere the balances stay in the menu's header. The last balances are kept per person, so the chips paint-- * at once without moving the bar; they refresh from the Network and Billing in the background, and again when the-- * tab comes back after a minute. On a phone they fold into one chip like Live's (4.16.0).++ * wallet: true — OpenCoins and Vibes as chips in the bar on any site (owner, 2026-10-27). A site's own chips with++ * those ids (Live) win. The last balances are kept per person so the chips paint at once; they refresh from the++ * Network and Billing in the background and when the tab returns after a minute; on a phone they fold into one.*/const WALLET_CHIPS = [{ id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'OpenCoins', href: 'https://openvibe.network/my#coins' },@@@ -1665,7 +1652,11 @@// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {-- try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ }++ // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome.++ if (_config.glyphs !== false && !window.OVGlyphs && !document.getElementById('ov-glyphs-loader') && document.querySelector && document.querySelector('i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]')) {++ try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }}nav.innerHTML = `${brandHTML(brand)}@@@ -1713,11 +1704,9 @@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.++ // The menu (4.23.0, the owner 2026-10-28: "more compactness… data-driven"): you, with + and the gear; your++ // places as icons in the order you use them (eight, then More); the site's sections; one row for light/dark,++ // text size, motion, themes and sign out. Notifications are the bell's; history and Ask Vibe, search'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,nnew file mode 100644@@@ -0,0 +1,64 @@++/* Generated by scripts/build-glyphs.js from glyphs.js: edit those, then run it. */++/**++ * openvibe-shared/ov-glyphs.js — the OpenVibe glyphs in place of Font Awesome (plan L32). A page that loads it keeps its++ * markup (<i class="fa-solid fa-tower-broadcast">) and shows the network's own icon instead: the same hand as the mark++ * (24-unit grid, 2-unit round strokes), in the text's colour and size. A name the glyphs do not have yet, and every++ * brand logo (fa-brands), stays Font Awesome's. A spinner is three dots that take turns, never a spin.++ *++ * <script src="/shared/ov-glyphs.js" defer></script> swaps what is in the page, then anything added later++ * OVGlyphs.swap(rootEl) swaps inside one element now++ * OVGlyphs.fromFa('fa-eye') → 'eye' the glyph a Font Awesome name maps to, or null++ *++ * 149 glyphs for 236 Font Awesome names (openvibe.design/icons shows them).++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OVGlyphs) return;++ const G = {"alert":"<path d=\"M12 4 2.8 19.5h18.4Z\"/><path d=\"M12 10v4M12 17h.01\"/>","alert-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5v5.5M12 16.5h.01\"/>","archive":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"5\" rx=\"1\"/><path d=\"M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9M10 13h4\"/>","arrow-down":"<path d=\"M12 4v16M6 14l6 6 6-6\"/>","arrow-left":"<path d=\"M20 12H4M10 6l-6 6 6 6\"/>","arrow-right":"<path d=\"M4 12h16M14 6l6 6-6 6\"/>","arrow-up":"<path d=\"M12 20V4M6 10l6-6 6 6\"/>","back":"<path d=\"M15 5l-7 7 7 7\"/>","ban":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m6 6 12 12\"/>","bell":"<path d=\"M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0\"/>","book":"<path d=\"M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z\"/><path d=\"M4 19V5\"/>","brain":"<path d=\"M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2\"/>","branch":"<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"7\" r=\"2\"/><path d=\"M6 7v10M18 9c0 5.5-6.5 4-11 8.5\"/>","bridge":"<path d=\"M2 15.5h20M4.5 15.5V10M19.5 15.5V10M2 9.5c4 0 6-3 10-3s6 3 10 3M8.5 15.5V12M15.5 15.5V12M12 15.5V8\"/>","busy":"<path d=\"M6 12h.01M12 12h.01M18 12h.01\" stroke-width=\"3.4\"/>","calendar":"<rect x=\"4\" y=\"5\" width=\"16\" height=\"15\" rx=\"2\"/><path d=\"M4 10h16M9 3v4M15 3v4\"/>","camera":"<path d=\"M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z\"/><circle cx=\"12\" cy=\"13.3\" r=\"3.4\"/>","care":"<path d=\"M3 15.5h3l4 3h5.5a2 2 0 0 0 0-4H12M7 15.5l2.2-2h3.3\"/><path d=\"M15.5 10s-3.2-2-3.2-4.4a1.9 1.9 0 0 1 3.2-1.3 1.9 1.9 0 0 1 3.2 1.3c0 2.4-3.2 4.4-3.2 4.4Z\"/>","cart":"<path d=\"M3 4h2l2.4 11h11L21 7H6.2\"/><circle cx=\"9\" cy=\"19.5\" r=\"1.3\"/><circle cx=\"17\" cy=\"19.5\" r=\"1.3\"/>","chart":"<path d=\"M4 4v16h16\"/><path d=\"m7.5 14.5 3.5-4 3 2.5 5-6\"/>","chat":"<path d=\"M4 5h16v11H8l-4 4Z\"/>","check":"<path d=\"m5 12.5 4.5 4.5L19 7.5\"/>","check-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8 12.4 2.8 2.8L16 9.6\"/>","chevron":"<path d=\"m9 6 6 6-6 6\"/>","chip":"<rect x=\"7\" y=\"7\" width=\"10\" height=\"10\" rx=\"1.5\"/><path d=\"M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4\"/>","circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/>","city":"<path d=\"M3 21h18M5 21V9h5v12M10 21V4h6v17M16 21v-8h3.5v8M7.5 12h.01M7.5 15.5h.01M13 7.5h.01M13 11h.01M13 14.5h.01\"/>","clipboard":"<rect x=\"5.5\" y=\"5\" width=\"13\" height=\"16\" rx=\"2\"/><path d=\"M9 5V3.5h6V5M9 11h6M9 15h4\"/>","clock":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5V12l3 2\"/>","close":"<path d=\"M6 6l12 12M18 6 6 18\"/>","cloud":"<path d=\"M7 18h10a4 4 0 0 0 .5-8A6 6 0 0 0 6 9.5 4.3 4.3 0 0 0 7 18Z\"/>","code":"<path d=\"m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14\"/>","coin":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11\"/>","compress":"<path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/>","computer":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\"/><path d=\"M8 20h8M12 16v4\"/>","copy":"<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2\"/>","crown":"<path d=\"M3 8.5 7.5 12.5 12 5.5l4.5 7 4.5-4L19 19H5Z\"/>","database":"<ellipse cx=\"12\" cy=\"6\" rx=\"7\" ry=\"3\"/><path d=\"M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3\"/>","dish":"<path d=\"M5 9.5A9.5 9.5 0 0 0 14.5 19Z\"/><path d=\"m9.5 14.5 4.5-4.5M14.5 4.5a5 5 0 0 1 5 5M14.5 8a1.5 1.5 0 0 1 1.5 1.5\"/>","dollar":"<path d=\"M16 7.5C15.3 6 13.8 5 12 5 9.5 5 8 6.3 8 8s1.7 2.6 4 3 4 1.4 4 3.3-1.6 3.2-4 3.2c-1.9 0-3.4-1-4-2.5M12 3v18\"/>","down":"<path d=\"m6 9 6 6 6-6\"/>","download":"<path d=\"M12 4v11M7 10l5 5 5-5M5 20h14\"/>","drop":"<path d=\"M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z\"/>","enter":"<path d=\"M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 8l4 4-4 4M14 12H4\"/>","expand":"<path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/>","eye":"<path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>","eye-off":"<path d=\"M3 12s3.5-6.5 9-6.5c1.6 0 3 .5 4.3 1.2M21 12s-3.5 6.5-9 6.5c-1.6 0-3-.5-4.3-1.2\"/><path d=\"M9.9 9.9a3 3 0 0 0 4.2 4.2M4 4l16 16\"/>","file":"<path d=\"M6 3h8l4 4v14H6Z\"/><path d=\"M14 3v4h4\"/>","film":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21\"/>","filter":"<path d=\"M4 6h16M7 12h10M10 18h4\"/>","fire":"<path d=\"M12 21a6 6 0 0 0 6-6c0-3.8-2.6-6-3.7-9-1.8 2-2.3 4-2.3 4S10.8 8 9.8 6.2C7.6 8.7 6 11.6 6 15a6 6 0 0 0 6 6Z\"/>","flag":"<path d=\"M5 21V4M5 4h11l-2 4 2 4H5\"/>","folder":"<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z\"/>","food":"<path d=\"M7 3v7M5 3v4.5a2 2 0 0 0 4 0V3M7 10v11M17 21V3c-2 1.2-3.2 3.6-3.2 7v3H17\"/>","game":"<rect x=\"2.5\" y=\"7\" width=\"19\" height=\"11\" rx=\"5\"/><path d=\"M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01\"/>","gauge":"<path d=\"M4 18a8 8 0 1 1 16 0\"/><path d=\"m12 14.5 4-5.5\"/><circle cx=\"12\" cy=\"14.5\" r=\"1.3\"/>","gavel":"<path d=\"m14 4.5 5.5 5.5M10.5 8l5.5 5.5M12.3 6.3l-6 6 5.4 5.4 6-6M3 21l6.3-6.3M14 21h7\"/>","gear":"<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z\"/>","gift":"<rect x=\"4\" y=\"9\" width=\"16\" height=\"11\" rx=\"1\"/><path d=\"M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9\"/>","globe":"<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18\"/>","grid":"<rect x=\"4\" y=\"4\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"13\" y=\"4\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"4\" y=\"13\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"13\" y=\"13\" width=\"7\" height=\"7\" rx=\"1.5\"/>","hand":"<path d=\"M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15\"/>","hash":"<path d=\"M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17\"/>","heart":"<path d=\"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z\"/>","help":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01\"/>","history":"<path d=\"M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1\"/><path d=\"M12 8v4.2l3 1.8\"/>","home":"<path d=\"M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z\"/>","id":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><circle cx=\"9\" cy=\"11\" r=\"2\"/><path d=\"M6 16c.5-1.5 1.7-2.3 3-2.3s2.5.8 3 2.3M15 10h3M15 13.5h3\"/>","idea":"<path d=\"M9 18h6M10 21h4M8.6 14.6A6 6 0 1 1 15.4 14.6c-.7.6-1.4 1.5-1.4 2.4h-4c0-.9-.7-1.8-1.4-2.4Z\"/>","image":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"3\"/><circle cx=\"9\" cy=\"10\" r=\"1.6\"/><path d=\"m21 16-5-5-8 8\"/>","info":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 11v5M12 8h.01\"/>","invite":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M19 8v6M16 11h6\"/>","key":"<circle cx=\"8\" cy=\"15\" r=\"4\"/><path d=\"m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2\"/>","keyboard":"<rect x=\"2.5\" y=\"6\" width=\"19\" height=\"12\" rx=\"2\"/><path d=\"M6 10h.01M9 10h.01M12 10h.01M15 10h.01M18 10h.01M8 14h8\"/>","language":"<path d=\"M4 5h9M8.5 3v2M6 5c.5 3 2.5 6 6 7.5M11 5c-.6 3.4-2.8 6.8-6.5 8.5M12 21l4-9 4 9M13.4 18h5.2\"/>","layers":"<path d=\"m12 3.5 9 5-9 5-9-5Z\"/><path d=\"m3 13 9 5 9-5\"/>","leaf":"<path d=\"M5 19c0-8.5 5-13.6 15-15-.9 9.9-6 15-15 15ZM5 19l8-8\"/>","leave":"<path d=\"M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10\"/>","link":"<path d=\"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1\"/>","list":"<path d=\"M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01\"/>","live":"<circle cx=\"12\" cy=\"12\" r=\"2.5\"/><path d=\"M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14\"/>","lock":"<rect x=\"5\" y=\"11\" width=\"14\" height=\"9\" rx=\"2\"/><path d=\"M8 11V8a4 4 0 0 1 8 0v3\"/>","mail":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"m4 7 8 6 8-6\"/>","map":"<path d=\"M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z\"/><path d=\"M9 4v14M15 6v14\"/>","members":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2\"/>","menu":"<path d=\"M4 7h16M4 12h16M4 17h16\"/>","mic":"<rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\"/><path d=\"M5 11a7 7 0 0 0 14 0M12 18v3\"/>","mic-off":"<rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\"/><path d=\"M5 11a7 7 0 0 0 11.6 5.3M19 11c0 .8-.1 1.6-.4 2.3M12 18v3M4 4l16 16\"/>","minus":"<path d=\"M5 12h14\"/>","moon":"<path d=\"M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z\"/>","more":"<circle cx=\"5\" cy=\"12\" r=\"1.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\"/>","mountain":"<path d=\"M3 20 9.5 8.5l4 6.5 2.5-3.5L21 20Z\"/>","music":"<path d=\"M9 18V6l11-2v12\"/><circle cx=\"6.5\" cy=\"18\" r=\"2.5\"/><circle cx=\"17.5\" cy=\"16\" r=\"2.5\"/>","mute":"<path d=\"M4 9.5h3.5L12 6v12l-4.5-3.5H4Z\"/><path d=\"m16 10 4 4M20 10l-4 4\"/>","news":"<path d=\"M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z\"/><path d=\"M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5\"/>","nodes":"<circle cx=\"12\" cy=\"5\" r=\"2.2\"/><circle cx=\"5.5\" cy=\"18\" r=\"2.2\"/><circle cx=\"18.5\" cy=\"18\" r=\"2.2\"/><path d=\"M11 7 6.5 16M13 7l4.5 9M7.7 18h8.6\"/>","open":"<path d=\"M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4\"/>","palette":"<path d=\"M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z\"/><circle cx=\"7.5\" cy=\"11\" r=\"1.2\"/><circle cx=\"10\" cy=\"7.3\" r=\"1.2\"/><circle cx=\"14.5\" cy=\"7.3\" r=\"1.2\"/>","pause":"<path d=\"M8 5v14M16 5v14\"/>","pencil":"<path d=\"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z\"/>","phone":"<path d=\"M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2\"/>","pin":"<path d=\"M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0C18.5 15.4 12 21 12 21Z\"/><circle cx=\"12\" cy=\"10\" r=\"2.4\"/>","plane":"<path d=\"M20.5 3.5 3.5 10.5l6.8 3.2 3.2 6.8Z\"/><path d=\"m10.3 13.7 10.2-10.2\"/>","play":"<path d=\"M8 5v14l11-7Z\"/>","plug":"<path d=\"M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0ZM12 16.5V21\"/>","plus":"<path d=\"M12 5v14M5 12h14\"/>","reply":"<path d=\"M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6v1\"/>","retry":"<path d=\"M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5\"/>","robot":"<rect x=\"5\" y=\"8\" width=\"14\" height=\"11\" rx=\"3\"/><path d=\"M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2\"/>","route":"<circle cx=\"6\" cy=\"18\" r=\"2.4\"/><circle cx=\"18\" cy=\"6\" r=\"2.4\"/><path d=\"M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6\"/>","save":"<path d=\"M5 4h11l3 3v13H5Z\"/><path d=\"M8 4v5h7V4M8 20v-6h8v6\"/>","scissors":"<circle cx=\"6\" cy=\"7\" r=\"2.6\"/><circle cx=\"6\" cy=\"17\" r=\"2.6\"/><path d=\"M8.2 8.6 20 18M8.2 15.4 20 6\"/>","search":"<circle cx=\"11\" cy=\"11\" r=\"6.5\"/><path d=\"m20 20-4.2-4.2\"/>","server":"<rect x=\"4\" y=\"4\" width=\"16\" height=\"7\" rx=\"2\"/><rect x=\"4\" y=\"13\" width=\"16\" height=\"7\" rx=\"2\"/><path d=\"M8 7.5h.01M8 16.5h.01\"/>","shield":"<path d=\"M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6Z\"/>","shirt":"<path d=\"M8 3 3 6l2 4 2-1v12h10V9l2 1 2-4-5-3c-.5 1.5-2 2.5-4 2.5S8.5 4.5 8 3Z\"/>","sliders":"<path d=\"M4 7h9.5M18.5 7H20M4 17h3.5M12.5 17H20\"/><circle cx=\"16\" cy=\"7\" r=\"2.3\"/><circle cx=\"10\" cy=\"17\" r=\"2.3\"/>","smile":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01\"/>","snow":"<path d=\"M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9M9.5 4.5 12 7l2.5-2.5M9.5 19.5 12 17l2.5 2.5\"/>","spark":"<path d=\"M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z\"/>","star":"<path d=\"m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z\"/>","steps":"<path d=\"M9 6h11M9 12h11M9 18h11\"/><path d=\"m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17\"/>","stop":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8.5 8.5 7 7\"/>","store":"<path d=\"M4 9 5.5 4h13L20 9M4 9h16v1.5a2.7 2.7 0 0 1-5.3 0 2.7 2.7 0 0 1-5.4 0A2.7 2.7 0 0 1 4 10.5ZM5 13v7h14v-7M10 20v-4h4v4\"/>","sun":"<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/>","tack":"<path d=\"M9 3h6l-1 5 3 3H7l3-3ZM12 11v10\"/>","tag":"<path d=\"M3 12V4h8l10 10-8 8Z\"/><circle cx=\"7.5\" cy=\"8.5\" r=\"1.3\"/>","target":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"5\"/><circle cx=\"12\" cy=\"12\" r=\"1.5\"/>","tent":"<path d=\"M2.5 20h19M12 5 4 20M12 5l8 15M12 13.5 9 20M12 13.5l3 6.5\"/>","terminal":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"m7 9 3 3-3 3M13 15h4\"/>","text":"<path d=\"M5.5 20 11 4h2l5.5 16M7.6 14h8.8\"/>","ticket":"<path d=\"M3 7h18v3a2 2 0 0 0 0 4v3H3v-3a2 2 0 0 0 0-4Z\"/><path d=\"M14 7v10\"/>","train":"<rect x=\"6\" y=\"3\" width=\"12\" height=\"13\" rx=\"3\"/><path d=\"M6 10h12M9 13h.01M15 13h.01M8.5 21l2-3.5M15.5 21l-2-3.5\"/>","trash":"<path d=\"M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3\"/>","tree":"<path d=\"M12 3 6.5 10.5h3L5 17h14l-4.5-6.5h3ZM12 17v4\"/>","trophy":"<path d=\"M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8\"/>","up":"<path d=\"m6 15 6-6 6 6\"/>","upload":"<path d=\"M12 20V9M7 14l5-5 5 5M5 4h14\"/>","user":"<circle cx=\"12\" cy=\"8\" r=\"3.5\"/><path d=\"M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6\"/>","user-check":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 11l2 2 4-4\"/>","video":"<rect x=\"3\" y=\"6\" width=\"13\" height=\"12\" rx=\"2\"/><path d=\"m16 10 5-3v10l-5-3\"/>","volume":"<path d=\"M4 9.5h3.5L12 6v12l-4.5-3.5H4Z\"/><path d=\"M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10\"/>","wallet":"<path d=\"M4 7.5A2.5 2.5 0 0 1 6.5 5H18v4M4 7.5V18a2 2 0 0 0 2 2h14V9H6.5A2.5 2.5 0 0 1 4 7.5Z\"/><path d=\"M16 14.5h.01\"/>","water":"<path d=\"M2.5 9c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2M2.5 15c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2\"/>","wave":"<path d=\"M2 16c2.5 0 2.5-2 5-2s2.5 2 5 2 2.5-2 5-2 2.5 2 5 2\"/><path d=\"M5.5 11.5A7 7 0 0 1 18 8.6c-2.3-.5-4.2.4-4.8 2.4\"/>","wifi":"<path d=\"M2.5 9a14 14 0 0 1 19 0M5.5 12.5a9.5 9.5 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01\"/>","wrench":"<path d=\"M14.5 4a5 5 0 0 0-4.4 7L4 17.1V20h2.9l6.1-6.1a5 5 0 0 0 7-4.4l-3 1.1-2.4-2.4 1.1-3Z\"/>","x-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m9 9 6 6M15 9l-6 6\"/>","zap":"<path d=\"M13 3 5 13h6l-1 8 8-10h-6Z\"/>"};++ const FA = {"angle-down":"down","angle-right":"chevron","arrow-down":"arrow-down","arrow-left":"arrow-left","arrow-right":"arrow-right","arrow-right-from-bracket":"leave","arrow-right-to-bracket":"enter","arrow-rotate-right":"retry","arrow-up":"arrow-up","arrow-up-right-from-square":"open","arrows-rotate":"retry","ban":"ban","bars":"menu","bell":"bell","bolt":"zap","bolt-lightning":"zap","book":"book","book-open":"book","box-archive":"archive","brain":"brain","bridge":"bridge","broadcast-tower":"live","bullseye":"target","calendar":"calendar","calendar-days":"calendar","camera":"camera","campground":"tent","cart":"cart","cart-shopping":"cart","chart-bar":"chart","chart-line":"chart","chart-simple":"chart","check":"check","check-circle":"check-circle","chevron-down":"down","chevron-left":"back","chevron-right":"chevron","chevron-up":"up","circle":"circle","circle-check":"check-circle","circle-exclamation":"alert-circle","circle-info":"info","circle-nodes":"nodes","circle-notch":"busy","circle-play":"play","circle-question":"help","circle-xmark":"x-circle","city":"city","clipboard":"clipboard","clipboard-list":"clipboard","clock":"clock","clock-rotate-left":"history","cloud":"cloud","cloud-arrow-up":"upload","cloud-rain":"cloud","cloud-showers-heavy":"cloud","cloud-sun":"cloud","code":"code","code-branch":"branch","code-fork":"branch","coin":"coin","coins":"coin","comment":"chat","comment-dots":"chat","comments":"chat","compress":"compress","copy":"copy","crown":"crown","cut":"scissors","database":"database","desktop":"computer","diagram-project":"nodes","display":"computer","dollar-sign":"dollar","download":"download","droplet":"drop","earth-americas":"globe","earth-europe":"globe","ellipsis":"more","ellipsis-vertical":"more","envelope":"mail","expand":"expand","external-link":"open","eye":"eye","eye-slash":"eye-off","face-smile":"smile","file":"file","file-audio":"file","file-image":"image","file-lines":"file","file-pdf":"file","film":"film","filter":"filter","fire":"fire","flag":"flag","floppy-disk":"save","folder":"folder","font":"text","gamepad":"game","gauge":"gauge","gauge-high":"gauge","gavel":"gavel","gear":"gear","gears":"gear","gift":"gift","globe":"globe","grid":"grid","hand":"hand","hand-holding-dollar":"coin","hand-holding-heart":"care","hand-pointer":"hand","hashtag":"hash","heart":"heart","house":"home","house-chimney":"home","id-card":"id","image":"image","images":"image","key":"key","keyboard":"keyboard","language":"language","laptop":"computer","layer-group":"layers","leaf":"leaf","lightbulb":"idea","link":"link","list":"list","list-check":"steps","list-ul":"list","location-dot":"pin","location-pin":"pin","lock":"lock","magnifying-glass":"search","map":"map","map-pin":"pin","message":"chat","microchip":"chip","microphone":"mic","microphone-slash":"mic-off","minus":"minus","mobile-screen":"phone","moon":"moon","mountain":"mountain","mountain-sun":"mountain","music":"music","network-wired":"nodes","newspaper":"news","palette":"palette","paper-plane":"plane","paste":"clipboard","pause":"pause","pen":"pencil","pen-nib":"pencil","pen-to-square":"pencil","pencil":"pencil","people-group":"members","person-swimming":"water","phone":"phone","photo-film":"film","play":"play","plug":"plug","plus":"plus","question":"help","reply":"reply","right-from-bracket":"leave","right-to-bracket":"enter","robot":"robot","rotate":"retry","rotate-left":"retry","rotate-right":"retry","route":"route","satellite-dish":"dish","scissors":"scissors","screwdriver-wrench":"wrench","search":"search","server":"server","share-nodes":"nodes","shield":"shield","shield-halved":"shield","shirt":"shirt","signal":"wifi","sitemap":"nodes","sliders":"sliders","sliders-h":"sliders","snowflake":"snow","sparkles":"spark","spinner":"busy","star":"star","star-of-life":"spark","stop":"stop","store":"store","sun":"sun","table-cells":"grid","tag":"tag","tags":"tag","terminal":"terminal","text-height":"text","thumbtack":"tack","ticket":"ticket","times":"close","tower-broadcast":"live","tower-cell":"live","train":"train","trash":"trash","trash-can":"trash","tree":"tree","triangle-exclamation":"alert","trophy":"trophy","unlock":"lock","up-right-and-down-left-from-center":"expand","up-right-from-square":"open","upload":"upload","user":"user","user-astronaut":"user","user-check":"user-check","user-group":"members","user-plus":"invite","user-secret":"user","user-shield":"shield","users":"members","utensils":"food","video":"video","video-slash":"video","volume-high":"volume","volume-low":"volume","volume-off":"mute","volume-xmark":"mute","wallet":"wallet","wand-magic":"spark","wand-magic-sparkles":"spark","water":"water","wave-square":"wave","wifi":"wifi","wrench":"wrench","xmark":"close","xmark-circle":"x-circle"};++ // Font Awesome's style and size classes, and its animations: they say how, not what.++ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity)$/;++ const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';++ const CSS = '.ovg::before,.ovg::after{content:none!important;display:none!important}'++ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}'++ + '.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'++ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;overflow:visible}'++ + '.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}'++ + '.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}'++ + '@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}'++ + '@media (prefers-reduced-motion:reduce){.ovg[data-ovg=busy] circle{animation:none}}html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none}';++ const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };++ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };++ function draw(el) {++ if (el.classList.contains('fa-brands') || el.classList.contains('fab')) return;++ const fa = nameOf(el);++ const g = fa && fromFa(fa);++ if (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; el.textContent = ''; } return; }++ if (el.dataset.ovgFa === fa) return;++ if (!el.dataset.ovg && el.textContent.trim()) return; // an icon element with text of its own is not an icon++ el.classList.add('ovg');++ el.dataset.ovg = g; el.dataset.ovgFa = fa;++ if (!el.hasAttribute('aria-label') && !el.hasAttribute('title')) el.setAttribute('aria-hidden', 'true');++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' + (g === 'busy' ? BUSY : G[g]) + '</svg>';++ }++ const SEL = 'i[class*="fa-"],span[class*="fa-"]';++ function swap(root) {++ const r = root || document;++ if (r.matches && r.matches(SEL)) draw(r);++ if (r.querySelectorAll) r.querySelectorAll(SEL).forEach(draw);++ }++ function start() {++ if (!document.getElementById('ov-glyphs-css')) { const s = document.createElement('style'); s.id = 'ov-glyphs-css'; s.textContent = CSS; document.head.appendChild(s); }++ swap(document);++ try {++ new MutationObserver((records) => {++ for (const r of records) {++ if (r.type === 'attributes') { if (r.target.matches && r.target.matches(SEL)) draw(r.target); continue; }++ r.addedNodes.forEach((n) => { if (n.nodeType === 1) swap(n); });++ }++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });++ } catch (e) { /* no observer: what was there is swapped */ }++ }++ window.OVGlyphs = { swap, fromFa };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();++})();@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.60.0",++ "version": "4.61.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -37,6 +37,7 @@"./profile-panel": "./profile-panel.js","./legal": "./legal.js","./express-async": "./express-async.js",++ "./glyphs": "./glyphs.js","./mark": "./mark.js","./metrics": "./metrics.js","./middleware": "./middleware.js",@@@ -49,6 +50,7 @@"./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js","./ov-icons": "./ov-icons.js",++ "./ov-glyphs": "./ov-glyphs.js","./ov-mark": "./ov-mark.js","./package.json": "./package.json","./panels": "./panels.js",nnew file mode 100644@@@ -0,0 +1,81 @@++#!/usr/bin/env node++'use strict';++// Writes ov-glyphs.js, the page drop-in that swaps Font Awesome icons for the OpenVibe glyphs, from glyphs.js (the one++// registry). Re-run after a glyph or an alias changes: node scripts/build-glyphs.js (test/glyphs.test.js checks the file++// is current). Only the glyphs some Font Awesome name stands for go in, so the drop-in carries no drawing it cannot use.++const fs = require('fs');++const path = require('path');++const glyphs = require('../glyphs');++++const used = [...new Set(Object.values(glyphs.FA))].sort();++const G = Object.fromEntries(used.map((n) => [n, glyphs.path(n)]));++const FA = Object.fromEntries(Object.entries(glyphs.FA).sort(([a], [b]) => (a < b ? -1 : 1)));++++const out = `/* Generated by scripts/build-glyphs.js from glyphs.js: edit those, then run it. */++/**++ * openvibe-shared/ov-glyphs.js — the OpenVibe glyphs in place of Font Awesome (plan L32). A page that loads it keeps its++ * markup (<i class="fa-solid fa-tower-broadcast">) and shows the network's own icon instead: the same hand as the mark++ * (24-unit grid, 2-unit round strokes), in the text's colour and size. A name the glyphs do not have yet, and every++ * brand logo (fa-brands), stays Font Awesome's. A spinner is three dots that take turns, never a spin.++ *++ * <script src="/shared/ov-glyphs.js" defer></script> swaps what is in the page, then anything added later++ * OVGlyphs.swap(rootEl) swaps inside one element now++ * OVGlyphs.fromFa('fa-eye') → 'eye' the glyph a Font Awesome name maps to, or null++ *++ * ${used.length} glyphs for ${Object.keys(FA).length} Font Awesome names (openvibe.design/icons shows them).++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OVGlyphs) return;++ const G = ${JSON.stringify(G)};++ const FA = ${JSON.stringify(FA)};++ // Font Awesome's style and size classes, and its animations: they say how, not what.++ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity)$/;++ const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';++ const CSS = '.ovg::before,.ovg::after{content:none!important;display:none!important}'++ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}'++ + '.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'++ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;overflow:visible}'++ + '.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}'++ + '.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}'++ + '@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}'++ + '@media (prefers-reduced-motion:reduce){.ovg[data-ovg=busy] circle{animation:none}}html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none}';++ const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };++ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };++ function draw(el) {++ if (el.classList.contains('fa-brands') || el.classList.contains('fab')) return;++ const fa = nameOf(el);++ const g = fa && fromFa(fa);++ if (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; el.textContent = ''; } return; }++ if (el.dataset.ovgFa === fa) return;++ if (!el.dataset.ovg && el.textContent.trim()) return; // an icon element with text of its own is not an icon++ el.classList.add('ovg');++ el.dataset.ovg = g; el.dataset.ovgFa = fa;++ if (!el.hasAttribute('aria-label') && !el.hasAttribute('title')) el.setAttribute('aria-hidden', 'true');++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' + (g === 'busy' ? BUSY : G[g]) + '</svg>';++ }++ const SEL = 'i[class*="fa-"],span[class*="fa-"]';++ function swap(root) {++ const r = root || document;++ if (r.matches && r.matches(SEL)) draw(r);++ if (r.querySelectorAll) r.querySelectorAll(SEL).forEach(draw);++ }++ function start() {++ if (!document.getElementById('ov-glyphs-css')) { const s = document.createElement('style'); s.id = 'ov-glyphs-css'; s.textContent = CSS; document.head.appendChild(s); }++ swap(document);++ try {++ new MutationObserver((records) => {++ for (const r of records) {++ if (r.type === 'attributes') { if (r.target.matches && r.target.matches(SEL)) draw(r.target); continue; }++ r.addedNodes.forEach((n) => { if (n.nodeType === 1) swap(n); });++ }++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });++ } catch (e) { /* no observer: what was there is swapped */ }++ }++ window.OVGlyphs = { swap, fromFa };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();++})();++`;++fs.writeFileSync(path.join(__dirname, '..', 'ov-glyphs.js'), out);++if (require.main === module) console.log(`ov-glyphs.js: ${used.length} glyphs for ${Object.keys(FA).length} Font Awesome names, ${(out.length / 1024).toFixed(1)} KB`);++module.exports = { build: () => out };nnew file mode 100644@@@ -0,0 +1,24 @@++#!/usr/bin/env node++'use strict';++// Writes navbar.js's SIBLING_REV: the content hash (openvibe-shared/assets hashAsset) of each file the navbar loads from++// beside itself, so a release gives each a new URL (?v=) that sites serve as immutable, and nobody is left on a copy an++// edge cached before the deploy (the Pair's first hour, 2026-10-31). Re-run after any of them changes:++// node scripts/sibling-revs.js (test/sibling-revs.test.js checks the constant is current).++const fs = require('fs');++const path = require('path');++const { hashAsset } = require('../assets');++++const ROOT = path.join(__dirname, '..');++const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2'];++const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));++const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;++++if (require.main === module) {++ const p = path.join(ROOT, 'navbar.js');++ const src = fs.readFileSync(p, 'utf8');++ const next = src.replace(/^ {4}const SIBLING_REV = \{[^\n]*\};$/m, line(revs()));++ if (next === src && !src.includes(line(revs()))) throw new Error('navbar.js has no SIBLING_REV line to update');++ fs.writeFileSync(p, next);++ console.log(line(revs()).trim());++}++module.exports = { FILES, revs, line };@@@ -30,7 +30,7 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-// Declared once by the navbar, from beside itself, for the characters it has; used with a fallback everywhere.const nav = read('navbar.js');-- assert.match(nav, /@font-face \{ font-family: 'OV Brand'; src: url\(\$\{sibling\('ov-brand\.woff2'\)\}\) format\('woff2'\); font-weight: 700 900;[^}]*font-display: swap; unicode-range: U\+0020-007E/, 'navbar.js declares it, swapping in when it arrives');++ assert.match(nav, /@font-face \{ font-family: 'OV Brand'; src: url\(\$\{siblingV\('ov-brand\.woff2'\)\}\) format\('woff2'\); font-weight: 700 900;[^}]*font-display: swap; unicode-range: U\+0020-007E/, 'navbar.js declares it, swapping in when it arrives');const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++// openvibe-shared/glyphs (4.61.0, plan L32): the one registry of the network's icons, and ov-glyphs.js, the drop-in that++// shows them in place of Font Awesome's. Every Font Awesome name maps to a glyph that exists; every drawing is strokes++// on the 24 grid with nothing but shapes in it; the drop-in is current with the registry; in a page it swaps an icon++// in place, follows a class change, leaves brand logos and unknown names to Font Awesome, and never spins.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { parseHTML } = require('linkedom');++const glyphs = require('../glyphs');++const kit = require('../kit');++++// ── The registry ──++assert.ok(glyphs.NAMES.length >= 150, `${glyphs.NAMES.length} glyphs`);++for (const name of kit.ICONS) assert.ok(glyphs.NAMES.includes(name), `the kit's ${name} is a glyph`);++for (const [fa, g] of Object.entries(glyphs.FA)) assert.ok(glyphs.path(g), `fa-${fa} → ${g}, a glyph that exists`);++for (const name of glyphs.NAMES) {++ const d = glyphs.path(name);++ const tags = [...d.matchAll(/<(\w+)/g)].map((m) => m[1]);++ assert.ok(tags.length && tags.every((t) => ['path', 'circle', 'rect', 'ellipse', 'line', 'polyline'].includes(t)), `${name}: shapes only (${tags})`);++ assert.ok(!/fill=|style=|on\w+=|href|<script|url\(/i.test(d), `${name}: no fills, styles, handlers or links`);++ for (const m of d.matchAll(/(?:cx|cy|x|y)="(-?[\d.]+)"/g)) assert.ok(Number(m[1]) >= 0 && Number(m[1]) <= 24, `${name}: on the 24 grid`);++}++assert.strictEqual(glyphs.fromFa('fa-tower-broadcast'), 'live');++assert.strictEqual(glyphs.fromFa('eye-slash'), 'eye-off');++assert.strictEqual(glyphs.fromFa('fa-spinner'), 'busy', 'a spinner is the busy dots');++assert.strictEqual(glyphs.fromFa('fa-github'), null, 'brand logos are not ours');++assert.match(glyphs.svg('live', { size: 20 }), /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">/);++assert.match(glyphs.svg('eye', { title: 'Seen <by>' }), /role="img" aria-label="Seen <by>"><title>Seen <by><\/title>/);++assert.strictEqual(glyphs.svg('nope'), '');++// The names the network asked Font Awesome for most (the 2026-10-31 count) are covered, brand logos aside.++for (const fa of ['wand-magic-sparkles', 'eye', 'tower-broadcast', 'xmark', 'video', 'robot', 'scissors', 'copy', 'bolt', 'check', 'comments', 'play', 'tree', 'spinner', 'map-pin', 'globe', 'code', 'coins', 'clock', 'shield-halved', 'plus', 'lock', 'arrow-right', 'star', 'volume-high', 'triangle-exclamation', 'link', 'image', 'user', 'server', 'magnifying-glass', 'campground', 'utensils', 'train', 'heart']) {++ assert.ok(glyphs.fromFa(fa), `fa-${fa} has a glyph`);++}++++// ── The drop-in is the registry's, current ──++const built = require('../scripts/build-glyphs').build();++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-glyphs.js'), 'utf8');++assert.strictEqual(SRC, built, 'ov-glyphs.js is current: run node scripts/build-glyphs.js');++++// ── In a page ──++(async () => {++ const { window, document, MutationObserver } = parseHTML(`<!doctype html><html><head></head><body>++<button><i class="fa-solid fa-tower-broadcast"></i> Go live</button>++<i class="fa-solid fa-spinner fa-spin" id="spin"></i>++<i class="fa-brands fa-github" id="gh"></i>++<i class="fa-solid fa-couch" id="unknown"></i>++<i class="fa-solid fa-eye" aria-label="Seen" id="labelled"></i>++<span class="fa-solid fa-play" id="toggle"></span>++</body></html>`);++ window.MutationObserver = MutationObserver;++ new Function('window', 'document', 'MutationObserver', SRC)(window, document, MutationObserver);++ const $ = (s) => document.querySelector(s);++ const live = $('button i');++ assert.ok(live.classList.contains('ovg') && live.dataset.ovg === 'live' && live.querySelector('svg path'), 'swapped in place');++ assert.strictEqual(live.getAttribute('aria-hidden'), 'true', 'a decorative icon is hidden from screen readers');++ assert.strictEqual($('#labelled').getAttribute('aria-hidden'), null, 'a labelled one is not');++ assert.ok($('#spin').dataset.ovg === 'busy' && $('#spin').querySelectorAll('circle').length === 3, 'a spinner becomes three dots');++ assert.ok(!$('#gh').classList.contains('ovg') && !$('#unknown').classList.contains('ovg'), 'brands and unknown names stay Font Awesome');++ const css = document.getElementById('ov-glyphs-css').textContent;++ assert.ok(css.includes('.ovg::before,.ovg::after{content:none!important') && css.includes('.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'), "Font Awesome's own glyph and spin are turned off on a swapped icon");++ // A page that changes an icon by its class (play ↔ pause) gets the new glyph.++ const t = $('#toggle');++ assert.strictEqual(t.dataset.ovg, 'play');++ t.className = 'fa-solid fa-pause';++ await new Promise((r) => setImmediate(r));++ assert.ok(t.dataset.ovg === 'pause' && t.classList.contains('ovg'), 'follows a class change');++ t.className = 'fa-solid fa-couch';++ await new Promise((r) => setImmediate(r));++ assert.ok(!t.classList.contains('ovg') && !t.dataset.ovg && !t.querySelector('svg'), 'and hands back a name it does not know');++ // Markup added later is swapped too.++ const later = document.createElement('div'); later.innerHTML = '<i class="fa-solid fa-heart"></i>';++ document.body.appendChild(later);++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(later.firstChild.dataset.ovg, 'heart');++ assert.strictEqual(window.OVGlyphs.fromFa('fa-xmark'), 'close');++ console.log(`glyphs: ${glyphs.NAMES.length} glyphs, ${Object.keys(glyphs.FA).length} Font Awesome names; shapes on the grid; the drop-in is current, swaps in place, follows classes, leaves brands, never spins`);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++// navbar.js loads ov-mark.js, ov-glyphs.js and the brand face from beside itself by content hash (4.61.0): a release++// gives each a new URL, so no edge serves the copy it cached before the deploy (the old mark showed some minutes after++// 4.59.0). The constant must match the files: node scripts/sibling-revs.js rewrites it.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { revs, line } = require('../scripts/sibling-revs');++++const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');++assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');++assert.ok(/const siblingV = \(name\) => `\$\{sibling\(name\)\}\$\{SIBLING_REV\[name\] \? `\?v=\$\{SIBLING_REV\[name\]\}` : ''\}`;/.test(src), 'siblingV adds the hash');++assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url(${siblingV('ov-brand.woff2')})") && src.includes("sc.src = siblingV('ov-glyphs.js')"), 'the mark, the face and the glyphs load by hash');++assert.ok(/_config\.glyphs !== false[^\n]*i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');++console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');
@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.61.0 — 2026-10-31
+
+- **The network's own icons: `openvibe-shared/glyphs` and `ov-glyphs.js`** (plan L32; the owner: "our own custom
+ icon system… replace Font Awesome… through openvibe.design").
+ - **One registry, in the mark's hand** (24-unit grid, 2-unit round strokes): the kit's 84 icons plus 71 drawn for
+ the names the network asked Font Awesome for most. 155 glyphs in all, and 236 Font Awesome names mapped. That
+ covers 179 of the 188 names used eight or more times; the rest are brand logos (which stay Font Awesome's) and
+ a few rare amenity icons.
+ - **`ov-glyphs.js`** (built by `scripts/build-glyphs.js`; 7.4 KB brotli) swaps `<i class="fa-… fa-x">` for the
+ glyph in place, in the text's colour and size.
+ - It follows class changes (play ↔ pause) and markup added later.
+ - It leaves `fa-brands` and names it does not know to Font Awesome.
+ - It turns Font Awesome's own glyph and spin off on what it swaps. A spinner becomes three dots that take turns.
+ - **The navbar loads it** after first paint on any page that uses Font Awesome (a site opts out with
+ `glyphs: false`). `kit.PATHS` exposes the kit's drawings to the registry.
+- **The files beside the navbar load by content hash** (`SIBLING_REV`, `scripts/sibling-revs.js`): ov-mark.js,
+ ov-glyphs.js and the brand face get a new URL each release. After 4.59.0 an edge served the old mark for minutes.
+- navbar.js is smaller than in 4.60.0 (its longest comments tightened), so no home page grows: 40.7 KB brotli.
+
## 4.60.0 — 2026-10-31
## 4.60.0 — 2026-10-31
- **One left sidebar for every product: `kit.sidebar`** (plan L35; the owner: "the navigation makes more sense in a
- **One left sidebar for every product: `kit.sidebar`** (plan L35; the owner: "the navigation makes more sense in a
@@ -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.60.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.61.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.
@@ -21,7 +21,7 @@ were reconciled first.
@@ -21,7 +21,7 @@ were reconciled first.
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `glyphs` (the network's icons, one registry: `NAMES`, `path(name)`, `svg(name, { size, title })`, `fromFa(name)`, `FA`; the browser drop-in `ov-glyphs.js` shows them in place of Font Awesome's), `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.60.0",
+ "version": "4.61.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)",
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
+ 'ov-glyphs.js', // the network's own icons in place of Font Awesome's (built from glyphs.js; navbar.js loads it)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'ov-icons.js', // ring icons (also required on the server as openvibe-shared/icons)
'history.js', // cross-site history
'history.js', // cross-site history
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+/**
+ * openvibe-shared/glyphs.js — the OpenVibe glyphs: one registry for every icon the network draws (plan L32; the owner,
+ * 2026-10-31: "our own custom icon system… replace Font Awesome… with our own… through openvibe.design").
+ *
+ * One hand for all of them, the mark's: a 24-unit grid, 2-unit round strokes, nothing filled but a dot. The kit's icons
+ * (kit.js) are the first of them; the drawings below fill in what the network asked Font Awesome for most, and FA
+ * maps every Font Awesome name in use to the glyph that stands for it, so a page can drop Font Awesome as soon as its
+ * names are covered. Brand logos (GitHub, YouTube, Discord…) are not ours to redraw and stay as they are.
+ *
+ * const glyphs = require('openvibe-shared/glyphs');
+ * glyphs.NAMES every glyph's name
+ * glyphs.path(name) its drawing (the inside of a 24-unit SVG), or ''
+ * glyphs.svg(name, { size, title }) a standalone SVG (stroke currentColor), or ''
+ * glyphs.fromFa('fa-tower-broadcast') → 'live' the glyph for a Font Awesome name (with or without fa-), or null
+ * glyphs.FA { 'tower-broadcast': 'live', … }
+ * glyphs.META[name] { by, tags } who drew it and what it is for (openvibe.design/icons)
+ *
+ * In a page, ov-glyphs.js (built from this file by scripts/build-glyphs.js) swaps <i class="fa-… fa-x"> for the glyph,
+ * in place, and leaves a name it does not know to Font Awesome.
+ */
+(function (root, factory) {
+ const api = factory(typeof module === 'object' && module.exports ? require('./kit') : root.OpenVibeKit);
+ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
+ root.OpenVibeGlyphs = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function (kit) {
+ 'use strict';
+ // ── The drawings that join the kit's: 24-unit grid, strokes only (round caps and joins), a dot is a 0.01 line ──
+ const DRAWN = {
+ scissors: '<circle cx="6" cy="7" r="2.6"/><circle cx="6" cy="17" r="2.6"/><path d="M8.2 8.6 20 18M8.2 15.4 20 6"/>',
+ tree: '<path d="M12 3 6.5 10.5h3L5 17h14l-4.5-6.5h3ZM12 17v4"/>',
+ pin: '<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0C18.5 15.4 12 21 12 21Z"/><circle cx="12" cy="10" r="2.4"/>',
+ volume: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4Z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10"/>',
+ mute: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4Z"/><path d="m16 10 4 4M20 10l-4 4"/>',
+ tent: '<path d="M2.5 20h19M12 5 4 20M12 5l8 15M12 13.5 9 20M12 13.5l3 6.5"/>',
+ food: '<path d="M7 3v7M5 3v4.5a2 2 0 0 0 4 0V3M7 10v11M17 21V3c-2 1.2-3.2 3.6-3.2 7v3H17"/>',
+ train: '<rect x="6" y="3" width="12" height="13" rx="3"/><path d="M6 10h12M9 13h.01M15 13h.01M8.5 21l2-3.5M15.5 21l-2-3.5"/>',
+ water: '<path d="M2.5 9c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2M2.5 15c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2"/>',
+ dish: '<path d="M5 9.5A9.5 9.5 0 0 0 14.5 19Z"/><path d="m9.5 14.5 4.5-4.5M14.5 4.5a5 5 0 0 1 5 5M14.5 8a1.5 1.5 0 0 1 1.5 1.5"/>',
+ history: '<path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1"/><path d="M12 8v4.2l3 1.8"/>',
+ film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21"/>',
+ circle: '<circle cx="12" cy="12" r="8.5"/>',
+ sliders: '<path d="M4 7h9.5M18.5 7H20M4 17h3.5M12.5 17H20"/><circle cx="16" cy="7" r="2.3"/><circle cx="10" cy="17" r="2.3"/>',
+ leaf: '<path d="M5 19c0-8.5 5-13.6 15-15-.9 9.9-6 15-15 15ZM5 19l8-8"/>',
+ clipboard: '<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/>',
+ gauge: '<path d="M4 18a8 8 0 1 1 16 0"/><path d="m12 14.5 4-5.5"/><circle cx="12" cy="14.5" r="1.3"/>',
+ save: '<path d="M5 4h11l3 3v13H5Z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',
+ plug: '<path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0ZM12 16.5V21"/>',
+ compress: '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
+ expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
+ care: '<path d="M3 15.5h3l4 3h5.5a2 2 0 0 0 0-4H12M7 15.5l2.2-2h3.3"/><path d="M15.5 10s-3.2-2-3.2-4.4a1.9 1.9 0 0 1 3.2-1.3 1.9 1.9 0 0 1 3.2 1.3c0 2.4-3.2 4.4-3.2 4.4Z"/>',
+ city: '<path d="M3 21h18M5 21V9h5v12M10 21V4h6v17M16 21v-8h3.5v8M7.5 12h.01M7.5 15.5h.01M13 7.5h.01M13 11h.01M13 14.5h.01"/>',
+ wrench: '<path d="M14.5 4a5 5 0 0 0-4.4 7L4 17.1V20h2.9l6.1-6.1a5 5 0 0 0 7-4.4l-3 1.1-2.4-2.4 1.1-3Z"/>',
+ music: '<path d="M9 18V6l11-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/>',
+ 'eye-off': '<path d="M3 12s3.5-6.5 9-6.5c1.6 0 3 .5 4.3 1.2M21 12s-3.5 6.5-9 6.5c-1.6 0-3-.5-4.3-1.2"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M4 4l16 16"/>',
+ plane: '<path d="M20.5 3.5 3.5 10.5l6.8 3.2 3.2 6.8Z"/><path d="m10.3 13.7 10.2-10.2"/>',
+ fire: '<path d="M12 21a6 6 0 0 0 6-6c0-3.8-2.6-6-3.7-9-1.8 2-2.3 4-2.3 4S10.8 8 9.8 6.2C7.6 8.7 6 11.6 6 15a6 6 0 0 0 6 6Z"/>',
+ nodes: '<circle cx="12" cy="5" r="2.2"/><circle cx="5.5" cy="18" r="2.2"/><circle cx="18.5" cy="18" r="2.2"/><path d="M11 7 6.5 16M13 7l4.5 9M7.7 18h8.6"/>',
+ bridge: '<path d="M2 15.5h20M4.5 15.5V10M19.5 15.5V10M2 9.5c4 0 6-3 10-3s6 3 10 3M8.5 15.5V12M15.5 15.5V12M12 15.5V8"/>',
+ 'check-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m8 12.4 2.8 2.8L16 9.6"/>',
+ 'x-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6M15 9l-6 6"/>',
+ 'alert-circle': '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5.5M12 16.5h.01"/>',
+ news: '<path d="M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z"/><path d="M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5"/>',
+ camera: '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/>',
+ crown: '<path d="M3 8.5 7.5 12.5 12 5.5l4.5 7 4.5-4L19 19H5Z"/>',
+ wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v4M4 7.5V18a2 2 0 0 0 2 2h14V9H6.5A2.5 2.5 0 0 1 4 7.5Z"/><path d="M16 14.5h.01"/>',
+ chip: '<rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4"/>',
+ id: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16c.5-1.5 1.7-2.3 3-2.3s2.5.8 3 2.3M15 10h3M15 13.5h3"/>',
+ brain: '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2"/>',
+ snow: '<path d="M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9M9.5 4.5 12 7l2.5-2.5M9.5 19.5 12 17l2.5 2.5"/>',
+ mountain: '<path d="M3 20 9.5 8.5l4 6.5 2.5-3.5L21 20Z"/>',
+ 'mic-off': '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 11.6 5.3M19 11c0 .8-.1 1.6-.4 2.3M12 18v3M4 4l16 16"/>',
+ database: '<ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3"/>',
+ layers: '<path d="m12 3.5 9 5-9 5-9-5Z"/><path d="m3 13 9 5 9-5"/>',
+ hash: '<path d="M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17"/>',
+ text: '<path d="M5.5 20 11 4h2l5.5 16M7.6 14h8.8"/>',
+ enter: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 8l4 4-4 4M14 12H4"/>',
+ language: '<path d="M4 5h9M8.5 3v2M6 5c.5 3 2.5 6 6 7.5M11 5c-.6 3.4-2.8 6.8-6.5 8.5M12 21l4-9 4 9M13.4 18h5.2"/>',
+ idea: '<path d="M9 18h6M10 21h4M8.6 14.6A6 6 0 1 1 15.4 14.6c-.7.6-1.4 1.5-1.4 2.4h-4c0-.9-.7-1.8-1.4-2.4Z"/>',
+ dollar: '<path d="M16 7.5C15.3 6 13.8 5 12 5 9.5 5 8 6.3 8 8s1.7 2.6 4 3 4 1.4 4 3.3-1.6 3.2-4 3.2c-1.9 0-3.4-1-4-2.5M12 3v18"/>',
+ target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>',
+ 'arrow-right': '<path d="M4 12h16M14 6l6 6-6 6"/>', 'arrow-left': '<path d="M20 12H4M10 6l-6 6 6 6"/>',
+ 'arrow-up': '<path d="M12 20V4M6 10l6-6 6 6"/>', 'arrow-down': '<path d="M12 4v16M6 14l6 6 6-6"/>',
+ shirt: '<path d="M8 3 3 6l2 4 2-1v12h10V9l2 1 2-4-5-3c-.5 1.5-2 2.5-4 2.5S8.5 4.5 8 3Z"/>',
+ route: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><path d="M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6"/>',
+ reply: '<path d="M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6v1"/>',
+ smile: '<circle cx="12" cy="12" r="8.5"/><path d="M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/>',
+ archive: '<rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9M10 13h4"/>',
+ 'user-check': '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 11l2 2 4-4"/>',
+ ticket: '<path d="M3 7h18v3a2 2 0 0 0 0 4v3H3v-3a2 2 0 0 0 0-4Z"/><path d="M14 7v10"/>',
+ tack: '<path d="M9 3h6l-1 5 3 3H7l3-3ZM12 11v10"/>',
+ store: '<path d="M4 9 5.5 4h13L20 9M4 9h16v1.5a2.7 2.7 0 0 1-5.3 0 2.7 2.7 0 0 1-5.4 0A2.7 2.7 0 0 1 4 10.5ZM5 13v7h14v-7M10 20v-4h4v4"/>',
+ keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9 10h.01M12 10h.01M15 10h.01M18 10h.01M8 14h8"/>',
+ gavel: '<path d="m14 4.5 5.5 5.5M10.5 8l5.5 5.5M12.3 6.3l-6 6 5.4 5.4 6-6M3 21l6.3-6.3M14 21h7"/>',
+ drop: '<path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z"/>',
+ wifi: '<path d="M2.5 9a14 14 0 0 1 19 0M5.5 12.5a9.5 9.5 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01"/>',
+ branch: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10M18 9c0 5.5-6.5 4-11 8.5"/>',
+ // A spinner is never drawn as one (the owner, 2026-10-31: nothing spins): three dots that take turns (ov-glyphs.js).
+ busy: '<path d="M6 12h.01M12 12h.01M18 12h.01" stroke-width="3.4"/>',
+ };
+ const BY = { kit: 'the OpenVibe Kit', opus: 'drawn by Opus for the glyph set (2026-10-31)' };
+ const ALL = { ...(kit && kit.PATHS ? kit.PATHS : {}), ...DRAWN };
+ const NAMES = Object.freeze(Object.keys(ALL).sort());
+ const META = Object.freeze(Object.fromEntries(NAMES.map((n) => [n, { by: DRAWN[n] ? BY.opus : BY.kit, tags: [] }])));
+
+ // ── Font Awesome's names, as the network uses them, mapped to the glyph that stands for each ──
+ const FA = Object.freeze({
+ 'wand-magic-sparkles': 'spark', 'wand-magic': 'spark', sparkles: 'spark', 'star-of-life': 'spark', eye: 'eye', 'eye-slash': 'eye-off',
+ 'tower-broadcast': 'live', 'broadcast-tower': 'live', 'satellite-dish': 'dish', 'tower-cell': 'live', 'signal': 'wifi', wifi: 'wifi',
+ xmark: 'close', times: 'close', 'circle-xmark': 'x-circle', 'xmark-circle': 'x-circle', video: 'video', 'video-slash': 'video', camera: 'camera',
+ robot: 'robot', scissors: 'scissors', cut: 'scissors', copy: 'copy', paste: 'clipboard', clipboard: 'clipboard', 'clipboard-list': 'clipboard',
+ bolt: 'zap', 'bolt-lightning': 'zap', check: 'check', 'check-circle': 'check-circle', 'circle-check': 'check-circle',
+ comments: 'chat', comment: 'chat', 'comment-dots': 'chat', message: 'chat', play: 'play', 'circle-play': 'play', pause: 'pause', stop: 'stop',
+ tree: 'tree', spinner: 'busy', 'circle-notch': 'busy', 'map-pin': 'pin', 'location-dot': 'pin', 'location-pin': 'pin', map: 'map', route: 'route',
+ globe: 'globe', 'earth-americas': 'globe', 'earth-europe': 'globe', language: 'language', code: 'code', terminal: 'terminal', 'code-branch': 'branch', 'code-fork': 'branch',
+ coins: 'coin', coin: 'coin', 'hand-holding-dollar': 'coin', 'dollar-sign': 'dollar', wallet: 'wallet', clock: 'clock', 'clock-rotate-left': 'history', 'rotate-left': 'retry',
+ rotate: 'retry', 'rotate-right': 'retry', 'arrows-rotate': 'retry', 'arrow-rotate-right': 'retry', 'shield-halved': 'shield', shield: 'shield', 'user-shield': 'shield',
+ plus: 'plus', minus: 'minus', lock: 'lock', unlock: 'lock', key: 'key', 'arrow-right': 'arrow-right', 'arrow-left': 'arrow-left', 'arrow-up': 'arrow-up', 'arrow-down': 'arrow-down',
+ 'chevron-right': 'chevron', 'chevron-left': 'back', 'chevron-down': 'down', 'chevron-up': 'up', 'angle-right': 'chevron', 'angle-down': 'down',
+ star: 'star', 'volume-high': 'volume', 'volume-low': 'volume', 'volume-xmark': 'mute', 'volume-off': 'mute', 'triangle-exclamation': 'alert',
+ 'circle-exclamation': 'alert-circle', 'circle-info': 'info', 'circle-question': 'help', question: 'help', link: 'link', image: 'image', images: 'image',
+ 'file-image': 'image', 'photo-film': 'film', film: 'film', user: 'user', 'user-secret': 'user', 'user-astronaut': 'user', 'user-plus': 'invite',
+ 'user-check': 'user-check', users: 'members', 'people-group': 'members', 'user-group': 'members', server: 'server', database: 'database',
+ 'magnifying-glass': 'search', search: 'search', campground: 'tent', utensils: 'food', train: 'train', heart: 'heart', 'hand-holding-heart': 'care', water: 'water',
+ 'person-swimming': 'water', house: 'home', 'house-chimney': 'home', download: 'download', upload: 'upload', 'cloud-arrow-up': 'upload', circle: 'circle',
+ sliders: 'sliders', 'sliders-h': 'sliders', leaf: 'leaf', gamepad: 'game', microphone: 'mic', 'microphone-slash': 'mic-off',
+ 'arrow-up-right-from-square': 'open', 'up-right-from-square': 'open', 'external-link': 'open', palette: 'palette', bell: 'bell', 'gauge-high': 'gauge', gauge: 'gauge',
+ 'floppy-disk': 'save', trash: 'trash', 'trash-can': 'trash', 'chart-line': 'chart', plug: 'plug', compress: 'compress', expand: 'expand',
+ 'up-right-and-down-left-from-center': 'expand', pen: 'pencil', 'pen-nib': 'pencil', pencil: 'pencil', 'pen-to-square': 'pencil', city: 'city', ban: 'ban',
+ 'screwdriver-wrench': 'wrench', wrench: 'wrench', music: 'music', gear: 'gear', gears: 'gear', 'paper-plane': 'plane', fire: 'fire',
+ 'circle-nodes': 'nodes', 'network-wired': 'nodes', 'diagram-project': 'nodes', sitemap: 'nodes', bridge: 'bridge', envelope: 'mail', sun: 'sun', moon: 'moon',
+ 'list-check': 'steps', list: 'list', 'list-ul': 'list', bars: 'menu', display: 'computer', desktop: 'computer', laptop: 'computer', 'wave-square': 'wave',
+ newspaper: 'news', 'file-lines': 'file', file: 'file', 'file-pdf': 'file', 'file-audio': 'file', folder: 'folder', book: 'book', 'book-open': 'book', crown: 'crown',
+ microchip: 'chip', 'id-card': 'id', brain: 'brain', cloud: 'cloud', 'cloud-sun': 'cloud', 'cloud-rain': 'cloud', 'cloud-showers-heavy': 'cloud', snowflake: 'snow',
+ mountain: 'mountain', 'mountain-sun': 'mountain', 'layer-group': 'layers', hashtag: 'hash', font: 'text', 'text-height': 'text',
+ 'right-from-bracket': 'leave', 'right-to-bracket': 'enter', 'arrow-right-from-bracket': 'leave', 'arrow-right-to-bracket': 'enter',
+ gift: 'gift', lightbulb: 'idea', 'cart-shopping': 'cart', cart: 'cart', bullseye: 'target', trophy: 'trophy', tag: 'tag', tags: 'tag', shirt: 'shirt', reply: 'reply',
+ 'face-smile': 'smile', 'box-archive': 'archive', ticket: 'ticket', thumbtack: 'tack', store: 'store', keyboard: 'keyboard', gavel: 'gavel', droplet: 'drop',
+ flag: 'flag', calendar: 'calendar', 'calendar-days': 'calendar', phone: 'phone', 'mobile-screen': 'phone', 'hand': 'hand', 'hand-pointer': 'hand', grid: 'grid',
+ 'table-cells': 'grid', 'chart-simple': 'chart', 'chart-bar': 'chart', 'filter': 'filter', 'ellipsis': 'more', 'ellipsis-vertical': 'more', 'share-nodes': 'nodes',
+ });
+
+ const path = (name) => (Object.prototype.hasOwnProperty.call(ALL, name) ? ALL[name] : '');
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ function svg(name, { size = 24, title = '' } = {}) {
+ const d = path(name);
+ if (!d) return '';
+ const label = title ? ` role="img" aria-label="${esc(title)}"><title>${esc(title)}</title>` : ' aria-hidden="true">';
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${Number(size) || 24}" height="${Number(size) || 24}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${label}${d}</svg>`;
+ }
+ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) && path(FA[n]) ? FA[n] : null; };
+
+ return { NAMES, META, FA, DRAWN: Object.freeze(Object.keys(DRAWN)), path, svg, fromFa };
+});
@@ -108,6 +108,8 @@
@@ -108,6 +108,8 @@
palette: '<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7.3" r="1.2"/><circle cx="14.5" cy="7.3" r="1.2"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',
palette: '<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z"/><circle cx="7.5" cy="11" r="1.2"/><circle cx="10" cy="7.3" r="1.2"/><circle cx="14.5" cy="7.3" r="1.2"/>', robot: '<rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/>',
};
};
const ICONS = Object.freeze(Object.keys(P));
const ICONS = Object.freeze(Object.keys(P));
+ /** Every icon's drawing (the inside of a 24-unit SVG), for openvibe-shared/glyphs, the one registry built on these. */
+ const PATHS = Object.freeze({ ...P });
/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
/** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
function icon(name, { size = null, cls = '' } = {}) {
function icon(name, { size = null, cls = '' } = {}) {
const d = P[name];
const d = P[name];
@@ -814,7 +816,7 @@
@@ -814,7 +816,7 @@
`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
`<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
return {
return {
- esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
+ esc, url, icon, ICONS, PATHS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
body.ov-panel-open .openvibe-navbar.ovnav-top { background-color: var(--bg-secondary, #252530); border-bottom-color: var(--border, #333340); }
body.ov-panel-open .openvibe-navbar.ovnav-top { background-color: var(--bg-secondary, #252530); border-bottom-color: var(--border, #333340); }
@media (prefers-reduced-motion: reduce) { .openvibe-navbar, .openvibe-navbar::before { transition: none; } }
@media (prefers-reduced-motion: reduce) { .openvibe-navbar, .openvibe-navbar::before { transition: none; } }
/* Names: the brand face (ov-brand.woff2: Nunito, Latin, 700-900). */
/* Names: the brand face (ov-brand.woff2: Nunito, Latin, 700-900). */
- @font-face { font-family: 'OV Brand'; src: url(${sibling('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; }
+ @font-face { font-family: 'OV Brand'; src: url(${siblingV('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
@@ -474,14 +474,11 @@
@@ -474,14 +474,11 @@
};
};
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
- // Icons are solid filled paths generated from the Font Awesome Free solid font, so every OpenVibe
- // page gets the same crisp glyphs with no dependency on Font Awesome CSS. The glyphs the navbar
- // draws itself ship above; the rest of the set (nav-icons.js, ~16 KB brotli) is fetched once,
- // after first paint, the first time a site's link, chip or menu row names one. Until it lands
- // that icon is an empty <svg> of the glyph's exact size, then its path is filled in. A page that
- // loads nav-icons.js itself (before navbar.js) has every glyph from the first frame. Names not in
- // the set, and every lazy glyph if nav-icons.js cannot load, fall back to an <i class="fa-solid">
- // for pages that do load Font Awesome.
+ // The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
+ const SIBLING_REV = { 'ov-mark.js': 'd53438811552', 'ov-glyphs.js': '2e3590333ae6', 'ov-brand.woff2': '3b4e6a8a5878' };
+ // The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
+ // (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
+ // size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const _selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
let _lazyWidth = null;
let _lazyWidth = null;
let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable
let _iconState = 0; // 0 idle, 1 loading, 2 loaded, 3 unavailable
@@ -505,6 +502,7 @@
@@ -505,6 +502,7 @@
* A shared browser file next to this navbar.js: the same origin and the same release (a site that
* A shared browser file next to this navbar.js: the same origin and the same release (a site that
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
+ const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
function sibling(name) {
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@ -1197,14 +1195,9 @@
@@ -1197,14 +1195,9 @@
}
}
// ─── SSO auth resolution ───────────────────────────────────
// ─── SSO auth resolution ───────────────────────────────────
- // Cookie policy (CONTRACTS.md): ov_token is host-only on openvibe.network,
- // and Domain=.openvibe.tools on the tools apex + every tool subdomain.
- // The tools gateway (apex) owns the OAuth client: /auth/login, /auth/logout,
- // /auth/me, POST /auth/refresh. Satellite subdomains (maps., audio., text.,
- // img., yt., docs., food., …) do NOT mount /auth/* — they only read the
- // shared cookie — so sign-in/out always goes through the gateway apex.
- // Detection order: ov_token cookie → localStorage → page-provided token →
- // app-specific session endpoint (opt-in via config.sessionUrl).
+ // ov_token is host-only on openvibe.network and Domain=.openvibe.tools on the tools (CONTRACTS.md). The tools'
+ // apex owns the OAuth client (/auth/*); its subdomains only read the cookie, so sign-in/out goes through the apex.
+ // Order: the cookie, localStorage, the page's token, then config.sessionUrl.
const TOOLS_APEX = 'openvibe.tools';
const TOOLS_APEX = 'openvibe.tools';
const TOKEN_KEY = 'ov_token';
const TOKEN_KEY = 'ov_token';
@@ -1418,13 +1411,9 @@
@@ -1418,13 +1411,9 @@
}
}
/**
/**
- * Two ways to find out that this browser is signed in to the network without a session here:
- * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the
- * silent sign-in (one quick redirect, back where you were);
- * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no
- * redirect unless the answer is yes. Browsers that partition third-party cookies answer
- * no and nothing happens, which is the same as before.
- * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.
+ * Signed in to the network but not here: the 'account' hint cookie goes straight to the silent sign-in (one quick
+ * redirect); otherwise a hidden iframe asks GET /sso/check and redirects only on yes (partitioned cookies: no).
+ * At most once per tab per 10 minutes; never after a sign-out ('guest'); never for bots.
*/
*/
function maybeSilentLogin() {
function maybeSilentLogin() {
if (!_config.silentLogin || typeof location === 'undefined') return false;
if (!_config.silentLogin || typeof location === 'undefined') return false;
@@ -1578,11 +1567,9 @@
@@ -1578,11 +1567,9 @@
}
}
/**
/**
- * 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
- * them; elsewhere the balances stay in the menu's header. The last balances are kept per person, so the chips paint
- * at once without moving the bar; they refresh from the Network and Billing in the background, and again when the
- * tab comes back after a minute. On a phone they fold into one chip like Live's (4.16.0).
+ * wallet: true — OpenCoins and Vibes as chips in the bar on any site (owner, 2026-10-27). A site's own chips with
+ * those ids (Live) win. The last balances are kept per person so the chips paint at once; they refresh from the
+ * Network and Billing in the background and when the tab returns after a minute; on a phone they fold into one.
*/
*/
const WALLET_CHIPS = [
const WALLET_CHIPS = [
{ id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'OpenCoins', href: 'https://openvibe.network/my#coins' },
{ id: 'coins', icon: 'fa-coins', tone: 'gold', label: 'OpenCoins', title: 'OpenCoins', href: 'https://openvibe.network/my#coins' },
@@ -1665,7 +1652,11 @@
@@ -1665,7 +1652,11 @@
// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
// The OV brand mark is a drop-in (mounts every .ov-mark); never a second copy of a page's own tag.
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
if (!window.__ovMark && !document.getElementById('ov-mark-loader') && !(document.querySelector && document.querySelector('script[src*="/ov-mark.js"]'))) {
- try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = sibling('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = siblingV('ov-mark.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ }
+ // The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome.
+ if (_config.glyphs !== false && !window.OVGlyphs && !document.getElementById('ov-glyphs-loader') && document.querySelector && document.querySelector('i[class*="fa-"],span[class*="fa-"],link[href*="fontawesome"],link[href*="font-awesome"]')) {
+ try { const sc = document.createElement('script'); sc.id = 'ov-glyphs-loader'; sc.src = siblingV('ov-glyphs.js'); sc.async = true; document.head.appendChild(sc); } catch { /* */ }
}
}
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
@@ -1713,11 +1704,9 @@
@@ -1713,11 +1704,9 @@
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.
+ // The menu (4.23.0, the owner 2026-10-28: "more compactness… data-driven"): you, with + and the gear; your
+ // places as icons in the order you use them (eight, then More); the site's sections; one row for light/dark,
+ // text size, motion, themes and sign out. Notifications are the bell's; history and Ask Vibe, search's.
const bell = _config.notifications !== false && !!_config.token && !isAnon;
const bell = _config.notifications !== false && !!_config.token && !isAnon;
const places = before.concat(after).filter((it) => it && !it.sep).concat(menuCfg.defaults === false ? [] : [
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,
!isAnon && u.username ? { id: 'ov-channel', label: 'My channel', short: 'Channel', drawn: navIcon('fa-tower-broadcast'), href: `https://openvibe.live/@${encodeURIComponent(u.username)}` } : null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+/* Generated by scripts/build-glyphs.js from glyphs.js: edit those, then run it. */
+/**
+ * openvibe-shared/ov-glyphs.js — the OpenVibe glyphs in place of Font Awesome (plan L32). A page that loads it keeps its
+ * markup (<i class="fa-solid fa-tower-broadcast">) and shows the network's own icon instead: the same hand as the mark
+ * (24-unit grid, 2-unit round strokes), in the text's colour and size. A name the glyphs do not have yet, and every
+ * brand logo (fa-brands), stays Font Awesome's. A spinner is three dots that take turns, never a spin.
+ *
+ * <script src="/shared/ov-glyphs.js" defer></script> swaps what is in the page, then anything added later
+ * OVGlyphs.swap(rootEl) swaps inside one element now
+ * OVGlyphs.fromFa('fa-eye') → 'eye' the glyph a Font Awesome name maps to, or null
+ *
+ * 149 glyphs for 236 Font Awesome names (openvibe.design/icons shows them).
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OVGlyphs) return;
+ const G = {"alert":"<path d=\"M12 4 2.8 19.5h18.4Z\"/><path d=\"M12 10v4M12 17h.01\"/>","alert-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5v5.5M12 16.5h.01\"/>","archive":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"5\" rx=\"1\"/><path d=\"M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9M10 13h4\"/>","arrow-down":"<path d=\"M12 4v16M6 14l6 6 6-6\"/>","arrow-left":"<path d=\"M20 12H4M10 6l-6 6 6 6\"/>","arrow-right":"<path d=\"M4 12h16M14 6l6 6-6 6\"/>","arrow-up":"<path d=\"M12 20V4M6 10l6-6 6 6\"/>","back":"<path d=\"M15 5l-7 7 7 7\"/>","ban":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m6 6 12 12\"/>","bell":"<path d=\"M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0\"/>","book":"<path d=\"M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z\"/><path d=\"M4 19V5\"/>","brain":"<path d=\"M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 6 1.2V5.5A2 2 0 0 0 9 4ZM15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1.2\"/>","branch":"<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"7\" r=\"2\"/><path d=\"M6 7v10M18 9c0 5.5-6.5 4-11 8.5\"/>","bridge":"<path d=\"M2 15.5h20M4.5 15.5V10M19.5 15.5V10M2 9.5c4 0 6-3 10-3s6 3 10 3M8.5 15.5V12M15.5 15.5V12M12 15.5V8\"/>","busy":"<path d=\"M6 12h.01M12 12h.01M18 12h.01\" stroke-width=\"3.4\"/>","calendar":"<rect x=\"4\" y=\"5\" width=\"16\" height=\"15\" rx=\"2\"/><path d=\"M4 10h16M9 3v4M15 3v4\"/>","camera":"<path d=\"M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z\"/><circle cx=\"12\" cy=\"13.3\" r=\"3.4\"/>","care":"<path d=\"M3 15.5h3l4 3h5.5a2 2 0 0 0 0-4H12M7 15.5l2.2-2h3.3\"/><path d=\"M15.5 10s-3.2-2-3.2-4.4a1.9 1.9 0 0 1 3.2-1.3 1.9 1.9 0 0 1 3.2 1.3c0 2.4-3.2 4.4-3.2 4.4Z\"/>","cart":"<path d=\"M3 4h2l2.4 11h11L21 7H6.2\"/><circle cx=\"9\" cy=\"19.5\" r=\"1.3\"/><circle cx=\"17\" cy=\"19.5\" r=\"1.3\"/>","chart":"<path d=\"M4 4v16h16\"/><path d=\"m7.5 14.5 3.5-4 3 2.5 5-6\"/>","chat":"<path d=\"M4 5h16v11H8l-4 4Z\"/>","check":"<path d=\"m5 12.5 4.5 4.5L19 7.5\"/>","check-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8 12.4 2.8 2.8L16 9.6\"/>","chevron":"<path d=\"m9 6 6 6-6 6\"/>","chip":"<rect x=\"7\" y=\"7\" width=\"10\" height=\"10\" rx=\"1.5\"/><path d=\"M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4\"/>","circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/>","city":"<path d=\"M3 21h18M5 21V9h5v12M10 21V4h6v17M16 21v-8h3.5v8M7.5 12h.01M7.5 15.5h.01M13 7.5h.01M13 11h.01M13 14.5h.01\"/>","clipboard":"<rect x=\"5.5\" y=\"5\" width=\"13\" height=\"16\" rx=\"2\"/><path d=\"M9 5V3.5h6V5M9 11h6M9 15h4\"/>","clock":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 7.5V12l3 2\"/>","close":"<path d=\"M6 6l12 12M18 6 6 18\"/>","cloud":"<path d=\"M7 18h10a4 4 0 0 0 .5-8A6 6 0 0 0 6 9.5 4.3 4.3 0 0 0 7 18Z\"/>","code":"<path d=\"m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14\"/>","coin":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11\"/>","compress":"<path d=\"M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5\"/>","computer":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\"/><path d=\"M8 20h8M12 16v4\"/>","copy":"<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2\"/>","crown":"<path d=\"M3 8.5 7.5 12.5 12 5.5l4.5 7 4.5-4L19 19H5Z\"/>","database":"<ellipse cx=\"12\" cy=\"6\" rx=\"7\" ry=\"3\"/><path d=\"M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3\"/>","dish":"<path d=\"M5 9.5A9.5 9.5 0 0 0 14.5 19Z\"/><path d=\"m9.5 14.5 4.5-4.5M14.5 4.5a5 5 0 0 1 5 5M14.5 8a1.5 1.5 0 0 1 1.5 1.5\"/>","dollar":"<path d=\"M16 7.5C15.3 6 13.8 5 12 5 9.5 5 8 6.3 8 8s1.7 2.6 4 3 4 1.4 4 3.3-1.6 3.2-4 3.2c-1.9 0-3.4-1-4-2.5M12 3v18\"/>","down":"<path d=\"m6 9 6 6 6-6\"/>","download":"<path d=\"M12 4v11M7 10l5 5 5-5M5 20h14\"/>","drop":"<path d=\"M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z\"/>","enter":"<path d=\"M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 8l4 4-4 4M14 12H4\"/>","expand":"<path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\"/>","eye":"<path d=\"M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>","eye-off":"<path d=\"M3 12s3.5-6.5 9-6.5c1.6 0 3 .5 4.3 1.2M21 12s-3.5 6.5-9 6.5c-1.6 0-3-.5-4.3-1.2\"/><path d=\"M9.9 9.9a3 3 0 0 0 4.2 4.2M4 4l16 16\"/>","file":"<path d=\"M6 3h8l4 4v14H6Z\"/><path d=\"M14 3v4h4\"/>","film":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21\"/>","filter":"<path d=\"M4 6h16M7 12h10M10 18h4\"/>","fire":"<path d=\"M12 21a6 6 0 0 0 6-6c0-3.8-2.6-6-3.7-9-1.8 2-2.3 4-2.3 4S10.8 8 9.8 6.2C7.6 8.7 6 11.6 6 15a6 6 0 0 0 6 6Z\"/>","flag":"<path d=\"M5 21V4M5 4h11l-2 4 2 4H5\"/>","folder":"<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z\"/>","food":"<path d=\"M7 3v7M5 3v4.5a2 2 0 0 0 4 0V3M7 10v11M17 21V3c-2 1.2-3.2 3.6-3.2 7v3H17\"/>","game":"<rect x=\"2.5\" y=\"7\" width=\"19\" height=\"11\" rx=\"5\"/><path d=\"M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01\"/>","gauge":"<path d=\"M4 18a8 8 0 1 1 16 0\"/><path d=\"m12 14.5 4-5.5\"/><circle cx=\"12\" cy=\"14.5\" r=\"1.3\"/>","gavel":"<path d=\"m14 4.5 5.5 5.5M10.5 8l5.5 5.5M12.3 6.3l-6 6 5.4 5.4 6-6M3 21l6.3-6.3M14 21h7\"/>","gear":"<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z\"/>","gift":"<rect x=\"4\" y=\"9\" width=\"16\" height=\"11\" rx=\"1\"/><path d=\"M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9\"/>","globe":"<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18\"/>","grid":"<rect x=\"4\" y=\"4\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"13\" y=\"4\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"4\" y=\"13\" width=\"7\" height=\"7\" rx=\"1.5\"/><rect x=\"13\" y=\"13\" width=\"7\" height=\"7\" rx=\"1.5\"/>","hand":"<path d=\"M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15\"/>","hash":"<path d=\"M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17\"/>","heart":"<path d=\"M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z\"/>","help":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01\"/>","history":"<path d=\"M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1\"/><path d=\"M12 8v4.2l3 1.8\"/>","home":"<path d=\"M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z\"/>","id":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><circle cx=\"9\" cy=\"11\" r=\"2\"/><path d=\"M6 16c.5-1.5 1.7-2.3 3-2.3s2.5.8 3 2.3M15 10h3M15 13.5h3\"/>","idea":"<path d=\"M9 18h6M10 21h4M8.6 14.6A6 6 0 1 1 15.4 14.6c-.7.6-1.4 1.5-1.4 2.4h-4c0-.9-.7-1.8-1.4-2.4Z\"/>","image":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"3\"/><circle cx=\"9\" cy=\"10\" r=\"1.6\"/><path d=\"m21 16-5-5-8 8\"/>","info":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M12 11v5M12 8h.01\"/>","invite":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M19 8v6M16 11h6\"/>","key":"<circle cx=\"8\" cy=\"15\" r=\"4\"/><path d=\"m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2\"/>","keyboard":"<rect x=\"2.5\" y=\"6\" width=\"19\" height=\"12\" rx=\"2\"/><path d=\"M6 10h.01M9 10h.01M12 10h.01M15 10h.01M18 10h.01M8 14h8\"/>","language":"<path d=\"M4 5h9M8.5 3v2M6 5c.5 3 2.5 6 6 7.5M11 5c-.6 3.4-2.8 6.8-6.5 8.5M12 21l4-9 4 9M13.4 18h5.2\"/>","layers":"<path d=\"m12 3.5 9 5-9 5-9-5Z\"/><path d=\"m3 13 9 5 9-5\"/>","leaf":"<path d=\"M5 19c0-8.5 5-13.6 15-15-.9 9.9-6 15-15 15ZM5 19l8-8\"/>","leave":"<path d=\"M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10\"/>","link":"<path d=\"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1\"/>","list":"<path d=\"M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01\"/>","live":"<circle cx=\"12\" cy=\"12\" r=\"2.5\"/><path d=\"M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14\"/>","lock":"<rect x=\"5\" y=\"11\" width=\"14\" height=\"9\" rx=\"2\"/><path d=\"M8 11V8a4 4 0 0 1 8 0v3\"/>","mail":"<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"m4 7 8 6 8-6\"/>","map":"<path d=\"M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z\"/><path d=\"M9 4v14M15 6v14\"/>","members":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2\"/>","menu":"<path d=\"M4 7h16M4 12h16M4 17h16\"/>","mic":"<rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\"/><path d=\"M5 11a7 7 0 0 0 14 0M12 18v3\"/>","mic-off":"<rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\"/><path d=\"M5 11a7 7 0 0 0 11.6 5.3M19 11c0 .8-.1 1.6-.4 2.3M12 18v3M4 4l16 16\"/>","minus":"<path d=\"M5 12h14\"/>","moon":"<path d=\"M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z\"/>","more":"<circle cx=\"5\" cy=\"12\" r=\"1.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\"/>","mountain":"<path d=\"M3 20 9.5 8.5l4 6.5 2.5-3.5L21 20Z\"/>","music":"<path d=\"M9 18V6l11-2v12\"/><circle cx=\"6.5\" cy=\"18\" r=\"2.5\"/><circle cx=\"17.5\" cy=\"16\" r=\"2.5\"/>","mute":"<path d=\"M4 9.5h3.5L12 6v12l-4.5-3.5H4Z\"/><path d=\"m16 10 4 4M20 10l-4 4\"/>","news":"<path d=\"M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z\"/><path d=\"M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5\"/>","nodes":"<circle cx=\"12\" cy=\"5\" r=\"2.2\"/><circle cx=\"5.5\" cy=\"18\" r=\"2.2\"/><circle cx=\"18.5\" cy=\"18\" r=\"2.2\"/><path d=\"M11 7 6.5 16M13 7l4.5 9M7.7 18h8.6\"/>","open":"<path d=\"M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4\"/>","palette":"<path d=\"M12 3a9 9 0 1 0 0 18c1.2 0 1.8-.8 1.8-1.7 0-1.2-1-1.6-1-2.6s.8-1.7 2-1.7H17a4 4 0 0 0 4-4C21 6.7 17 3 12 3Z\"/><circle cx=\"7.5\" cy=\"11\" r=\"1.2\"/><circle cx=\"10\" cy=\"7.3\" r=\"1.2\"/><circle cx=\"14.5\" cy=\"7.3\" r=\"1.2\"/>","pause":"<path d=\"M8 5v14M16 5v14\"/>","pencil":"<path d=\"M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z\"/>","phone":"<path d=\"M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2\"/>","pin":"<path d=\"M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0C18.5 15.4 12 21 12 21Z\"/><circle cx=\"12\" cy=\"10\" r=\"2.4\"/>","plane":"<path d=\"M20.5 3.5 3.5 10.5l6.8 3.2 3.2 6.8Z\"/><path d=\"m10.3 13.7 10.2-10.2\"/>","play":"<path d=\"M8 5v14l11-7Z\"/>","plug":"<path d=\"M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0ZM12 16.5V21\"/>","plus":"<path d=\"M12 5v14M5 12h14\"/>","reply":"<path d=\"M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6v1\"/>","retry":"<path d=\"M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5\"/>","robot":"<rect x=\"5\" y=\"8\" width=\"14\" height=\"11\" rx=\"3\"/><path d=\"M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2\"/>","route":"<circle cx=\"6\" cy=\"18\" r=\"2.4\"/><circle cx=\"18\" cy=\"6\" r=\"2.4\"/><path d=\"M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6\"/>","save":"<path d=\"M5 4h11l3 3v13H5Z\"/><path d=\"M8 4v5h7V4M8 20v-6h8v6\"/>","scissors":"<circle cx=\"6\" cy=\"7\" r=\"2.6\"/><circle cx=\"6\" cy=\"17\" r=\"2.6\"/><path d=\"M8.2 8.6 20 18M8.2 15.4 20 6\"/>","search":"<circle cx=\"11\" cy=\"11\" r=\"6.5\"/><path d=\"m20 20-4.2-4.2\"/>","server":"<rect x=\"4\" y=\"4\" width=\"16\" height=\"7\" rx=\"2\"/><rect x=\"4\" y=\"13\" width=\"16\" height=\"7\" rx=\"2\"/><path d=\"M8 7.5h.01M8 16.5h.01\"/>","shield":"<path d=\"M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6Z\"/>","shirt":"<path d=\"M8 3 3 6l2 4 2-1v12h10V9l2 1 2-4-5-3c-.5 1.5-2 2.5-4 2.5S8.5 4.5 8 3Z\"/>","sliders":"<path d=\"M4 7h9.5M18.5 7H20M4 17h3.5M12.5 17H20\"/><circle cx=\"16\" cy=\"7\" r=\"2.3\"/><circle cx=\"10\" cy=\"17\" r=\"2.3\"/>","smile":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01\"/>","snow":"<path d=\"M12 3v18M4.2 7.5l15.6 9M4.2 16.5l15.6-9M9.5 4.5 12 7l2.5-2.5M9.5 19.5 12 17l2.5 2.5\"/>","spark":"<path d=\"M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z\"/>","star":"<path d=\"m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z\"/>","steps":"<path d=\"M9 6h11M9 12h11M9 18h11\"/><path d=\"m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17\"/>","stop":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m8.5 8.5 7 7\"/>","store":"<path d=\"M4 9 5.5 4h13L20 9M4 9h16v1.5a2.7 2.7 0 0 1-5.3 0 2.7 2.7 0 0 1-5.4 0A2.7 2.7 0 0 1 4 10.5ZM5 13v7h14v-7M10 20v-4h4v4\"/>","sun":"<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/>","tack":"<path d=\"M9 3h6l-1 5 3 3H7l3-3ZM12 11v10\"/>","tag":"<path d=\"M3 12V4h8l10 10-8 8Z\"/><circle cx=\"7.5\" cy=\"8.5\" r=\"1.3\"/>","target":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><circle cx=\"12\" cy=\"12\" r=\"5\"/><circle cx=\"12\" cy=\"12\" r=\"1.5\"/>","tent":"<path d=\"M2.5 20h19M12 5 4 20M12 5l8 15M12 13.5 9 20M12 13.5l3 6.5\"/>","terminal":"<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"m7 9 3 3-3 3M13 15h4\"/>","text":"<path d=\"M5.5 20 11 4h2l5.5 16M7.6 14h8.8\"/>","ticket":"<path d=\"M3 7h18v3a2 2 0 0 0 0 4v3H3v-3a2 2 0 0 0 0-4Z\"/><path d=\"M14 7v10\"/>","train":"<rect x=\"6\" y=\"3\" width=\"12\" height=\"13\" rx=\"3\"/><path d=\"M6 10h12M9 13h.01M15 13h.01M8.5 21l2-3.5M15.5 21l-2-3.5\"/>","trash":"<path d=\"M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3\"/>","tree":"<path d=\"M12 3 6.5 10.5h3L5 17h14l-4.5-6.5h3ZM12 17v4\"/>","trophy":"<path d=\"M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8\"/>","up":"<path d=\"m6 15 6-6 6 6\"/>","upload":"<path d=\"M12 20V9M7 14l5-5 5 5M5 4h14\"/>","user":"<circle cx=\"12\" cy=\"8\" r=\"3.5\"/><path d=\"M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6\"/>","user-check":"<circle cx=\"9\" cy=\"8\" r=\"3.2\"/><path d=\"M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 11l2 2 4-4\"/>","video":"<rect x=\"3\" y=\"6\" width=\"13\" height=\"12\" rx=\"2\"/><path d=\"m16 10 5-3v10l-5-3\"/>","volume":"<path d=\"M4 9.5h3.5L12 6v12l-4.5-3.5H4Z\"/><path d=\"M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10\"/>","wallet":"<path d=\"M4 7.5A2.5 2.5 0 0 1 6.5 5H18v4M4 7.5V18a2 2 0 0 0 2 2h14V9H6.5A2.5 2.5 0 0 1 4 7.5Z\"/><path d=\"M16 14.5h.01\"/>","water":"<path d=\"M2.5 9c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2M2.5 15c2.4 0 2.4-2 4.8-2s2.4 2 4.7 2 2.4-2 4.8-2 2.4 2 4.7 2\"/>","wave":"<path d=\"M2 16c2.5 0 2.5-2 5-2s2.5 2 5 2 2.5-2 5-2 2.5 2 5 2\"/><path d=\"M5.5 11.5A7 7 0 0 1 18 8.6c-2.3-.5-4.2.4-4.8 2.4\"/>","wifi":"<path d=\"M2.5 9a14 14 0 0 1 19 0M5.5 12.5a9.5 9.5 0 0 1 13 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01\"/>","wrench":"<path d=\"M14.5 4a5 5 0 0 0-4.4 7L4 17.1V20h2.9l6.1-6.1a5 5 0 0 0 7-4.4l-3 1.1-2.4-2.4 1.1-3Z\"/>","x-circle":"<circle cx=\"12\" cy=\"12\" r=\"8.5\"/><path d=\"m9 9 6 6M15 9l-6 6\"/>","zap":"<path d=\"M13 3 5 13h6l-1 8 8-10h-6Z\"/>"};
+ const FA = {"angle-down":"down","angle-right":"chevron","arrow-down":"arrow-down","arrow-left":"arrow-left","arrow-right":"arrow-right","arrow-right-from-bracket":"leave","arrow-right-to-bracket":"enter","arrow-rotate-right":"retry","arrow-up":"arrow-up","arrow-up-right-from-square":"open","arrows-rotate":"retry","ban":"ban","bars":"menu","bell":"bell","bolt":"zap","bolt-lightning":"zap","book":"book","book-open":"book","box-archive":"archive","brain":"brain","bridge":"bridge","broadcast-tower":"live","bullseye":"target","calendar":"calendar","calendar-days":"calendar","camera":"camera","campground":"tent","cart":"cart","cart-shopping":"cart","chart-bar":"chart","chart-line":"chart","chart-simple":"chart","check":"check","check-circle":"check-circle","chevron-down":"down","chevron-left":"back","chevron-right":"chevron","chevron-up":"up","circle":"circle","circle-check":"check-circle","circle-exclamation":"alert-circle","circle-info":"info","circle-nodes":"nodes","circle-notch":"busy","circle-play":"play","circle-question":"help","circle-xmark":"x-circle","city":"city","clipboard":"clipboard","clipboard-list":"clipboard","clock":"clock","clock-rotate-left":"history","cloud":"cloud","cloud-arrow-up":"upload","cloud-rain":"cloud","cloud-showers-heavy":"cloud","cloud-sun":"cloud","code":"code","code-branch":"branch","code-fork":"branch","coin":"coin","coins":"coin","comment":"chat","comment-dots":"chat","comments":"chat","compress":"compress","copy":"copy","crown":"crown","cut":"scissors","database":"database","desktop":"computer","diagram-project":"nodes","display":"computer","dollar-sign":"dollar","download":"download","droplet":"drop","earth-americas":"globe","earth-europe":"globe","ellipsis":"more","ellipsis-vertical":"more","envelope":"mail","expand":"expand","external-link":"open","eye":"eye","eye-slash":"eye-off","face-smile":"smile","file":"file","file-audio":"file","file-image":"image","file-lines":"file","file-pdf":"file","film":"film","filter":"filter","fire":"fire","flag":"flag","floppy-disk":"save","folder":"folder","font":"text","gamepad":"game","gauge":"gauge","gauge-high":"gauge","gavel":"gavel","gear":"gear","gears":"gear","gift":"gift","globe":"globe","grid":"grid","hand":"hand","hand-holding-dollar":"coin","hand-holding-heart":"care","hand-pointer":"hand","hashtag":"hash","heart":"heart","house":"home","house-chimney":"home","id-card":"id","image":"image","images":"image","key":"key","keyboard":"keyboard","language":"language","laptop":"computer","layer-group":"layers","leaf":"leaf","lightbulb":"idea","link":"link","list":"list","list-check":"steps","list-ul":"list","location-dot":"pin","location-pin":"pin","lock":"lock","magnifying-glass":"search","map":"map","map-pin":"pin","message":"chat","microchip":"chip","microphone":"mic","microphone-slash":"mic-off","minus":"minus","mobile-screen":"phone","moon":"moon","mountain":"mountain","mountain-sun":"mountain","music":"music","network-wired":"nodes","newspaper":"news","palette":"palette","paper-plane":"plane","paste":"clipboard","pause":"pause","pen":"pencil","pen-nib":"pencil","pen-to-square":"pencil","pencil":"pencil","people-group":"members","person-swimming":"water","phone":"phone","photo-film":"film","play":"play","plug":"plug","plus":"plus","question":"help","reply":"reply","right-from-bracket":"leave","right-to-bracket":"enter","robot":"robot","rotate":"retry","rotate-left":"retry","rotate-right":"retry","route":"route","satellite-dish":"dish","scissors":"scissors","screwdriver-wrench":"wrench","search":"search","server":"server","share-nodes":"nodes","shield":"shield","shield-halved":"shield","shirt":"shirt","signal":"wifi","sitemap":"nodes","sliders":"sliders","sliders-h":"sliders","snowflake":"snow","sparkles":"spark","spinner":"busy","star":"star","star-of-life":"spark","stop":"stop","store":"store","sun":"sun","table-cells":"grid","tag":"tag","tags":"tag","terminal":"terminal","text-height":"text","thumbtack":"tack","ticket":"ticket","times":"close","tower-broadcast":"live","tower-cell":"live","train":"train","trash":"trash","trash-can":"trash","tree":"tree","triangle-exclamation":"alert","trophy":"trophy","unlock":"lock","up-right-and-down-left-from-center":"expand","up-right-from-square":"open","upload":"upload","user":"user","user-astronaut":"user","user-check":"user-check","user-group":"members","user-plus":"invite","user-secret":"user","user-shield":"shield","users":"members","utensils":"food","video":"video","video-slash":"video","volume-high":"volume","volume-low":"volume","volume-off":"mute","volume-xmark":"mute","wallet":"wallet","wand-magic":"spark","wand-magic-sparkles":"spark","water":"water","wave-square":"wave","wifi":"wifi","wrench":"wrench","xmark":"close","xmark-circle":"x-circle"};
+ // Font Awesome's style and size classes, and its animations: they say how, not what.
+ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity)$/;
+ const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';
+ const CSS = '.ovg::before,.ovg::after{content:none!important;display:none!important}'
+ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}'
+ + '.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'
+ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;overflow:visible}'
+ + '.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}'
+ + '.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}'
+ + '@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}'
+ + '@media (prefers-reduced-motion:reduce){.ovg[data-ovg=busy] circle{animation:none}}html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none}';
+ const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };
+ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };
+ function draw(el) {
+ if (el.classList.contains('fa-brands') || el.classList.contains('fab')) return;
+ const fa = nameOf(el);
+ const g = fa && fromFa(fa);
+ if (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; el.textContent = ''; } return; }
+ if (el.dataset.ovgFa === fa) return;
+ if (!el.dataset.ovg && el.textContent.trim()) return; // an icon element with text of its own is not an icon
+ el.classList.add('ovg');
+ el.dataset.ovg = g; el.dataset.ovgFa = fa;
+ if (!el.hasAttribute('aria-label') && !el.hasAttribute('title')) el.setAttribute('aria-hidden', 'true');
+ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' + (g === 'busy' ? BUSY : G[g]) + '</svg>';
+ }
+ const SEL = 'i[class*="fa-"],span[class*="fa-"]';
+ function swap(root) {
+ const r = root || document;
+ if (r.matches && r.matches(SEL)) draw(r);
+ if (r.querySelectorAll) r.querySelectorAll(SEL).forEach(draw);
+ }
+ function start() {
+ if (!document.getElementById('ov-glyphs-css')) { const s = document.createElement('style'); s.id = 'ov-glyphs-css'; s.textContent = CSS; document.head.appendChild(s); }
+ swap(document);
+ try {
+ new MutationObserver((records) => {
+ for (const r of records) {
+ if (r.type === 'attributes') { if (r.target.matches && r.target.matches(SEL)) draw(r.target); continue; }
+ r.addedNodes.forEach((n) => { if (n.nodeType === 1) swap(n); });
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
+ } catch (e) { /* no observer: what was there is swapped */ }
+ }
+ window.OVGlyphs = { swap, fromFa };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
+})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.60.0",
+ "version": "4.61.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": {
@@ -37,6 +37,7 @@
@@ -37,6 +37,7 @@
"./profile-panel": "./profile-panel.js",
"./profile-panel": "./profile-panel.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
"./express-async": "./express-async.js",
+ "./glyphs": "./glyphs.js",
"./mark": "./mark.js",
"./mark": "./mark.js",
"./metrics": "./metrics.js",
"./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
@@ -49,6 +50,7 @@
@@ -49,6 +50,7 @@
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
"./ov-icons": "./ov-icons.js",
"./ov-icons": "./ov-icons.js",
+ "./ov-glyphs": "./ov-glyphs.js",
"./ov-mark": "./ov-mark.js",
"./ov-mark": "./ov-mark.js",
"./package.json": "./package.json",
"./package.json": "./package.json",
"./panels": "./panels.js",
"./panels": "./panels.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+'use strict';
+// Writes ov-glyphs.js, the page drop-in that swaps Font Awesome icons for the OpenVibe glyphs, from glyphs.js (the one
+// registry). Re-run after a glyph or an alias changes: node scripts/build-glyphs.js (test/glyphs.test.js checks the file
+// is current). Only the glyphs some Font Awesome name stands for go in, so the drop-in carries no drawing it cannot use.
+const fs = require('fs');
+const path = require('path');
+const glyphs = require('../glyphs');
+
+const used = [...new Set(Object.values(glyphs.FA))].sort();
+const G = Object.fromEntries(used.map((n) => [n, glyphs.path(n)]));
+const FA = Object.fromEntries(Object.entries(glyphs.FA).sort(([a], [b]) => (a < b ? -1 : 1)));
+
+const out = `/* Generated by scripts/build-glyphs.js from glyphs.js: edit those, then run it. */
+/**
+ * openvibe-shared/ov-glyphs.js — the OpenVibe glyphs in place of Font Awesome (plan L32). A page that loads it keeps its
+ * markup (<i class="fa-solid fa-tower-broadcast">) and shows the network's own icon instead: the same hand as the mark
+ * (24-unit grid, 2-unit round strokes), in the text's colour and size. A name the glyphs do not have yet, and every
+ * brand logo (fa-brands), stays Font Awesome's. A spinner is three dots that take turns, never a spin.
+ *
+ * <script src="/shared/ov-glyphs.js" defer></script> swaps what is in the page, then anything added later
+ * OVGlyphs.swap(rootEl) swaps inside one element now
+ * OVGlyphs.fromFa('fa-eye') → 'eye' the glyph a Font Awesome name maps to, or null
+ *
+ * ${used.length} glyphs for ${Object.keys(FA).length} Font Awesome names (openvibe.design/icons shows them).
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OVGlyphs) return;
+ const G = ${JSON.stringify(G)};
+ const FA = ${JSON.stringify(FA)};
+ // Font Awesome's style and size classes, and its animations: they say how, not what.
+ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|classic|brands|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|border|inverse|li|ul|stack|stack-1x|stack-2x|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity)$/;
+ const BUSY = '<circle cx="6" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="18" cy="12" r="1.8"/>';
+ const CSS = '.ovg::before,.ovg::after{content:none!important;display:none!important}'
+ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-family:inherit!important;font-style:normal!important}'
+ + '.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'
+ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;overflow:visible}'
+ + '.ovg[data-ovg=busy]>svg{fill:currentColor;stroke:none}.ovg[data-ovg=busy] circle{animation:ovgHop 1s ease-in-out infinite;transform-box:fill-box;transform-origin:center}'
+ + '.ovg[data-ovg=busy] circle:nth-child(2){animation-delay:.15s}.ovg[data-ovg=busy] circle:nth-child(3){animation-delay:.3s}'
+ + '@keyframes ovgHop{0%,60%,100%{transform:translateY(0);opacity:.55}30%{transform:translateY(-3px);opacity:1}}'
+ + '@media (prefers-reduced-motion:reduce){.ovg[data-ovg=busy] circle{animation:none}}html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none}';
+ const nameOf = (el) => { for (const c of el.classList) if (c.startsWith('fa-') && !MOD.test(c)) return c.slice(3); return null; };
+ const fromFa = (fa) => { const n = String(fa || '').replace(/^fa-/, ''); return Object.prototype.hasOwnProperty.call(FA, n) ? FA[n] : null; };
+ function draw(el) {
+ if (el.classList.contains('fa-brands') || el.classList.contains('fab')) return;
+ const fa = nameOf(el);
+ const g = fa && fromFa(fa);
+ if (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; el.textContent = ''; } return; }
+ if (el.dataset.ovgFa === fa) return;
+ if (!el.dataset.ovg && el.textContent.trim()) return; // an icon element with text of its own is not an icon
+ el.classList.add('ovg');
+ el.dataset.ovg = g; el.dataset.ovgFa = fa;
+ if (!el.hasAttribute('aria-label') && !el.hasAttribute('title')) el.setAttribute('aria-hidden', 'true');
+ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' + (g === 'busy' ? BUSY : G[g]) + '</svg>';
+ }
+ const SEL = 'i[class*="fa-"],span[class*="fa-"]';
+ function swap(root) {
+ const r = root || document;
+ if (r.matches && r.matches(SEL)) draw(r);
+ if (r.querySelectorAll) r.querySelectorAll(SEL).forEach(draw);
+ }
+ function start() {
+ if (!document.getElementById('ov-glyphs-css')) { const s = document.createElement('style'); s.id = 'ov-glyphs-css'; s.textContent = CSS; document.head.appendChild(s); }
+ swap(document);
+ try {
+ new MutationObserver((records) => {
+ for (const r of records) {
+ if (r.type === 'attributes') { if (r.target.matches && r.target.matches(SEL)) draw(r.target); continue; }
+ r.addedNodes.forEach((n) => { if (n.nodeType === 1) swap(n); });
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
+ } catch (e) { /* no observer: what was there is swapped */ }
+ }
+ window.OVGlyphs = { swap, fromFa };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
+})();
+`;
+fs.writeFileSync(path.join(__dirname, '..', 'ov-glyphs.js'), out);
+if (require.main === module) console.log(`ov-glyphs.js: ${used.length} glyphs for ${Object.keys(FA).length} Font Awesome names, ${(out.length / 1024).toFixed(1)} KB`);
+module.exports = { build: () => out };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+'use strict';
+// Writes navbar.js's SIBLING_REV: the content hash (openvibe-shared/assets hashAsset) of each file the navbar loads from
+// beside itself, so a release gives each a new URL (?v=) that sites serve as immutable, and nobody is left on a copy an
+// edge cached before the deploy (the Pair's first hour, 2026-10-31). Re-run after any of them changes:
+// node scripts/sibling-revs.js (test/sibling-revs.test.js checks the constant is current).
+const fs = require('fs');
+const path = require('path');
+const { hashAsset } = require('../assets');
+
+const ROOT = path.join(__dirname, '..');
+const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2'];
+const revs = () => Object.fromEntries(FILES.map((f) => [f, hashAsset(fs.readFileSync(path.join(ROOT, f)))]));
+const line = (r) => ` const SIBLING_REV = { ${FILES.map((f) => `'${f}': '${r[f]}'`).join(', ')} };`;
+
+if (require.main === module) {
+ const p = path.join(ROOT, 'navbar.js');
+ const src = fs.readFileSync(p, 'utf8');
+ const next = src.replace(/^ {4}const SIBLING_REV = \{[^\n]*\};$/m, line(revs()));
+ if (next === src && !src.includes(line(revs()))) throw new Error('navbar.js has no SIBLING_REV line to update');
+ fs.writeFileSync(p, next);
+ console.log(line(revs()).trim());
+}
+module.exports = { FILES, revs, line };
@@ -30,7 +30,7 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-
@@ -30,7 +30,7 @@ assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-
// Declared once by the navbar, from beside itself, for the characters it has; used with a fallback everywhere.
// Declared once by the navbar, from beside itself, for the characters it has; used with a fallback everywhere.
const nav = read('navbar.js');
const nav = read('navbar.js');
- assert.match(nav, /@font-face \{ font-family: 'OV Brand'; src: url\(\$\{sibling\('ov-brand\.woff2'\)\}\) format\('woff2'\); font-weight: 700 900;[^}]*font-display: swap; unicode-range: U\+0020-007E/, 'navbar.js declares it, swapping in when it arrives');
+ assert.match(nav, /@font-face \{ font-family: 'OV Brand'; src: url\(\$\{siblingV\('ov-brand\.woff2'\)\}\) format\('woff2'\); font-weight: 700 900;[^}]*font-display: swap; unicode-range: U\+0020-007E/, 'navbar.js declares it, swapping in when it arrives');
const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;
const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;
assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');
assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');
assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");
assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+// openvibe-shared/glyphs (4.61.0, plan L32): the one registry of the network's icons, and ov-glyphs.js, the drop-in that
+// shows them in place of Font Awesome's. Every Font Awesome name maps to a glyph that exists; every drawing is strokes
+// on the 24 grid with nothing but shapes in it; the drop-in is current with the registry; in a page it swaps an icon
+// in place, follows a class change, leaves brand logos and unknown names to Font Awesome, and never spins.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { parseHTML } = require('linkedom');
+const glyphs = require('../glyphs');
+const kit = require('../kit');
+
+// ── The registry ──
+assert.ok(glyphs.NAMES.length >= 150, `${glyphs.NAMES.length} glyphs`);
+for (const name of kit.ICONS) assert.ok(glyphs.NAMES.includes(name), `the kit's ${name} is a glyph`);
+for (const [fa, g] of Object.entries(glyphs.FA)) assert.ok(glyphs.path(g), `fa-${fa} → ${g}, a glyph that exists`);
+for (const name of glyphs.NAMES) {
+ const d = glyphs.path(name);
+ const tags = [...d.matchAll(/<(\w+)/g)].map((m) => m[1]);
+ assert.ok(tags.length && tags.every((t) => ['path', 'circle', 'rect', 'ellipse', 'line', 'polyline'].includes(t)), `${name}: shapes only (${tags})`);
+ assert.ok(!/fill=|style=|on\w+=|href|<script|url\(/i.test(d), `${name}: no fills, styles, handlers or links`);
+ for (const m of d.matchAll(/(?:cx|cy|x|y)="(-?[\d.]+)"/g)) assert.ok(Number(m[1]) >= 0 && Number(m[1]) <= 24, `${name}: on the 24 grid`);
+}
+assert.strictEqual(glyphs.fromFa('fa-tower-broadcast'), 'live');
+assert.strictEqual(glyphs.fromFa('eye-slash'), 'eye-off');
+assert.strictEqual(glyphs.fromFa('fa-spinner'), 'busy', 'a spinner is the busy dots');
+assert.strictEqual(glyphs.fromFa('fa-github'), null, 'brand logos are not ours');
+assert.match(glyphs.svg('live', { size: 20 }), /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">/);
+assert.match(glyphs.svg('eye', { title: 'Seen <by>' }), /role="img" aria-label="Seen <by>"><title>Seen <by><\/title>/);
+assert.strictEqual(glyphs.svg('nope'), '');
+// The names the network asked Font Awesome for most (the 2026-10-31 count) are covered, brand logos aside.
+for (const fa of ['wand-magic-sparkles', 'eye', 'tower-broadcast', 'xmark', 'video', 'robot', 'scissors', 'copy', 'bolt', 'check', 'comments', 'play', 'tree', 'spinner', 'map-pin', 'globe', 'code', 'coins', 'clock', 'shield-halved', 'plus', 'lock', 'arrow-right', 'star', 'volume-high', 'triangle-exclamation', 'link', 'image', 'user', 'server', 'magnifying-glass', 'campground', 'utensils', 'train', 'heart']) {
+ assert.ok(glyphs.fromFa(fa), `fa-${fa} has a glyph`);
+}
+
+// ── The drop-in is the registry's, current ──
+const built = require('../scripts/build-glyphs').build();
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-glyphs.js'), 'utf8');
+assert.strictEqual(SRC, built, 'ov-glyphs.js is current: run node scripts/build-glyphs.js');
+
+// ── In a page ──
+(async () => {
+ const { window, document, MutationObserver } = parseHTML(`<!doctype html><html><head></head><body>
+<button><i class="fa-solid fa-tower-broadcast"></i> Go live</button>
+<i class="fa-solid fa-spinner fa-spin" id="spin"></i>
+<i class="fa-brands fa-github" id="gh"></i>
+<i class="fa-solid fa-couch" id="unknown"></i>
+<i class="fa-solid fa-eye" aria-label="Seen" id="labelled"></i>
+<span class="fa-solid fa-play" id="toggle"></span>
+</body></html>`);
+ window.MutationObserver = MutationObserver;
+ new Function('window', 'document', 'MutationObserver', SRC)(window, document, MutationObserver);
+ const $ = (s) => document.querySelector(s);
+ const live = $('button i');
+ assert.ok(live.classList.contains('ovg') && live.dataset.ovg === 'live' && live.querySelector('svg path'), 'swapped in place');
+ assert.strictEqual(live.getAttribute('aria-hidden'), 'true', 'a decorative icon is hidden from screen readers');
+ assert.strictEqual($('#labelled').getAttribute('aria-hidden'), null, 'a labelled one is not');
+ assert.ok($('#spin').dataset.ovg === 'busy' && $('#spin').querySelectorAll('circle').length === 3, 'a spinner becomes three dots');
+ assert.ok(!$('#gh').classList.contains('ovg') && !$('#unknown').classList.contains('ovg'), 'brands and unknown names stay Font Awesome');
+ const css = document.getElementById('ov-glyphs-css').textContent;
+ assert.ok(css.includes('.ovg::before,.ovg::after{content:none!important') && css.includes('.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:none!important}'), "Font Awesome's own glyph and spin are turned off on a swapped icon");
+ // A page that changes an icon by its class (play ↔ pause) gets the new glyph.
+ const t = $('#toggle');
+ assert.strictEqual(t.dataset.ovg, 'play');
+ t.className = 'fa-solid fa-pause';
+ await new Promise((r) => setImmediate(r));
+ assert.ok(t.dataset.ovg === 'pause' && t.classList.contains('ovg'), 'follows a class change');
+ t.className = 'fa-solid fa-couch';
+ await new Promise((r) => setImmediate(r));
+ assert.ok(!t.classList.contains('ovg') && !t.dataset.ovg && !t.querySelector('svg'), 'and hands back a name it does not know');
+ // Markup added later is swapped too.
+ const later = document.createElement('div'); later.innerHTML = '<i class="fa-solid fa-heart"></i>';
+ document.body.appendChild(later);
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(later.firstChild.dataset.ovg, 'heart');
+ assert.strictEqual(window.OVGlyphs.fromFa('fa-xmark'), 'close');
+ console.log(`glyphs: ${glyphs.NAMES.length} glyphs, ${Object.keys(glyphs.FA).length} Font Awesome names; shapes on the grid; the drop-in is current, swaps in place, follows classes, leaves brands, never spins`);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+// navbar.js loads ov-mark.js, ov-glyphs.js and the brand face from beside itself by content hash (4.61.0): a release
+// gives each a new URL, so no edge serves the copy it cached before the deploy (the old mark showed some minutes after
+// 4.59.0). The constant must match the files: node scripts/sibling-revs.js rewrites it.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { revs, line } = require('../scripts/sibling-revs');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'navbar.js'), 'utf8');
+assert.ok(src.includes(line(revs())), 'SIBLING_REV is current: run node scripts/sibling-revs.js');
+assert.ok(/const siblingV = \(name\) => `\$\{sibling\(name\)\}\$\{SIBLING_REV\[name\] \? `\?v=\$\{SIBLING_REV\[name\]\}` : ''\}`;/.test(src), 'siblingV adds the hash');
+assert.ok(src.includes("sc.src = siblingV('ov-mark.js')") && src.includes("url(${siblingV('ov-brand.woff2')})") && src.includes("sc.src = siblingV('ov-glyphs.js')"), 'the mark, the face and the glyphs load by hash');
+assert.ok(/_config\.glyphs !== false[^\n]*i\[class\*="fa-"\]/.test(src), 'the glyphs load where Font Awesome is used, unless a site says glyphs: false');
+console.log('sibling revs: the navbar loads the mark, the glyphs and the brand face by content hash');