openvibers/openvibe.shared Commits

Font Awesome's features on the glyphs, and the glyphs as fonts and a sprite (4.62.0) (#214)

OP OpenVibers committed 13 days ago · fd02ce779e · 21 files, +1164 −47

ov-glyphs.js carries a rule for every documented Font Awesome class with Font
Awesome's CSS variables (sizes, fw, border, pull, rotate and flip, ul/li, stack,
layers with counters and text, beat/bounce/fade/beat-fade/flip/shake, thin and
light strokes, duotone colour), data-fa-transform, and a title that names the
icon. fa-spin breathes: nothing on the network spins. Reduced motion stops all.

The glyphs as fonts: ov-glyphs.woff2 (served), glyph-fonts/ov-glyphs.ttf and
.otf, built by scripts/build-glyph-fonts.py (strokes expanded by picosvg and
merged by skia-pathops), with stable Private Use Area code points in
glyph-codepoints.json, ov-glyphs-font.css (ovi ovi-<name>) and ov-glyphs-fa.css
(Font Awesome's names). The sprite ov-glyphs.svg is served too.

Changes

ddiff --git a/.gitattributes b/.gitattributes
iindex 1125b702..30f8d9d 100644
---- a/.gitattributes
++++ b/.gitattributes
@@@ -1,3 +1,5 @@
* text=auto eol=lf
*.png binary
*.woff2 binary
++*.ttf binary
++*.otf binary
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0ca2659..a92d234 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,35 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.62.0 — 2026-10-31
++
++- **Font Awesome's features, kept, on the glyphs** (plan L32 I6; the owner: "basically want to keep Font Awesome, so all
++ their features"). `ov-glyphs.js` now carries its own rules for every documented Font Awesome class, with Font
++ Awesome's CSS variables, so a site can drop Font Awesome's stylesheet and keep its markup:
++ - sizes `fa-2xs` … `fa-2xl` and `fa-1x` … `fa-10x`, `fa-fw`, `fa-border`, `fa-pull-left` / `-right`;
++ - `fa-rotate-90/180/270`, `fa-rotate-by` (`--fa-rotate-angle`), `fa-flip-horizontal/vertical/both`;
++ - `fa-ul` / `fa-li`, `fa-stack` (`-1x`, `-2x`, `fa-inverse`), `fa-layers` with `fa-layers-text` and
++ `fa-layers-counter`;
++ - the animations `fa-beat`, `fa-bounce`, `fa-fade`, `fa-beat-fade`, `fa-flip` and `fa-shake`. `fa-spin` and
++ `fa-spin-pulse` breathe instead, since nothing on the network spins (L27). Reduced motion stops all of them;
++ - `fa-thin` and `fa-light` as thinner strokes of the same drawing (`--ovg-stroke`, `ovg-bold`), and
++ `--fa-primary-color` on duotone icons;
++ - `data-fa-transform` (grow, shrink, up, down, left, right, rotate, flip-h, flip-v), in Font Awesome's sixteenths;
++ - a `title` names the icon to screen readers (`role="img"`).
++ - A page that resets an icon's class list but keeps its name keeps the glyph. The drop-in is 9.1 KB brotli and loads
++ after first paint.
++- **The glyphs as fonts** (plan L32 I5; the owner: "exporting like web fonts, regular font files"):
++ - `ov-glyphs.woff2` (12 KB, served at `/shared/ov-glyphs.woff2`), plus `glyph-fonts/ov-glyphs.ttf` and
++ `glyph-fonts/ov-glyphs.otf` for desktop apps and design tools. Each glyph's strokes are expanded to outlines
++ (picosvg, skia-pathops) by `scripts/build-glyph-fonts.py`, so the font draws what the SVG does.
++ - `glyph-codepoints.json`: each glyph's Private Use Area code point from U+E001, given once and never reused.
++ - `ov-glyphs-font.css`: `<i class="ovi ovi-eye">`. `ov-glyphs-fa.css`: Font Awesome's class names on the font, for a
++ page whose every name is covered.
++ - `test/glyph-fonts.test.js` fails when the fonts no longer match the registry. Builds are reproducible
++ (`SOURCE_DATE_EPOCH`).
++- **The sprite:** `ov-glyphs.svg`, every glyph as a `<symbol id="ovg-<name>">`, served at `/shared/ov-glyphs.svg`
++ (`files.IMAGES`; `serve.js` answers `image/svg+xml`).
++
## 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
ddiff --git a/README.md b/README.md
iindex ed79295..4dd11ef 100644
---- a/README.md
++++ b/README.md
@@@ -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.61.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.62.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
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
the tag it pins.
@@@ -21,9 +21,9 @@ 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`, `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()` |
++| 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 and keeps Font Awesome's classes, animations and `data-fa-transform`; also as fonts, `/shared/ov-glyphs.woff2` with `ov-glyphs-font.css` (`ovi ovi-<name>`) or `ov-glyphs-fa.css` (Font Awesome's names), `glyph-fonts/ov-glyphs.{ttf,otf}` and `glyph-codepoints.json`, and as the sprite `/shared/ov-glyphs.svg#ovg-<name>`), `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) |
++| Generators | `scripts/build-glyphs.js` (`ov-glyphs.js` and `ov-glyphs.svg` from `glyphs.js`), `scripts/build-glyph-fonts.py` (the glyph fonts and their CSS; Python with picosvg, fonttools and brotli), `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 |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found). The native code is compiled too: `scripts/native-check.js` writes the generated Compose and SwiftUI sources out, and `.github/workflows/native.yml` compiles the Kotlin against the Compose artifacts and type-checks the Swift against SwiftUI |
ddiff --git a/STATUS.json b/STATUS.json
iindex 22f00a7..c0b65a0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.61.0",
++ "version": "4.62.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)",
ddiff --git a/files.js b/files.js
iindex cfa10e3..8270700 100644
---- a/files.js
++++ b/files.js
@@@ -65,18 +65,27 @@ const STYLES = Object.freeze([
'room.css', // kit.card, kit.rail and a room's accent (4.29.1; load after kit.css where cards, rails or sc.room() are)
'kit-data.css', // Kit 2's look: steps, tables, charts, rankings, meters, drop zones, receipts (4.37.0; after kit.css)
'agent-session.css', // agent-session.js's look (4.32.0; the script links it from beside itself unless the page does)
++ 'ov-glyphs-font.css', // the glyphs as a font: <i class="ovi ovi-eye"> (4.62.0; built with the font, scripts/build-glyph-fonts.py)
++ 'ov-glyphs-fa.css', // Font Awesome's class names on the glyph font, for a site that drops Font Awesome's own (4.62.0)
]);
--// Fonts sites serve at /shared/<file>: the brand face the names are set in (navbar.js declares it from beside itself).
++// Fonts sites serve at /shared/<file>: the brand face the names are set in (navbar.js declares it from beside itself),
++// and the glyph font.
const FONTS = Object.freeze([
'ov-brand.woff2', // Nunito (SIL OFL 1.1, OFL-Nunito.txt), Latin, weights 700-900: the OpenVibe names (4.59.0)
++ 'ov-glyphs.woff2', // the OpenVibe glyphs as a font (4.62.0; TTF and OTF for desktop apps are in glyph-fonts/)
]);
--const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name)) || FONTS.includes(String(name));
++// Pictures sites serve at /shared/<file>.
++const IMAGES = Object.freeze([
++ 'ov-glyphs.svg', // every glyph as a <symbol>: <svg><use href="/shared/ov-glyphs.svg#ovg-eye"/></svg> (4.62.0)
++]);
++const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name)) || FONTS.includes(String(name)) || IMAGES.includes(String(name));
module.exports = {
dir: __dirname,
BROWSER,
STYLES,
FONTS,
++ IMAGES,
isBrowserFile: served,
path: (name) => {
if (!served(name)) throw new Error(`openvibe-shared: ${name} is not a browser file`);
ddiff --git a/glyph-codepoints.json b/glyph-codepoints.json
nnew file mode 100644
iindex 0000000..3f5a963
---- /dev/null
++++ b/glyph-codepoints.json
@@@ -0,0 +1,160 @@
++{
++ "about": "Each OpenVibe glyph's code point in the OV Glyphs font (U+E001 up). Given once, never changed or reused.",
++ "glyphs": {
++ "alert": "E001",
++ "alert-circle": "E002",
++ "archive": "E003",
++ "arrow-down": "E004",
++ "arrow-left": "E005",
++ "arrow-right": "E006",
++ "arrow-up": "E007",
++ "back": "E008",
++ "ban": "E009",
++ "bell": "E00A",
++ "book": "E00B",
++ "brain": "E00C",
++ "branch": "E00D",
++ "bridge": "E00E",
++ "busy": "E00F",
++ "calendar": "E010",
++ "camera": "E011",
++ "care": "E012",
++ "cart": "E013",
++ "chart": "E014",
++ "chat": "E015",
++ "check": "E016",
++ "check-circle": "E017",
++ "chevron": "E018",
++ "chip": "E019",
++ "circle": "E01A",
++ "city": "E01B",
++ "clipboard": "E01C",
++ "clock": "E01D",
++ "close": "E01E",
++ "cloud": "E01F",
++ "code": "E020",
++ "coin": "E021",
++ "compress": "E022",
++ "computer": "E023",
++ "copy": "E024",
++ "crown": "E025",
++ "database": "E026",
++ "dish": "E027",
++ "dollar": "E028",
++ "down": "E029",
++ "download": "E02A",
++ "drop": "E02B",
++ "enter": "E02C",
++ "expand": "E02D",
++ "eye": "E02E",
++ "eye-off": "E02F",
++ "file": "E030",
++ "film": "E031",
++ "filter": "E032",
++ "fire": "E033",
++ "flag": "E034",
++ "folder": "E035",
++ "food": "E036",
++ "forward": "E037",
++ "game": "E038",
++ "gauge": "E039",
++ "gavel": "E03A",
++ "gear": "E03B",
++ "gift": "E03C",
++ "globe": "E03D",
++ "grid": "E03E",
++ "hand": "E03F",
++ "hash": "E040",
++ "heart": "E041",
++ "help": "E042",
++ "history": "E043",
++ "home": "E044",
++ "id": "E045",
++ "idea": "E046",
++ "image": "E047",
++ "info": "E048",
++ "invite": "E049",
++ "key": "E04A",
++ "keyboard": "E04B",
++ "language": "E04C",
++ "layers": "E04D",
++ "leaf": "E04E",
++ "leave": "E04F",
++ "link": "E050",
++ "list": "E051",
++ "live": "E052",
++ "lock": "E053",
++ "mail": "E054",
++ "map": "E055",
++ "members": "E056",
++ "menu": "E057",
++ "mic": "E058",
++ "mic-off": "E059",
++ "minus": "E05A",
++ "moon": "E05B",
++ "more": "E05C",
++ "mountain": "E05D",
++ "music": "E05E",
++ "mute": "E05F",
++ "news": "E060",
++ "nodes": "E061",
++ "open": "E062",
++ "palette": "E063",
++ "pause": "E064",
++ "pencil": "E065",
++ "phone": "E066",
++ "pin": "E067",
++ "plane": "E068",
++ "play": "E069",
++ "plug": "E06A",
++ "plus": "E06B",
++ "reply": "E06C",
++ "retry": "E06D",
++ "robot": "E06E",
++ "route": "E06F",
++ "save": "E070",
++ "scissors": "E071",
++ "search": "E072",
++ "send": "E073",
++ "server": "E074",
++ "shield": "E075",
++ "shirt": "E076",
++ "shoe": "E077",
++ "sidebar": "E078",
++ "sliders": "E079",
++ "smile": "E07A",
++ "snow": "E07B",
++ "spark": "E07C",
++ "star": "E07D",
++ "steps": "E07E",
++ "stop": "E07F",
++ "store": "E080",
++ "sun": "E081",
++ "swap": "E082",
++ "tack": "E083",
++ "tag": "E084",
++ "target": "E085",
++ "task": "E086",
++ "tent": "E087",
++ "terminal": "E088",
++ "text": "E089",
++ "ticket": "E08A",
++ "train": "E08B",
++ "trash": "E08C",
++ "tree": "E08D",
++ "trophy": "E08E",
++ "up": "E08F",
++ "upload": "E090",
++ "user": "E091",
++ "user-check": "E092",
++ "video": "E093",
++ "volume": "E094",
++ "wallet": "E095",
++ "water": "E096",
++ "wave": "E097",
++ "wifi": "E098",
++ "wrench": "E099",
++ "x-circle": "E09A",
++ "zap": "E09B"
++ }
++}
ddiff --git a/glyph-fonts/manifest.json b/glyph-fonts/manifest.json
nnew file mode 100644
iindex 0000000..c90d762
---- /dev/null
++++ b/glyph-fonts/manifest.json
@@@ -0,0 +1,10 @@
++{
++ "family": "OV Glyphs",
++ "glyphs": 155,
++ "source_sha256": "fb052064fcb5279c7c291230e2e5389d164613d278969ad437f975b26ab40bdc",
++ "files": {
++ "ov-glyphs.woff2": 12320,
++ "glyph-fonts/ov-glyphs.ttf": 28500,
++ "glyph-fonts/ov-glyphs.otf": 35816
++ }
++}
ddiff --git a/glyph-fonts/ov-glyphs.otf b/glyph-fonts/ov-glyphs.otf
nnew file mode 100644
iindex 0000000..1ed9dd3
BBinary files /dev/null and b/glyph-fonts/ov-glyphs.otf differ
ddiff --git a/glyph-fonts/ov-glyphs.ttf b/glyph-fonts/ov-glyphs.ttf
nnew file mode 100644
iindex 0000000..bd2b1e3
BBinary files /dev/null and b/glyph-fonts/ov-glyphs.ttf differ
ddiff --git a/navbar.js b/navbar.js
iindex 819780d..c66880a 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -475,7 +475,7 @@
const NAV_ICONS_REV = '2a303c60dc83'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// 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' };
++ const SIBLING_REV = { 'ov-mark.js': 'd53438811552', 'ov-glyphs.js': 'ba57d6e1f417', '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">.
ddiff --git a/ov-glyphs-fa.css b/ov-glyphs-fa.css
nnew file mode 100644
iindex 0000000..fdc60c0
---- /dev/null
++++ b/ov-glyphs-fa.css
@@@ -0,0 +1,243 @@
++/* Generated by scripts/build-glyph-fonts.py from glyphs.js (openvibe-shared, MIT): edit those, then run it. */
++/* Font Awesome's class names on the OV Glyphs font, for a site that drops Font Awesome's stylesheet and its
++ font. A name with no glyph shows nothing, so load it only where every name a page uses is covered
++ (openvibe.design/icons lists them); brand logos keep Font Awesome Brands. */
++@font-face{font-family:'OV Glyphs';src:url(ov-glyphs.woff2) format('woff2');font-weight:400;font-style:normal;font-display:block}
++.fa,.fas,.far,.fal,.fat,.fa-solid,.fa-regular,.fa-light,.fa-thin,.fa-duotone{display:inline-block;font-family:'OV Glyphs';font-style:normal;font-weight:400;font-variant:normal;line-height:1;text-rendering:auto;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:never}
++.fa-fw{width:1.25em;text-align:center}
++.fa-triangle-exclamation::before{content:"\e001"}
++.fa-circle-exclamation::before{content:"\e002"}
++.fa-box-archive::before{content:"\e003"}
++.fa-arrow-down::before{content:"\e004"}
++.fa-arrow-left::before{content:"\e005"}
++.fa-arrow-right::before{content:"\e006"}
++.fa-arrow-up::before{content:"\e007"}
++.fa-chevron-left::before{content:"\e008"}
++.fa-ban::before{content:"\e009"}
++.fa-bell::before{content:"\e00a"}
++.fa-book::before{content:"\e00b"}
++.fa-book-open::before{content:"\e00b"}
++.fa-brain::before{content:"\e00c"}
++.fa-code-branch::before{content:"\e00d"}
++.fa-code-fork::before{content:"\e00d"}
++.fa-bridge::before{content:"\e00e"}
++.fa-circle-notch::before{content:"\e00f"}
++.fa-spinner::before{content:"\e00f"}
++.fa-calendar::before{content:"\e010"}
++.fa-calendar-days::before{content:"\e010"}
++.fa-camera::before{content:"\e011"}
++.fa-hand-holding-heart::before{content:"\e012"}
++.fa-cart::before{content:"\e013"}
++.fa-cart-shopping::before{content:"\e013"}
++.fa-chart-bar::before{content:"\e014"}
++.fa-chart-line::before{content:"\e014"}
++.fa-chart-simple::before{content:"\e014"}
++.fa-comment::before{content:"\e015"}
++.fa-comment-dots::before{content:"\e015"}
++.fa-comments::before{content:"\e015"}
++.fa-message::before{content:"\e015"}
++.fa-check::before{content:"\e016"}
++.fa-check-circle::before{content:"\e017"}
++.fa-circle-check::before{content:"\e017"}
++.fa-angle-right::before{content:"\e018"}
++.fa-chevron-right::before{content:"\e018"}
++.fa-microchip::before{content:"\e019"}
++.fa-circle::before{content:"\e01a"}
++.fa-city::before{content:"\e01b"}
++.fa-clipboard::before{content:"\e01c"}
++.fa-clipboard-list::before{content:"\e01c"}
++.fa-paste::before{content:"\e01c"}
++.fa-clock::before{content:"\e01d"}
++.fa-times::before{content:"\e01e"}
++.fa-xmark::before{content:"\e01e"}
++.fa-cloud::before{content:"\e01f"}
++.fa-cloud-rain::before{content:"\e01f"}
++.fa-cloud-showers-heavy::before{content:"\e01f"}
++.fa-cloud-sun::before{content:"\e01f"}
++.fa-code::before{content:"\e020"}
++.fa-coin::before{content:"\e021"}
++.fa-coins::before{content:"\e021"}
++.fa-hand-holding-dollar::before{content:"\e021"}
++.fa-compress::before{content:"\e022"}
++.fa-desktop::before{content:"\e023"}
++.fa-display::before{content:"\e023"}
++.fa-laptop::before{content:"\e023"}
++.fa-copy::before{content:"\e024"}
++.fa-crown::before{content:"\e025"}
++.fa-database::before{content:"\e026"}
++.fa-satellite-dish::before{content:"\e027"}
++.fa-dollar-sign::before{content:"\e028"}
++.fa-angle-down::before{content:"\e029"}
++.fa-chevron-down::before{content:"\e029"}
++.fa-download::before{content:"\e02a"}
++.fa-droplet::before{content:"\e02b"}
++.fa-arrow-right-to-bracket::before{content:"\e02c"}
++.fa-right-to-bracket::before{content:"\e02c"}
++.fa-expand::before{content:"\e02d"}
++.fa-up-right-and-down-left-from-center::before{content:"\e02d"}
++.fa-eye::before{content:"\e02e"}
++.fa-eye-slash::before{content:"\e02f"}
++.fa-file::before{content:"\e030"}
++.fa-file-audio::before{content:"\e030"}
++.fa-file-lines::before{content:"\e030"}
++.fa-file-pdf::before{content:"\e030"}
++.fa-film::before{content:"\e031"}
++.fa-photo-film::before{content:"\e031"}
++.fa-filter::before{content:"\e032"}
++.fa-fire::before{content:"\e033"}
++.fa-flag::before{content:"\e034"}
++.fa-folder::before{content:"\e035"}
++.fa-utensils::before{content:"\e036"}
++.fa-gamepad::before{content:"\e038"}
++.fa-gauge::before{content:"\e039"}
++.fa-gauge-high::before{content:"\e039"}
++.fa-gavel::before{content:"\e03a"}
++.fa-gear::before{content:"\e03b"}
++.fa-gears::before{content:"\e03b"}
++.fa-gift::before{content:"\e03c"}
++.fa-earth-americas::before{content:"\e03d"}
++.fa-earth-europe::before{content:"\e03d"}
++.fa-globe::before{content:"\e03d"}
++.fa-grid::before{content:"\e03e"}
++.fa-table-cells::before{content:"\e03e"}
++.fa-hand::before{content:"\e03f"}
++.fa-hand-pointer::before{content:"\e03f"}
++.fa-hashtag::before{content:"\e040"}
++.fa-heart::before{content:"\e041"}
++.fa-circle-question::before{content:"\e042"}
++.fa-question::before{content:"\e042"}
++.fa-clock-rotate-left::before{content:"\e043"}
++.fa-house::before{content:"\e044"}
++.fa-house-chimney::before{content:"\e044"}
++.fa-id-card::before{content:"\e045"}
++.fa-lightbulb::before{content:"\e046"}
++.fa-file-image::before{content:"\e047"}
++.fa-image::before{content:"\e047"}
++.fa-images::before{content:"\e047"}
++.fa-circle-info::before{content:"\e048"}
++.fa-user-plus::before{content:"\e049"}
++.fa-key::before{content:"\e04a"}
++.fa-keyboard::before{content:"\e04b"}
++.fa-language::before{content:"\e04c"}
++.fa-layer-group::before{content:"\e04d"}
++.fa-leaf::before{content:"\e04e"}
++.fa-arrow-right-from-bracket::before{content:"\e04f"}
++.fa-right-from-bracket::before{content:"\e04f"}
++.fa-link::before{content:"\e050"}
++.fa-list::before{content:"\e051"}
++.fa-list-ul::before{content:"\e051"}
++.fa-broadcast-tower::before{content:"\e052"}
++.fa-tower-broadcast::before{content:"\e052"}
++.fa-tower-cell::before{content:"\e052"}
++.fa-lock::before{content:"\e053"}
++.fa-unlock::before{content:"\e053"}
++.fa-envelope::before{content:"\e054"}
++.fa-map::before{content:"\e055"}
++.fa-people-group::before{content:"\e056"}
++.fa-user-group::before{content:"\e056"}
++.fa-users::before{content:"\e056"}
++.fa-bars::before{content:"\e057"}
++.fa-microphone::before{content:"\e058"}
++.fa-microphone-slash::before{content:"\e059"}
++.fa-minus::before{content:"\e05a"}
++.fa-moon::before{content:"\e05b"}
++.fa-ellipsis::before{content:"\e05c"}
++.fa-ellipsis-vertical::before{content:"\e05c"}
++.fa-mountain::before{content:"\e05d"}
++.fa-mountain-sun::before{content:"\e05d"}
++.fa-music::before{content:"\e05e"}
++.fa-volume-off::before{content:"\e05f"}
++.fa-volume-xmark::before{content:"\e05f"}
++.fa-newspaper::before{content:"\e060"}
++.fa-circle-nodes::before{content:"\e061"}
++.fa-diagram-project::before{content:"\e061"}
++.fa-network-wired::before{content:"\e061"}
++.fa-share-nodes::before{content:"\e061"}
++.fa-sitemap::before{content:"\e061"}
++.fa-arrow-up-right-from-square::before{content:"\e062"}
++.fa-external-link::before{content:"\e062"}
++.fa-up-right-from-square::before{content:"\e062"}
++.fa-palette::before{content:"\e063"}
++.fa-pause::before{content:"\e064"}
++.fa-pen::before{content:"\e065"}
++.fa-pen-nib::before{content:"\e065"}
++.fa-pen-to-square::before{content:"\e065"}
++.fa-pencil::before{content:"\e065"}
++.fa-mobile-screen::before{content:"\e066"}
++.fa-phone::before{content:"\e066"}
++.fa-location-dot::before{content:"\e067"}
++.fa-location-pin::before{content:"\e067"}
++.fa-map-pin::before{content:"\e067"}
++.fa-paper-plane::before{content:"\e068"}
++.fa-circle-play::before{content:"\e069"}
++.fa-play::before{content:"\e069"}
++.fa-plug::before{content:"\e06a"}
++.fa-plus::before{content:"\e06b"}
++.fa-reply::before{content:"\e06c"}
++.fa-arrow-rotate-right::before{content:"\e06d"}
++.fa-arrows-rotate::before{content:"\e06d"}
++.fa-rotate::before{content:"\e06d"}
++.fa-rotate-left::before{content:"\e06d"}
++.fa-rotate-right::before{content:"\e06d"}
++.fa-robot::before{content:"\e06e"}
++.fa-route::before{content:"\e06f"}
++.fa-floppy-disk::before{content:"\e070"}
++.fa-cut::before{content:"\e071"}
++.fa-scissors::before{content:"\e071"}
++.fa-magnifying-glass::before{content:"\e072"}
++.fa-search::before{content:"\e072"}
++.fa-server::before{content:"\e074"}
++.fa-shield::before{content:"\e075"}
++.fa-shield-halved::before{content:"\e075"}
++.fa-user-shield::before{content:"\e075"}
++.fa-shirt::before{content:"\e076"}
++.fa-sliders::before{content:"\e079"}
++.fa-sliders-h::before{content:"\e079"}
++.fa-face-smile::before{content:"\e07a"}
++.fa-snowflake::before{content:"\e07b"}
++.fa-sparkles::before{content:"\e07c"}
++.fa-star-of-life::before{content:"\e07c"}
++.fa-wand-magic::before{content:"\e07c"}
++.fa-wand-magic-sparkles::before{content:"\e07c"}
++.fa-star::before{content:"\e07d"}
++.fa-list-check::before{content:"\e07e"}
++.fa-stop::before{content:"\e07f"}
++.fa-store::before{content:"\e080"}
++.fa-sun::before{content:"\e081"}
++.fa-thumbtack::before{content:"\e083"}
++.fa-tag::before{content:"\e084"}
++.fa-tags::before{content:"\e084"}
++.fa-bullseye::before{content:"\e085"}
++.fa-campground::before{content:"\e087"}
++.fa-terminal::before{content:"\e088"}
++.fa-font::before{content:"\e089"}
++.fa-text-height::before{content:"\e089"}
++.fa-ticket::before{content:"\e08a"}
++.fa-train::before{content:"\e08b"}
++.fa-trash::before{content:"\e08c"}
++.fa-trash-can::before{content:"\e08c"}
++.fa-tree::before{content:"\e08d"}
++.fa-trophy::before{content:"\e08e"}
++.fa-chevron-up::before{content:"\e08f"}
++.fa-cloud-arrow-up::before{content:"\e090"}
++.fa-upload::before{content:"\e090"}
++.fa-user::before{content:"\e091"}
++.fa-user-astronaut::before{content:"\e091"}
++.fa-user-secret::before{content:"\e091"}
++.fa-user-check::before{content:"\e092"}
++.fa-video::before{content:"\e093"}
++.fa-video-slash::before{content:"\e093"}
++.fa-volume-high::before{content:"\e094"}
++.fa-volume-low::before{content:"\e094"}
++.fa-wallet::before{content:"\e095"}
++.fa-person-swimming::before{content:"\e096"}
++.fa-water::before{content:"\e096"}
++.fa-wave-square::before{content:"\e097"}
++.fa-signal::before{content:"\e098"}
++.fa-wifi::before{content:"\e098"}
++.fa-screwdriver-wrench::before{content:"\e099"}
++.fa-wrench::before{content:"\e099"}
++.fa-circle-xmark::before{content:"\e09a"}
++.fa-xmark-circle::before{content:"\e09a"}
++.fa-bolt::before{content:"\e09b"}
++.fa-bolt-lightning::before{content:"\e09b"}
ddiff --git a/ov-glyphs-font.css b/ov-glyphs-font.css
nnew file mode 100644
iindex 0000000..8483678
---- /dev/null
++++ b/ov-glyphs-font.css
@@@ -0,0 +1,160 @@
++/* Generated by scripts/build-glyph-fonts.py from glyphs.js (openvibe-shared, MIT): edit those, then run it. */
++/* <i class="ovi ovi-eye"></i>: the OpenVibe glyphs as a font. Sizes follow the text. */
++@font-face{font-family:'OV Glyphs';src:url(ov-glyphs.woff2) format('woff2');font-weight:400;font-style:normal;font-display:block}
++.ovi{display:inline-block;font-family:'OV Glyphs';font-style:normal;font-weight:400;font-variant:normal;line-height:1;text-rendering:auto;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:never}
++.ovi-fw{width:1.25em;text-align:center}
++.ovi-alert::before{content:"\e001"}
++.ovi-alert-circle::before{content:"\e002"}
++.ovi-archive::before{content:"\e003"}
++.ovi-arrow-down::before{content:"\e004"}
++.ovi-arrow-left::before{content:"\e005"}
++.ovi-arrow-right::before{content:"\e006"}
++.ovi-arrow-up::before{content:"\e007"}
++.ovi-back::before{content:"\e008"}
++.ovi-ban::before{content:"\e009"}
++.ovi-bell::before{content:"\e00a"}
++.ovi-book::before{content:"\e00b"}
++.ovi-brain::before{content:"\e00c"}
++.ovi-branch::before{content:"\e00d"}
++.ovi-bridge::before{content:"\e00e"}
++.ovi-busy::before{content:"\e00f"}
++.ovi-calendar::before{content:"\e010"}
++.ovi-camera::before{content:"\e011"}
++.ovi-care::before{content:"\e012"}
++.ovi-cart::before{content:"\e013"}
++.ovi-chart::before{content:"\e014"}
++.ovi-chat::before{content:"\e015"}
++.ovi-check::before{content:"\e016"}
++.ovi-check-circle::before{content:"\e017"}
++.ovi-chevron::before{content:"\e018"}
++.ovi-chip::before{content:"\e019"}
++.ovi-circle::before{content:"\e01a"}
++.ovi-city::before{content:"\e01b"}
++.ovi-clipboard::before{content:"\e01c"}
++.ovi-clock::before{content:"\e01d"}
++.ovi-close::before{content:"\e01e"}
++.ovi-cloud::before{content:"\e01f"}
++.ovi-code::before{content:"\e020"}
++.ovi-coin::before{content:"\e021"}
++.ovi-compress::before{content:"\e022"}
++.ovi-computer::before{content:"\e023"}
++.ovi-copy::before{content:"\e024"}
++.ovi-crown::before{content:"\e025"}
++.ovi-database::before{content:"\e026"}
++.ovi-dish::before{content:"\e027"}
++.ovi-dollar::before{content:"\e028"}
++.ovi-down::before{content:"\e029"}
++.ovi-download::before{content:"\e02a"}
++.ovi-drop::before{content:"\e02b"}
++.ovi-enter::before{content:"\e02c"}
++.ovi-expand::before{content:"\e02d"}
++.ovi-eye::before{content:"\e02e"}
++.ovi-eye-off::before{content:"\e02f"}
++.ovi-file::before{content:"\e030"}
++.ovi-film::before{content:"\e031"}
++.ovi-filter::before{content:"\e032"}
++.ovi-fire::before{content:"\e033"}
++.ovi-flag::before{content:"\e034"}
++.ovi-folder::before{content:"\e035"}
++.ovi-food::before{content:"\e036"}
++.ovi-forward::before{content:"\e037"}
++.ovi-game::before{content:"\e038"}
++.ovi-gauge::before{content:"\e039"}
++.ovi-gavel::before{content:"\e03a"}
++.ovi-gear::before{content:"\e03b"}
++.ovi-gift::before{content:"\e03c"}
++.ovi-globe::before{content:"\e03d"}
++.ovi-grid::before{content:"\e03e"}
++.ovi-hand::before{content:"\e03f"}
++.ovi-hash::before{content:"\e040"}
++.ovi-heart::before{content:"\e041"}
++.ovi-help::before{content:"\e042"}
++.ovi-history::before{content:"\e043"}
++.ovi-home::before{content:"\e044"}
++.ovi-id::before{content:"\e045"}
++.ovi-idea::before{content:"\e046"}
++.ovi-image::before{content:"\e047"}
++.ovi-info::before{content:"\e048"}
++.ovi-invite::before{content:"\e049"}
++.ovi-key::before{content:"\e04a"}
++.ovi-keyboard::before{content:"\e04b"}
++.ovi-language::before{content:"\e04c"}
++.ovi-layers::before{content:"\e04d"}
++.ovi-leaf::before{content:"\e04e"}
++.ovi-leave::before{content:"\e04f"}
++.ovi-link::before{content:"\e050"}
++.ovi-list::before{content:"\e051"}
++.ovi-live::before{content:"\e052"}
++.ovi-lock::before{content:"\e053"}
++.ovi-mail::before{content:"\e054"}
++.ovi-map::before{content:"\e055"}
++.ovi-members::before{content:"\e056"}
++.ovi-menu::before{content:"\e057"}
++.ovi-mic::before{content:"\e058"}
++.ovi-mic-off::before{content:"\e059"}
++.ovi-minus::before{content:"\e05a"}
++.ovi-moon::before{content:"\e05b"}
++.ovi-more::before{content:"\e05c"}
++.ovi-mountain::before{content:"\e05d"}
++.ovi-music::before{content:"\e05e"}
++.ovi-mute::before{content:"\e05f"}
++.ovi-news::before{content:"\e060"}
++.ovi-nodes::before{content:"\e061"}
++.ovi-open::before{content:"\e062"}
++.ovi-palette::before{content:"\e063"}
++.ovi-pause::before{content:"\e064"}
++.ovi-pencil::before{content:"\e065"}
++.ovi-phone::before{content:"\e066"}
++.ovi-pin::before{content:"\e067"}
++.ovi-plane::before{content:"\e068"}
++.ovi-play::before{content:"\e069"}
++.ovi-plug::before{content:"\e06a"}
++.ovi-plus::before{content:"\e06b"}
++.ovi-reply::before{content:"\e06c"}
++.ovi-retry::before{content:"\e06d"}
++.ovi-robot::before{content:"\e06e"}
++.ovi-route::before{content:"\e06f"}
++.ovi-save::before{content:"\e070"}
++.ovi-scissors::before{content:"\e071"}
++.ovi-search::before{content:"\e072"}
++.ovi-send::before{content:"\e073"}
++.ovi-server::before{content:"\e074"}
++.ovi-shield::before{content:"\e075"}
++.ovi-shirt::before{content:"\e076"}
++.ovi-shoe::before{content:"\e077"}
++.ovi-sidebar::before{content:"\e078"}
++.ovi-sliders::before{content:"\e079"}
++.ovi-smile::before{content:"\e07a"}
++.ovi-snow::before{content:"\e07b"}
++.ovi-spark::before{content:"\e07c"}
++.ovi-star::before{content:"\e07d"}
++.ovi-steps::before{content:"\e07e"}
++.ovi-stop::before{content:"\e07f"}
++.ovi-store::before{content:"\e080"}
++.ovi-sun::before{content:"\e081"}
++.ovi-swap::before{content:"\e082"}
++.ovi-tack::before{content:"\e083"}
++.ovi-tag::before{content:"\e084"}
++.ovi-target::before{content:"\e085"}
++.ovi-task::before{content:"\e086"}
++.ovi-tent::before{content:"\e087"}
++.ovi-terminal::before{content:"\e088"}
++.ovi-text::before{content:"\e089"}
++.ovi-ticket::before{content:"\e08a"}
++.ovi-train::before{content:"\e08b"}
++.ovi-trash::before{content:"\e08c"}
++.ovi-tree::before{content:"\e08d"}
++.ovi-trophy::before{content:"\e08e"}
++.ovi-up::before{content:"\e08f"}
++.ovi-upload::before{content:"\e090"}
++.ovi-user::before{content:"\e091"}
++.ovi-user-check::before{content:"\e092"}
++.ovi-video::before{content:"\e093"}
++.ovi-volume::before{content:"\e094"}
++.ovi-wallet::before{content:"\e095"}
++.ovi-water::before{content:"\e096"}
++.ovi-wave::before{content:"\e097"}
++.ovi-wifi::before{content:"\e098"}
++.ovi-wrench::before{content:"\e099"}
++.ovi-x-circle::before{content:"\e09a"}
++.ovi-zap::before{content:"\e09b"}
ddiff --git a/ov-glyphs.js b/ov-glyphs.js
iindex 682e383..8768a28 100644
---- a/ov-glyphs.js
++++ b/ov-glyphs.js
@@@ -5,6 +5,12 @@
* (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.
*
++ * Font Awesome's features keep working without its stylesheet (4.62.0): sizes (fa-2xs … fa-2xl, fa-1x … fa-10x), fa-fw,
++ * fa-ul/fa-li, fa-border, fa-pull-*, fa-rotate-* and fa-flip-*, fa-stack, fa-layers with counters and text, the
++ * animations (fa-beat, fa-bounce, fa-fade, fa-beat-fade, fa-flip, fa-shake; fa-spin breathes), fa-thin and fa-light
++ * as thinner strokes, data-fa-transform (grow, shrink, up, down, left, right, rotate, flip-h, flip-v), and a title that
++ * names the icon to screen readers. Every variable Font Awesome documents (--fa-beat-scale …) is honoured.
++ *
* <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
@@@ -16,30 +22,44 @@
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)$/;
++ // Font Awesome's style, size, layout and animation classes: they say how, not what.
++ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|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|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;
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 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>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:var(--ovg-stroke,2);stroke-linecap:round;stroke-linejoin:round;overflow:visible}.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}.ovg.fa-1x{font-size:1em}.ovg.fa-2x{font-size:2em}.ovg.fa-3x{font-size:3em}.ovg.fa-4x{font-size:4em}.ovg.fa-5x{font-size:5em}.ovg.fa-6x{font-size:6em}.ovg.fa-7x{font-size:7em}.ovg.fa-8x{font-size:8em}.ovg.fa-9x{font-size:9em}.ovg.fa-10x{font-size:10em}.ovg.fa-fw{width:1.25em;text-align:center}.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}.ovg.fa-beat{animation:ovgBeat var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-bounce{animation:ovgBounce var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-fade{animation:ovgFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-beat-fade{animation:ovgBeatFade var(--fa-animation-duration,1s) var(--fa-animation-timing,cubic-bezier(.4,0,.6,1)) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-flip{animation:ovgFlip var(--fa-animation-duration,1s) var(--fa-animation-timing,ease-in-out) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-shake{animation:ovgShake var(--fa-animation-duration,1s) var(--fa-animation-timing,linear) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}.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,.ovg[data-ovg=busy] circle{animation:none!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none!important}";
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; };
++ // data-fa-transform, in Font Awesome's units (a sixteenth of the icon).
++ const TF = /^(grow|shrink|up|down|left|right|rotate)-(-?[0-9.]+)$|^flip-(h|v)$/;
++ function transformOf(spec) {
++ let s = 1, x = 0, y = 0, r = 0, fx = 1, fy = 1, any = false;
++ for (const p of String(spec || '').toLowerCase().split(/\s+/)) {
++ const m = TF.exec(p);
++ if (!m) continue;
++ any = true;
++ if (m[3]) { if (m[3] === 'h') fx = -1; else fy = -1; continue; }
++ const n = Number(m[2]);
++ if (m[1] === 'grow') s += n / 16; else if (m[1] === 'shrink') s -= n / 16;
++ else if (m[1] === 'up') y -= n / 16; else if (m[1] === 'down') y += n / 16;
++ else if (m[1] === 'left') x -= n / 16; else if (m[1] === 'right') x += n / 16; else r += n;
++ }
++ return any ? 'translate(' + x + 'em,' + y + 'em) rotate(' + r + 'deg) scale(' + (fx * s) + ',' + (fy * s) + ')' : '';
++ }
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 (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; delete el.dataset.ovgTf; el.textContent = ''; } return; }
++ const tf = el.getAttribute('data-fa-transform') || '';
++ if (el.dataset.ovgFa === fa && (el.dataset.ovgTf || '') === tf) { if (!el.classList.contains('ovg')) el.classList.add('ovg'); return; } // adding a class it has would observe itself forever
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>';
++ if (tf) el.dataset.ovgTf = tf; else delete el.dataset.ovgTf;
++ const title = el.getAttribute('title');
++ if (title && !el.hasAttribute('aria-label')) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', title); }
++ else if (!el.hasAttribute('aria-label') && !title) el.setAttribute('aria-hidden', 'true');
++ const t = transformOf(tf);
++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"' + (t ? ' style="transform:' + t + '"' : '') + '>' + (g === 'busy' ? BUSY : G[g]) + '</svg>';
}
const SEL = 'i[class*="fa-"],span[class*="fa-"]';
function swap(root) {
@@@ -56,7 +76,7 @@
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'] });
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-fa-transform'] });
} catch (e) { /* no observer: what was there is swapped */ }
}
window.OVGlyphs = { swap, fromFa };
ddiff --git a/ov-glyphs.svg b/ov-glyphs.svg
nnew file mode 100644
iindex 0000000..8d3619a
---- /dev/null
++++ b/ov-glyphs.svg
@@@ -0,0 +1,158 @@
++<svg xmlns="http://www.w3.org/2000/svg">
++<!-- The OpenVibe glyphs (openvibe-shared, MIT): <svg><use href="ov-glyphs.svg#ovg-eye"/></svg>. Generated by scripts/build-glyphs.js. -->
++<symbol id="ovg-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4 2.8 19.5h18.4Z"/><path d="M12 10v4M12 17h.01"/></symbol>
++<symbol id="ovg-alert-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5.5M12 16.5h.01"/></symbol>
++<symbol id="ovg-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v16M6 14l6 6 6-6"/></symbol>
++<symbol id="ovg-arrow-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12H4M10 6l-6 6 6 6"/></symbol>
++<symbol id="ovg-arrow-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16M14 6l6 6-6 6"/></symbol>
++<symbol id="ovg-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V4M6 10l6-6 6 6"/></symbol>
++<symbol id="ovg-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></symbol>
++<symbol id="ovg-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/></symbol>
++<symbol id="ovg-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/></symbol>
++<symbol id="ovg-book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z"/><path d="M4 19V5"/></symbol>
++<symbol id="ovg-brain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-bridge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-busy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h.01M12 12h.01M18 12h.01" stroke-width="3.4"/></symbol>
++<symbol id="ovg-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></symbol>
++<symbol id="ovg-camera" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/></symbol>
++<symbol id="ovg-care" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-cart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 5-6"/></symbol>
++<symbol id="ovg-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v11H8l-4 4Z"/></symbol>
++<symbol id="ovg-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12.5 4.5 4.5L19 7.5"/></symbol>
++<symbol id="ovg-check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8 12.4 2.8 2.8L16 9.6"/></symbol>
++<symbol id="ovg-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></symbol>
++<symbol id="ovg-chip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4"/></symbol>
++<symbol id="ovg-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/></symbol>
++<symbol id="ovg-city" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V9h5v12M10 21V4h6v17M16 21v-8h3.5v8M7.5 12h.01M7.5 15.5h.01M13 7.5h.01M13 11h.01M13 14.5h.01"/></symbol>
++<symbol id="ovg-clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/></symbol>
++<symbol id="ovg-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
++<symbol id="ovg-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6 6 18"/></symbol>
++<symbol id="ovg-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/></symbol>
++<symbol id="ovg-coin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-compress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></symbol>
++<symbol id="ovg-computer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></symbol>
++<symbol id="ovg-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-crown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5 7.5 12.5 12 5.5l4.5 7 4.5-4L19 19H5Z"/></symbol>
++<symbol id="ovg-database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-dish" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-dollar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
++<symbol id="ovg-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></symbol>
++<symbol id="ovg-drop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3s6 6.5 6 11a6 6 0 0 1-12 0c0-4.5 6-11 6-11Z"/></symbol>
++<symbol id="ovg-enter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 8l4 4-4 4M14 12H4"/></symbol>
++<symbol id="ovg-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></symbol>
++<symbol id="ovg-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6Z"/><path d="M14 3v4h4"/></symbol>
++<symbol id="ovg-film" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M7 12h10M10 18h4"/></symbol>
++<symbol id="ovg-fire" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-flag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></symbol>
++<symbol id="ovg-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-food" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3v7M5 3v4.5a2 2 0 0 0 4 0V3M7 10v11M17 21V3c-2 1.2-3.2 3.6-3.2 7v3H17"/></symbol>
++<symbol id="ovg-forward" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 5 7 7-7 7"/></symbol>
++<symbol id="ovg-game" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-gavel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-gift" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/></symbol>
++<symbol id="ovg-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-hand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-hash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17"/></symbol>
++<symbol id="ovg-heart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-help" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12a8 8 0 1 0 2.4-5.7L4 8.6M4 4.5v4.1h4.1"/><path d="M12 8v4.2l3 1.8"/></symbol>
++<symbol id="ovg-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z"/></symbol>
++<symbol id="ovg-id" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-idea" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/></symbol>
++<symbol id="ovg-invite" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.7-8.7M16 7l3 3M14 9l2 2"/></symbol>
++<symbol id="ovg-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-language" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3.5 9 5-9 5-9-5Z"/><path d="m3 13 9 5 9-5"/></symbol>
++<symbol id="ovg-leaf" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c0-8.5 5-13.6 15-15-.9 9.9-6 15-15 15ZM5 19l8-8"/></symbol>
++<symbol id="ovg-leave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10"/></symbol>
++<symbol id="ovg-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/></symbol>
++<symbol id="ovg-live" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></symbol>
++<symbol id="ovg-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/></symbol>
++<symbol id="ovg-map" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z"/><path d="M9 4v14M15 6v14"/></symbol>
++<symbol id="ovg-members" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
++<symbol id="ovg-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></symbol>
++<symbol id="ovg-mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></symbol>
++<symbol id="ovg-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"/></symbol>
++<symbol id="ovg-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
++<symbol id="ovg-mountain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 20 9.5 8.5l4 6.5 2.5-3.5L21 20Z"/></symbol>
++<symbol id="ovg-music" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V6l11-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/></symbol>
++<symbol id="ovg-mute" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4Z"/><path d="m16 10 4 4M20 10l-4 4"/></symbol>
++<symbol id="ovg-news" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-nodes" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5v14M16 5v14"/></symbol>
++<symbol id="ovg-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></symbol>
++<symbol id="ovg-phone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-plane" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5v14l11-7Z"/></symbol>
++<symbol id="ovg-plug" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0ZM12 16.5V21"/></symbol>
++<symbol id="ovg-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol>
++<symbol id="ovg-reply" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7 4 12l5 5M4 12h10a6 6 0 0 1 6 6v1"/></symbol>
++<symbol id="ovg-retry" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-robot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="8" width="14" height="11" rx="3"/><path d="M12 4v4M9 13h.01M15 13h.01M9.5 16.5h5M3 13v2M21 13v2"/></symbol>
++<symbol id="ovg-route" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-save" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h11l3 3v13H5Z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/></symbol>
++<symbol id="ovg-scissors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></symbol>
++<symbol id="ovg-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></symbol>
++<symbol id="ovg-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 5 6v6c0 4.4 3 7.6 7 9 4-1.4 7-4.6 7-9V6Z"/></symbol>
++<symbol id="ovg-shirt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-shoe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 17v-5.5l4-.5 3-4 3.2 1.4-.9 2.6 4 2.2 4.7 1a2 2 0 0 1 1.5 1.9V17Z"/><path d="M2.5 20h19"/></symbol>
++<symbol id="ovg-sidebar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15"/></symbol>
++<symbol id="ovg-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-smile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-snow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-steps" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 8.5 7 7"/></symbol>
++<symbol id="ovg-store" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h14l-3.5-3.5M20 16H6l3.5 3.5"/></symbol>
++<symbol id="ovg-tack" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6l-1 5 3 3H7l3-3ZM12 11v10"/></symbol>
++<symbol id="ovg-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V4h8l10 10-8 8Z"/><circle cx="7.5" cy="8.5" r="1.3"/></symbol>
++<symbol id="ovg-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/></symbol>
++<symbol id="ovg-task" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/></symbol>
++<symbol id="ovg-tent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 20h19M12 5 4 20M12 5l8 15M12 13.5 9 20M12 13.5l3 6.5"/></symbol>
++<symbol id="ovg-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></symbol>
++<symbol id="ovg-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 20 11 4h2l5.5 16M7.6 14h8.8"/></symbol>
++<symbol id="ovg-ticket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h18v3a2 2 0 0 0 0 4v3H3v-3a2 2 0 0 0 0-4Z"/><path d="M14 7v10"/></symbol>
++<symbol id="ovg-train" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></symbol>
++<symbol id="ovg-tree" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 6.5 10.5h3L5 17h14l-4.5-6.5h3ZM12 17v4"/></symbol>
++<symbol id="ovg-trophy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 15 6-6 6 6"/></symbol>
++<symbol id="ovg-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></symbol>
++<symbol id="ovg-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5"/><path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6"/></symbol>
++<symbol id="ovg-user-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-video" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/></symbol>
++<symbol id="ovg-volume" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-wallet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-water" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-wifi" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>
++<symbol id="ovg-x-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6M15 9l-6 6"/></symbol>
++<symbol id="ovg-zap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 3 5 13h6l-1 8 8-10h-6Z"/></symbol>
++</svg>
ddiff --git a/ov-glyphs.woff2 b/ov-glyphs.woff2
nnew file mode 100644
iindex 0000000..f02dec3
BBinary files /dev/null and b/ov-glyphs.woff2 differ
ddiff --git a/package.json b/package.json
iindex c69ee66..adb3e48 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.61.0",
++ "version": "4.62.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -97,6 +97,9 @@
"*.js",
"*.css",
"*.woff2",
++ "*.svg",
++ "glyph-codepoints.json",
++ "glyph-fonts/",
"OFL-Nunito.txt",
"analytics/",
"docs/schemas/",
ddiff --git a/scripts/build-glyph-fonts.py b/scripts/build-glyph-fonts.py
nnew file mode 100644
iindex 0000000..aec2d31
---- /dev/null
++++ b/scripts/build-glyph-fonts.py
@@@ -0,0 +1,168 @@
++#!/usr/bin/env python3
++"""
++The OpenVibe glyphs as fonts (plan L32 I5, 4.62.0), built from glyphs.js (the one registry):
++
++ ov-glyphs.woff2 the web font, served at /shared/ov-glyphs.woff2
++ glyph-fonts/ov-glyphs.ttf TrueType, for desktop apps and design tools
++ glyph-fonts/ov-glyphs.otf OpenType (CFF)
++ ov-glyphs-font.css @font-face plus .ovi .ovi-<name> classes
++ ov-glyphs-fa.css Font Awesome's class names on the font, for a site that drops Font Awesome's own
++ glyph-codepoints.json each glyph's Private Use Area code point: given once, never changed or reused
++ glyph-fonts/manifest.json what the fonts were built from (test/glyph-fonts.test.js checks it is current)
++
++A glyph is drawn in strokes; a font holds outlines. Each glyph's strokes (2 units, round caps and joins) are expanded
++to outlines by picosvg and merged into one shape by skia-pathops, so the font draws exactly what the SVG does.
++
++Needs Python 3 with picosvg, fonttools and brotli (picosvg brings skia-pathops):
++ python3 -m venv .venv && .venv/bin/pip install picosvg fonttools brotli
++ .venv/bin/python scripts/build-glyph-fonts.py
++"""
++import hashlib
++import json
++import os
++import subprocess
++import sys
++
++import pathops
++from fontTools.fontBuilder import FontBuilder
++from fontTools.pens.cu2quPen import Cu2QuPen
++from fontTools.pens.t2CharStringPen import T2CharStringPen
++from fontTools.pens.transformPen import TransformPen
++from fontTools.pens.ttGlyphPen import TTGlyphPen
++from fontTools.svgLib.path import parse_path
++from fontTools.ttLib import TTFont
++from picosvg.svg import SVG
++
++ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
++# A fixed build date in the fonts' head table, so building twice gives the same bytes (fontTools reads it).
++os.environ.setdefault('SOURCE_DATE_EPOCH', '1793404800')
++FAMILY = 'OV Glyphs'
++UPM = 1024
++DESCENT = 128 # the icon sits like Font Awesome's: an eighth below the baseline
++SCALE = UPM / 24
++FIRST = 0xE001 # the Private Use Area, from U+E001
++VERSION = json.load(open(os.path.join(ROOT, 'package.json')))['version']
++
++
++def registry():
++ """Every glyph's drawing, and the Font Awesome names each stands for, from glyphs.js."""
++ js = ("const g=require('./glyphs');const fa={};for(const[k,v]of Object.entries(g.FA))(fa[v]=fa[v]||[]).push(k);"
++ "console.log(JSON.stringify({names:g.NAMES,paths:Object.fromEntries(g.NAMES.map(n=>[n,g.path(n)])),fa}))")
++ return json.loads(subprocess.check_output(['node', '-e', js], cwd=ROOT))
++
++
++def codepoints(names):
++ """glyph-codepoints.json: a new glyph gets the next free code point; a code point once given is never reused."""
++ p = os.path.join(ROOT, 'glyph-codepoints.json')
++ cps = json.load(open(p))['glyphs'] if os.path.exists(p) else {}
++ nxt = max([int(v, 16) for v in cps.values()] + [FIRST - 1]) + 1
++ for n in sorted(names):
++ if n not in cps:
++ cps[n] = '%04X' % nxt
++ nxt += 1
++ data = {'about': 'Each OpenVibe glyph\'s code point in the OV Glyphs font (U+E001 up). Given once, never changed or reused.',
++ 'glyphs': dict(sorted(cps.items(), key=lambda kv: int(kv[1], 16)))}
++ with open(p, 'w') as f:
++ f.write(json.dumps(data, indent=2) + '\n')
++ return {n: int(v, 16) for n, v in cps.items()}
++
++
++def outline(inner):
++ """The glyph's strokes as one merged outline (a skia-pathops Path), in the 24-unit grid."""
++ svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="#000" stroke-width="2" '
++ 'stroke-linecap="round" stroke-linejoin="round">' + inner + '</g></svg>')
++ shapes = []
++ for shape in SVG.fromstring(svg).topicosvg().shapes():
++ p = pathops.Path()
++ parse_path(shape.d, p.getPen())
++ shapes.append(p)
++ out = pathops.Path()
++ pathops.union(shapes, out.getPen())
++ return out
++
++
++def to_font(path, pen):
++ """Draws a 24-unit outline into a font pen: y up, the em square, an eighth below the baseline."""
++ path.draw(TransformPen(pen, (SCALE, 0, 0, -SCALE, 0, 24 * SCALE - DESCENT)))
++
++
++def build(flavour, order, cmap, outlines):
++ fb = FontBuilder(UPM, isTTF=(flavour == 'ttf'))
++ fb.setupGlyphOrder(order)
++ fb.setupCharacterMap(cmap)
++ if flavour == 'ttf':
++ glyfs = {'.notdef': TTGlyphPen(None).glyph(), 'space': TTGlyphPen(None).glyph()}
++ for name, path in outlines.items():
++ pen = TTGlyphPen(None)
++ to_font(path, Cu2QuPen(pen, max_err=1, reverse_direction=True))
++ glyfs[name] = pen.glyph()
++ fb.setupGlyf(glyfs)
++ else:
++ charstrings = {}
++ for name in order:
++ pen = T2CharStringPen(UPM, None)
++ if name in outlines:
++ to_font(outlines[name], pen)
++ charstrings[name] = pen.getCharString()
++ fb.setupCFF(FAMILY.replace(' ', ''), {'FullName': FAMILY}, charstrings, {})
++ fb.setupHorizontalMetrics({n: (UPM, 0) for n in order})
++ fb.setupHorizontalHeader(ascent=UPM - DESCENT, descent=-DESCENT)
++ fb.setupNameTable({'familyName': FAMILY, 'styleName': 'Regular', 'uniqueFontIdentifier': f'OpenVibe:{FAMILY}:{VERSION}',
++ 'fullName': FAMILY, 'psName': FAMILY.replace(' ', '') + '-Regular', 'version': f'Version {VERSION}',
++ 'copyright': 'OpenVibe glyphs, MIT licence. https://openvibe.design/icons',
++ 'licenseDescription': 'MIT', 'licenseInfoURL': 'https://opensource.org/license/mit'})
++ fb.setupOS2(sTypoAscender=UPM - DESCENT, sTypoDescender=-DESCENT, sTypoLineGap=0, usWinAscent=UPM - DESCENT,
++ usWinDescent=DESCENT, achVendID='OVIB', fsType=0)
++ fb.setupPost(keepGlyphNames=True)
++ return fb.font
++
++
++def main():
++ reg = registry()
++ names = reg['names']
++ cps = codepoints(names)
++ outlines = {n: outline(reg['paths'][n]) for n in names}
++ order = ['.notdef', 'space'] + names
++ cmap = {0x20: 'space', **{cps[n]: n for n in names}}
++
++ os.makedirs(os.path.join(ROOT, 'glyph-fonts'), exist_ok=True)
++ ttf = build('ttf', order, cmap, outlines)
++ ttf.save(os.path.join(ROOT, 'glyph-fonts', 'ov-glyphs.ttf'))
++ build('otf', order, cmap, outlines).save(os.path.join(ROOT, 'glyph-fonts', 'ov-glyphs.otf'))
++ web = TTFont(os.path.join(ROOT, 'glyph-fonts', 'ov-glyphs.ttf'))
++ web.flavor = 'woff2'
++ web.save(os.path.join(ROOT, 'ov-glyphs.woff2'))
++
++ esc = lambda n: '\\' + ('%04x' % cps[n])
++ head = '/* Generated by scripts/build-glyph-fonts.py from glyphs.js (openvibe-shared, MIT): edit those, then run it. */\n'
++ face = ("@font-face{font-family:'OV Glyphs';src:url(ov-glyphs.woff2) format('woff2');font-weight:400;font-style:normal;font-display:block}\n")
++ base = ("{display:inline-block;font-family:'OV Glyphs';font-style:normal;font-weight:400;font-variant:normal;line-height:1;"
++ "text-rendering:auto;text-transform:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:never}\n")
++ font_css = (head + '/* <i class="ovi ovi-eye"></i>: the OpenVibe glyphs as a font. Sizes follow the text. */\n' + face
++ + '.ovi' + base + '.ovi-fw{width:1.25em;text-align:center}\n'
++ + ''.join(f'.ovi-{n}::before{{content:"{esc(n)}"}}\n' for n in names))
++ with open(os.path.join(ROOT, 'ov-glyphs-font.css'), 'w') as f:
++ f.write(font_css)
++ fa_rules = []
++ for n in names:
++ for fa in sorted(reg['fa'].get(n, [])):
++ fa_rules.append(f'.fa-{fa}::before{{content:"{esc(n)}"}}')
++ fa_css = (head + "/* Font Awesome's class names on the OV Glyphs font, for a site that drops Font Awesome's stylesheet and its\n"
++ " font. A name with no glyph shows nothing, so load it only where every name a page uses is covered\n"
++ " (openvibe.design/icons lists them); brand logos keep Font Awesome Brands. */\n" + face
++ + '.fa,.fas,.far,.fal,.fat,.fa-solid,.fa-regular,.fa-light,.fa-thin,.fa-duotone' + base
++ + '.fa-fw{width:1.25em;text-align:center}\n' + '\n'.join(fa_rules) + '\n')
++ with open(os.path.join(ROOT, 'ov-glyphs-fa.css'), 'w') as f:
++ f.write(fa_css)
++
++ source = json.dumps([[n, reg['paths'][n], '%04X' % cps[n]] for n in names], separators=(',', ':'), ensure_ascii=False)
++ manifest = {'family': FAMILY, 'glyphs': len(names), 'source_sha256': hashlib.sha256(source.encode('utf8')).hexdigest(),
++ 'files': {p: os.path.getsize(os.path.join(ROOT, p)) for p in
++ ['ov-glyphs.woff2', 'glyph-fonts/ov-glyphs.ttf', 'glyph-fonts/ov-glyphs.otf']}}
++ with open(os.path.join(ROOT, 'glyph-fonts', 'manifest.json'), 'w') as f:
++ f.write(json.dumps(manifest, indent=2) + '\n')
++ print(f"{len(names)} glyphs: " + ', '.join(f'{k} {v / 1024:.1f} KB' for k, v in manifest['files'].items()))
++
++
++if __name__ == '__main__':
++ sys.exit(main())
ddiff --git a/scripts/build-glyphs.js b/scripts/build-glyphs.js
iindex 0b16b91..873417f 100644
---- a/scripts/build-glyphs.js
++++ b/scripts/build-glyphs.js
@@@ -1,8 +1,9 @@
#!/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.
++// Writes ov-glyphs.js, the page drop-in that swaps Font Awesome icons for the OpenVibe glyphs, and ov-glyphs.svg, the
++// sprite (<symbol id="ovg-eye">), 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 both files are current). Only the glyphs some Font Awesome
++// name stands for go in the drop-in, so it carries no drawing it cannot use; the sprite has every glyph.
const fs = require('fs');
const path = require('path');
const glyphs = require('../glyphs');
@@@ -11,6 +12,55 @@ 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)));
++// Font Awesome's features on a swapped icon (4.62.0), with Font Awesome's own class names and CSS variables, so a site
++// can drop Font Awesome's stylesheet and keep its markup. Rules about the icon itself are scoped to .ovg; the wrappers
++// (fa-ul, fa-stack, fa-layers) carry Font Awesome's own values. Nothing spins (plan L27): fa-spin breathes instead.
++const anim = (cls, name, timing, dur = '1s') => `.ovg.${cls}{animation:${name} var(--fa-animation-duration,${dur}) var(--fa-animation-timing,${timing}) var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite) var(--fa-animation-direction,normal)}`;
++const CSS = [
++ // The icon: Font Awesome's glyph off, ours in the text's colour and size, one drawing in four weights.
++ '.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>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:var(--ovg-stroke,2);stroke-linecap:round;stroke-linejoin:round;overflow:visible}',
++ '.ovg.fa-thin{--ovg-stroke:1}.ovg.fa-light{--ovg-stroke:1.5}.ovg.ovg-bold{--ovg-stroke:2.5}.ovg.fa-duotone>svg{stroke:var(--fa-primary-color,currentColor)}',
++ // Sizes, fixed width, borders and pulls.
++ '.ovg.fa-2xs{font-size:.625em}.ovg.fa-xs{font-size:.75em}.ovg.fa-sm{font-size:.875em}.ovg.fa-lg{font-size:1.25em}.ovg.fa-xl{font-size:1.5em}.ovg.fa-2xl{font-size:2em}',
++ Array.from({ length: 10 }, (_, i) => `.ovg.fa-${i + 1}x{font-size:${i + 1}em}`).join(''),
++ '.ovg.fa-fw{width:1.25em;text-align:center}',
++ '.ovg.fa-border{box-sizing:content-box;border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);border-radius:var(--fa-border-radius,.1em);padding:var(--fa-border-padding,.2em .25em .15em)}',
++ '.ovg.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.ovg.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}',
++ // Turns and flips act on the drawing, so they combine with an animation on the icon.
++ '.ovg.fa-rotate-90>svg{transform:rotate(90deg)}.ovg.fa-rotate-180>svg{transform:rotate(180deg)}.ovg.fa-rotate-270>svg{transform:rotate(270deg)}.ovg.fa-rotate-by>svg{transform:rotate(var(--fa-rotate-angle,0))}',
++ '.ovg.fa-flip-horizontal>svg{transform:scale(-1,1)}.ovg.fa-flip-vertical>svg{transform:scale(1,-1)}.ovg.fa-flip-both>svg{transform:scale(-1,-1)}',
++ // Lists, stacks and layers (Font Awesome's wrappers).
++ '.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{position:absolute;left:calc(var(--fa-li-width,2em)*-1);width:var(--fa-li-width,2em);text-align:center;line-height:inherit}',
++ '.fa-stack{display:inline-block;position:relative;width:2.5em;height:2em;line-height:2em;vertical-align:middle}.fa-stack>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}.fa-stack>.ovg.fa-stack-2x{font-size:2em}.ovg.fa-inverse{color:var(--fa-inverse,#fff)}',
++ '.fa-layers{display:inline-block;position:relative;width:1em;height:1em;text-align:center;vertical-align:-.125em}.fa-layers>.ovg{position:absolute;inset:0;margin:auto;width:1em;height:1em}',
++ '.fa-layers-text{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:inline-block}',
++ '.fa-layers-counter{position:absolute;top:var(--fa-top,0);right:var(--fa-right,0);display:inline-block;box-sizing:border-box;min-width:var(--fa-counter-min-width,1.5em);max-width:var(--fa-counter-max-width,5em);padding:var(--fa-counter-padding,.25em .5em);border-radius:var(--fa-counter-border-radius,1em);background:var(--fa-counter-background-color,#ff253a);color:var(--fa-inverse,#fff);line-height:var(--fa-counter-line-height,1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transform:scale(var(--fa-counter-scale,.25));transform-origin:top right}',
++ // Animations, with Font Awesome's variables and timings.
++ anim('fa-beat', 'ovgBeat', 'ease-in-out'),
++ anim('fa-bounce', 'ovgBounce', 'cubic-bezier(.28,.84,.42,1)'),
++ anim('fa-fade', 'ovgFade', 'cubic-bezier(.4,0,.6,1)'),
++ anim('fa-beat-fade', 'ovgBeatFade', 'cubic-bezier(.4,0,.6,1)'),
++ anim('fa-flip', 'ovgFlip', 'ease-in-out'),
++ anim('fa-shake', 'ovgShake', 'linear'),
++ '.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe var(--fa-animation-duration,1.6s) ease-in-out var(--fa-animation-delay,0s) var(--fa-animation-iteration-count,infinite)!important}',
++ '.ovg[data-ovg=busy].fa-spin,.ovg[data-ovg=busy].fa-pulse,.ovg[data-ovg=busy].fa-spin-pulse{animation:none!important}',
++ '@keyframes ovgBeat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}',
++ '@keyframes ovgBounce{0%{transform:scale(1,1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1,1) translateY(var(--fa-bounce-rebound,-.125em))}64%,100%{transform:scale(1,1) translateY(0)}}',
++ '@keyframes ovgFade{50%{opacity:var(--fa-fade-opacity,.4)}}',
++ '@keyframes ovgBeatFade{0%,100%{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}',
++ '@keyframes ovgFlip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}',
++ '@keyframes ovgShake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,100%{transform:rotate(0)}}',
++ '@keyframes ovgBreathe{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.9)}}',
++ // The busy dots take turns.
++ '.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}}',
++ // Reduced motion: nothing moves.
++ '@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle{animation:none!important}}html[data-ov-motion=reduced] .ovg,html[data-ov-motion=reduced] .ovg[data-ovg=busy] circle{animation:none!important}',
++].join('');
++
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
@@@ -18,6 +68,12 @@ const out = `/* Generated by scripts/build-glyphs.js from glyphs.js: edit those,
* (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.
*
++ * Font Awesome's features keep working without its stylesheet (4.62.0): sizes (fa-2xs … fa-2xl, fa-1x … fa-10x), fa-fw,
++ * fa-ul/fa-li, fa-border, fa-pull-*, fa-rotate-* and fa-flip-*, fa-stack, fa-layers with counters and text, the
++ * animations (fa-beat, fa-bounce, fa-fade, fa-beat-fade, fa-flip, fa-shake; fa-spin breathes), fa-thin and fa-light
++ * as thinner strokes, data-fa-transform (grow, shrink, up, down, left, right, rotate, flip-h, flip-v), and a title that
++ * names the icon to screen readers. Every variable Font Awesome documents (--fa-beat-scale …) is honoured.
++ *
* <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
@@@ -29,30 +85,44 @@ const out = `/* Generated by scripts/build-glyphs.js from glyphs.js: edit those,
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)$/;
++ // Font Awesome's style, size, layout and animation classes: they say how, not what.
++ const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|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|layers|layers-text|layers-counter|pull-left|pull-right|rotate-[0-9]+|rotate-by|flip-horizontal|flip-vertical|flip-both|swap-opacity|width-auto|width-fixed)$/;
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 CSS = ${JSON.stringify(CSS)};
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; };
++ // data-fa-transform, in Font Awesome's units (a sixteenth of the icon).
++ const TF = /^(grow|shrink|up|down|left|right|rotate)-(-?[0-9.]+)$|^flip-(h|v)$/;
++ function transformOf(spec) {
++ let s = 1, x = 0, y = 0, r = 0, fx = 1, fy = 1, any = false;
++ for (const p of String(spec || '').toLowerCase().split(/\\s+/)) {
++ const m = TF.exec(p);
++ if (!m) continue;
++ any = true;
++ if (m[3]) { if (m[3] === 'h') fx = -1; else fy = -1; continue; }
++ const n = Number(m[2]);
++ if (m[1] === 'grow') s += n / 16; else if (m[1] === 'shrink') s -= n / 16;
++ else if (m[1] === 'up') y -= n / 16; else if (m[1] === 'down') y += n / 16;
++ else if (m[1] === 'left') x -= n / 16; else if (m[1] === 'right') x += n / 16; else r += n;
++ }
++ return any ? 'translate(' + x + 'em,' + y + 'em) rotate(' + r + 'deg) scale(' + (fx * s) + ',' + (fy * s) + ')' : '';
++ }
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 (!g) { if (el.dataset.ovg) { el.classList.remove('ovg'); delete el.dataset.ovg; delete el.dataset.ovgFa; delete el.dataset.ovgTf; el.textContent = ''; } return; }
++ const tf = el.getAttribute('data-fa-transform') || '';
++ if (el.dataset.ovgFa === fa && (el.dataset.ovgTf || '') === tf) { if (!el.classList.contains('ovg')) el.classList.add('ovg'); return; } // adding a class it has would observe itself forever
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>';
++ if (tf) el.dataset.ovgTf = tf; else delete el.dataset.ovgTf;
++ const title = el.getAttribute('title');
++ if (title && !el.hasAttribute('aria-label')) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', title); }
++ else if (!el.hasAttribute('aria-label') && !title) el.setAttribute('aria-hidden', 'true');
++ const t = transformOf(tf);
++ el.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"' + (t ? ' style="transform:' + t + '"' : '') + '>' + (g === 'busy' ? BUSY : G[g]) + '</svg>';
}
const SEL = 'i[class*="fa-"],span[class*="fa-"]';
function swap(root) {
@@@ -69,13 +139,20 @@ const out = `/* Generated by scripts/build-glyphs.js from glyphs.js: edit those,
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'] });
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'data-fa-transform'] });
} 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 };
++
++// The sprite: every glyph as a <symbol>, for <svg><use href="/shared/ov-glyphs.svg#ovg-eye"/></svg>.
++const sprite = `<svg xmlns="http://www.w3.org/2000/svg">\n<!-- The OpenVibe glyphs (openvibe-shared, MIT): <svg><use href="ov-glyphs.svg#ovg-eye"/></svg>. Generated by scripts/build-glyphs.js. -->\n${glyphs.NAMES.map((n) => `<symbol id="ovg-${n}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${glyphs.path(n)}</symbol>`).join('\n')}\n</svg>\n`;
++
++if (require.main === module) {
++ fs.writeFileSync(path.join(__dirname, '..', 'ov-glyphs.js'), out);
++ fs.writeFileSync(path.join(__dirname, '..', 'ov-glyphs.svg'), sprite);
++ console.log(`ov-glyphs.js: ${used.length} glyphs for ${Object.keys(FA).length} Font Awesome names, ${(out.length / 1024).toFixed(1)} KB; ov-glyphs.svg: ${glyphs.NAMES.length} symbols, ${(sprite.length / 1024).toFixed(1)} KB`);
++}
++module.exports = { build: () => out, sprite: () => sprite, CSS };
ddiff --git a/serve.js b/serve.js
iindex 2bc3964..c6500a2 100644
---- a/serve.js
++++ b/serve.js
@@@ -80,6 +80,7 @@ function contentType(name) {
if (name.endsWith('.jpg')) return 'image/jpeg';
if (name.endsWith('.png')) return 'image/png';
if (name.endsWith('.woff2')) return 'font/woff2';
++ if (name.endsWith('.svg')) return 'image/svg+xml; charset=utf-8';
return 'application/javascript; charset=utf-8';
}
ddiff --git a/test/glyph-fonts.test.js b/test/glyph-fonts.test.js
nnew file mode 100644
iindex 0000000..d5d1205
---- /dev/null
++++ b/test/glyph-fonts.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The glyphs as fonts (4.62.0, plan L32 I5): built by scripts/build-glyph-fonts.py from glyphs.js. The fonts are
++// current (the manifest names exactly what they were drawn from), every glyph has its own code point in the Private
++// Use Area that never changes, the CSS names every glyph and every Font Awesome name, and sites serve the web font,
++// both style sheets and the sprite.
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const glyphs = require('../glyphs');
++const files = require('../files');
++
++const ROOT = path.join(__dirname, '..');
++const read = (f) => fs.readFileSync(path.join(ROOT, f));
++const cps = JSON.parse(read('glyph-codepoints.json')).glyphs;
++const manifest = JSON.parse(read('glyph-fonts/manifest.json'));
++
++// Every glyph has a code point; none is shared; all sit in the Private Use Area from U+E001.
++for (const n of glyphs.NAMES) assert.ok(/^[0-9A-F]{4}$/.test(cps[n] || ''), `${n} has a code point`);
++const values = Object.values(cps);
++assert.strictEqual(new Set(values).size, values.length, 'no code point is given twice');
++values.forEach((v) => assert.ok(parseInt(v, 16) >= 0xE001 && parseInt(v, 16) <= 0xF8FF, `U+${v} is in the Private Use Area`));
++
++// The fonts were built from today's registry (run scripts/build-glyph-fonts.py after a glyph changes).
++const source = JSON.stringify(glyphs.NAMES.map((n) => [n, glyphs.path(n), cps[n]]));
++assert.strictEqual(manifest.source_sha256, crypto.createHash('sha256').update(source).digest('hex'), 'the fonts are current: run scripts/build-glyph-fonts.py');
++assert.strictEqual(manifest.glyphs, glyphs.NAMES.length);
++for (const [f, size] of Object.entries(manifest.files)) assert.strictEqual(read(f).length, size, `${f} is the file the build wrote`);
++assert.strictEqual(read('ov-glyphs.woff2').subarray(0, 4).toString('latin1'), 'wOF2', 'a WOFF2 file');
++assert.strictEqual(read('glyph-fonts/ov-glyphs.ttf').readUInt32BE(0), 0x00010000, 'a TrueType file');
++assert.strictEqual(read('glyph-fonts/ov-glyphs.otf').subarray(0, 4).toString('latin1'), 'OTTO', 'an OpenType (CFF) file');
++
++// The style sheets.
++const font = read('ov-glyphs-font.css').toString();
++assert.ok(font.includes("@font-face{font-family:'OV Glyphs';src:url(ov-glyphs.woff2) format('woff2')"));
++for (const n of glyphs.NAMES) assert.ok(font.includes(`.ovi-${n}::before{content:"\\${cps[n].toLowerCase()}"}`), `.ovi-${n}`);
++const fa = read('ov-glyphs-fa.css').toString();
++for (const [name, g] of Object.entries(glyphs.FA)) assert.ok(fa.includes(`.fa-${name}::before{content:"\\${cps[g].toLowerCase()}"}`), `.fa-${name} → ${g}`);
++assert.ok(!/fa-brands|\.fab\b/.test(fa), 'brand logos stay Font Awesome Brands');
++
++// Sites serve them.
++for (const f of ['ov-glyphs.woff2', 'ov-glyphs-font.css', 'ov-glyphs-fa.css', 'ov-glyphs.svg']) assert.ok(files.isBrowserFile(f), `/shared/${f}`);
++assert.ok(files.FONTS.includes('ov-glyphs.woff2') && files.IMAGES.includes('ov-glyphs.svg'));
++
++console.log(`glyph fonts: ${glyphs.NAMES.length} glyphs at U+${values.reduce((a, b) => (a < b ? a : b))}–U+${values.reduce((a, b) => (a > b ? a : b))}; WOFF2, TTF and OTF current; both style sheets; served`);
ddiff --git a/test/glyphs.test.js b/test/glyphs.test.js
iindex 931e108..57a0263 100644
---- a/test/glyphs.test.js
++++ b/test/glyphs.test.js
@@@ -34,9 +34,25 @@ for (const fa of ['wand-magic-sparkles', 'eye', 'tower-broadcast', 'xmark', 'vid
}
// ── The drop-in is the registry's, current ──
--const built = require('../scripts/build-glyphs').build();
++const builder = require('../scripts/build-glyphs');
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');
++assert.strictEqual(SRC, builder.build(), 'ov-glyphs.js is current: run node scripts/build-glyphs.js');
++const SPRITE = fs.readFileSync(path.join(__dirname, '..', 'ov-glyphs.svg'), 'utf8');
++assert.strictEqual(SPRITE, builder.sprite(), 'ov-glyphs.svg is current: run node scripts/build-glyphs.js');
++assert.strictEqual((SPRITE.match(/<symbol id="ovg-/g) || []).length, glyphs.NAMES.length, 'the sprite has every glyph');
++assert.ok(SPRITE.includes(`<symbol id="ovg-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${glyphs.path('eye')}</symbol>`));
++
++// Font Awesome's features, kept (4.62.0): every documented class has a rule, with Font Awesome's own variables.
++const CSS = builder.CSS;
++const ON_ICON = ['fa-2xs', 'fa-xs', 'fa-sm', 'fa-lg', 'fa-xl', 'fa-2xl', 'fa-1x', 'fa-10x', 'fa-fw', 'fa-border', 'fa-pull-left', 'fa-pull-right',
++ 'fa-rotate-90', 'fa-rotate-180', 'fa-rotate-270', 'fa-rotate-by', 'fa-flip-horizontal', 'fa-flip-vertical', 'fa-flip-both', 'fa-inverse',
++ 'fa-beat', 'fa-bounce', 'fa-fade', 'fa-beat-fade', 'fa-flip', 'fa-shake', 'fa-spin', 'fa-thin', 'fa-light', 'fa-duotone'];
++ON_ICON.forEach((cls) => assert.ok(CSS.includes(`.ovg.${cls}{`) || CSS.includes(`.ovg.${cls}>svg{`) || CSS.includes(`.ovg.${cls},`), `.${cls} on a swapped icon`));
++['fa-ul', 'fa-li', 'fa-stack', 'fa-layers', 'fa-layers-text', 'fa-layers-counter'].forEach((cls) => assert.ok(CSS.includes(`.${cls}{`), `.${cls}, Font Awesome's wrapper`));
++['--fa-beat-scale', '--fa-bounce-height', '--fa-fade-opacity', '--fa-flip-angle', '--fa-rotate-angle', '--fa-animation-duration', '--fa-li-width',
++ '--fa-border-color', '--fa-counter-background-color', '--fa-primary-color', '--fa-inverse'].forEach((v) => assert.ok(CSS.includes(v), `Font Awesome's ${v}`));
++assert.ok(!CSS.includes('360deg') && /\.ovg\.fa-spin[^{]*\{animation:ovgBreathe/.test(CSS), 'fa-spin breathes: nothing spins (L27)');
++assert.ok(CSS.includes('@media (prefers-reduced-motion:reduce){.ovg,.ovg[data-ovg=busy] circle{animation:none!important}}'), 'reduced motion stops every animation');
// ── In a page ──
(async () => {
@@@ -47,6 +63,9 @@ assert.strictEqual(SRC, built, 'ov-glyphs.js is current: run node scripts/build-
<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>
++<i class="fa-solid fa-heart fa-2x fa-beat" data-fa-transform="grow-4 up-2 rotate-90 flip-h" id="tf"></i>
++<i class="fa-solid fa-bell" title="Notifications" id="titled"></i>
++<span class="fa-layers"><i class="fa-solid fa-envelope"></i><span class="fa-layers-counter" id="counter">12</span></span>
</body></html>`);
window.MutationObserver = MutationObserver;
new Function('window', 'document', 'MutationObserver', SRC)(window, document, MutationObserver);
@@@ -58,13 +77,26 @@ assert.strictEqual(SRC, built, 'ov-glyphs.js is current: run node scripts/build-
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");
++ assert.ok(css.includes('.ovg::before,.ovg::after{content:none!important') && css.includes('.ovg.fa-spin,.ovg.fa-pulse,.ovg.fa-spin-pulse{animation:ovgBreathe'), "Font Awesome's own glyph is off on a swapped icon, and its spin breathes");
++ // data-fa-transform: Font Awesome's sixteenths, on the drawing; size and animation classes stay on the icon.
++ const tf = $('#tf');
++ assert.ok(tf.classList.contains('ovg') && tf.classList.contains('fa-2x') && tf.classList.contains('fa-beat'));
++ assert.strictEqual(tf.querySelector('svg').getAttribute('style'), 'transform:translate(0em,-0.125em) rotate(90deg) scale(-1.25,1.25)');
++ // A title names the icon to screen readers.
++ assert.ok($('#titled').getAttribute('role') === 'img' && $('#titled').getAttribute('aria-label') === 'Notifications' && !$('#titled').hasAttribute('aria-hidden'));
++ // A layer's counter is text, not an icon.
++ assert.ok(!$('#counter').classList.contains('ovg') && $('#counter').textContent === '12');
++ assert.ok($('.fa-layers > i').dataset.ovg, 'the layer\'s icon is swapped');
// 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');
++ // A page that resets the class list but keeps the name keeps the glyph (the swap class comes back).
++ t.className = 'fa-solid fa-pause is-on';
++ await new Promise((r) => setImmediate(r));
++ assert.ok(t.classList.contains('ovg') && t.dataset.ovg === 'pause', 'the swap class comes back after a reset');
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');