openvibers/openvibe.shared Commits

The OpenVibe mark is the Pair, and the names are set in Nunito (4.59.0) (#211)

OP OpenVibers committed 13 days ago · 4a746c1726 · 93 files, +693 −206

* The OpenVibe mark is the Pair, and the names are set in Nunito (4.59.0)

The owner chose "Pair, halo" as the new logo: a ring O in blue and a round V in violet, with a gap of the ground
around the V where it crosses the ring (a mask, so it sits on any ground). The V is as tall as the O and the mark
is centred in its box, so it sits level beside a name and centred in a tile.

- openvibe-shared/mark (new): the mark as static SVG for servers, icons, share cards and anyone else.
- ov-mark.js draws the same numbers: the pair and the heavy forms, a wide box, ink and white tones, and every
  state remade for it (the V becomes a tick, "!" or "x", the gap following it; the ring is the page-load progress).
- The brand face: Nunito, cut to Latin 700-900 (ov-brand.woff2, 13.5 KB, SIL OFL), served at /shared/ as a font
  (files.FONTS), declared by navbar.js and used for the site name, the footer, the switcher and the share cards.
- App icons, favicons, share cards and the loading screen are the Pair; icons/ and social/ rebuilt.

* OVMark.intro(el) replays the intro on one mark

* The season's colour is as weak as the mark's base colour, so a page's own colour wins

* On a phone the mark and the name are a little smaller, so the site's word reads whole beside the balances

* Up to 480px the brand uses the small-phone sizes, so the site's word reads whole in any fallback face

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6bf86e3..557fa3e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,40 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 4.59.0 — 2026-10-31
++
++- **The OpenVibe mark is the Pair** (the owner, from OpenVibe Mark Systems: "Pair, halo … I like this icon the most.
++ This should be our new logo"): a ring O in blue and a round V in violet, with a gap of the ground around the V
++ where it crosses the ring. The gap is a mask, so the mark sits on any ground. The V is as tall as the O and the mark
++ is centred in its box, so it sits level beside a name in the navbar and centred in a tile.
++ - **`openvibe-shared/mark`** (new): the mark as static SVG for servers, icons, share cards and anyone else:
++ `svg({ form, tone, wide, size, title })`, `art()` to place it inside another SVG, `geo()`, `shapes()`, `COLORS`.
++ - **`ov-mark.js`** draws the same numbers (`test/mark.test.js`). Two forms, the pair and the heavy (22 px wide and
++ under; the old `data-form="solid"` draws the heavy). `data-wide` gives a box that hugs the mark; `data-tone="ink"`
++ or `"white"` gives one colour.
++ - **Every state remade for it, none of them a spin:** the intro pops the O and grows the V out of its point; hover
++ leans the V back; busy bobs the V while the O breathes; ok turns the V into a tick, green; error into "!", red,
++ with a shake; no into "×", grey; notify pops a dot by the V; live makes the O the record light with ripples; sleep
++ closes the O like an eye. The V's shapes morph, and the gap in the ring follows them.
++ - **The page-load progress is the ring:** following a link, the navbar's ring empties and draws itself round again
++ at this site's learned pace (the same pace as 4.58.0), the V nodding; on arrival the ring closes and the V hops.
++ `data-progress` (island.js) draws the ring to its level. `OVMark.intro(el)` replays the intro on one mark (a
++ brand page, a splash).
++ - **Variants:** Live, VIP, Tips, Deals and Coupons carry a light in the O (Live's red record light). **The season:**
++ the O is a pumpkin with its stem and leaf in October, with caps of snow in December.
++ - **The navbar** shows the mark in the wide box (`data-size="34" data-wide`), the brand button hugging it; the footer
++ at 38 (28 small).
++- **The brand face for the names: Nunito** (SIL OFL 1.1, `OFL-Nunito.txt`), cut to Latin and weights 700–900:
++ `ov-brand.woff2`, 13.5 KB. A new `files.FONTS` list serves it at `/shared/ov-brand.woff2` (`serve.js`: `font/woff2`,
++ CORS as for every shared file); it is not a browser script, so the size report and the bundle test do not count it.
++ `navbar.js` declares it from beside itself (`font-display: swap`, its characters only) and sets the site name in
++ it; the footer's brand and the site switcher's names use it; every one falls back to the system face. A site with
++ its own `/shared` route serves `files.FONTS` beside `files.BROWSER` (`test/brand-face.test.js`).
++- **App icons, favicons and share cards** are the Pair: `app-icon.js` draws it (the heavy on the favicon's tile; the
++ maskable icon keeps it inside the 40 % safe circle), and `icons/*.png` and `social/*.jpg` are rebuilt; the cards set
++ the names in the brand face. **The loading screen** (`loading.js`) shows the Pair at work, and its V becomes "!"
++ when loading fails.
++
## 4.58.0 — 2026-10-31
- **The OpenVibe mark is the Loop** (the owner chose it from OpenVibe Mark Systems: "use the one that's easiest to see
ddiff --git a/OFL-Nunito.txt b/OFL-Nunito.txt
nnew file mode 100644
iindex 0000000..c8210f0
---- /dev/null
++++ b/OFL-Nunito.txt
@@@ -0,0 +1,93 @@
++Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
++
++This Font Software is licensed under the SIL Open Font License, Version 1.1.
++This license is copied below, and is also available with a FAQ at:
++http://scripts.sil.org/OFL
++
++
++-----------------------------------------------------------
++SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
++-----------------------------------------------------------
++
++PREAMBLE
++The goals of the Open Font License (OFL) are to stimulate worldwide
++development of collaborative font projects, to support the font creation
++efforts of academic and linguistic communities, and to provide a free and
++open framework in which fonts may be shared and improved in partnership
++with others.
++
++The OFL allows the licensed fonts to be used, studied, modified and
++redistributed freely as long as they are not sold by themselves. The
++fonts, including any derivative works, can be bundled, embedded,
++redistributed and/or sold with any software provided that any reserved
++names are not used by derivative works. The fonts and derivatives,
++however, cannot be released under any other type of license. The
++requirement for fonts to remain under this license does not apply
++to any document created using the fonts or their derivatives.
++
++DEFINITIONS
++"Font Software" refers to the set of files released by the Copyright
++Holder(s) under this license and clearly marked as such. This may
++include source files, build scripts and documentation.
++
++"Reserved Font Name" refers to any names specified as such after the
++copyright statement(s).
++
++"Original Version" refers to the collection of Font Software components as
++distributed by the Copyright Holder(s).
++
++"Modified Version" refers to any derivative made by adding to, deleting,
++or substituting -- in part or in whole -- any of the components of the
++Original Version, by changing formats or by porting the Font Software to a
++new environment.
++
++"Author" refers to any designer, engineer, programmer, technical
++writer or other person who contributed to the Font Software.
++
++PERMISSION & CONDITIONS
++Permission is hereby granted, free of charge, to any person obtaining
++a copy of the Font Software, to use, study, copy, merge, embed, modify,
++redistribute, and sell modified and unmodified copies of the Font
++Software, subject to the following conditions:
++
++1) Neither the Font Software nor any of its individual components,
++in Original or Modified Versions, may be sold by itself.
++
++2) Original or Modified Versions of the Font Software may be bundled,
++redistributed and/or sold with any software, provided that each copy
++contains the above copyright notice and this license. These can be
++included either as stand-alone text files, human-readable headers or
++in the appropriate machine-readable metadata fields within text or
++binary files as long as those fields can be easily viewed by the user.
++
++3) No Modified Version of the Font Software may use the Reserved Font
++Name(s) unless explicit written permission is granted by the corresponding
++Copyright Holder. This restriction only applies to the primary font name as
++presented to the users.
++
++4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
++Software shall not be used to promote, endorse or advertise any
++Modified Version, except to acknowledge the contribution(s) of the
++Copyright Holder(s) and the Author(s) or with their explicit written
++permission.
++
++5) The Font Software, modified or unmodified, in part or in whole,
++must be distributed entirely under this license, and must not be
++distributed under any other license. The requirement for fonts to
++remain under this license does not apply to any document created
++using the Font Software.
++
++TERMINATION
++This license becomes null and void if any of the above conditions are
++not met.
++
++DISCLAIMER
++THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
++EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
++MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
++OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
++COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
++INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
++DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
++FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
++OTHER DEALINGS IN THE FONT SOFTWARE.
ddiff --git a/README.md b/README.md
iindex abf30e1..05418ee 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.58.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.59.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.
@@@ -20,7 +20,8 @@ were reconciled first.
| Kind | Files |
|---|---|
| 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` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `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()` |
++| Fonts, served at `/shared/<file>` (`files.FONTS`) | `ov-brand.woff2`: the brand face the names are set in (Nunito, SIL OFL 1.1, `OFL-Nunito.txt`; Latin, 700–900), declared by `navbar.js` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `mark` (the OpenVibe mark, the Pair, as static SVG: `svg({ form, tone, wide, size, title })`, `art()`, `geo()`, `shapes()`, `COLORS`; the browser drop-in `ov-mark.js` draws the same numbers), `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
ddiff --git a/STATUS.json b/STATUS.json
iindex 61b153b..774ef4e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.58.0",
++ "version": "4.59.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/app-icon.js b/app-icon.js
iindex e357bc2..821cfb0 100644
---- a/app-icon.js
++++ b/app-icon.js
@@@ -2,14 +2,14 @@
// ═══════════════════════════════════════════════════════════════
// openvibe-shared/app-icon — one app icon design for every OpenVibe site.
//
--// The icon is the OV mark as it appears in the navbar (the open loop and the site's dot) on the network's
--// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.
++// The icon is the OpenVibe mark (openvibe-shared/mark: the Pair, a ring O and a round V) on the network's deep navy,
++// with a soft glow behind it. No text: a home-screen icon is a few millimetres wide.
// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius
// safe zone, so round, squircle and teardrop masks never clip it.
--// favicon({ site }) the same mark on a rounded tile, tuned for 16–32 px.
++// favicon({ site }) the heavy form on a rounded tile, tuned for 16–32 px.
// manifest({ site, name, shortName, description, startUrl }) web app manifest with shared colours.
--// SITES accent + dot colour per site (the dot is the one thing that differs,
--// matching ov-mark.js variants: Live is "on air" red).
++// SITES accent (the glow and the share cards) and the light in the O per site: Live's is the
++// red record light, as in ov-mark.js's variants; white means none.
// PNG sets are rendered from these by scripts/build-app-icons.js.
// ═══════════════════════════════════════════════════════════════
const BG = '#0a0f1c', BG_EDGE = '#060912';
@@@ -25,33 +25,34 @@ const SITES = {
};
const of = (site) => SITES[site] || SITES.network;
--function mark(site, scale) {
-- // Drawn in a 48-unit box centred on 0,0 (ov-mark.js's open loop moved by -24,-24), then scaled.
-- const c = of(site);
-- return `<g transform="scale(${scale})">
--<circle r="22" fill="url(#glow)"/>
--<path d="M-8.8,-15.2 A17.5,17.5 0 1,0 8.8,-15.2 L0,6.5 Z" fill="none" stroke="url(#ring)" stroke-width="5.6" stroke-linecap="round" stroke-linejoin="round"/>
--<circle cy="-16.6" r="3" fill="${c.dot}"/></g>`;
++const MARK = require('./mark');
++
++// The mark centred on 0,0 (openvibe-shared/mark draws it centred on 50,50 in a 100-unit box), scaled.
++function mark(site, scale, form = 'pair') {
++ const c = of(site), g = MARK.geo(form), { body } = MARK.art({ form, id: 'ov' });
++ const light = c.dot && c.dot.toLowerCase() !== '#ffffff' ? `<circle cx="${g.cx}" cy="${g.cy}" r="${+((g.r - g.sw / 2) * 0.62).toFixed(2)}" fill="${c.dot}"/>` : '';
++ return `<g transform="scale(${scale}) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>${body}${light}</g>`;
}
--function defs(site) {
-- const [a, b] = of(site).accent;
-- // The stroke runs from the site's accent towards violet, as the navbar's mark does.
-- return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset=".65" stop-color="${b}"/><stop offset="1" stop-color="#c026d3"/></linearGradient>
--<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
++function defs(site, form = 'pair') {
++ const [a] = of(site).accent;
++ return `<defs>${MARK.art({ form, id: 'ov' }).defs}
++<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".3"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
}
/** Full-bleed square. `maskable`: mark radius ≤ 40 % of the half-width; otherwise it fills more of the tile. */
function svg({ site = 'network', maskable = true, size = 512 } = {}) {
-- const scale = maskable ? 3.7 : 4.6; // 21u glow radius × 3.7 ≈ 78u of the 256u half-width… well inside 40 %
++ // The mark's farthest point (the V's caps) is 50 units from its centre: × 4 is 200 of the 256 half-width, inside
++ // the maskable safe zone (40 % of the width, 204.8). A rounded tile carries it at three quarters of its width.
++ const scale = maskable ? 4 : 4.2;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-256 -256 512 512" width="${size}" height="${size}">${defs(site)}
--<rect x="-256" y="-256" width="512" height="512" fill="url(#bg)"${maskable ? '' : ' rx="112"'}/>${mark(site, scale * (maskable ? 1.55 : 1.6))}</svg>`;
++<rect x="-256" y="-256" width="512" height="512" fill="url(#bg)"${maskable ? '' : ' rx="112"'}/>${mark(site, scale)}</svg>`;
}
--/** Small sizes: thicker strokes read better, and the tile is rounded because nothing masks a favicon. */
++/** Small sizes: the heavy form, as wide as the tile allows, and the tile rounded because nothing masks a favicon. */
function favicon({ site = 'network' } = {}) {
-- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64">${defs(site)}<rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/>${mark(site, 1.22)}</svg>`;
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64">${defs(site, 'heavy')}<rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/>${mark(site, 0.58, 'heavy')}</svg>`;
}
function manifest({ site = 'network', name, shortName, description, startUrl = '/', iconBase = '/assets' }) {
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 6a8b84e..7391764 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -415,15 +415,34 @@ put it under `/`.
## The mark
--`ov-mark.js` draws the OpenVibe mark (4.25.0, the "open loop"): one stroke, an O opened at the top by the V, so it
--reads OV and "open" at once. The site's dot sits in the opening.
--- **Colour:** the stroke runs from the site's accent towards violet (`--ovm-to`).
--- **Motion:**
-- - it draws itself in on a visit's first view, and a short light runs along it while it moves;
-- - otherwise it rests (the motion budget).
--- **Variants:** each site keeps its own rhythm (`data-variant`). Live's dot is red and on air.
--- **App icons, favicons and share cards** draw the same loop (`app-icon.js`, `scripts/build-icons.js`,
-- `scripts/build-social-cards.js`).
++The OpenVibe mark is **the Pair** (4.59.0; the owner chose "Pair, halo" from OpenVibe Mark Systems on 2026-10-31): a
++ring O in blue (`#4f7cff`) and a round V in violet (`#8b5cf6`), with a gap of the ground around the V where it crosses
++the ring. The gap is a mask, so the mark sits on any ground: a photo, a gradient, a light or a dark theme.
++- **Geometry:** the V is exactly as tall as the O, and the mark is centred in its box, so it sits level beside a word
++ and centred in a tile. One set of numbers, drawn by `openvibe-shared/mark` (static SVG for servers, icons, share
++ cards and downloads) and by `ov-mark.js` (the browser drop-in); `test/mark.test.js` holds them together.
++- **Forms:** the pair, and the heavy (thicker strokes) at 22 px wide and under, where the pair's gap would close. The
++ form follows `data-size`, or set `data-form="pair|heavy"`.
++- **Boxes:** square by default (`data-size` is its width and height); `data-wide` hugs the mark (94 × 57), for a
++ mark beside a name: the navbar uses `data-size="34" data-wide`, the footer `38`.
++- **Tones:** brand by default; `data-tone="ink"` (the text colour, for stamps and UI) or `"white"` (on a colour tile).
++- **Motion, all of it meaning something and none of it a spin:**
++ - the intro: the O pops in and the V grows out of its point; hover: the V leans back and the O hops;
++ - a page on its way (the navbar's mark): the ring empties and draws itself round again at this site's usual pace,
++ the V nods while it waits and hops when the page is here (`OVMark.navStart()` / `navEnd()` for single-page apps);
++ a known progress (`data-progress`, island.js) draws the ring to its level;
++ - the states (`data-state`): busy (the V bobs, the O breathes), ok (the V becomes a tick, green; the tick only ever
++ means done), error (the V becomes "!", red, and it shakes), no (a "×", grey), notify (a dot pops up by the V),
++ live (the O is the record light, ripples go out), sleep (the O closes like an eye).
++ - otherwise it rests (the motion budget), and never moves under either reduced-motion setting.
++- **Variants:** each site keeps its own pace (`data-variant`); Live, VIP, Tips, Deals and Coupons carry a light in
++ the O (Live's is the red record light).
++- **The season:** the O is a pumpkin with its stem and leaf in October, and wears caps of snow in December.
++- **The names** beside it are set in the brand face, Nunito (`ov-brand.woff2`, served at `/shared/ov-brand.woff2`,
++ SIL OFL; Latin, 700–900): the navbar's site name, the footer's brand, the site switcher and the share cards.
++- **App icons, favicons and share cards** draw the same mark (`app-icon.js`, `scripts/build-icons.js`,
++ `scripts/build-social-cards.js`); the loading screen (`loading.js`) shows it at work, and its V turns into "!" when
++ loading fails.
## The app bar
ddiff --git a/files.js b/files.js
iindex 2ce1e1e..cabf05b 100644
---- a/files.js
++++ b/files.js
@@@ -65,12 +65,17 @@ const STYLES = Object.freeze([
'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)
]);
--const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
++// Fonts sites serve at /shared/<file>: the brand face the names are set in (navbar.js declares it from beside itself).
++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)
++]);
++const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name)) || FONTS.includes(String(name));
module.exports = {
dir: __dirname,
BROWSER,
STYLES,
++ FONTS,
isBrowserFile: served,
path: (name) => {
if (!served(name)) throw new Error(`openvibe-shared: ${name} is not a browser file`);
ddiff --git a/footer.js b/footer.js
iindex 9a6f463..6805197 100644
---- a/footer.js
++++ b/footer.js
@@@ -261,7 +261,7 @@
if (c.variant === 'compact') {
return `<div class="ovf-inner ovf-inner--compact">
-- <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
++ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="28" data-wide></span><span>${esc(brand)}</span></a>
<nav class="ovf-compact-links" aria-label="Site links">
${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
@@@ -290,7 +290,7 @@
${c.pulse === false ? '' : '<p class="ovf-pulse" data-ovf-pulse hidden></p>'}
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
-- <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
++ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="38" data-wide></span><span>${esc(brand)}</span></a>
<p class="ovf-tagline">${esc(tagline)}</p>
<div class="ovf-social">
${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}
@@@ -380,7 +380,7 @@
.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}
@media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:32px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:0;right:0;top:-12px;bottom:-12px}}
.ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85}
--.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
++.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-family:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-size:1.12rem;font-weight:800;letter-spacing:-.1px;text-decoration:none}
.ovf-brand--sm{font-size:.9rem}
.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
.ovf-social{display:flex;gap:8px;margin-top:4px}
ddiff --git a/icons/ai-192.png b/icons/ai-192.png
iindex 659ae7c..0bf8180 100644
BBinary files a/icons/ai-192.png and b/icons/ai-192.png differ
ddiff --git a/icons/ai-512.png b/icons/ai-512.png
iindex 2636b88..638de87 100644
BBinary files a/icons/ai-512.png and b/icons/ai-512.png differ
ddiff --git a/icons/ai-maskable-192.png b/icons/ai-maskable-192.png
iindex 362a451..ba0bbd8 100644
BBinary files a/icons/ai-maskable-192.png and b/icons/ai-maskable-192.png differ
ddiff --git a/icons/ai-maskable-512.png b/icons/ai-maskable-512.png
iindex 7fdb7ba..62d42a8 100644
BBinary files a/icons/ai-maskable-512.png and b/icons/ai-maskable-512.png differ
ddiff --git a/icons/community-192.png b/icons/community-192.png
iindex d6cfbff..d2154e6 100644
BBinary files a/icons/community-192.png and b/icons/community-192.png differ
ddiff --git a/icons/community-512.png b/icons/community-512.png
iindex 5229681..1957052 100644
BBinary files a/icons/community-512.png and b/icons/community-512.png differ
ddiff --git a/icons/community-maskable-192.png b/icons/community-maskable-192.png
iindex 2899858..f1e4cf3 100644
BBinary files a/icons/community-maskable-192.png and b/icons/community-maskable-192.png differ
ddiff --git a/icons/community-maskable-512.png b/icons/community-maskable-512.png
iindex 1527ff3..7ef2ffc 100644
BBinary files a/icons/community-maskable-512.png and b/icons/community-maskable-512.png differ
ddiff --git a/icons/games-192.png b/icons/games-192.png
iindex 9a0058c..6ff26ea 100644
BBinary files a/icons/games-192.png and b/icons/games-192.png differ
ddiff --git a/icons/games-512.png b/icons/games-512.png
iindex ec47ebb..2a3cd99 100644
BBinary files a/icons/games-512.png and b/icons/games-512.png differ
ddiff --git a/icons/games-maskable-192.png b/icons/games-maskable-192.png
iindex c87830e..6e85942 100644
BBinary files a/icons/games-maskable-192.png and b/icons/games-maskable-192.png differ
ddiff --git a/icons/games-maskable-512.png b/icons/games-maskable-512.png
iindex 0f1536b..7f91a18 100644
BBinary files a/icons/games-maskable-512.png and b/icons/games-maskable-512.png differ
ddiff --git a/icons/live-192.png b/icons/live-192.png
iindex a86de71..82d72c8 100644
BBinary files a/icons/live-192.png and b/icons/live-192.png differ
ddiff --git a/icons/live-512.png b/icons/live-512.png
iindex c322a1c..25b82ed 100644
BBinary files a/icons/live-512.png and b/icons/live-512.png differ
ddiff --git a/icons/live-maskable-192.png b/icons/live-maskable-192.png
iindex 8727903..54bf86d 100644
BBinary files a/icons/live-maskable-192.png and b/icons/live-maskable-192.png differ
ddiff --git a/icons/live-maskable-512.png b/icons/live-maskable-512.png
iindex dda5d7b..f232c6d 100644
BBinary files a/icons/live-maskable-512.png and b/icons/live-maskable-512.png differ
ddiff --git a/icons/media-192.png b/icons/media-192.png
iindex be3b8c3..b53300e 100644
BBinary files a/icons/media-192.png and b/icons/media-192.png differ
ddiff --git a/icons/media-512.png b/icons/media-512.png
iindex 8f80e67..a6ee745 100644
BBinary files a/icons/media-512.png and b/icons/media-512.png differ
ddiff --git a/icons/media-maskable-192.png b/icons/media-maskable-192.png
iindex e28830a..5b42428 100644
BBinary files a/icons/media-maskable-192.png and b/icons/media-maskable-192.png differ
ddiff --git a/icons/media-maskable-512.png b/icons/media-maskable-512.png
iindex 5b5a724..3f632b1 100644
BBinary files a/icons/media-maskable-512.png and b/icons/media-maskable-512.png differ
ddiff --git a/icons/network-192.png b/icons/network-192.png
iindex a119ee8..488bd33 100644
BBinary files a/icons/network-192.png and b/icons/network-192.png differ
ddiff --git a/icons/network-512.png b/icons/network-512.png
iindex 24b8007..633ed8e 100644
BBinary files a/icons/network-512.png and b/icons/network-512.png differ
ddiff --git a/icons/network-maskable-192.png b/icons/network-maskable-192.png
iindex 6c79df9..1decfe1 100644
BBinary files a/icons/network-maskable-192.png and b/icons/network-maskable-192.png differ
ddiff --git a/icons/network-maskable-512.png b/icons/network-maskable-512.png
iindex fa7e299..ea2b674 100644
BBinary files a/icons/network-maskable-512.png and b/icons/network-maskable-512.png differ
ddiff --git a/icons/space-192.png b/icons/space-192.png
iindex 3253e45..8564370 100644
BBinary files a/icons/space-192.png and b/icons/space-192.png differ
ddiff --git a/icons/space-512.png b/icons/space-512.png
iindex 1e8f82c..48a07ab 100644
BBinary files a/icons/space-512.png and b/icons/space-512.png differ
ddiff --git a/icons/space-maskable-192.png b/icons/space-maskable-192.png
iindex acea8b5..e4e1c2e 100644
BBinary files a/icons/space-maskable-192.png and b/icons/space-maskable-192.png differ
ddiff --git a/icons/space-maskable-512.png b/icons/space-maskable-512.png
iindex bc1f18a..774de56 100644
BBinary files a/icons/space-maskable-512.png and b/icons/space-maskable-512.png differ
ddiff --git a/icons/tools-192.png b/icons/tools-192.png
iindex 48f7975..f43ed9c 100644
BBinary files a/icons/tools-192.png and b/icons/tools-192.png differ
ddiff --git a/icons/tools-512.png b/icons/tools-512.png
iindex f6fd5b5..aaaa2da 100644
BBinary files a/icons/tools-512.png and b/icons/tools-512.png differ
ddiff --git a/icons/tools-maskable-192.png b/icons/tools-maskable-192.png
iindex 38f1371..df0c819 100644
BBinary files a/icons/tools-maskable-192.png and b/icons/tools-maskable-192.png differ
ddiff --git a/icons/tools-maskable-512.png b/icons/tools-maskable-512.png
iindex 41bed60..dba5195 100644
BBinary files a/icons/tools-maskable-512.png and b/icons/tools-maskable-512.png differ
ddiff --git a/loading.js b/loading.js
iindex acb673d..08a735e 100644
---- a/loading.js
++++ b/loading.js
@@@ -5,7 +5,7 @@
* sites… a loading system you can use in the SDK and API."
*
* It covers what is loading edge to edge, with no card: the thing's own picture (a stream's latest frame, a video's
-- * poster) blurred and drifting behind, the OpenVibe mark with its dot hopping in the V (nothing spins), one word of status with one quiet line under
++ * poster) blurred and drifting behind, the OpenVibe mark at work (the V bobs and the O breathes; nothing spins), one word of status with one quiet line under
* it, and a two-pixel line along the bottom that fills as the steps go by (or sweeps while the progress is unknown).
* Only when it is slow does a hint and its actions appear; a failure says what happened and offers Retry. Done, it fades.
*
@@@ -23,6 +23,9 @@
*/
(function (root) {
'use strict';
++ // The V's two strokes, and the "!" it becomes when loading fails (openvibe-shared/mark shapes('pair').v and .err).
++ const V = ['M71.13 70L54.17 30', 'M71.13 70L88.62 30'], BANG = ['M71.4 30L71.4 53.36', 'M71.4 70L71.4 70.01'];
++ let marks = 0;
const CSS = `
.ovld { position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; overflow: hidden; background: #05070c; color: #f4f6fb; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: opacity .38s ease; contain: strict; }
.ovld.is-done { opacity: 0; pointer-events: none; }
@@@ -30,10 +33,10 @@
.ovld-poster.is-in { opacity: 1; }
.ovld::after { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 45%, rgba(5,7,12,0) 0%, rgba(5,7,12,.35) 55%, rgba(5,7,12,.75) 100%); pointer-events: none; }
.ovld-core { position: relative; z-index: 1; display: grid; justify-items: center; gap: 10px; max-width: min(86%, 420px); text-align: center; text-shadow: 0 1px 12px rgba(0,0,0,.45); }
--.ovld-ring { position: relative; width: 46px; height: 46px; color: var(--accent-light, #9cc0ff); filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 45%, transparent)); }
++.ovld-ring { position: relative; width: 60px; height: 60px; --ovm-o: #4f7cff; --ovm-v: #8b5cf6; filter: drop-shadow(0 0 12px rgba(124, 108, 255, .4)); }
.ovld-ring svg { width: 100%; height: 100%; overflow: visible; }
--.ovld-ring .lp { fill: none; stroke: currentColor; stroke-width: 6.24; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 100; animation: ovld-draw 1.1s cubic-bezier(.3,.8,.2,1) both; }
--.ovld-ring .d { fill: #fff; transform-box: fill-box; transform-origin: center; animation: ovld-hop .82s ease-in-out .9s infinite; }
++.ovld-ring .o { fill: none; stroke: var(--ovm-o); stroke-width: 13; transition: stroke .35s; } .ovld-ring .va, .ovld-ring .vb { fill: none; stroke: var(--ovm-v); stroke-width: 13; stroke-linecap: round; transition: stroke .35s; } .ovld-ring .ha, .ovld-ring .hb { fill: none; stroke: #000; stroke-width: 20; stroke-linecap: round; }
++.ovld-ring .og, .ovld-ring .vg { transform-box: view-box; } .ovld-ring .og { transform-origin: 31.38px 50px; animation: ovld-pop .56s cubic-bezier(.34,1.56,.64,1) both, ovld-breathe .9s ease-in-out 1.05s infinite; } .ovld-ring .vg { transform-origin: 71.13px 70px; animation: ovld-pop .52s cubic-bezier(.34,1.56,.64,1) .18s both, ovld-bob .9s ease-in-out .6s infinite; }
.ovld-label { font-size: 15px; font-weight: 650; letter-spacing: .01em; transition: opacity .2s ease, transform .2s ease; }
.ovld-sub { font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.72); min-height: 1.45em; transition: opacity .2s ease; }
.ovld-swap .ovld-label, .ovld-swap .ovld-sub { opacity: 0; transform: translateY(3px); }
@@@ -46,8 +49,8 @@
.ovld-line { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; height: 2px; background: rgba(255,255,255,.08); overflow: hidden; }
.ovld-line i { position: absolute; left: 0; top: 0; bottom: 0; width: var(--ovld-p, 0%); background: linear-gradient(90deg, var(--accent, #3b82f6), var(--accent-light, #9cc0ff)); box-shadow: 0 0 10px var(--accent-light, #9cc0ff); transition: width .7s cubic-bezier(.2,.7,.2,1); }
.ovld.is-unknown .ovld-line i { width: 30%; animation: ovld-sweep 1.4s ease-in-out infinite; }
--.ovld.is-failed .ovld-ring { color: var(--danger, #ef4444); animation: ovld-shake .56s ease-in-out 1; }
--.ovld.is-failed .ovld-ring .d { fill: var(--danger, #ef4444); animation: none; transform: translateY(5.6px); transition: transform .5s ease-in; }
++.ovld.is-failed .ovld-ring { --ovm-o: var(--danger, #ef4444); --ovm-v: var(--danger, #ef4444); filter: none; animation: ovld-shake .56s ease-in-out 1; }
++.ovld.is-failed .ovld-ring .og, .ovld.is-failed .ovld-ring .vg { animation: none; }
.ovld.is-failed .ovld-line i { background: var(--danger, #ef4444); box-shadow: none; animation: none; width: 100%; }
.ovld-skel { display: grid; gap: 10px; padding: 4px 0; }
.ovld-skel-row { display: flex; align-items: center; gap: 12px; }
@@@ -56,14 +59,14 @@
.ovld-skel-line { height: 11px; border-radius: 6px; }
.ovld-skel-line:last-child { width: 62%; }
.ovld-skel-mark, .ovld-skel-line { background: linear-gradient(90deg, var(--bg-hover, rgba(255,255,255,.06)) 25%, rgba(255,255,255,.12) 50%, var(--bg-hover, rgba(255,255,255,.06)) 75%); background-size: 300% 100%; animation: ovld-shimmer 1.3s ease-in-out infinite; }
--@keyframes ovld-draw { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
--@keyframes ovld-hop { 0%, 100% { transform: translateY(0) scale(1); } 45% { transform: translateY(5.2px) scale(1.1, .84); } 55% { transform: translateY(5.2px) scale(.92, 1.1); } }
++@keyframes ovld-pop { from { transform: scale(0); } to { transform: scale(1); } }
++@keyframes ovld-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } } @keyframes ovld-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(.88); } }
@keyframes ovld-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } }
@keyframes ovld-breathe { 0%, 100% { transform: scale(.72); opacity: .75; } 50% { transform: scale(1); opacity: 1; } }
@keyframes ovld-drift { from { transform: scale(1.06) translate3d(-1.5%, -1%, 0); } to { transform: scale(1.14) translate3d(1.5%, 1%, 0); } }
@keyframes ovld-sweep { from { left: -30%; } to { left: 100%; } }
@keyframes ovld-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
--@media (prefers-reduced-motion: reduce) { .ovld-poster, .ovld-ring, .ovld-ring .lp, .ovld-ring .d, .ovld-line i, .ovld-skel-mark, .ovld-skel-line { animation: none !important; } }`;
++@media (prefers-reduced-motion: reduce) { .ovld-poster, .ovld-ring, .ovld-ring .og, .ovld-ring .vg, .ovld-line i, .ovld-skel-mark, .ovld-skel-line { animation: none !important; } }`;
function styles() {
if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return;
@@@ -85,7 +88,11 @@
const core = el('div', 'ovld-core');
const ring = el('div', 'ovld-ring');
ring.setAttribute('aria-hidden', 'true');
-- ring.innerHTML = '<svg viewBox="0 0 48 48"><path class="lp" d="M15.37 11.72A15.84 15.84 0 1 0 32.63 11.72L24 25.96Z" pathLength="100"/><circle class="d" cx="24" cy="7.4" r="3.36"/></svg>';
++ // The OpenVibe mark (openvibe-shared/mark, the pair form): the O pops in, the V grows out of its point, then they
++ // work in turn. The gap around the V is a mask, so it sits on the blurred picture.
++ const mid = `ovld${++marks}`;
++ ring.innerHTML = `<svg viewBox="2.88 2.88 94.24 94.24"><defs><mask id="${mid}" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg"><path class="ha" d="${V[0]}"/><path class="hb" d="${V[1]}"/></g></mask></defs>`
++ + `<g mask="url(#${mid})"><g class="og"><circle class="o" cx="31.38" cy="50" r="20"/></g></g><g class="vg"><path class="va" d="${V[0]}"/><path class="vb" d="${V[1]}"/></g></svg>`;
const label = el('div', 'ovld-label', opts.label || 'Loading');
const sub = el('div', 'ovld-sub', opts.sub || '');
const help = el('div', 'ovld-help');
@@@ -153,6 +160,8 @@
box.classList.remove('is-slow', 'is-unknown');
box.classList.add('is-failed');
box.setAttribute('role', 'alert');
++ ring.querySelectorAll('.va, .ha').forEach((p) => p.setAttribute('d', BANG[0]));
++ ring.querySelectorAll('.vb, .hb').forEach((p) => p.setAttribute('d', BANG[1]));
setText(title, detail);
hint.textContent = '';
setActions(actions);
ddiff --git a/mark.js b/mark.js
nnew file mode 100644
iindex 0000000..2b7d251
---- /dev/null
++++ b/mark.js
@@@ -0,0 +1,86 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/mark — the OpenVibe mark as static SVG, for servers, icons, share cards and anyone else.
++//
++// The mark is the Pair: a ring O and a round V, the V with a gap of the ground around it where it crosses the ring
++// (the owner, 2026-10-31: "Pair, halo … this should be our new logo"). The gap is a mask, not a painted halo, so the
++// mark sits on any ground: a photo, a gradient, a light or a dark theme.
++//
++// Geometry, in a 100-unit box with the mark centred on (50, 50): the V is exactly as tall as the O (its caps meet the
++// O's top and bottom), so it sits level beside a word and centred in a tile. Two forms:
++// pair the mark: ring 20 + stroke 13, the V's stroke 13.
++// heavy for 22 px wide and under (favicons, buttons): ring 18 + stroke 18, the V's stroke 17.
++// ov-mark.js (the browser drop-in with its states and motion) draws the same numbers; test/mark.test.js checks it.
++//
++// geo(form) → { cx, cy, r, sw, R, vsw, halo, A, T, B, ring, box, wideBox }
++// shapes(form) → the V's two strokes for each shape it takes: v, ok (a tick), no (a cross),
++// err (an exclamation mark), tip (folded into its point, for the intro)
++// art({ form, o, v, id, shape }) → { defs, body }: the mark's <defs> and drawing, to place inside another SVG
++// svg({ form, tone, wide, size, title, id })
++// → a standalone SVG. tone: 'brand' (blue O, violet V), 'ink' (currentColor),
++// 'white', or { o, v }. wide: the box hugs the mark (92 × 56) instead of a square.
++// COLORS → { o: '#4f7cff', v: '#8b5cf6' }
++// ═══════════════════════════════════════════════════════════════
++
++const COLORS = Object.freeze({ o: '#4f7cff', v: '#8b5cf6' });
++const f = (v) => +v.toFixed(2);
++
++/** The Pair: ring radius r and stroke sw, the V's stroke vsw; the V's left cap a, tip t and right cap b, as multiples of the ring's outer radius from its centre. */
++function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
++ const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
++ const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];
++ // The ring as a path from its top, clockwise: a page load draws it as progress (pathLength 100).
++ const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`;
++ const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2;
++ const w = f(x1 - x0 + 2 * pad);
++ return {
++ cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring,
++ // A square box centred on the mark, and a wide one that hugs it.
++ box: `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`,
++ wideBox: `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`,
++ };
++}
++const FORMS = Object.freeze({ pair: pair(20, 13, 13), heavy: pair(18, 18, 17) });
++const geo = (form) => FORMS[form === 'heavy' || form === 'solid' ? 'heavy' : 'pair'];
++
++/** The V's two strokes, each from its first point, for every shape it takes: [x1, y1, x2, y2, x3, y3, x4, y4]. */
++function shapes(form) {
++ const g = geo(form), [A, T, B] = [g.A, g.T, g.B];
++ const w = B[0] - A[0], h = T[1] - A[1], cx = A[0] + w / 2, cy = A[1] + h * 0.55, s = Math.min(w, h) * 0.42;
++ const tick = [A[0] + w * 0.46, T[1]];
++ return Object.fromEntries(Object.entries({
++ v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]],
++ tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]],
++ ok: [tick[0], tick[1], A[0] + w * 0.16, A[1] + h * 0.52, tick[0], tick[1], B[0] + w * 0.06, A[1]],
++ no: [cx - s + w * 0.1, cy - s, cx + s + w * 0.1, cy + s, cx - s + w * 0.1, cy + s, cx + s + w * 0.1, cy - s],
++ err: [cx, A[1], cx, T[1] - g.vsw * 1.28, cx, T[1], cx, T[1] + 0.01],
++ }).map(([k, p]) => [k, p.map(f)]));
++}
++
++const stroke = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`;
++
++/** The mark's defs and drawing, to place inside another SVG. ids are prefixed with `id` (unique per document). */
++function art({ form = 'pair', o = COLORS.o, v = COLORS.v, id = 'ovm', shape = 'v' } = {}) {
++ const g = geo(form), p = shapes(form)[shape] || shapes(form).v;
++ const defs = `<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/>`
++ + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="#000" stroke-width="${g.halo}" stroke-linecap="round"/></mask>`;
++ const body = `<circle cx="${g.cx}" cy="${g.cy}" r="${g.r}" fill="none" stroke="${o}" stroke-width="${g.sw}" mask="url(#${id}h)"/>`
++ + `<path d="${stroke(p, 0)}${stroke(p, 4)}" fill="none" stroke="${v}" stroke-width="${g.vsw}" stroke-linecap="round" stroke-linejoin="round"/>`;
++ return { defs, body };
++}
++
++const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
++
++/** A standalone SVG of the mark. */
++function svg({ form = 'pair', tone = 'brand', wide = false, size = null, title = 'OpenVibe', id = 'ovm' } = {}) {
++ const c = typeof tone === 'object' && tone ? tone
++ : tone === 'ink' ? { o: 'currentColor', v: 'currentColor' }
++ : tone === 'white' ? { o: '#fff', v: '#fff' } : COLORS;
++ const g = geo(form), box = wide ? g.wideBox : g.box;
++ const [, , bw, bh] = box.split(' ').map(Number);
++ const dims = size ? ` width="${f(wide ? size * bw / bh : size)}" height="${size}"` : '';
++ const { defs, body } = art({ form, o: c.o, v: c.v, id });
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box}"${dims} role="img" aria-label="${esc(title)}"><title>${esc(title)}</title><defs>${defs}</defs>${body}</svg>`;
++}
++
++module.exports = { COLORS, FORMS, geo, shapes, art, svg };
ddiff --git a/navbar.js b/navbar.js
iindex 05b9b59..6156560 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -45,6 +45,8 @@
body.ov-nav-overlay > #navbar-mount, body.ov-nav-overlay > .openvibe-navbar { margin-bottom: calc(-1 * var(--ovnav-real-h, 52px)); }
body.ov-panel-open .openvibe-navbar.ovnav-top { background-color: var(--bg-secondary, #252530); border-bottom-color: var(--border, #333340); }
@media (prefers-reduced-motion: reduce) { .openvibe-navbar, .openvibe-navbar::before { transition: none; } }
++ /* Names: the brand face (ov-brand.woff2: Nunito, Latin, 700-900). */
++ @font-face { font-family: 'OV Brand'; src: url(${sibling('ov-brand.woff2')}) format('woff2'); font-weight: 700 900; font-style: normal; font-display: swap; unicode-range: U+0020-007E, U+00A0, U+00B7, U+00E9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
/* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
@@@ -53,14 +55,14 @@
.openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }
.openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
-- .openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
++ .openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 30px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; }
.openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
-- .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-family: 'OV Brand', Nunito, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 17px; letter-spacing: -.1px; }
.openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 1px; border-radius: 6px; transition: color .15s, background .15s; }
-- .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
-- .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
-- .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
-- .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 900; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 800; }
++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 800; }
++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 700; }
.openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
.openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0; opacity: .5; }
.openvibe-navbar .openvibe-navbar-brand.joined .name a.b-core { padding-right: 0; }
@@@ -170,7 +172,7 @@
@media (max-width: 440px) { .ovnav-chip-mini { padding: 0 5px; gap: 3px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 12px; } .ovnav-chip-mini .ovnav-ic { width: 13px; height: 13px; } }
@media (max-width: 400px) { .openvibe-navbar .openvibe-navbar-right { gap: 1px; } .ovnav-chip-mini { padding: 0 3px; gap: 2px; } .openvibe-navbar .ovnav-chip.ovnav-chip--all { font-size: 11.5px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
.openvibe-navbar { padding-left: 8px; padding-right: 6px; gap: 2px; } .openvibe-navbar .ovnav-burger { width: 34px; } .openvibe-navbar .openvibe-navbar-avatar { width: 32px; height: 32px; box-sizing: border-box; }
-- .openvibe-navbar .openvibe-navbar-brand .name { font-size: 14px; letter-spacing: -.3px; } .openvibe-navbar .openvibe-navbar-brand .flame { width: 28px; height: 28px; } }
++ .openvibe-navbar .openvibe-navbar-brand .name { font-size: 15px; letter-spacing: -.2px; } .openvibe-navbar .openvibe-navbar-brand .flame { min-width: 28px; height: 28px; padding: 0 2px; } .openvibe-navbar .openvibe-navbar-brand .flame .ov-mark { width: 28px !important; } }
/* Under 350px the mark alone names the site, so both balances keep their icons (a number alone says nothing). */
@media (max-width: 350px) { .openvibe-navbar .openvibe-navbar-brand .name { display: none; } }
.openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #3b82f6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
@@@ -195,6 +197,11 @@
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
}
++ @media (min-width: 401px) and (max-width: 480px) {
++ .openvibe-navbar .openvibe-navbar-brand .flame .ov-mark { width: 28px !important; }
++ .openvibe-navbar .openvibe-navbar-brand .name { font-size: 15px; letter-spacing: -.2px; }
++ .openvibe-navbar .openvibe-navbar-brand { gap: 4px; }
++ }
@media (max-width: 480px) {
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-core,
.openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub):not(.joined) .b-dot { display: none; }
@@@ -670,7 +677,7 @@
: seg('b-core', brand.core, to.core, brand.name) + join + seg('b-tld', brand.tldText, to.tld, brand.name);
const mark = brand.icon
? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
-- : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
++ : `<span class="ov-mark" data-size="34" data-wide data-variant="${escapeAttr(brand.variant)}"></span>`;
return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}${brand.joined ? ' joined' : ''}">
${_config.switcher === false
? `<a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>`
ddiff --git a/ov-brand.woff2 b/ov-brand.woff2
nnew file mode 100644
iindex 0000000..176ce5d
BBinary files /dev/null and b/ov-brand.woff2 differ
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 1dbeee3..7346d49 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -1,22 +1,31 @@
/**
* ov-mark.js — the OpenVibe mark as a drop-in.
-- * <span class="ov-mark"></span> → 32px mark
-- * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
-- * <span class="ov-mark" data-variant="live"> → the site's own dot colour and rhythm (see the variants below)
-- * <span class="ov-mark" data-form="solid|line"> → force a form (default: solid up to 24px, the line above)
++ * <span class="ov-mark"></span> → a 32px mark (the box is square, the mark centred in it)
++ * <span class="ov-mark" data-size="56"> → any size (the box's width); add data-static="1" for no motion
++ * <span class="ov-mark" data-wide> → the box hugs the mark (92 × 56) instead of a square
++ * <span class="ov-mark" data-variant="live"> → the site's own touch (Live's record light in the O, see below)
++ * <span class="ov-mark" data-tone="ink|white"> → one colour: the text's (stamps, UI) or white (on a colour tile)
++ * <span class="ov-mark" data-form="heavy"> → force a form (default: heavy up to 22px wide, the pair above)
* data-state="busy|ok|error|no|notify|live|sleep" → what it shows (island.js sets busy/ok/error on the navbar's)
-- * data-progress="0.4" with style="--ovm-p:.4" → a known progress: the coin fills to that level
-- * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
++ * data-progress="0.4" with style="--ovm-p:.4" → a known progress: the ring draws round to that much
++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages). The same mark as static
++ * SVG, for servers and icons: openvibe-shared/mark (mark.js), whose numbers this file draws.
*
-- * The mark is the Loop (the owner, 2026-10-31, from OpenVibe Mark Systems: "use the one that's easiest to see across
-- * all these resolutions and add lots of animations to it"). One shape, two forms:
-- * - the line: an O opened at the top by a V, one stroke, with the site's dot in the opening (headers, the navbar);
-- * - the solid: the same circle filled, with the V cut through it (16-24px, where a line would blur).
-- * Between them the coin's fill rises and drains, and that fill is also the progress: following a link, the navbar's mark
-- * fills as the next page loads, paced by how long this site's pages usually take in this browser (learned per site,
-- * boost moves and full loads apart), and drains back to the line when the page is here (window.OVMark.navStart/navEnd
-- * for single-page apps). Nothing spins: busy is the dot hopping in the V, ok fills the coin green, an error shakes and
-- * drops the dot into the V, live sends ripples out from the ring.
++ * The mark is the Pair (the owner, 2026-10-31: "Pair, halo … this should be our new logo"): a ring O and a round V,
++ * with a gap of the ground around the V where it crosses the ring. The gap is a mask, so the mark sits on any ground.
++ * The V is as tall as the O, so it sits level beside a word in the navbar and centred in a tile. Two forms: the pair,
++ * and the heavy (thicker strokes) for 22px and under, where the pair's gap would close.
++ *
++ * Everything it does means something, and nothing spins:
++ * - intro: the O pops in and the V grows out of its point;
++ * - hover: the V leans back and the O hops;
++ * - a page on its way (navbar): the ring empties and draws itself round again as the next page loads, paced by how
++ * long this site's pages usually take in this browser (learned per site, boost moves and full loads apart); the
++ * V nods while it waits and hops when the page is here (window.OVMark.navStart/navEnd for single-page apps);
++ * - busy: the V bobs and the O breathes, in turn; ok: the V becomes a tick, green, and hops (the tick only ever
++ * means "done"); error: the V becomes "!", red, and the mark shakes; no: the V becomes "×", grey, and the O
++ * shakes its head; notify: a dot pops up by the V and the O bounces; live: the O is the record light, red, and
++ * ripples go out from it; sleep: the O closes like an eye and the V sinks.
*
* Motion budget: SVG animations restyle and re-lay-out on the main thread every frame, so an endlessly moving mark kept
* idle pages 12-29% busy (browser check, OpenVibe.Host). A mark moves only for its intro (the first INTRO_MS of a
@@@ -24,114 +33,126 @@
* rest), while it or its link is hovered or focused (and LINGER_MS after), and while it shows a state, a page load or
* progress. Otherwise its CSS animations are paused where they are. It never moves while the page is hidden, while it
* is off-screen (IntersectionObserver), with data-static, under prefers-reduced-motion or under the network's own
-- * reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js); its colours and fill still change.
++ * reduced-motion setting (html[data-ov-motion=reduced], theme-loader.js); its colours and shapes still change.
*
* The season (theme-loader.js sets <html data-ov-season-mark="halloween|winter"> for anyone who has not turned seasonal
-- * themes off, on any theme): the mark wears it, a pumpkin's stem and leaf in October, caps of snow in December.
-- * Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the day a few
-- * leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
++ * themes off, on any theme): the mark wears it, the O a pumpkin with its stem and leaf in October, caps of snow in
++ * December. Now and then (every 4 to 8 minutes) a mark on screen flickers for a moment, and on the first visit of the
++ * day a few leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
*/
(function () {
'use strict';
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
-- // The Loop in a 48-unit box (the coin's V cut is the line's V plus a 1.7-unit gap each side, so a filling coin keeps
-- // the V visible as an inlay): ring radius 15.84, opened 33° each side of the top, the V's point just under the
-- // middle, stroke 6.24. Centred on (24, 24) with its dot; the solid form (no dot) moves up SOLID_DY to stay centred.
-- const GEO = (() => {
-- const r = 15.84, a = 33 * Math.PI / 180, sw = 6.24, cy = 25, f = (v) => +v.toFixed(2);
-- const lx = r * Math.sin(a), ly = -r * Math.cos(a);
-- const L = [f(24 - lx), f(cy + ly)], Rt = [f(24 + lx), f(cy + ly)], T = [24, f(cy + 0.96)];
-- const at = (deg, rr) => [f(24 + rr * Math.cos(deg * Math.PI / 180)), f(cy + rr * Math.sin(deg * Math.PI / 180))];
-- const cap = (d0, d1) => { const p = at(d0, r + sw / 2 + 1.2), q = at(d1, r + sw / 2 + 1.2); return `M${p[0]} ${p[1]}A${f(r + sw / 2 + 1.2)} ${f(r + sw / 2 + 1.2)} 0 0 1 ${q[0]} ${q[1]}`; };
-- return {
-- r, sw, cy, R: f(r + sw / 2), dotY: f(cy + ly - 4.32), dotR: 3.36,
-- loop: `M${L[0]} ${L[1]}A${r} ${r} 0 1 0 ${Rt[0]} ${Rt[1]}L${T[0]} ${T[1]}Z`,
-- vee: `M${L[0]} ${L[1]}L${T[0]} ${T[1]}L${Rt[0]} ${Rt[1]}`,
-- snow: [cap(-162, -128), cap(-52, -18)], flake: [at(-150, r + 7.6), at(-36, r + 7.2)],
-- // The fill's clip slides up from below the coin (0) to its top (1): translateY in user units.
-- fill0: f(cy + r + sw / 2 + 4), span: f(2 * (r + sw / 2)),
++ const f = (v) => +v.toFixed(2);
++ // The Pair in a 100-unit box, centred on (50, 50) (openvibe-shared/mark: the same numbers; test/mark.test.js).
++ function pair(r, sw, vsw, a = 0.86, t = 1.5, b = 2.16) {
++ const R = r + sw / 2, right = b * R + vsw / 2, cx = 50 - (right - R) / 2, cy = 50;
++ const A = [f(cx + a * R), f(cy - R + vsw / 2)], T = [f(cx + t * R), f(cy + R - vsw / 2)], B = [f(cx + b * R), f(cy - R + vsw / 2)];
++ const ring = `M${f(cx)} ${f(cy - r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy + r)}A${r} ${r} 0 1 1 ${f(cx)} ${f(cy - r)}`;
++ const x0 = f(cx - R), x1 = f(B[0] + vsw / 2), pad = 2, w = f(x1 - x0 + 2 * pad);
++ const box = `${f(50 - w / 2)} ${f(50 - w / 2)} ${w} ${w}`, wideBox = `${f(50 - w / 2)} ${f(50 - R - pad)} ${w} ${f(2 * R + 2 * pad)}`;
++ // The V's two strokes, each from its first point, for every shape it takes.
++ const vw = B[0] - A[0], vh = T[1] - A[1], mx = A[0] + vw / 2, my = A[1] + vh * 0.55, s = Math.min(vw, vh) * 0.42, tick = [A[0] + vw * 0.46, T[1]];
++ const shapes = {
++ v: [T[0], T[1], A[0], A[1], T[0], T[1], B[0], B[1]],
++ tip: [T[0], T[1], T[0], T[1], T[0], T[1], T[0], T[1]],
++ ok: [tick[0], tick[1], A[0] + vw * 0.16, A[1] + vh * 0.52, tick[0], tick[1], B[0] + vw * 0.06, A[1]],
++ no: [mx - s + vw * 0.1, my - s, mx + s + vw * 0.1, my + s, mx - s + vw * 0.1, my + s, mx + s + vw * 0.1, my - s],
++ err: [mx, A[1], mx, T[1] - vsw * 1.28, mx, T[1], mx, T[1] + 0.01],
};
-- })();
-- const SOLID_DY = -2.28;
++ for (const k in shapes) shapes[k] = shapes[k].map(f);
++ return { cx: f(cx), cy, r, sw, R: f(R), vsw, halo: vsw + 7, A, T, B, ring, box, wideBox, shapes };
++ }
++ const FORMS = { pair: pair(20, 13, 13), heavy: pair(18, 18, 17) };
++ const formOf = (v) => (v === 'heavy' || v === 'solid' ? 'heavy' : 'pair');
const CSS = `
--@property --ovm-fill{syntax:'<number>';inherits:true;initial-value:0}
--.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0;--ovm-to:color-mix(in oklab,currentColor 45%,#a78bfa);--ovm-fill:0;transition:--ovm-fill .55s cubic-bezier(.3,.8,.2,1),color .35s}
++@property --ovm-fill{syntax:'<number>';inherits:true;initial-value:1}
++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;vertical-align:middle;line-height:0;--ovm-o:#4f7cff;--ovm-v:#8b5cf6;--ovm-fill:1;transition:--ovm-fill .55s cubic-bezier(.3,.8,.2,1)}
++.ov-mark[data-wide]{height:auto;aspect-ratio:94/57}
.ov-mark svg{width:100%;height:100%;overflow:visible}
--.ov-mark .m{transform-box:view-box;transform-origin:24px 24px;transition:translate .35s}
--.ov-mark .fr{transform-box:view-box;transform:translateY(calc((${GEO.fill0} - var(--ovm-fill) * ${GEO.span + 4}) * 1px))}
--.ov-mark .lp{fill:none;stroke-width:${GEO.sw};stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;stroke-dashoffset:0;transition:opacity .3s}
--.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;transition:opacity .3s,fill .35s}
--.ov-mark .rp{fill:none;stroke:var(--danger,#ef4444);stroke-width:1.6;opacity:0;transform-box:fill-box;transform-origin:center}
--/* The solid form: the coin, the line hidden in it, the dot away (it would vanish into the disc). */
--.ov-mark[data-form=solid]{--ovm-fill:1}
--.ov-mark[data-form=solid] .m{translate:0 ${SOLID_DY}px}
--.ov-mark[data-form=solid] .lp,.ov-mark[data-form=solid] .d{opacity:0}
--/* Hover and focus: the line fills into the coin and drains again when let go. */
--a:hover>.ov-mark:not([data-form=solid]),button:hover>.ov-mark:not([data-form=solid]),.ov-mark:not([data-form=solid]):hover,a:focus-visible>.ov-mark:not([data-form=solid]){--ovm-fill:1}
--a:hover>.ov-mark .d,button:hover>.ov-mark .d,.ov-mark:hover .d{animation:ovmHop .6s cubic-bezier(.3,.7,.4,1) 1}
--/* A page on its way, or a known progress: the coin fills to the level; the line stays drawn over it. */
--.ov-mark[data-nav],.ov-mark[data-progress]{transition:--ovm-fill .3s linear,color .35s}
++.ov-mark .m,.ov-mark .og,.ov-mark .vg{transform-box:view-box}
++.ov-mark .m{transform-origin:50px 50px}
++.ov-mark .o,.ov-mark .ot{fill:none;stroke:var(--ovm-o);transition:stroke .35s}
++.ov-mark .ot{opacity:0;transition:opacity .3s,stroke .35s}
++.ov-mark .va,.ov-mark .vb{fill:none;stroke:var(--ovm-v);stroke-linecap:round;stroke-linejoin:round;transition:stroke .35s,opacity .5s}
++.ov-mark .ha,.ov-mark .hb{fill:none;stroke:#000;stroke-linecap:round}
++.ov-mark .cd{display:none;fill:var(--ovm-dot,#ff3b5c);transform-box:fill-box;transform-origin:center}
++.ov-mark .nd{fill:var(--danger,#ef4444);opacity:0;transform:scale(0);transform-box:fill-box;transform-origin:center;transition:opacity .2s,transform .42s cubic-bezier(.34,1.56,.64,1)}
++.ov-mark .rp{fill:none;stroke:#ff3b5c;stroke-width:3;opacity:0;transform-box:fill-box;transform-origin:center}
++.ov-mark[data-tone=ink]{--ovm-o:currentColor;--ovm-v:currentColor}
++.ov-mark[data-tone=white]{--ovm-o:#fff;--ovm-v:#fff}
++/* The season: the O is a pumpkin in October (states below still colour it). */
++.ov-mark .sh,.ov-mark .sw2{display:none}
++html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}
++/* As weak as the base rule, so a state, a tone or a page's own colour (a brand page) wins over the season. */
++:where(html[data-ov-season-mark=halloween]) .ov-mark{--ovm-o:#f97316}
++.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:4.4;stroke-linecap:round}
++.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 80%}
++.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:5.6;stroke-linecap:round}
++.ov-mark .sd{fill:#f4fbff}
++/* Hover and focus: the V leans back and the O hops. */
++a:hover>.ov-mark:not([data-state]) .vg,button:hover>.ov-mark:not([data-state]) .vg,.ov-mark:not([data-state]):hover .vg,a:focus-visible>.ov-mark:not([data-state]) .vg{animation:ovmLean .62s ease-out 1}
++a:hover>.ov-mark:not([data-state]) .og,button:hover>.ov-mark:not([data-state]) .og,.ov-mark:not([data-state]):hover .og,a:focus-visible>.ov-mark:not([data-state]) .og{animation:ovmHopO .62s ease-out 1}
++/* The intro: the O pops in, then the V grows out of its point. */
++.ov-mark[data-ovm-intro] .og{animation:ovmPop .56s cubic-bezier(.34,1.56,.64,1) both}
++.ov-mark[data-ovm-intro] .vg{animation:ovmPop .52s cubic-bezier(.34,1.56,.64,1) .18s both}
++html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) .5s both}
++/* A page on its way, or a known progress: the ring draws itself round to the level over a faint track. */
++.ov-mark[data-nav],.ov-mark[data-progress]{transition:--ovm-fill .3s linear}
.ov-mark[data-progress]{--ovm-fill:var(--ovm-p,0)}
--.ov-mark[data-nav] .lp,.ov-mark[data-progress] .lp{opacity:1}
--.ov-mark[data-nav] .m,.ov-mark[data-progress] .m{translate:none}
--.ov-mark[data-nav] .d,.ov-mark[data-progress] .d{opacity:0}
++.ov-mark[data-nav] .o,.ov-mark[data-progress] .o{stroke-dasharray:100 100;stroke-dashoffset:calc((1 - var(--ovm-fill)) * 100px)}
++.ov-mark[data-nav] .ot,.ov-mark[data-progress] .ot{opacity:.24}
++.ov-mark[data-nav]:not([data-ovm-arrive]) .vg{animation:ovmNod .9s ease-in-out infinite}
++.ov-mark[data-ovm-arrive] .vg{animation:ovmHop .5s ease-out 1}
++.ov-mark[data-ovm-arrive] .og{animation:ovmPulse .5s ease-out 1}
/* The states. */
--.ov-mark[data-state=busy] .d{animation:ovmHop .82s ease-in-out infinite}
--.ov-mark[data-state=busy][data-form=solid] .m{animation:ovmBreathe 1.1s ease-in-out infinite}
--.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important;--ovm-fill:1}
--.ov-mark[data-state=ok] .m{animation:ovmPop .55s cubic-bezier(.3,1.5,.5,1) 1}
--.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important;--ovm-dot:var(--danger,#ef4444)}
++.ov-mark[data-state=busy] .vg{animation:ovmBob .9s ease-in-out infinite}
++.ov-mark[data-state=busy] .og{animation:ovmBreathe .9s ease-in-out .45s infinite}
++.ov-mark[data-state=ok]{--ovm-o:var(--success,#22c55e);--ovm-v:var(--success,#22c55e)}
++.ov-mark[data-state=ok] .vg{animation:ovmHop .56s ease-out .14s 1 both}
++.ov-mark[data-state=error]{--ovm-o:var(--danger,#ef4444);--ovm-v:var(--danger,#ef4444)}
.ov-mark[data-state=error] .m{animation:ovmShake .56s ease-in-out 1}
--.ov-mark[data-state=error] .d{animation:ovmDrop .7s ease-in 1 both}
--.ov-mark[data-state=no]{color:var(--text-muted,#94a3b8)!important}
--.ov-mark[data-state=no] .m{animation:ovmNo .52s ease-in-out 1}
--.ov-mark[data-state=notify] .d{animation:ovmPing .76s ease-out 2}
--.ov-mark[data-state=live]{--ovm-dot:var(--danger,#ef4444)}
--.ov-mark[data-state=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
++.ov-mark[data-state=no]{--ovm-o:var(--text-muted,#94a3b8);--ovm-v:var(--text-muted,#94a3b8)}
++.ov-mark[data-state=no] .og{animation:ovmNo .52s ease-in-out .1s 1}
++.ov-mark[data-state=notify] .nd{opacity:1;transform:scale(1)}
++.ov-mark[data-state=notify] .og{animation:ovmBounce .76s ease-out .14s 1}
++.ov-mark[data-state=live]{--ovm-o:#ff3b5c}
++.ov-mark[data-state=live] .og{animation:ovmPulse 1.6s ease-in-out infinite}
.ov-mark[data-state=live] .rp{animation:ovmRipple 1.6s ease-out infinite}
.ov-mark[data-state=live] .rp2{animation-delay:.8s}
.ov-mark[data-state=sleep] svg{filter:grayscale(1);opacity:.7}
--.ov-mark[data-state=sleep] .d{transform:translateY(5.2px);transition:transform .6s,opacity .3s}
--/* The intro: the line draws itself, then the dot drops into the opening; the coin pops. */
--.ov-mark[data-ovm-intro]:not([data-form=solid]) .lp{animation:ovmDraw 1.1s cubic-bezier(.3,.8,.2,1) both}
--.ov-mark[data-ovm-intro]:not([data-form=solid]) .d{animation:ovmIn .5s cubic-bezier(.3,1.5,.5,1) .85s both}
--.ov-mark[data-ovm-intro][data-form=solid] .m{animation:ovmPop .6s cubic-bezier(.3,1.5,.5,1) both}
--/* Per-site rhythm: one family, each site with its own dot and pace. Colours stay with the theme (currentColor). */
--.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
--.ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .d{animation:ovmOnAir 1.4s ease-in-out infinite}
++.ov-mark[data-state=sleep] .og{transform:scaleY(.14);transition:transform .52s ease-in-out}
++.ov-mark[data-state=sleep] .vg{transform:translateY(4px);transition:transform .52s ease-in-out}
++.ov-mark[data-state=sleep] .va,.ov-mark[data-state=sleep] .vb{opacity:.5}
++/* Per-site touches: one family, each site with its own light in the O and its own pace. */
++.ov-mark[data-variant=live] .cd,.ov-mark[data-variant=vip] .cd,.ov-mark[data-variant=tips] .cd,.ov-mark[data-variant=deals] .cd,.ov-mark[data-variant=coupons] .cd{display:inline}
++.ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .cd{animation:ovmOnAir 1.4s ease-in-out infinite}
.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
--.ov-mark[data-variant=games][data-state=busy] .d{animation-duration:.6s}
--.ov-mark[data-variant=chat][data-state=busy] .d{animation-duration:.55s}
--.ov-mark[data-variant=codes][data-state=busy] .d{animation-timing-function:steps(4,end)}
--.ov-mark[data-variant=blog][data-state=busy] .d,.ov-mark[data-variant=wiki][data-state=busy] .d,.ov-mark[data-variant=news][data-state=busy] .d{animation-duration:1.2s}
--@keyframes ovmHop{0%,100%{transform:translateY(0) scale(1)}45%{transform:translateY(5.2px) scale(1.1,.84)}55%{transform:translateY(5.2px) scale(.92,1.1)}}
--@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.9)}}
--@keyframes ovmPop{0%{transform:scale(.6)}60%{transform:scale(1.08)}100%{transform:scale(1)}}
--@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-3.2px)}30%{transform:translateX(3.2px)}45%{transform:translateX(-2.6px)}60%{transform:translateX(2.6px)}80%{transform:translateX(-1.2px)}}
--@keyframes ovmNo{0%,100%{transform:translateX(0)}25%{transform:translateX(-2px)}50%{transform:translateX(2px)}75%{transform:translateX(-1.5px)}}
--@keyframes ovmDrop{0%{transform:translateY(0)}55%{transform:translateY(5.6px)}70%{transform:translateY(4.2px)}100%{transform:translateY(5.6px)}}
--@keyframes ovmPing{0%{transform:scale(1)}30%{transform:scale(1.6)}55%{transform:scale(.9)}75%{transform:scale(1.25)}100%{transform:scale(1)}}
--@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.35);opacity:1}}
--@keyframes ovmRipple{0%{transform:scale(1);opacity:.65}100%{transform:scale(1.4);opacity:0}}
--@keyframes ovmDraw{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
--@keyframes ovmIn{from{transform:scale(0)}to{transform:scale(1)}}
++.ov-mark[data-variant=games][data-state=busy] .vg,.ov-mark[data-variant=games][data-state=busy] .og{animation-duration:.6s}
++.ov-mark[data-variant=chat][data-state=busy] .vg,.ov-mark[data-variant=chat][data-state=busy] .og{animation-duration:.55s}
++.ov-mark[data-variant=codes][data-state=busy] .vg{animation-timing-function:steps(4,end)}
++.ov-mark[data-variant=blog][data-state=busy] .vg,.ov-mark[data-variant=wiki][data-state=busy] .vg,.ov-mark[data-variant=news][data-state=busy] .vg,.ov-mark[data-variant=blog][data-state=busy] .og,.ov-mark[data-variant=wiki][data-state=busy] .og,.ov-mark[data-variant=news][data-state=busy] .og{animation-duration:1.2s}
++@keyframes ovmPop{0%{transform:scale(0)}100%{transform:scale(1)}}
++@keyframes ovmLean{0%,100%{transform:rotate(0)}45%{transform:rotate(-11deg)}}
++@keyframes ovmHopO{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px)}}
++@keyframes ovmNod{0%,100%{transform:rotate(0)}50%{transform:rotate(-7deg)}}
++@keyframes ovmHop{0%,100%{transform:translateY(0) scaleY(1)}35%{transform:translateY(-9px) scaleY(1.04)}70%{transform:translateY(0) scaleY(.96)}}
++@keyframes ovmBob{0%,100%{transform:translateY(0)}50%{transform:translateY(6px)}}
++@keyframes ovmBreathe{0%,100%{transform:scale(1)}50%{transform:scale(.88)}}
++@keyframes ovmPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
++@keyframes ovmShake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}30%{transform:translateX(7px)}45%{transform:translateX(-6px)}60%{transform:translateX(6px)}80%{transform:translateX(-3px)}}
++@keyframes ovmNo{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}
++@keyframes ovmBounce{0%,80%,100%{transform:translateY(0)}20%{transform:translateY(-6px)}40%{transform:translateY(0)}60%{transform:translateY(-4px)}}
++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.3);opacity:1}}
++@keyframes ovmRipple{0%{transform:scale(1);opacity:.7}100%{transform:scale(1.9);opacity:0}}
.ov-mark[data-static] *{animation:none!important}
.ov-mark[data-ov]:not([data-ovm-run]) *{animation-play-state:paused!important}
--.ov-mark .sh,.ov-mark .sw2{display:none}
--html[data-ov-season-mark=halloween] .ov-mark .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}
--html[data-ov-season-mark=halloween] .ov-mark .d{display:none}
--.ov-mark .ss{fill:none;stroke:#5fae3c;stroke-width:3.2;stroke-linecap:round}
--.ov-mark .sl{fill:#7fd04e;transform-box:fill-box;transform-origin:0 70%}
--.ov-mark .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}
--.ov-mark .sd{fill:#f4fbff}
--html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}
--html[data-ov-season-mark] .ov-mark[data-ovm-intro] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
.ov-mark[data-ovm-flourish] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
--.ov-mark[data-ovm-flourish] .d{animation:ovmPing .76s ease-out 1}
++.ov-mark[data-ovm-flourish] .vg{animation:ovmLean .7s ease-out 1}
.ovm-fall{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
@@@ -139,20 +160,48 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@keyframes ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes ovmFall{0%{opacity:0;transform:none}12%{opacity:1}72%{opacity:1;transform:translate(var(--x),var(--y)) rotate(var(--r))}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
--@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark .m{transition:none}.ovm-fall{display:none}}
++@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark *{transition:none!important}.ovm-fall{display:none}}
html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
-- function svg(id) {
-- const G = GEO;
-- return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
--<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" style="stop-color:var(--ovm-to)"/></linearGradient>
--<mask id="${id}v" maskUnits="userSpaceOnUse" x="-4" y="-4" width="56" height="56"><rect x="-4" y="-4" width="56" height="56" fill="#fff"/><path d="${G.vee}" fill="none" stroke="#000" stroke-width="${G.sw + 3.4}" stroke-linecap="round" stroke-linejoin="round"/></mask>
--<clipPath id="${id}f"><rect class="fr" x="-4" y="-4" width="56" height="${G.span + 8}"/></clipPath></defs>
--<g class="m"><circle class="rp" cx="24" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="24" cy="${G.cy}" r="${G.R}"/>
--<g clip-path="url(#${id}f)"><circle class="fl" cx="24" cy="${G.cy}" r="${G.R}" fill="url(#${id}g)" mask="url(#${id}v)"/></g>
--<path class="lp" d="${G.loop}" pathLength="100" stroke="url(#${id}g)"/>
--<circle class="d" cx="24" cy="${G.dotY}" r="${G.dotR}"/>
--<g class="sh"><path class="ss" d="M24 9.6c.2-2.8 1-4.8 2.7-6.1"/><path class="sl" d="M26.4 4.6c2.3-2.5 5.8-2.7 8.3-1.2-1.9 2.7-5.4 3.5-8.3 1.2z"/></g>
--<g class="sw2"><path class="sc" d="${G.snow[0]}"/><path class="sc" d="${G.snow[1]}"/><circle class="sd" cx="${G.flake[0][0]}" cy="${G.flake[0][1]}" r="1.4"/><circle class="sd" cx="${G.flake[1][0]}" cy="${G.flake[1][1]}" r="1.2"/></g></g></svg>`;
++ const seg = (p, i) => `M${f(p[i])} ${f(p[i + 1])}L${f(p[i + 2])} ${f(p[i + 3])}`;
++ function svg(id, G, wide) {
++ const p = G.shapes.v, oc = `transform-origin:${G.cx}px ${G.cy}px`, vc = `transform-origin:${G.T[0]}px ${G.T[1]}px`;
++ const ri = G.r - G.sw / 2, top = G.cy - G.R;
++ // The season: a pumpkin's stem and leaf on the O; snow on the O and on the V's caps.
++ const stem = `M${f(G.cx + 0.6)} ${f(top + 2.5)}c.3-4.6 1.6-8 5-10.6`, leaf = `M${f(G.cx + 5.2)} ${f(top - 7.4)}c4.4-4.8 11-5.2 15.8-2.4-3.6 5.2-10.4 6.8-15.8 2.4z`;
++ const capR = f(G.r + G.sw / 2 - 1.6), at = (deg) => [f(G.cx + capR * Math.cos(deg * Math.PI / 180)), f(G.cy + capR * Math.sin(deg * Math.PI / 180))];
++ const c0 = at(-150), c1 = at(-40);
++ return `<svg viewBox="${wide ? G.wideBox : G.box}" aria-hidden="true"><defs>
++<mask id="${id}h" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg" style="${vc}"><path class="ha" d="${seg(p, 0)}" stroke-width="${G.halo}"/><path class="hb" d="${seg(p, 4)}" stroke-width="${G.halo}"/></g></mask></defs>
++<g class="m"><circle class="rp" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/><circle class="rp rp2" cx="${G.cx}" cy="${G.cy}" r="${G.R}"/>
++<g mask="url(#${id}h)"><g class="og" style="${oc}"><path class="ot" d="${G.ring}" stroke-width="${G.sw}"/><path class="o" d="${G.ring}" pathLength="100" stroke-width="${G.sw}"/><circle class="cd" cx="${G.cx}" cy="${G.cy}" r="${f(ri * 0.62)}"/>
++<g class="sw2"><path class="sc" d="M${c0[0]} ${c0[1]}A${capR} ${capR} 0 0 1 ${c1[0]} ${c1[1]}"/></g></g></g>
++<g class="vg" style="${vc}"><path class="va" d="${seg(p, 0)}" stroke-width="${G.vsw}"/><path class="vb" d="${seg(p, 4)}" stroke-width="${G.vsw}"/>
++<g class="sw2"><circle class="sd" cx="${G.A[0]}" cy="${f(G.A[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/><circle class="sd" cx="${G.B[0]}" cy="${f(G.B[1] - G.vsw * 0.18)}" r="${f(G.vsw * 0.36)}"/></g></g>
++<circle class="nd" cx="${f(G.B[0] + 5.5)}" cy="${f(G.B[1] - 9)}" r="${f(G.vsw * 0.55)}"/>
++<g class="sh"><path class="ss" d="${stem}"/><path class="sl" d="${leaf}"/></g>
++<g class="sw2"><circle class="sd" cx="${f(G.cx - 12)}" cy="${f(top - 6)}" r="1.8"/><circle class="sd" cx="${f(G.B[0] - 6)}" cy="${f(G.B[1] - 12)}" r="1.5"/></g></g></svg>`;
++ }
++
++ // ── The V's shapes: a state turns it into a tick, a cross or "!" and back, the gap in the ring following it ──
++ const SHAPE_OF = { ok: 'ok', error: 'err', no: 'no' };
++ const backOut = (p) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); };
++ function drawV(q, p) {
++ q.pts = p;
++ const a = seg(p, 0), b = seg(p, 4);
++ q.va.setAttribute('d', a); q.ha.setAttribute('d', a); q.vb.setAttribute('d', b); q.hb.setAttribute('d', b);
++ }
++ function shape(el, animate) {
++ const q = el.__ovm; if (!q) return;
++ const name = SHAPE_OF[el.getAttribute('data-state')] || 'v';
++ if (q.name === name) return;
++ q.name = name;
++ const to = FORMS[q.form].shapes[name], from = q.pts.slice();
++ const raf = typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : null;
++ if (q.raf && window.cancelAnimationFrame) window.cancelAnimationFrame(q.raf);
++ if (!animate || !raf) { drawV(q, to); return; }
++ const t0 = Date.now(), ms = 420;
++ const step = () => { const t = Math.min(1, (Date.now() - t0) / ms), e = backOut(t); drawV(q, from.map((v, i) => v + (to[i] - v) * e)); q.raf = t < 1 ? raf(step) : 0; };
++ q.raf = raf(step);
}
const INTRO_MS = 8000, LINGER_MS = 1200;
@@@ -183,8 +232,9 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
const intro = now < st.introEnd;
const run = may && (intro || st.hover || st.focus || now < st.lingerEnd || showing);
if (run !== el.hasAttribute('data-ovm-run')) { if (run) el.setAttribute('data-ovm-run', ''); else el.removeAttribute('data-ovm-run'); }
-- // The intro's draw-in and pop are on only inside the intro (paused with the rest while the mark cannot move).
++ // The intro's pop and growth are on only inside the intro (paused with the rest while the mark cannot move).
if ((may && intro) !== el.hasAttribute('data-ovm-intro') && (may || !intro)) { if (may && intro) el.setAttribute('data-ovm-intro', ''); else el.removeAttribute('data-ovm-intro'); }
++ shape(el, run);
try { if (run) svgEl.unpauseAnimations(); else svgEl.pauseAnimations(); } catch { /* no SMIL */ }
clearTimeout(st.timer); st.timer = 0;
const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
@@@ -263,14 +313,17 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
if (!el.hasAttribute('data-ov')) {
el.setAttribute('data-ov', '1');
const size = parseInt(el.getAttribute('data-size'), 10);
-- if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
-- // The form follows the size unless the page names one: a line under 24px blurs, the coin holds.
++ if (size) { el.style.width = size + 'px'; if (!el.hasAttribute('data-wide')) el.style.height = size + 'px'; }
++ // The form follows the size unless the page names one: under 22px the pair's gap closes, the heavy holds.
if (!el.hasAttribute('data-form')) {
let px = size;
if (!px) { try { px = Math.round(el.getBoundingClientRect().width); } catch { px = 0; } }
-- el.setAttribute('data-form', px && px <= 24 ? 'solid' : 'line');
++ el.setAttribute('data-form', px && px <= 22 ? 'heavy' : 'pair');
}
-- el.innerHTML = svg('ovm' + (++n) + '_');
++ const form = formOf(el.getAttribute('data-form'));
++ el.innerHTML = svg('ovm' + (++n) + '_', FORMS[form], el.hasAttribute('data-wide'));
++ const pick = (c) => el.querySelector(c);
++ el.__ovm = { form, name: 'v', pts: FORMS[form].shapes.v.slice(), raf: 0, va: pick('.va'), vb: pick('.vb'), ha: pick('.ha'), hb: pick('.hb') };
}
if (!state.has(el) && el.isConnected) track(el);
});
@@@ -304,10 +357,10 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-nav', 'data-progress', 'data-ov-motion', 'data-ov-season-mark'] });
} catch { /* */ }
}
-- // ── A page on its way: the navbar's mark fills as the next page loads ──
++ // ── A page on its way: the navbar's mark draws its ring round as the next page loads ──
// The pace is learned: the usual time of this site's boost moves and full page loads in this browser (a running
-- // average in localStorage). The fill reaches about four fifths at the usual time and never the top before the page
-- // is really here; then it fills, and drains back to the line on the new page.
++ // average in localStorage). The ring reaches about four fifths at the usual time and never closes before the page
++ // is really here; then it closes, the V hops, and the track fades on the new page.
const loc = () => window.location || { host: '', href: 'http://localhost/', origin: 'http://localhost', pathname: '/', search: '' };
const NAV_KEY = 'ov-nav-ms', NAV_FLAG = 'ov-nav-click', NAV_DEFAULT = { boost: 450, page: 1600 };
const navMarks = () => [...document.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark, .ov-mark[data-ov-nav]')].filter((m) => !m.closest('.openvibe-network-badge'));
@@@ -326,6 +379,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
function navStart(kind = 'page') {
navStop();
const marks = navMarks(); if (!marks.length) return;
++ marks.forEach((m) => m.removeAttribute('data-ovm-arrive'));
const t0 = Date.now(), avg = usual(kind);
const me = { kind, t0, timer: 0 };
const tick = () => {
@@@ -338,14 +392,18 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
tick();
}
function navStop() { if (nav) { clearTimeout(nav.timer); nav = null; } }
-- /** The page is here: fill to the top, then drain back. A navbar re-rendered by the move gets the same ending. */
++ /** The page is here: the ring closes and the V hops. A navbar re-rendered by the move gets the same ending. */
// The ending runs after every other listener of the arrival (the navbar re-renders on it), and no timer keeps a mark:
// each step looks the marks up again, so a replaced navbar is never held in memory.
-- const clearNav = () => { if (!nav) navMarks().forEach((m) => { m.removeAttribute('data-nav'); m.style.removeProperty('--ovm-fill'); }); };
++ const clearNav = () => { if (!nav) navMarks().forEach((m) => { m.removeAttribute('data-nav'); m.removeAttribute('data-ovm-arrive'); m.style.removeProperty('--ovm-fill'); }); };
function navEnd(kind) {
const was = nav; navStop();
if (was && kind) learn(kind, Date.now() - was.t0);
-- setTimeout(() => { if (nav) return; mount(); setFill(navMarks(), 1); setTimeout(clearNav, 320); }, 0);
++ setTimeout(() => {
++ if (nav) return; mount();
++ const marks = navMarks(); setFill(marks, 1); marks.forEach((m) => m.setAttribute('data-ovm-arrive', ''));
++ setTimeout(clearNav, 520);
++ }, 0);
}
function hookNav() {
document.addEventListener('ov:boost:before', () => navStart('boost'));
@@@ -362,7 +420,7 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
});
// Back to a page from the back/forward cache: nothing is loading any more.
window.addEventListener('pageshow', (e) => { if (e.persisted) { navStop(); clearNav(); } });
-- // This page came from a click on the last one: learn how long it took, and finish the fill here.
++ // This page came from a click on the last one: learn how long it took, and finish the ring here.
let t = 0; try { t = Number(sessionStorage.getItem(NAV_FLAG)) || 0; sessionStorage.removeItem(NAV_FLAG); } catch { /* */ }
if (t && Date.now() - t < 20000) {
learn('page', Date.now() - t);
@@@ -370,7 +428,14 @@ html[data-ov-motion=reduced] .ov-mark *{animation:none!important}`;
let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again();
}
}
-- window.OVMark = { mount, navStart, navEnd, usual };
++ /** Replay the intro on one mark (a brand page, a splash): the O pops in and the V grows out of its point. */
++ function intro(el) {
++ const st = el && state.get(el); if (!st) return;
++ el.removeAttribute('data-ovm-intro');
++ try { void el.getBoundingClientRect(); } catch { /* */ }
++ st.introEnd = Date.now() + 1400; update(el);
++ }
++ window.OVMark = { mount, navStart, navEnd, usual, intro, FORMS };
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
ddiff --git a/package.json b/package.json
iindex 8ed6d81..fbc45fd 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.58.0",
++ "version": "4.59.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -37,6 +37,7 @@
"./profile-panel": "./profile-panel.js",
"./legal": "./legal.js",
"./express-async": "./express-async.js",
++ "./mark": "./mark.js",
"./metrics": "./metrics.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
@@@ -93,6 +94,8 @@
"files": [
"*.js",
"*.css",
++ "*.woff2",
++ "OFL-Nunito.txt",
"analytics/",
"docs/schemas/",
"scripts/",
ddiff --git a/scripts/build-social-cards.js b/scripts/build-social-cards.js
iindex 3d5364e..321ac77 100644
---- a/scripts/build-social-cards.js
++++ b/scripts/build-social-cards.js
@@@ -6,8 +6,9 @@
// --only takes site ids (a site's page cards come with it) or page card ids.
// Needs Chrome (the drawing) and ImageMagick's `convert` (PNG → a ~60 KB progressive JPEG).
// The name, tagline, domain and accent come from each service manifest's `site` block (Contracts is the source of
--// truth; this script only draws them). The mark is openvibe-shared/app-icon. Inter is fetched once from Google Fonts
--// and embedded, so a card never depends on the fonts of the machine that rendered it. Re-run after a tagline,
++// truth; this script only draws them). The mark is openvibe-shared/app-icon. The names are set in the brand face
++// (ov-brand.woff2, Nunito, as the navbar draws them) and the rest in Inter, fetched once from Google Fonts; both are
++// embedded, so a card never depends on the fonts of the machine that rendered it. Re-run after a tagline,
// accent or domain changes, and commit the PNGs: sites serve them from their pinned copy (openvibe-shared/serve).
const fs = require('fs');
const os = require('os');
@@@ -50,6 +51,12 @@ async function interFaces() {
return out.join('\n');
}
++/** The brand face the names are set in (ov-brand.woff2 beside navbar.js), embedded. */
++function brandFace() {
++ const data = fs.readFileSync(path.join(__dirname, '..', 'ov-brand.woff2')).toString('base64');
++ return `@font-face{font-family:'OV Brand';font-style:normal;font-weight:700 900;font-display:block;src:url(data:font/woff2;base64,${data}) format('woff2')}`;
++}
++
function sites() {
const dir = path.join(contracts, 'manifests', 'services');
const out = [];
@@@ -97,13 +104,13 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
.ring2{position:absolute;right:-30px;top:50%;width:380px;height:380px;margin-top:-190px;border-radius:50%;border:2px solid ${a1};opacity:.16}
.wrap{position:absolute;inset:0;padding:60px 84px 60px;display:flex;flex-direction:column}
.top{display:flex;align-items:center;gap:22px}
--.icon{width:88px;height:88px;border-radius:22px;overflow:hidden;box-shadow:0 18px 50px -12px ${a2}88, 0 0 0 1px #ffffff1a}
--.icon svg{width:88px;height:88px;display:block}
++.icon{width:96px;height:96px;border-radius:24px;overflow:hidden;box-shadow:0 18px 50px -12px ${a2}88, 0 0 0 1px #ffffff1a}
++.icon svg{width:96px;height:96px;display:block}
.net{font-size:26px;font-weight:600;color:#94a3b8;letter-spacing:.01em}
--.net.site{font-size:34px;font-weight:800;letter-spacing:-.02em}
++.net.site{font-family:'OV Brand','Inter',sans-serif;font-size:36px;font-weight:900;letter-spacing:-.01em}
.net.site .w{color:#f8fafc}.net.site .d{color:${a1}}
.net.site .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
--.name{margin-top:36px;font-size:${long ? 92 : 108}px;font-weight:800;letter-spacing:-.035em;line-height:1}
++.name{margin-top:36px;font-family:'OV Brand','Inter',sans-serif;font-size:${long ? 94 : 110}px;font-weight:900;letter-spacing:-.02em;line-height:1}
.name .w{color:#f8fafc}.name .d{color:${a1}}
.name .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
.tag{margin-top:22px;max-width:900px;font-size:42px;font-weight:600;line-height:1.18;color:#cbd5e1;letter-spacing:-.012em;
@@@ -128,7 +135,7 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
(async () => {
fs.mkdirSync(OUT, { recursive: true });
-- const fonts = await interFaces();
++ const fonts = `${brandFace()}\n${await interFaces()}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-social-'));
const list = sites();
for (const site of list) {
ddiff --git a/serve.js b/serve.js
iindex d9d9052..2bc3964 100644
---- a/serve.js
++++ b/serve.js
@@@ -79,6 +79,7 @@ function contentType(name) {
if (name.endsWith('.css')) return 'text/css; charset=utf-8';
if (name.endsWith('.jpg')) return 'image/jpeg';
if (name.endsWith('.png')) return 'image/png';
++ if (name.endsWith('.woff2')) return 'font/woff2';
return 'application/javascript; charset=utf-8';
}
ddiff --git a/site-switcher.js b/site-switcher.js
iindex b12cb13..18584bc 100644
---- a/site-switcher.js
++++ b/site-switcher.js
@@@ -47,7 +47,7 @@
.ovsw-row { position: relative; display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; }
.ovsw-row::-webkit-scrollbar { display: none; }
.ovsw-row .ovsw-t { flex: 0 0 72px; }
--.ovsw-t { all: unset; box-sizing: border-box; position: relative; display: grid; justify-items: center; gap: 5px; padding: 8px 2px 7px; border-radius: 13px; cursor: pointer; font-size: 11.5px; font-weight: 600; line-height: 1.15; text-align: center; color: var(--text-secondary, #a8b3c4); -webkit-touch-callout: none; user-select: none; animation: ovsw-tile .36s cubic-bezier(.2, 1.3, .3, 1) both; animation-delay: calc(var(--k, 0) * 10ms + 50ms); }
++.ovsw-t { all: unset; box-sizing: border-box; position: relative; display: grid; justify-items: center; gap: 5px; padding: 8px 2px 7px; border-radius: 13px; cursor: pointer; font-family: 'OV Brand', Nunito, 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 12px; font-weight: 800; line-height: 1.15; text-align: center; color: var(--text-secondary, #a8b3c4); -webkit-touch-callout: none; user-select: none; animation: ovsw-tile .36s cubic-bezier(.2, 1.3, .3, 1) both; animation-delay: calc(var(--k, 0) * 10ms + 50ms); }
.ovsw-t:hover, .ovsw-t:focus-visible { outline: none; color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
.ovsw-t:focus-visible { box-shadow: inset 0 0 0 1.5px var(--accent, #3b82f6); }
.ovsw-t .ov-icon { width: 36px; height: 36px; transition: transform .26s cubic-bezier(.2, 1.4, .3, 1); }
ddiff --git a/social/actor.jpg b/social/actor.jpg
iindex b78be28..2f3a3d6 100644
BBinary files a/social/actor.jpg and b/social/actor.jpg differ
ddiff --git a/social/ai.jpg b/social/ai.jpg
iindex 0d27309..de1b3d6 100644
BBinary files a/social/ai.jpg and b/social/ai.jpg differ
ddiff --git a/social/blog.jpg b/social/blog.jpg
iindex 401d164..1b69a16 100644
BBinary files a/social/blog.jpg and b/social/blog.jpg differ
ddiff --git a/social/bot.jpg b/social/bot.jpg
iindex fc549e8..4fe7102 100644
BBinary files a/social/bot.jpg and b/social/bot.jpg differ
ddiff --git a/social/chat.jpg b/social/chat.jpg
iindex 01c0977..1be65e8 100644
BBinary files a/social/chat.jpg and b/social/chat.jpg differ
ddiff --git a/social/codes.jpg b/social/codes.jpg
iindex 89b224d..37ca341 100644
BBinary files a/social/codes.jpg and b/social/codes.jpg differ
ddiff --git a/social/community.jpg b/social/community.jpg
iindex 9d07565..cec7aab 100644
BBinary files a/social/community.jpg and b/social/community.jpg differ
ddiff --git a/social/coupons.jpg b/social/coupons.jpg
iindex 4ee2bee..425c144 100644
BBinary files a/social/coupons.jpg and b/social/coupons.jpg differ
ddiff --git a/social/deals.jpg b/social/deals.jpg
iindex a46ba5c..21ad35c 100644
BBinary files a/social/deals.jpg and b/social/deals.jpg differ
ddiff --git a/social/events.jpg b/social/events.jpg
iindex 2ff6347..e660f75 100644
BBinary files a/social/events.jpg and b/social/events.jpg differ
ddiff --git a/social/food.jpg b/social/food.jpg
iindex 798f6e9..96fcec6 100644
BBinary files a/social/food.jpg and b/social/food.jpg differ
ddiff --git a/social/games.jpg b/social/games.jpg
iindex 7c89efa..a47af0a 100644
BBinary files a/social/games.jpg and b/social/games.jpg differ
ddiff --git a/social/help.jpg b/social/help.jpg
iindex 9102277..f0b6ee3 100644
BBinary files a/social/help.jpg and b/social/help.jpg differ
ddiff --git a/social/host.jpg b/social/host.jpg
iindex 61e4706..3a29b28 100644
BBinary files a/social/host.jpg and b/social/host.jpg differ
ddiff --git a/social/inventory.jpg b/social/inventory.jpg
iindex 4e89ece..d01e263 100644
BBinary files a/social/inventory.jpg and b/social/inventory.jpg differ
ddiff --git a/social/live-chat.jpg b/social/live-chat.jpg
iindex 002eea1..d08983f 100644
BBinary files a/social/live-chat.jpg and b/social/live-chat.jpg differ
ddiff --git a/social/live-content.jpg b/social/live-content.jpg
iindex e41ccb3..0cd480d 100644
BBinary files a/social/live-content.jpg and b/social/live-content.jpg differ
ddiff --git a/social/live-moments.jpg b/social/live-moments.jpg
iindex 81d7708..03955cd 100644
BBinary files a/social/live-moments.jpg and b/social/live-moments.jpg differ
ddiff --git a/social/live-rundown.jpg b/social/live-rundown.jpg
iindex 8a160dc..2d8a154 100644
BBinary files a/social/live-rundown.jpg and b/social/live-rundown.jpg differ
ddiff --git a/social/live.jpg b/social/live.jpg
iindex 6b91abf..06fed75 100644
BBinary files a/social/live.jpg and b/social/live.jpg differ
ddiff --git a/social/media-hub.jpg b/social/media-hub.jpg
iindex 2161116..613d8c2 100644
BBinary files a/social/media-hub.jpg and b/social/media-hub.jpg differ
ddiff --git a/social/media.jpg b/social/media.jpg
iindex 8f6e503..f4bb857 100644
BBinary files a/social/media.jpg and b/social/media.jpg differ
ddiff --git a/social/network.jpg b/social/network.jpg
iindex 2720166..3cd41ae 100644
BBinary files a/social/network.jpg and b/social/network.jpg differ
ddiff --git a/social/news.jpg b/social/news.jpg
iindex 60888f8..6ff1fb9 100644
BBinary files a/social/news.jpg and b/social/news.jpg differ
ddiff --git a/social/openre.jpg b/social/openre.jpg
iindex 28eb16e..06fd2d0 100644
BBinary files a/social/openre.jpg and b/social/openre.jpg differ
ddiff --git a/social/play.jpg b/social/play.jpg
iindex eb0beca..d2d5306 100644
BBinary files a/social/play.jpg and b/social/play.jpg differ
ddiff --git a/social/quest.jpg b/social/quest.jpg
iindex e8382b5..840b12f 100644
BBinary files a/social/quest.jpg and b/social/quest.jpg differ
ddiff --git a/social/rent.jpg b/social/rent.jpg
iindex c51f50b..5ca16d1 100644
BBinary files a/social/rent.jpg and b/social/rent.jpg differ
ddiff --git a/social/reviews.jpg b/social/reviews.jpg
iindex e723db0..fbcd434 100644
BBinary files a/social/reviews.jpg and b/social/reviews.jpg differ
ddiff --git a/social/run.jpg b/social/run.jpg
iindex 4b1eb0a..2bc7f8a 100644
BBinary files a/social/run.jpg and b/social/run.jpg differ
ddiff --git a/social/search.jpg b/social/search.jpg
iindex 0385e0a..c55f6af 100644
BBinary files a/social/search.jpg and b/social/search.jpg differ
ddiff --git a/social/services.jpg b/social/services.jpg
iindex 91ec338..092fe1e 100644
BBinary files a/social/services.jpg and b/social/services.jpg differ
ddiff --git a/social/space.jpg b/social/space.jpg
iindex 2f238c3..d83df2d 100644
BBinary files a/social/space.jpg and b/social/space.jpg differ
ddiff --git a/social/tips.jpg b/social/tips.jpg
iindex 993f883..370577b 100644
BBinary files a/social/tips.jpg and b/social/tips.jpg differ
ddiff --git a/social/tools.jpg b/social/tools.jpg
iindex 87395eb..02e3743 100644
BBinary files a/social/tools.jpg and b/social/tools.jpg differ
ddiff --git a/social/trade.jpg b/social/trade.jpg
iindex 8af042e..7541829 100644
BBinary files a/social/trade.jpg and b/social/trade.jpg differ
ddiff --git a/social/vip.jpg b/social/vip.jpg
iindex 73cb7ab..deb1a9e 100644
BBinary files a/social/vip.jpg and b/social/vip.jpg differ
ddiff --git a/social/watch.jpg b/social/watch.jpg
iindex b7c1f32..203e7b1 100644
BBinary files a/social/watch.jpg and b/social/watch.jpg differ
ddiff --git a/social/wiki.jpg b/social/wiki.jpg
iindex 7dc0c89..18b8b67 100644
BBinary files a/social/wiki.jpg and b/social/wiki.jpg differ
ddiff --git a/social/work.jpg b/social/work.jpg
iindex 5a40508..b34692e 100644
BBinary files a/social/work.jpg and b/social/work.jpg differ
ddiff --git a/social/zone.jpg b/social/zone.jpg
iindex df888aa..7caf2bd 100644
BBinary files a/social/zone.jpg and b/social/zone.jpg differ
ddiff --git a/test/brand-face.test.js b/test/brand-face.test.js
nnew file mode 100644
iindex 0000000..c38137f
---- /dev/null
++++ b/test/brand-face.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The OpenVibe names are set in the brand face (4.59.0): Nunito, rounded like the mark, cut to Latin and weights
++// 700-900 (ov-brand.woff2, SIL OFL 1.1). Sites serve it at /shared/ov-brand.woff2 beside navbar.js, which declares it;
++// the navbar's site name, the footer's brand, the site switcher's names and the share cards use it, and every one
++// falls back to the system face, so a site that does not serve the file still reads.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const files = require('../files');
++const serve = require('../serve');
++
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++const font = fs.readFileSync(path.join(__dirname, '..', 'ov-brand.woff2'));
++assert.strictEqual(font.subarray(0, 4).toString('latin1'), 'wOF2', 'a WOFF2 file');
++assert.ok(font.length < 20 * 1024, `small enough for every first visit (${(font.length / 1024).toFixed(1)} KB)`);
++assert.ok(/SIL OPEN FONT LICENSE Version 1\.1/i.test(read('OFL-Nunito.txt')), 'its licence travels with it');
++const pkg = JSON.parse(read('package.json'));
++assert.ok(pkg.files.includes('*.woff2') && pkg.files.includes('OFL-Nunito.txt'), 'both are in the package');
++
++// Served at /shared/<file>, as a font, and never counted as a browser script.
++assert.ok(files.FONTS.includes('ov-brand.woff2') && files.isBrowserFile('ov-brand.woff2'), 'sites serve it');
++assert.ok(!files.BROWSER.includes('ov-brand.woff2'), 'not a script: the size report and the bundle test read BROWSER');
++assert.strictEqual(files.path('ov-brand.woff2'), path.join(__dirname, '..', 'ov-brand.woff2'));
++(async () => {
++ const headers = {};
++ const res = { statusCode: 200, setHeader: (k, v) => { headers[k.toLowerCase()] = v; }, end() {} };
++ await new Promise((resolve, reject) => serve.handler()({ method: 'HEAD', path: '/ov-brand.woff2', query: {}, headers: {} }, Object.assign(res, { end: resolve }), reject));
++ assert.strictEqual(headers['content-type'], 'font/woff2', 'with its own type');
++ assert.strictEqual(headers['access-control-allow-origin'], '*', 'and readable from another site (fonts need CORS)');
++
++ // Declared once by the navbar, from beside itself, for the characters it has; used with a fallback everywhere.
++ const nav = read('navbar.js');
++ assert.match(nav, /@font-face \{ font-family: 'OV Brand'; src: url\(\$\{sibling\('ov-brand\.woff2'\)\}\) format\('woff2'\); font-weight: 700 900;[^}]*font-display: swap; unicode-range: U\+0020-007E/, 'navbar.js declares it, swapping in when it arrives');
++ const fallback = /font-family: ?'OV Brand', ?Nunito, ?'?Inter'?, ?-apple-system/;
++ assert.match(nav, new RegExp(`\\.openvibe-navbar-brand \\.name \\{[^}]*${fallback.source}`), 'the site name');
++ assert.match(read('footer.js'), new RegExp(`\\.ovf-brand\\{[^}]*${fallback.source}`), "the footer's brand");
++ assert.match(read('site-switcher.js'), new RegExp(`\\.ovsw-t \\{[^}]*${fallback.source}`), "the switcher's names");
++ const cards = read('scripts/build-social-cards.js');
++ assert.ok(/function brandFace\(\)[\s\S]*ov-brand\.woff2/.test(cards) && /\.name\{[^}]*font-family:'OV Brand'/.test(cards), 'the share cards, with the face embedded');
++
++ // The mark sits beside the name in the wide box: level with the words, not a square with space above and below.
++ assert.match(nav, /<span class="ov-mark" data-size="34" data-wide data-variant=/, 'navbar: the wide mark');
++ assert.match(read('footer.js'), /<span class="ov-mark" data-size="38" data-wide><\/span>/, 'footer: the wide mark');
++ console.log('brand face: served as a font with CORS, declared by the navbar, used for the names with a fallback; the wide mark beside them');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/mark.test.js b/test/mark.test.js
nnew file mode 100644
iindex 0000000..e16f6da
---- /dev/null
++++ b/test/mark.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++// openvibe-shared/mark (4.59.0): the OpenVibe mark, the Pair, as static SVG. One set of numbers is drawn in four places:
++// mark.js (servers, icons, share cards, downloads), ov-mark.js (the browser drop-in), app-icon.js (favicons and app
++// icons, through mark.js) and loading.js (the loader's mark, by its own copy of the V). They must agree, or the logo
++// differs between a tab, the navbar and the loader.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { parseHTML } = require('linkedom');
++const mark = require('../mark');
++const appIcon = require('../app-icon');
++
++// ov-mark.js's own numbers, from a page that mounts it.
++const { window, document, MutationObserver } = parseHTML('<!doctype html><html><head></head><body><span class="ov-mark" data-size="40"></span></body></html>');
++window.__ovMark = false;
++window.matchMedia = () => ({ matches: false, addEventListener() {} });
++new Function('window', 'document', 'IntersectionObserver', 'MutationObserver', 'sessionStorage', fs.readFileSync(path.join(__dirname, '..', 'ov-mark.js'), 'utf8'))(window, document, undefined, MutationObserver, { getItem: () => null, setItem() {}, removeItem() {} });
++const drop = window.OVMark.FORMS;
++
++for (const form of ['pair', 'heavy']) {
++ const g = mark.geo(form), d = drop[form];
++ for (const k of ['cx', 'cy', 'r', 'sw', 'R', 'vsw', 'halo', 'ring', 'box', 'wideBox']) assert.deepStrictEqual(d[k], g[k], `${form}.${k}: ov-mark.js and mark.js agree`);
++ for (const k of ['A', 'T', 'B']) assert.deepStrictEqual(d[k], g[k], `${form}.${k}`);
++ assert.deepStrictEqual(d.shapes, mark.shapes(form), `${form}: the V's shapes agree`);
++}
++
++// The geometry: the V as tall as the O, the mark centred on (50, 50) in its box, and the V's caps clear of the ring.
++for (const form of ['pair', 'heavy']) {
++ const g = mark.geo(form);
++ assert.strictEqual(+(g.T[1] + g.vsw / 2).toFixed(2), +(g.cy + g.R).toFixed(2), `${form}: the V's point meets the O's bottom`);
++ assert.strictEqual(+(g.A[1] - g.vsw / 2).toFixed(2), +(g.cy - g.R).toFixed(2), `${form}: its caps meet the O's top`);
++ const left = g.cx - g.R, right = g.B[0] + g.vsw / 2;
++ assert.ok(Math.abs((left + right) / 2 - 50) < 0.02, `${form}: centred across (${left}, ${right})`);
++ const [x, y, w, h] = g.box.split(' ').map(Number);
++ assert.ok(Math.abs(x + w / 2 - 50) < 0.02 && Math.abs(y + h / 2 - 50) < 0.02 && w === h, `${form}: the square box is centred on the mark`);
++}
++
++// The standalone SVG: the gap is a mask, so it holds on any ground; the tones; the wide box.
++const svg = mark.svg({ size: 64, title: 'OpenVibe' });
++assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" viewBox="[\d. ]+" width="64" height="64" role="img" aria-label="OpenVibe"><title>OpenVibe<\/title>/);
++assert.match(svg, /<mask id="ovmh"[\s\S]*stroke="#000" stroke-width="20"[\s\S]*<\/mask>[\s\S]*<circle [^>]*stroke="#4f7cff"[^>]*mask="url\(#ovmh\)"\/><path [^>]*stroke="#8b5cf6"/, 'blue O cut by a mask, violet V on top');
++assert.match(mark.svg({ tone: 'ink' }), /stroke="currentColor"[\s\S]*stroke="currentColor"/, 'ink: the text colour');
++assert.match(mark.svg({ tone: 'white' }), /stroke="#fff"[\s\S]*stroke="#fff"/, 'white: for a colour tile');
++const wide = mark.svg({ wide: true, size: 40 });
++assert.match(wide, new RegExp(`viewBox="${mark.geo('pair').wideBox}" width="66.13" height="40"`), 'wide: the box hugs the mark, the width follows');
++assert.match(mark.svg({ title: '<b>&' }), /aria-label="&lt;b&gt;&amp;"><title>&lt;b&gt;&amp;<\/title>/, 'the title is escaped');
++
++// Favicons and app icons draw the same mark (the heavy at favicon size) on the navy tile.
++const fav = appIcon.favicon({ site: 'network' });
++assert.ok(fav.includes(`stroke-width="${mark.geo('heavy').vsw}"`) && fav.includes('<mask id="ovh"'), 'favicon: the heavy form, with its gap');
++const live = appIcon.svg({ site: 'live', maskable: false });
++assert.ok(live.includes('fill="#ef4444"'), "Live's icon carries the record light in the O");
++assert.ok(!appIcon.svg({ site: 'network' }).includes('fill="#ffffff"'), 'a white "dot" means no light');
++// Maskable: the farthest point of the mark (the V's caps) stays inside the 40 % safe circle of a 512 icon.
++{
++ const g = mark.geo('pair'), scale = 4;
++ const far = Math.max(...[g.A, g.B].map(([x, y]) => Math.hypot(x - 50, y - 50) + g.vsw / 2), 50 - (g.cx - g.R));
++ assert.ok(far * scale <= 204.8, `maskable: ${(far * scale).toFixed(1)} of 204.8`);
++ assert.ok(appIcon.svg({ site: 'network', maskable: true }).includes(`scale(${scale})`), 'drawn at that scale');
++}
++
++// The loader's mark is the pair's V, and its "!" on a failure.
++{
++ const src = fs.readFileSync(path.join(__dirname, '..', 'loading.js'), 'utf8');
++ const p = mark.shapes('pair'), seg = (s, i) => `M${s[i]} ${s[i + 1]}L${s[i + 2]} ${s[i + 3]}`;
++ assert.ok(src.includes(`const V = ['${seg(p.v, 0)}', '${seg(p.v, 4)}'], BANG = ['${seg(p.err, 0)}', '${seg(p.err, 4)}'];`), 'loading.js draws the same V and "!"');
++ assert.ok(src.includes(`viewBox="${mark.geo('pair').box}"`) && src.includes(`cx="${mark.geo('pair').cx}" cy="50" r="20"`), 'and the same O in the same box');
++}
++
++console.log('mark: one geometry in mark.js, ov-mark.js, the icons and the loader; centred, level, the gap a mask; tones, wide box, maskable safe zone');
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex 2137b4e..ffe9854 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -1,5 +1,5 @@
'use strict';
--// ov-mark.js (the Loop, 4.58.0): its two forms by size, the page-load fill and its learned pace, and the motion budget: the mark moves for its intro, on hover/focus and while it shows activity,
++// ov-mark.js (the Pair, 4.59.0): its two forms by size, the V's shapes per state, the page-load ring and its learned pace, and the motion budget: the mark moves for its intro, on hover/focus and while it shows activity,
// and is paused (CSS paused where it is, SMIL clock stopped) when the page is hidden, when it is
// off-screen, under reduced motion and with data-static. Browser check (OpenVibe.Host): the endless
// mark kept idle pages 12-29% busy. linkedom with a fake clock, IntersectionObserver and matchMedia.
@@@ -125,19 +125,30 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
const t = setup();
t.io.fire(t.mark, true);
assert.strictEqual(t.running(), true, 'intro runs');
-- assert.ok(t.mark.hasAttribute('data-ovm-intro'), 'the draw-in is on');
++ assert.ok(t.mark.hasAttribute('data-ovm-intro'), 'the pop and growth are on');
assert.strictEqual(t.smilPaused(), false);
t.clock.advance(7000);
assert.strictEqual(t.running(), true, 'still in the intro');
t.clock.advance(1100);
assert.strictEqual(t.running(), false, 'intro over: paused');
-- assert.ok(!t.mark.hasAttribute('data-ovm-intro'), 'and the draw-in is off');
++ assert.ok(!t.mark.hasAttribute('data-ovm-intro'), 'and they are off');
assert.strictEqual(t.smilPaused(), true);
// Scrolling away and back does not replay the intro.
t.io.fire(t.mark, false); t.io.fire(t.mark, true);
assert.strictEqual(t.running(), false, 'no second intro');
}
++ // ── OVMark.intro(el) replays the intro on one mark, later in the visit, and it ends by itself ─────────────
++ {
++ const t = setup();
++ t.io.fire(t.mark, true); t.clock.advance(9000);
++ assert.ok(!t.mark.hasAttribute('data-ovm-intro'), 'the intro of the visit is over');
++ t.window.OVMark.intro(t.mark);
++ assert.ok(t.mark.hasAttribute('data-ovm-intro') && t.running(), 'replayed');
++ t.clock.advance(1500);
++ assert.ok(!t.mark.hasAttribute('data-ovm-intro') && !t.running(), 'and over again');
++ }
++
// ── one intro per visit: the next page of a multi-page site finishes it, never restarts it ─
{
const session = memoryStorage();
@@@ -238,19 +249,48 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.strictEqual(t.running(span), true, 'its own intro');
}
-- // ── the form follows the size: the coin up to 24px, the line above; a page's own data-form wins ──────────
++ // ── the form follows the size: the heavy up to 22px, the pair above; a page's own data-form wins ──────────
{
-- const t = setup({ html: '<a href="/" class="brand"><span class="ov-mark" data-size="28"></span></a><span class="ov-mark" id="small" data-size="16"></span><span class="ov-mark" id="mid" data-size="24"></span><span class="ov-mark" id="forced" data-size="16" data-form="line"></span>' });
-- assert.strictEqual(t.mark.getAttribute('data-form'), 'line', '28px: the line');
-- assert.strictEqual(t.document.getElementById('small').getAttribute('data-form'), 'solid', '16px: the coin');
-- assert.strictEqual(t.document.getElementById('mid').getAttribute('data-form'), 'solid', '24px: the coin');
-- assert.strictEqual(t.document.getElementById('forced').getAttribute('data-form'), 'line', 'a named form is kept');
++ const t = setup({ html: '<a href="/" class="brand"><span class="ov-mark" data-size="28"></span></a><span class="ov-mark" id="small" data-size="16"></span><span class="ov-mark" id="mid" data-size="22"></span><span class="ov-mark" id="forced" data-size="16" data-form="pair"></span><span class="ov-mark" id="old" data-size="40" data-form="solid"></span><span class="ov-mark" id="wide" data-size="34" data-wide></span>' });
++ const $ = (id) => t.document.getElementById(id);
++ assert.strictEqual(t.mark.getAttribute('data-form'), 'pair', '28px: the pair');
++ assert.strictEqual($('small').getAttribute('data-form'), 'heavy', '16px: the heavy');
++ assert.strictEqual($('mid').getAttribute('data-form'), 'heavy', '22px: the heavy');
++ assert.strictEqual($('forced').getAttribute('data-form'), 'pair', 'a named form is kept');
++ const G = t.window.OVMark.FORMS;
++ assert.strictEqual($('old').querySelector('.va').getAttribute('stroke-width'), String(G.heavy.vsw), 'the old name "solid" draws the heavy');
const svg = t.mark.querySelector('svg').outerHTML;
-- assert.match(svg, /class="lp"/, 'the line');
-- assert.match(svg, /class="fl"[^>]*mask=/, 'the coin, with the V cut through it');
++ assert.match(svg, /<mask id="ovm\d+_h"[^>]*>[\s\S]*class="ha"/, 'the gap around the V is a mask, so the mark sits on any ground');
++ assert.match(svg, /<g mask="url\(#ovm\d+_h\)"><g class="og"/, 'it cuts the O');
++ assert.match(svg, /class="o" d="M[\d.]+ [\d.]+A20 20 0 1 1[^"]*" pathLength="100"/, 'the O is a ring drawn from its top, so a page load can draw it round');
++ assert.strictEqual(t.mark.querySelector('svg').getAttribute('viewBox'), G.pair.box, 'a square box centred on the mark');
++ assert.strictEqual($('wide').querySelector('svg').getAttribute('viewBox'), G.pair.wideBox, 'data-wide: the box hugs the mark');
++ assert.strictEqual($('wide').style.height, '', 'and its height follows the mark (aspect-ratio), not the size');
++ }
++
++ // ── a state reshapes the V, the gap in the ring following it, and it comes back a V ──────────────────────
++ {
++ const t = setup();
++ const G = t.window.OVMark.FORMS.pair;
++ const d = () => [t.mark.querySelector('.va').getAttribute('d'), t.mark.querySelector('.ha').getAttribute('d'), t.mark.querySelector('.vb').getAttribute('d')];
++ const seg = (p, i) => `M${p[i]} ${p[i + 1]}L${p[i + 2]} ${p[i + 3]}`;
++ const as = (shape) => [seg(G.shapes[shape], 0), seg(G.shapes[shape], 0), seg(G.shapes[shape], 4)];
++ assert.deepStrictEqual(d(), as('v'), 'at rest: the V');
++ t.mark.setAttribute('data-state', 'ok'); await t.flush();
++ assert.deepStrictEqual(d(), as('ok'), 'ok: a tick, and the gap moves with it');
++ t.mark.setAttribute('data-state', 'error'); await t.flush();
++ assert.deepStrictEqual(d(), as('err'), 'error: "!"');
++ t.mark.setAttribute('data-state', 'no'); await t.flush();
++ assert.deepStrictEqual(d(), as('no'), 'no: a cross');
++ t.mark.setAttribute('data-state', 'busy'); await t.flush();
++ assert.deepStrictEqual(d(), as('v'), 'busy: a V again');
++ const css = t.document.getElementById('ov-mark-css').textContent;
++ const looks = ['busy', 'ok', 'error', 'no', 'notify', 'live', 'sleep'].filter((st) => !css.includes(`.ov-mark[data-state=${st}]`));
++ assert.deepStrictEqual(looks, [], 'every state has its look');
++ assert.ok(css.includes('.ov-mark[data-nav] .o,.ov-mark[data-progress] .o{stroke-dasharray:100 100;stroke-dashoffset:calc((1 - var(--ovm-fill)) * 100px)}'), 'progress draws the ring round');
}
-- // ── a page on its way: the navbar's mark fills at the learned pace, then drains on arrival ─────────────
++ // ── a page on its way: the navbar's ring draws round at the learned pace, closes on arrival, the V hops ─────
{
const t = setup({ html: '<div class="openvibe-navbar-brand"><a href="/"><span class="ov-mark" data-size="28"></span></a></div>' });
const local = memoryStorage(); t.window.localStorage = local;
@@@ -267,9 +307,10 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.ok(Number(m.style.getPropertyValue('--ovm-fill')) <= 0.94, 'never full before the page is here');
t.window.OVMark.navEnd('boost');
t.clock.advance(1); // after every other listener of the arrival (the navbar re-renders on it)
-- assert.strictEqual(m.style.getPropertyValue('--ovm-fill'), '1', 'full on arrival');
-- t.clock.advance(400);
-- assert.ok(!m.hasAttribute('data-nav') && !m.style.getPropertyValue('--ovm-fill'), 'then it drains back to the line');
++ assert.strictEqual(m.style.getPropertyValue('--ovm-fill'), '1', 'the ring closes on arrival');
++ assert.ok(m.hasAttribute('data-ovm-arrive'), 'and the V hops');
++ t.clock.advance(600);
++ assert.ok(!m.hasAttribute('data-nav') && !m.hasAttribute('data-ovm-arrive') && !m.style.getPropertyValue('--ovm-fill'), 'then the mark is at rest');
const learned = JSON.parse(local.getItem('ov-nav-ms'));
assert.ok(learned['openvibe.test:boost'] > 450, `the slow move is learned (${learned['openvibe.test:boost']} ms)`);
assert.strictEqual(t.window.OVMark.usual('boost'), learned['openvibe.test:boost'], 'and paces the next one');
@@@ -278,5 +319,5 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.strictEqual(t.running(m), true, 'a page on its way runs the mark after its intro');
}
-- console.log('ov-mark: forms by size, the page-load fill and its pace, intro, hover/focus, activity; paused when hidden, off-screen, reduced or static');
++ console.log('ov-mark: forms by size, the shapes of the V, the page-load ring and its pace, intro, hover/focus, activity; paused when hidden, off-screen, reduced or static');
})().catch((e) => { console.error(e); process.exit(1); });