openvibers/openvibe.shared Commits

The OpenVibe mark is the Loop, its fill is the page-load progress, and nothing spins (4.58.0) (#210)

OP OpenVibers committed 13 days ago · 360f6b6637 · 15 files, +332 −129

* The OpenVibe mark is the Loop, its fill is the page-load progress, and nothing spins (4.58.0)

ov-mark.js draws the refined open loop in two forms (the line from 28 px, the coin with the V cut through it up to
24 px). Its fill shows a page on its way, paced by the site's learned load time, and island's progress. Its states are
hops, fills, shakes and pings, never a spin. The kit's spinning rings become the mark (the loader) or three hopping dots
(the player, Action Center, Dock, notifications, agent sessions); decorative rings rest.

* The page-load ending waits for the navbar's re-render and holds no mark in a timer

A boost arrival's ending ran before the navbar re-rendered and its drain timer kept the old navbar's mark, so the whole
old bar stayed in memory (boost-navbar-chrome: one detached bar). It now runs after the arrival's other listeners and
looks the marks up again at each step.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fb6898a..6bf86e3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,38 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++## 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
++ across all these resolutions and add lots of animations to it"). `ov-mark.js` draws today's open loop, refined: a
++ heavier stroke, the V dipping to the middle, the dot balanced in the opening, centred in its box.
++ - **Two forms of one shape:** the line (28 px and up: the navbar, headers) and the solid coin with the V cut through
++ it (16–24 px, where a line would blur). The form follows `data-size`, or set `data-form="solid|line"`.
++ - **Its fill is the progress.** Following a link, the navbar's mark fills as the next page loads, paced by how long
++ this site's boost moves and full loads usually take in this browser (a running average per site in localStorage).
++ It reaches about four fifths at the usual time, never the top before the page is here, then fills and drains back
++ to the line. Single-page apps call `OVMark.navStart()` / `OVMark.navEnd()`. island.js's `data-progress` fills
++ the coin to its level instead of drawing a ring around the mark.
++ - **States, none of them a spin:**
++ - busy: the dot hops in the V;
++ - ok: the coin fills green;
++ - error: it shakes and the dot drops into the V;
++ - no: a small shake in grey;
++ - notify: the dot pings;
++ - live: ripples go out from the ring;
++ - sleep: grey, the dot sinks.
++
++ The intro draws the line in and drops the dot. Hover fills the line into the coin. The motion budget and the
++ seasons are unchanged.
++- **Nothing spins any more.** The kit's spinning rings are gone:
++ - the loader shows the mark with its dot hopping;
++ - the player's buffering is three hopping dots;
++ - the Action Center, the Dock, the notifications list and agent sessions use three dots, and the Action Center's
++ refresh icon pulses;
++ - the showcase's and the room kit's decorative rings rest.
++
++ The owner, 2026-10-31: "a spinning circle around an icon… is so lame… things only spin if they're active."
++
## 4.57.2 — 2026-10-31
- **One page is marked in the navbar after a boost page move.** A site that marks its links on the server (Codes'
ddiff --git a/README.md b/README.md
iindex c056355..abf30e1 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.57.2 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.58.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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 71405e5..61b153b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.57.2",
++ "version": "4.58.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/action-center.js b/action-center.js
iindex 4f765ba..3736b80 100644
---- a/action-center.js
++++ b/action-center.js
@@@ -96,8 +96,8 @@
.ovac-acts button { width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
.ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
.ovac-acts button svg { width: 18px; height: 18px; }
-- .ovac-acts button.spin svg { animation: ovac-spin .8s linear infinite; }
-- @keyframes ovac-spin { to { transform: rotate(360deg); } }
++ .ovac-acts button.spin svg { animation: ovac-pulse 1s ease-in-out infinite; }
++ @keyframes ovac-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
.ovac-acts [data-act="close"] { display: none; }
.ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
.ovac-tabs::-webkit-scrollbar { display: none; }
@@@ -137,7 +137,10 @@
.ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; }
.ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
.ovac-wait { display: flex; justify-content: center; padding: 28px; }
-- .ovac-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovac-spin .8s linear infinite; }
++ .ovac-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovac-hop 1s ease-in-out .15s infinite; }
++ .ovac-wait span::before, .ovac-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovac-hop 1s ease-in-out infinite; }
++ .ovac-wait span::before { left: -10px; } .ovac-wait span::after { left: 10px; animation-delay: .3s; }
++ @keyframes ovac-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
.ovac-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); }
.ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }
@media (max-width: 640px) {
ddiff --git a/agent-session.css b/agent-session.css
iindex 380bc0c..0ad1c55 100644
---- a/agent-session.css
++++ b/agent-session.css
@@@ -29,7 +29,7 @@
.ovas-plan li { display: flex; gap: 8px; align-items: baseline; color: var(--ovas-sec); }
.ovas-plan li::before { content: ''; flex: none; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--ovas-mut); transform: translateY(1px); }
.ovas-plan li[data-s="in_progress"] { color: var(--text-primary, #e6edf7); font-weight: 600; }
--.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); border-right-color: transparent; animation: ovas-spin .9s linear infinite; }
++.ovas-plan li[data-s="in_progress"]::before { border-color: var(--accent, #3b82f6); background: var(--accent, #3b82f6); animation: ovas-beat 1.2s ease-in-out infinite; }
.ovas-plan li[data-s="completed"] { color: var(--ovas-mut); text-decoration: line-through; }
.ovas-plan li[data-s="completed"]::before { background: var(--ovas-ok); border-color: var(--ovas-ok); }
.ovas-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-width: 0; }
@@@ -66,7 +66,10 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-state { flex: none; display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--ovas-mut); font-variant-numeric: tabular-nums; }
.ovas-state .ovas-ic { width: 15px; height: 15px; }
.ovas-state[data-s="ok"] .ovas-ic { color: var(--ovas-ok); } .ovas-state[data-s="fail"] { color: var(--ovas-bad); }
--.ovas-spin { width: 13px; height: 13px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent; animation: ovas-spin .8s linear infinite; color: var(--accent, #3b82f6); }
++.ovas-spin { position: relative; display: inline-block; width: 4px; height: 4px; margin: 0 6px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovas-hop 1s ease-in-out .15s infinite; }
++.ovas-spin::before, .ovas-spin::after { content: ''; position: absolute; top: 0; width: 4px; height: 4px; border-radius: 50%; background: inherit; animation: ovas-hop 1s ease-in-out infinite; }
++.ovas-spin::before { left: -6px; } .ovas-spin::after { left: 6px; animation-delay: .3s; }
++@keyframes ovas-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-3px); opacity: 1; } }
.ovas-group > ol { list-style: none; margin: 0; padding: 0 8px 8px; display: grid; gap: 6px; }
.ovas-group.is-one { border: 0; background: none; }
.ovas-group.is-one > summary { display: none; }
@@@ -102,7 +105,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-extra { display: grid; gap: 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--ovas-b); background: var(--ovas-s); font-size: 13.5px; color: var(--ovas-sec); }
.ovas-empty { padding: 18px; text-align: center; color: var(--ovas-mut); font-size: 13.5px; border: 1px dashed var(--ovas-b); border-radius: 14px; }
.ovas [hidden] { display: none !important; }
--@keyframes ovas-spin { to { transform: rotate(360deg); } }
++@keyframes ovas-beat { 0%, 100% { transform: translateY(1px) scale(.8); opacity: .6; } 50% { transform: translateY(1px) scale(1); opacity: 1; } }
@media (hover: hover) { .ovas-group > summary:hover, .ovas-step > summary:hover { background: var(--bg-hover, rgba(255,255,255,.04)); } }
@media (max-width: 640px) {
.ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }
@@@ -110,7 +113,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
.ovas-bar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding: 0 2px; } .ovas-bar::-webkit-scrollbar { display: none; }
.ovas-chip { flex: none; } .ovas-icons .ovas-kind + .ovas-kind { display: none; }
}
--@media (prefers-reduced-motion: reduce) { .ovas-spin, .ovas-plan li::before { animation: none; } }
++@media (prefers-reduced-motion: reduce) { .ovas-spin, .ovas-spin::before, .ovas-spin::after, .ovas-plan li::before { animation: none; } }
/* Files changed (4.32.0): every file the agents edited or wrote, with its +/−; a tap opens its step. */
.ovas-files { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
ddiff --git a/dock.js b/dock.js
iindex b4b80b8..dce0c76 100644
---- a/dock.js
++++ b/dock.js
@@@ -167,8 +167,10 @@
.ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
.ovdk-foot svg { width: 15px; height: 15px; }
.ovdk-wait { display: flex; justify-content: center; padding: 36px; }
-- .ovdk-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovdk-spin .8s linear infinite; }
-- @keyframes ovdk-spin { to { transform: rotate(360deg); } }
++ .ovdk-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovdk-hop 1s ease-in-out .15s infinite; }
++ .ovdk-wait span::before, .ovdk-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovdk-hop 1s ease-in-out infinite; }
++ .ovdk-wait span::before { left: -10px; } .ovdk-wait span::after { left: 10px; animation-delay: .3s; }
++ @keyframes ovdk-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
.ovdk-none { padding: 40px 24px; text-align: center; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
.ovdk-size { position: absolute; z-index: 2; width: 22px; height: 22px; top: 0; left: 0; cursor: nwse-resize; touch-action: none; }
.ovdk-size::after { content: ''; position: absolute; top: 9px; left: 9px; width: 7px; height: 7px; border-top: 2px solid var(--text-muted, #8b8b96); border-left: 2px solid var(--text-muted, #8b8b96); border-top-left-radius: 3px; opacity: 0; transition: opacity .15s; }
ddiff --git a/loading.js b/loading.js
iindex 02634ac..acb673d 100644
---- a/loading.js
++++ b/loading.js
@@@ -5,14 +5,14 @@
* 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, a thin ring turning around a soft dot, one word of status with one quiet line under
++ * 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
* 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.
*
* const l = OpenVibeLoading.mount(el, { poster, label: 'Connecting', sub: 'Getting your viewer ready', steps: 5 })
* l.update({ label, sub, step, progress }) step: 0-based of steps (or progress 0-1); omit both to keep it
* l.slow({ hint, actions: [{ label, onClick }] }) a hint and actions under the status (it keeps loading)
-- * l.fail({ title, detail, actions: [{ label, onClick }] }) stops the ring, says what happened
++ * l.fail({ title, detail, actions: [{ label, onClick }] }) stops the mark, says what happened
* l.done() fades out and removes itself (the picture underneath shows through as it goes)
* l.destroy() removes it at once
* OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() }
@@@ -30,9 +30,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; border-radius: 50%; }
--.ovld-ring::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: conic-gradient(from 0deg, rgba(255,255,255,0) 0 62%, var(--accent-light, #9cc0ff) 100%); -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px)); mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2px)); animation: ovld-spin 1.15s linear infinite; }
--.ovld-ring::after { content: ''; position: absolute; inset: 16px; border-radius: 50%; background: var(--accent-light, #9cc0ff); box-shadow: 0 0 18px var(--accent-light, #9cc0ff); animation: ovld-breathe 1.6s ease-in-out infinite; }
++.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 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-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); }
@@@ -45,8 +46,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::before { animation: none; background: none; }
--.ovld.is-failed .ovld-ring::after { inset: 14px; background: var(--danger, #ef4444); box-shadow: 0 0 18px var(--danger, #ef4444); animation: none; }
++.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-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; }
@@@ -55,12 +56,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-spin { to { transform: rotate(1turn); } }
++@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-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::before, .ovld-ring::after, .ovld-line i, .ovld-skel-mark, .ovld-skel-line { animation: none !important; } .ovld-ring::before { background: conic-gradient(var(--accent-light, #9cc0ff) 0 30%, rgba(255,255,255,.15) 30%); } }`;
++@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; } }`;
function styles() {
if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return;
@@@ -82,6 +85,7 @@
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>';
const label = el('div', 'ovld-label', opts.label || 'Loading');
const sub = el('div', 'ovld-sub', opts.sub || '');
const help = el('div', 'ovld-help');
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 4b41fb3..70302b9 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -116,7 +116,7 @@
@keyframes ovn-bell-ring { 0%{ transform: rotate(0); } 12%{ transform: rotate(13deg); } 24%{ transform: rotate(-12deg); } 36%{ transform: rotate(8deg); } 48%{ transform: rotate(-4deg); } 60%{ transform: rotate(0); } }
@keyframes ovn-panel-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes ovn-sheet-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
-- @keyframes ovn-spin { to { transform: rotate(360deg); } }
++ @keyframes ovn-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } }
/* Toasts. Opaque cards on the theme's raised surface; held (hidden, timers paused) while a menu is open. */
.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
@@@ -280,7 +280,9 @@
.openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
.openvibe-nv-more { display: flex; justify-content: center; padding: 12px; }
.openvibe-nv-more button { border: 1px solid var(--border, rgba(255,255,255,.1)); background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12.5px; padding: 7px 16px; border-radius: 999px; cursor: pointer; }
-- .openvibe-nv-more .spinner { width: 18px; height: 18px; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); border-radius: 50%; animation: ovn-spin .8s linear infinite; }
++ .openvibe-nv-more .spinner { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovn-hop 1s ease-in-out .15s infinite; }
++ .openvibe-nv-more .spinner::before, .openvibe-nv-more .spinner::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovn-hop 1s ease-in-out infinite; }
++ .openvibe-nv-more .spinner::before { left: -10px; } .openvibe-nv-more .spinner::after { left: 10px; animation-delay: .3s; }
.openvibe-nv-foot { padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.openvibe-nv-foot a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; font-weight: 650; }
.openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 6d73dc7..1dbeee3 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -1,26 +1,33 @@
/**
-- * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
-- * <span class="ov-mark"></span> → 32px animated 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 twist on the motion (see VARIANTS)
++ * 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)
++ * 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).
-- * The mark is one stroke, the "open loop" (4.25.0; the owner, 2026-10-28: "the open vibe logo isn't even that great…
-- * come up with new ideas… more modern… better effects"): an O opened at the top by the V, so it reads OV and "open" at
-- * once. The stroke runs from the site's accent towards violet; it draws itself in on a visit's first view, a short light
-- * runs along it, and the site's dot sits in the opening (Live's is red and on air). Transform/opacity/stroke motion only.
*
-- * 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 now
-- * moves only for its intro (the first INTRO_MS of a visit, from the first time a mark is on screen
-- * in this tab; later pages of a multi-page site open with the mark at rest), while it or its link
-- * is hovered or focused (and LINGER_MS after), and while it shows activity (data-state busy/ok,
-- * island.js). Otherwise its CSS animations are paused where they are and its SMIL clock stopped,
-- * so it keeps its look. 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).
++ * 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.
++ *
++ * 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
++ * visit, from the first time a mark is on screen in this tab; later pages of a multi-page site open with the mark at
++ * 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.
*
* 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, leaf and ribs in October, a cap of snow in December.
++ * 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.
*/
@@@ -28,53 +35,91 @@
'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 SOLID_DY = -2.28;
const CSS = `
--.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 50%,#e879f9)}
--.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%}
--.ov-mark .g{fill:currentColor;opacity:.12;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
--.ov-mark .lp{fill:none;stroke-width:5.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:100;animation:ovmDraw 1.2s cubic-bezier(.3,.8,.2,1) both}
--.ov-mark .sw{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:7 93;opacity:.9;animation:ovmSweep 3s linear infinite}
--.ov-mark .d{fill:var(--ovm-dot,var(--text-primary,#fff));transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
--/* Per-site variants: one family, each site with its own rhythm so the network reads as one brand with many rooms.
-- Colours stay with the theme (currentColor); only the dot and the motion change. */
++@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}
++.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[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}
++/* 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=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=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] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
--.ov-mark[data-variant=tools] .sw{animation-duration:2s}
--.ov-mark[data-variant=media] .sw,.ov-mark[data-variant=video] .sw{stroke-dasharray:3 6;animation-duration:6s}
--.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
--.ov-mark[data-variant=games] .d{animation-duration:1.6s}
--.ov-mark[data-variant=community] .sw{animation-direction:reverse}
--.ov-mark[data-variant=network] .g{animation-duration:2.4s}
--.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
--.ov-mark[data-variant=stream] .sw{stroke-dasharray:20 80;animation-duration:2.4s}
--.ov-mark[data-variant=codes] .lp{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
--.ov-mark[data-variant=blog] .sw,.ov-mark[data-variant=wiki] .sw,.ov-mark[data-variant=news] .sw{animation-duration:6s}
++.ov-mark[data-variant=live][data-ovm-run]:not([data-state]) .d{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=trade] .sw{animation-timing-function:cubic-bezier(.4,0,.2,1)}
--.ov-mark[data-variant=host] svg{animation:none}
--@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.6);opacity:1}}
--@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
--.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
--.ov-mark:hover .sw,a:hover>.ov-mark .sw{animation-duration:1s}
--.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .lp,.ov-mark[data-static] .sw,.ov-mark[data-static] .d{animation:none!important}
--.ov-mark[data-static] .sw{display:none}
--.ov-mark[data-ov]:not([data-ovm-run]) svg,.ov-mark[data-ov]:not([data-ovm-run]) .g,.ov-mark[data-ov]:not([data-ovm-run]) .sw,.ov-mark[data-ov]:not([data-ovm-run]) .d{animation-play-state:paused!important}
--.ov-mark[data-ov]:not([data-ovm-run]) .sw{opacity:0;transition:opacity .4s}
--.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .lp{animation:none!important;stroke-dashoffset:0}
--.ov-mark .p{fill:none;stroke:currentColor;stroke-width:3.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:138.2;stroke-dashoffset:calc(138.2px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
--.ov-mark[data-progress] .p{opacity:1}
--.ov-mark[data-progress] .sw{opacity:0}
--.ov-mark[data-state=busy] .sw{animation-duration:1s}
--.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
--.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
--.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
--@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
++.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 ovmSweep{from{stroke-dashoffset:100}to{stroke-dashoffset:0}}
--@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.45);opacity:1}}
--@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.15)}to{transform:rotate(360deg) scale(1)}}
++@keyframes ovmIn{from{transform:scale(0)}to{transform:scale(1)}}
++.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}
@@@ -83,10 +128,10 @@ html[data-ov-season-mark=halloween] .ov-mark .d{display:none}
.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-run] .sl{animation:ovmLeaf .9s cubic-bezier(.3,1.6,.5,1) both}
++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] .sw{animation:ovmSweep .9s linear 1;opacity:.9}
++.ov-mark[data-ovm-flourish] .d{animation:ovmPing .76s 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%}
@@@ -94,22 +139,22 @@ 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 svg,.ov-mark .g,.ov-mark .lp,.ov-mark .sw,.ov-mark .d,.ov-mark .sl,.ov-mark .sd{animation:none!important}.ov-mark .sw,.ovm-fall{display:none}}
--html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
-- // The open loop: from the opening's left end round the bottom to its right end, then down the V and back up.
-- // pathLength=100 lets the draw-in and the running light be written in hundredths of the stroke.
-- const LOOP = 'M15.2 8.8A17.5 17.5 0 1 0 32.8 8.8L24 30.5Z';
++@media (prefers-reduced-motion:reduce){.ov-mark *{animation:none!important}.ov-mark,.ov-mark .m{transition:none}.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></defs>
--<circle class="g" cx="24" cy="24" r="22"/>
--<circle class="p" cx="24" cy="24" r="22"/>
--<path class="lp" d="${LOOP}" pathLength="100" stroke="url(#${id}g)"/>
--<path class="sw" d="${LOOP}" pathLength="100"/>
--<circle class="d" cx="24" cy="7.4" r="2.7"/>
++<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="M6.6 15.4A19.4 19.4 0 0 1 14.3 7.2"/><path class="sc" d="M33.7 7.2A19.4 19.4 0 0 1 41.4 15.4"/><circle class="sd" cx="10.4" cy="9" r="1.4"/><circle class="sd" cx="38.2" cy="9.6" r="1.2"/></g></svg>`;
++<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 INTRO_MS = 8000, LINGER_MS = 1200;
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
let io = null, rm = null;
@@@ -133,16 +178,14 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
-- const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
++ // Anything the mark is showing keeps it moving: a state, a page on its way, a known progress.
++ const showing = (s && s !== 'idle') || el.hasAttribute('data-nav') || el.hasAttribute('data-progress');
++ 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'); }
-- try {
-- if (run) svgEl.unpauseAnimations();
-- else {
-- // Never run yet: rest on the frame where the V's light sweep is centred.
-- if (svgEl.getCurrentTime() < 0.5) svgEl.setCurrentTime(1.6);
-- svgEl.pauseAnimations();
-- }
-- } catch { /* no SMIL */ }
++ // The intro's draw-in and pop 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'); }
++ 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));
if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
@@@ -221,6 +264,12 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
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 (!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.innerHTML = svg('ovm' + (++n) + '_');
}
if (!state.has(el) && el.isConnected) track(el);
@@@ -237,6 +286,7 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
document.addEventListener('visibilitychange', updateAll);
mount();
++ hookNav();
scheduleFlourish();
try {
new MutationObserver((records) => {
@@@ -251,9 +301,76 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
});
}
if (added) mount();
-- }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion', 'data-ov-season-mark'] });
++ }).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 ──
++ // 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.
++ 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'));
++ const navStore = () => { try { return JSON.parse(window.localStorage.getItem(NAV_KEY) || '{}') || {}; } catch { return {}; } };
++ function usual(kind) { const v = Number(navStore()[`${loc().host}:${kind}`]); return v > 0 ? v : NAV_DEFAULT[kind] || 1000; }
++ function learn(kind, ms) {
++ if (!(ms > 30 && ms < 30000)) return;
++ try {
++ const all = navStore(), k = `${loc().host}:${kind}`;
++ all[k] = Math.round(all[k] > 0 ? all[k] * 0.7 + ms * 0.3 : ms);
++ window.localStorage.setItem(NAV_KEY, JSON.stringify(all));
++ } catch { /* no storage: the default pace */ }
++ }
++ let nav = null;
++ function setFill(marks, p) { marks.forEach((m) => { m.setAttribute('data-nav', ''); m.style.setProperty('--ovm-fill', String(p)); }); }
++ function navStart(kind = 'page') {
++ navStop();
++ const marks = navMarks(); if (!marks.length) return;
++ const t0 = Date.now(), avg = usual(kind);
++ const me = { kind, t0, timer: 0 };
++ const tick = () => {
++ if (nav !== me) return;
++ const t = Date.now() - t0;
++ setFill(navMarks(), Math.min(0.94, 0.08 + 0.86 * (1 - Math.exp(-1.6 * t / avg))).toFixed(3));
++ me.timer = setTimeout(tick, 120);
++ };
++ nav = me;
++ 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 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'); }); };
++ 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);
++ }
++ function hookNav() {
++ document.addEventListener('ov:boost:before', () => navStart('boost'));
++ document.addEventListener('ov:boost:load', () => navEnd('boost'));
++ // A full page load: a plain click on a link that leaves this page, in this tab, not taken by boost or the app.
++ document.addEventListener('click', (e) => {
++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ const a = e.target && e.target.closest && e.target.closest('a[href]');
++ if (!a || a.hasAttribute('download') || (a.target && a.target !== '_self')) return;
++ const here = loc(); let u; try { u = new URL(a.href, here.href); } catch { return; }
++ if (!/^https?:$/.test(u.protocol) || (u.origin === here.origin && u.pathname === here.pathname && u.search === here.search)) return;
++ navStart('page');
++ try { if (u.origin === here.origin) sessionStorage.setItem(NAV_FLAG, String(Date.now())); } catch { /* */ }
++ });
++ // 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.
++ 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);
++ const finish = () => { const marks = navMarks(); if (!marks.length) return false; setFill(marks, 0.9); setTimeout(() => navEnd(null), 60); return true; };
++ let tries = 0; const again = () => { if (!finish() && ++tries <= 20) setTimeout(again, 100); }; again();
++ }
++ }
++ window.OVMark = { mount, navStart, navEnd, usual };
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
ddiff --git a/package.json b/package.json
iindex c9584e2..8ed6d81 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.57.2",
++ "version": "4.58.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/player.css b/player.css
iindex c173a40..2f8dec5 100644
---- a/player.css
++++ b/player.css
@@@ -164,16 +164,14 @@
.ov-player-big:hover { background: var(--ovp-accent); transform: translate(-50%, -50%) scale(1.06); }
.ov-player:not(.is-start):not(.is-ended) .ov-player-big { opacity: 0; pointer-events: none; }
--/* Buffering. */
--.ov-player-spinner {
-- position: absolute; left: 50%; top: 50%; z-index: 2;
-- width: 44px; height: 44px; margin: -22px 0 0 -22px;
-- border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.25); border-top-color: #fff;
-- animation: ov-player-spin 0.8s linear infinite; display: none;
--}
++/* Buffering: three dots hopping in turn over the picture (nothing spins). */
++.ov-player-spinner { position: relative; display: inline-block; width: 8px; height: 8px; margin: 0 14px; border: 0; border-radius: 50%; background: #fff; animation: ov-player-hop 1s ease-in-out .15s infinite; }
++.ov-player-spinner::before, .ov-player-spinner::after { content: ''; position: absolute; top: 0; width: 8px; height: 8px; border-radius: 50%; background: inherit; animation: ov-player-hop 1s ease-in-out infinite; }
++.ov-player-spinner::before { left: -14px; } .ov-player-spinner::after { left: 14px; animation-delay: .3s; }
++@keyframes ov-player-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-5px); opacity: 1; } }
++.ov-player-spinner { position: absolute; left: 50%; top: 50%; z-index: 2; margin: -4px 0 0 -4px; display: none; filter: drop-shadow(0 1px 6px rgba(0, 0, 0, .5)); }
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
--@keyframes ov-player-spin { to { transform: rotate(360deg); } }
--@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }
++@media (prefers-reduced-motion: reduce) { .ov-player-spinner, .ov-player-spinner::before, .ov-player-spinner::after { animation: none; opacity: .8; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }
/* Touch screens: the slider shows, finger-sized, wherever the page can set the volume (.can-volume: Android). An iPhone
leaves volume to its own buttons, so there the mute button stays alone. */
@media (hover: none) {
ddiff --git a/room.css b/room.css
iindex 3893324..46d10e0 100644
---- a/room.css
++++ b/room.css
@@@ -159,7 +159,7 @@ html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none;
.sc-room-pic{display:flex;justify-content:center;min-width:0}
.sc-room-motif{position:relative;width:min(100%,340px);aspect-ratio:1;color:var(--room-accent,#3b82f6)}
.sc-rm-glow{position:absolute;inset:20%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--room-accent,#3b82f6) 48%,transparent),transparent);filter:blur(10px);animation:sc-breathe 6s ease-in-out infinite}
--.sc-rm-orbit{position:absolute;border-radius:50%;border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 32%,transparent);animation:sc-spin 26s linear infinite}
++.sc-rm-orbit{position:absolute;border-radius:50%;border:1.5px solid color-mix(in srgb,var(--room-accent,#3b82f6) 32%,transparent)}
.sc-rm-orbit.o1{inset:15%}
.sc-rm-orbit.o2{inset:1%;border-style:dashed;animation-duration:52s;animation-direction:reverse;opacity:.7}
.sc-rm-orbit i{position:absolute;top:-6px;left:calc(50% - 6px);width:12px;height:12px;border-radius:50%;background:currentColor;box-shadow:0 0 18px currentColor}
ddiff --git a/showcase.css b/showcase.css
iindex c14688a..cfee4ed 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -2,18 +2,18 @@
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
--.sc-ring{position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 34%,transparent);animation:sc-spin 70s linear infinite}
++.sc-ring{position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 34%,transparent);}
.sc-ring.r1{inset:24%}.sc-ring.r2{inset:12%;animation-duration:105s;animation-direction:reverse;opacity:.8}.sc-ring.r3{inset:1%;animation-duration:150s;opacity:.55}
.sc-ring i{position:absolute;top:-5px;left:calc(50% - 5px);width:10px;height:10px;border-radius:50%;background:var(--accent,#3b82f6);box-shadow:0 0 14px var(--accent,#3b82f6)}
.sc-ring i+i{top:auto;bottom:-4px;left:calc(30% - 4px);width:8px;height:8px;background:var(--accent-light,#60a5fa)}
.sc-vis-mark{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2}
--.sc-orb-ring{position:absolute;inset:0;border-radius:50%;animation:sc-spin 90s linear infinite}
++.sc-orb-ring{position:absolute;inset:0;border-radius:50%}
.sc-orb-ring::before{content:"";position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent)}
.sc-orb-ring.o1{--R:30cqw;z-index:2}.sc-orb-ring.o1::before{inset:20%}
.sc-orb-ring.o2{--R:44cqw;animation-duration:140s;animation-direction:reverse}.sc-orb-ring.o2::before{inset:6%;opacity:.55}
.sc-orb-node{position:absolute;left:50%;top:50%;width:40px;height:40px;margin:-20px;transform:rotate(calc(360deg / var(--n) * var(--i))) translateY(calc(-1 * var(--R))) rotate(calc(-360deg / var(--n) * var(--i)));color:var(--text-primary,#e6edf7);text-decoration:none}
.sc-orb-node.is-big{width:54px;height:54px;margin:-27px}
--.sc-orb-face{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;height:100%;animation:sc-spin 90s linear infinite reverse}
++.sc-orb-face{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;height:100%}
.o2 .sc-orb-face{animation-duration:140s;animation-direction:normal}
.sc-orb-face>.ov-icon,.sc-orb-face>svg,.sc-orb-face>i{display:grid;place-items:center;flex:none;width:100%;height:100%;border-radius:50%;background:color-mix(in srgb,var(--bg-secondary,#101828) 88%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent),0 8px 22px rgba(0,0,0,.35);transition:transform .3s cubic-bezier(.2,1.4,.3,1)}
.sc-orb-name{font-size:11.5px;font-weight:700;color:var(--text-secondary,#96a7c2);white-space:nowrap;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--bg-primary,#0a0f1c) 78%,transparent)}
@@@ -21,7 +21,7 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
.sc-orb-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:3;display:flex;flex-direction:column;align-items:center;gap:2px;color:var(--text-primary,#e6edf7);text-decoration:none;text-align:center}
.sc-orb-core .sc-vis-mark{position:static;transform:none}
.sc-orb-core b{margin-top:6px;font-size:14px;font-weight:800}.sc-orb-core small{font-size:12px;color:var(--text-muted,#7386a3)}
--@keyframes sc-spin{to{transform:rotate(360deg)}}
++
@keyframes sc-breathe{0%,100%{opacity:.7;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}}
@media (prefers-reduced-motion:reduce){.sc-ring,.sc-aura-glow,.sc-orb-ring,.sc-orb-face{animation:none}}
html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow,html[data-ov-motion=reduced] .sc-orb-ring,html[data-ov-motion=reduced] .sc-orb-face{animation:none}
ddiff --git a/showcase.js b/showcase.js
iindex 9176123..9287275 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -422,18 +422,18 @@ const CSS = `
/* The hero's visual (4.26.0): the aura (rings, dots, the mark) or an orbit of the site's places. */
.sc-vis{position:relative;width:min(100%,380px);aspect-ratio:1;margin:0 auto;container-type:inline-size}
.sc-aura-glow{position:absolute;inset:28%;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent,#3b82f6) 42%,transparent),transparent);filter:blur(8px);animation:sc-breathe 6s ease-in-out infinite}
--.sc-ring{position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 34%,transparent);animation:sc-spin 70s linear infinite}
++.sc-ring{position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 34%,transparent);}
.sc-ring.r1{inset:24%}.sc-ring.r2{inset:12%;animation-duration:105s;animation-direction:reverse;opacity:.8}.sc-ring.r3{inset:1%;animation-duration:150s;opacity:.55}
.sc-ring i{position:absolute;top:-5px;left:calc(50% - 5px);width:10px;height:10px;border-radius:50%;background:var(--accent,#3b82f6);box-shadow:0 0 14px var(--accent,#3b82f6)}
.sc-ring i+i{top:auto;bottom:-4px;left:calc(30% - 4px);width:8px;height:8px;background:var(--accent-light,#60a5fa)}
.sc-vis-mark{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2}
--.sc-orb-ring{position:absolute;inset:0;border-radius:50%;animation:sc-spin 90s linear infinite}
++.sc-orb-ring{position:absolute;inset:0;border-radius:50%}
.sc-orb-ring::before{content:"";position:absolute;border-radius:50%;border:1px dashed color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent)}
.sc-orb-ring.o1{--R:30cqw;z-index:2}.sc-orb-ring.o1::before{inset:20%}
.sc-orb-ring.o2{--R:44cqw;animation-duration:140s;animation-direction:reverse}.sc-orb-ring.o2::before{inset:6%;opacity:.55}
.sc-orb-node{position:absolute;left:50%;top:50%;width:40px;height:40px;margin:-20px;transform:rotate(calc(360deg / var(--n) * var(--i))) translateY(calc(-1 * var(--R))) rotate(calc(-360deg / var(--n) * var(--i)));color:var(--text-primary,#e6edf7);text-decoration:none}
.sc-orb-node.is-big{width:54px;height:54px;margin:-27px}
--.sc-orb-face{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;height:100%;animation:sc-spin 90s linear infinite reverse}
++.sc-orb-face{display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;height:100%}
.o2 .sc-orb-face{animation-duration:140s;animation-direction:normal}
.sc-orb-face>.ov-icon,.sc-orb-face>svg,.sc-orb-face>i{display:grid;place-items:center;flex:none;width:100%;height:100%;border-radius:50%;background:color-mix(in srgb,var(--bg-secondary,#101828) 88%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent-light,#60a5fa) 30%,transparent),0 8px 22px rgba(0,0,0,.35);transition:transform .3s cubic-bezier(.2,1.4,.3,1)}
.sc-orb-name{font-size:11.5px;font-weight:700;color:var(--text-secondary,#96a7c2);white-space:nowrap;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--bg-primary,#0a0f1c) 78%,transparent)}
@@@ -441,7 +441,7 @@ a.sc-orb-node:hover .sc-orb-face>*:first-child,a.sc-orb-node:focus-visible .sc-o
.sc-orb-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:3;display:flex;flex-direction:column;align-items:center;gap:2px;color:var(--text-primary,#e6edf7);text-decoration:none;text-align:center}
.sc-orb-core .sc-vis-mark{position:static;transform:none}
.sc-orb-core b{margin-top:6px;font-size:14px;font-weight:800}.sc-orb-core small{font-size:12px;color:var(--text-muted,#7386a3)}
--@keyframes sc-spin{to{transform:rotate(360deg)}}
++
@keyframes sc-breathe{0%,100%{opacity:.7;transform:scale(.95)}50%{opacity:1;transform:scale(1.05)}}
@media (prefers-reduced-motion:reduce){.sc-ring,.sc-aura-glow,.sc-orb-ring,.sc-orb-face{animation:none}}
html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow,html[data-ov-motion=reduced] .sc-orb-ring,html[data-ov-motion=reduced] .sc-orb-face{animation:none}
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
iindex 89aa5c8..2137b4e 100644
---- a/test/ov-mark.test.js
++++ b/test/ov-mark.test.js
@@@ -1,5 +1,5 @@
'use strict';
--// ov-mark.js motion budget: the mark moves for its intro, on hover/focus and while it shows activity,
++// 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,
// 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.
@@@ -78,13 +78,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
{
const t = setup();
const css = t.document.getElementById('ov-mark-css').textContent;
-- assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) svg,[^{]*\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \.sw,[^{]*\{animation-play-state:paused!important\}/, 'paused rule (the running light, 4.25.0)');
++ assert.match(css, /\.ov-mark\[data-ov\]:not\(\[data-ovm-run\]\) \*\{animation-play-state:paused!important\}/, 'everything in a mark that is not running is paused');
++ assert.ok(!/ovmSpin|rotate\(360deg\)/.test(css), 'nothing spins (the owner, 2026-10-31: things only move when they mean something)');
assert.ok(t.mark.querySelector('svg'), 'mounted');
assert.ok(t.io.targets.has(t.mark), 'observed for visibility');
-- // Not yet known to be on screen: still, SMIL parked on the centred V sweep.
++ // Not yet known to be on screen: still.
assert.strictEqual(t.running(), false);
assert.strictEqual(t.smilPaused(), true);
-- assert.strictEqual(t.smilTime(), 1.6);
}
// ── the seasonal fall lands on the bar's bottom edge, never over the page's heading (4.50.1) ─────────
@@@ -125,11 +125,13 @@ 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.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.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);
@@@ -236,5 +238,45 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.strictEqual(t.running(span), true, 'its own intro');
}
-- console.log('ov-mark: intro, hover/focus, activity; paused when hidden, off-screen, reduced or static');
++ // ── the form follows the size: the coin up to 24px, the line 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 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');
++ }
++
++ // ── a page on its way: the navbar's mark fills at the learned pace, then drains on arrival ─────────────
++ {
++ 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;
++ t.window.location = { host: 'openvibe.test', href: 'https://openvibe.test/a', origin: 'https://openvibe.test', pathname: '/a', search: '' };
++ const m = t.document.querySelector('.ov-mark');
++ assert.strictEqual(t.window.OVMark.usual('boost'), 450, 'a first visit uses the default pace');
++ t.window.OVMark.navStart('boost');
++ assert.ok(m.hasAttribute('data-nav'), 'the mark shows the move');
++ const p0 = Number(m.style.getPropertyValue('--ovm-fill'));
++ t.clock.advance(480);
++ const p1 = Number(m.style.getPropertyValue('--ovm-fill'));
++ assert.ok(p1 > p0 && p1 > 0.6 && p1 < 0.94, `about four fifths at the usual time (${p1})`);
++ t.clock.advance(20000);
++ 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');
++ 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');
++ t.io.fire(m, true); t.clock.advance(9000);
++ m.setAttribute('data-nav', ''); await t.flush();
++ 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');
})().catch((e) => { console.error(e); process.exit(1); });