openvibers/openvibe.shared Commits
The OpenVibe mark is the Loop, its fill is the page-load progress, and nothing spins (4.58.0) (#210)
Changes
@@@ -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'@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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) {@@@ -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; }@@@ -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; }@@@ -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');@@@ -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); }@@@ -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();})();@@@ -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": {@@@ -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 iPhoneleaves volume to its own buttons, so there the mute button stays alone. */@media (hover: none) {@@@ -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}@@@ -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}@@@ -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}@@@ -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, rooconst 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, rooassert.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); });
@@ -6,6 +6,38 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,38 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
## 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'
- **One page is marked in the navbar after a boost page move.** A site that marks its links on the server (Codes'
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.57.2",
+ "version": "4.58.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -96,8 +96,8 @@
@@ -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 { 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: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 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-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 { 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; }
.ovac-tabs::-webkit-scrollbar { display: none; }
@@ -137,7 +137,10 @@
@@ -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 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-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 { 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 { 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; }
.ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }
@media (max-width: 640px) {
@media (max-width: 640px) {
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
.ovas-plan li { display: flex; gap: 8px; align-items: baseline; color: var(--ovas-sec); }
.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::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"] { 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"] { 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-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; }
.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); }
@@ -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 { 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 .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-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 > 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 { border: 0; background: none; }
.ovas-group.is-one > summary { display: none; }
.ovas-group.is-one > summary { display: none; }
@@ -102,7 +105,7 @@ details[open] > summary .ovas-chev { transform: rotate(90deg); }
@@ -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-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-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; }
.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 (hover: hover) { .ovas-group > summary:hover, .ovas-step > summary:hover { background: var(--bg-hover, rgba(255,255,255,.04)); } }
@media (max-width: 640px) {
@media (max-width: 640px) {
.ovas-head { padding: 10px 12px; border-radius: 14px; } .ovas-msg { font-size: 14.5px; }
.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); }
@@ -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-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; }
.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. */
/* 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; }
.ovas-files { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
@@ -167,8 +167,10 @@
@@ -167,8 +167,10 @@
.ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
.ovdk-foot button:hover { color: var(--text-primary, #ececf1); }
.ovdk-foot svg { width: 15px; height: 15px; }
.ovdk-foot svg { width: 15px; height: 15px; }
.ovdk-wait { display: flex; justify-content: center; padding: 36px; }
.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-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 { 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; }
.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; }
@@ -5,14 +5,14 @@
@@ -5,14 +5,14 @@
* sites… a loading system you can use in the SDK and API."
* 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
* 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).
* 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.
* 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 })
* 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.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.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.done() fades out and removes itself (the picture underneath shows through as it goes)
* l.destroy() removes it at once
* l.destroy() removes it at once
* OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() }
* OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() }
@@ -30,9 +30,10 @@
@@ -30,9 +30,10 @@
.ovld-poster.is-in { opacity: 1; }
.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::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-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-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-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); }
.ovld-swap .ovld-label, .ovld-swap .ovld-sub { opacity: 0; transform: translateY(3px); }
@@ -45,8 +46,8 @@
@@ -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 { 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-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-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.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 { display: grid; gap: 10px; padding: 4px 0; }
.ovld-skel-row { display: flex; align-items: center; gap: 12px; }
.ovld-skel-row { display: flex; align-items: center; gap: 12px; }
@@ -55,12 +56,14 @@
@@ -55,12 +56,14 @@
.ovld-skel-line { height: 11px; border-radius: 6px; }
.ovld-skel-line { height: 11px; border-radius: 6px; }
.ovld-skel-line:last-child { width: 62%; }
.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; }
.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-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-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-sweep { from { left: -30%; } to { left: 100%; } }
@keyframes ovld-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
@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() {
function styles() {
if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return;
if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return;
@@ -82,6 +85,7 @@
@@ -82,6 +85,7 @@
const core = el('div', 'ovld-core');
const core = el('div', 'ovld-core');
const ring = el('div', 'ovld-ring');
const ring = el('div', 'ovld-ring');
ring.setAttribute('aria-hidden', 'true');
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 label = el('div', 'ovld-label', opts.label || 'Loading');
const sub = el('div', 'ovld-sub', opts.sub || '');
const sub = el('div', 'ovld-sub', opts.sub || '');
const help = el('div', 'ovld-help');
const help = el('div', 'ovld-help');
@@ -116,7 +116,7 @@
@@ -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-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-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-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. */
/* 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;
.openvibe-toast-container { position: fixed; z-index: 2147482600; display: flex; flex-direction: column; gap: 10px; pointer-events: none;
@@ -280,7 +280,9 @@
@@ -280,7 +280,9 @@
.openvibe-nv-empty b { display: block; font-size: 15px; color: var(--text-primary, #ececf1); margin-bottom: 4px; }
.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 { 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 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 { 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-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); }
.openvibe-nv-note { font-size: 12px; color: var(--text-secondary, #b6b6c0); padding: 8px 16px; background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
@@ -1,26 +1,33 @@
@@ -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).
* 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
* 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
* 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.
* leaves (snowflakes in December) drift down from the first mark seen. Never under either reduced-motion setting.
*/
*/
@@ -28,53 +35,91 @@
@@ -28,53 +35,91 @@
'use strict';
'use strict';
if (window.__ovMark) return; window.__ovMark = true;
if (window.__ovMark) return; window.__ovMark = true;
let n = 0;
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 = `
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]{--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=vip]{--ovm-dot:#facc15}
.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
.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=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 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}
.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 .sh,html[data-ov-season-mark=winter] .ov-mark .sw2{display:inline}
html[data-ov-season-mark=halloween] .ov-mark .d{display:none}
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}
@@ -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 .sc{fill:none;stroke:#f4fbff;stroke-width:4.6;stroke-linecap:round}
.ov-mark .sd{fill:#f4fbff}
.ov-mark .sd{fill:#f4fbff}
html[data-ov-season-mark=halloween] .ov-mark{--ovm-to:#f97316}
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] .sl{animation:ovmWiggle 1.2s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
.ov-mark[data-ovm-flourish] .sd{animation:ovmTwinkle 1.4s ease-in-out 1}
-.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{position:fixed;width:0;height:0;pointer-events:none;z-index:10010}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
.ovm-fall i{position:absolute;left:-4px;top:-4px;width:9px;height:9px;border-radius:0 70% 0 70%;opacity:0;animation:ovmFall 3s cubic-bezier(.3,.1,.5,1) forwards}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:50%}
@@ -94,22 +139,22 @@ html[data-ov-season-mark=winter] .ovm-fall i{width:6px;height:6px;border-radius:
@@ -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 ovmWiggle{0%,100%{transform:none}25%{transform:rotate(-18deg)}60%{transform:rotate(12deg)}}
@keyframes ovmTwinkle{0%,100%{opacity:1}50%{opacity:.3}}
@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))}}
@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) {
function svg(id) {
+ const G = GEO;
return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
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="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 INTRO_MS = 8000, LINGER_MS = 1200;
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
let io = null, rm = null;
let io = null, rm = null;
@@ -133,16 +178,14 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -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;
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); }
if (may && !st.introEnd) { st.introEnd = introDeadline(now); if (season()) setTimeout(() => fallOnce(el), 900); }
const s = el.getAttribute('data-state');
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'); }
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;
clearTimeout(st.timer); st.timer = 0;
const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
const next = Math.min(...[st.introEnd, st.lingerEnd].filter((t) => t > now));
if (run && next !== Infinity) st.timer = setTimeout(() => update(el), next - now + 20);
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}`;
@@ -221,6 +264,12 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
el.setAttribute('data-ov', '1');
el.setAttribute('data-ov', '1');
const size = parseInt(el.getAttribute('data-size'), 10);
const size = parseInt(el.getAttribute('data-size'), 10);
if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
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) + '_');
el.innerHTML = svg('ovm' + (++n) + '_');
}
}
if (!state.has(el) && el.isConnected) track(el);
if (!state.has(el) && el.isConnected) track(el);
@@ -237,6 +286,7 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -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; }
try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
document.addEventListener('visibilitychange', updateAll);
document.addEventListener('visibilitychange', updateAll);
mount();
mount();
+ hookNav();
scheduleFlourish();
scheduleFlourish();
try {
try {
new MutationObserver((records) => {
new MutationObserver((records) => {
@@ -251,9 +301,76 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
@@ -251,9 +301,76 @@ html[data-ov-motion=reduced] .ov-mark .sw{display:none}`;
});
});
}
}
if (added) mount();
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 { /* */ }
} 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;
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();
})();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -164,16 +164,14 @@
@@ -164,16 +164,14 @@
.ov-player-big:hover { background: var(--ovp-accent); transform: translate(-50%, -50%) scale(1.06); }
.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; }
.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; }
.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
/* 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. */
leaves volume to its own buttons, so there the mute button stays alone. */
@media (hover: none) {
@media (hover: none) {
@@ -159,7 +159,7 @@ html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none;
@@ -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-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-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-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.o1{inset:15%}
.sc-rm-orbit.o2{inset:1%;border-style:dashed;animation-duration:52s;animation-direction:reverse;opacity:.7}
.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}
.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}
@@ -2,18 +2,18 @@
@@ -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. */
/* 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-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-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.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{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-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-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::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.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-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{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-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}
.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-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)}
.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
@@ -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{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 .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)}
.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)}}
@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}}
@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}
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}
@@ -422,18 +422,18 @@ const CSS = `
@@ -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. */
/* 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-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-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.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{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-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-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::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.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-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{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-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}
.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-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)}
.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
@@ -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{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 .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)}
.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)}}
@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}}
@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}
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}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'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
// 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
// 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.
// 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
@@ -78,13 +78,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
{
{
const t = setup();
const t = setup();
const css = t.document.getElementById('ov-mark-css').textContent;
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.mark.querySelector('svg'), 'mounted');
assert.ok(t.io.targets.has(t.mark), 'observed for visibility');
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.running(), false);
assert.strictEqual(t.smilPaused(), true);
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) ─────────
// ── 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
@@ -125,11 +125,13 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
const t = setup();
const t = setup();
t.io.fire(t.mark, true);
t.io.fire(t.mark, true);
assert.strictEqual(t.running(), true, 'intro runs');
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);
assert.strictEqual(t.smilPaused(), false);
t.clock.advance(7000);
t.clock.advance(7000);
assert.strictEqual(t.running(), true, 'still in the intro');
assert.strictEqual(t.running(), true, 'still in the intro');
t.clock.advance(1100);
t.clock.advance(1100);
assert.strictEqual(t.running(), false, 'intro over: paused');
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);
assert.strictEqual(t.smilPaused(), true);
// Scrolling away and back does not replay the intro.
// Scrolling away and back does not replay the intro.
t.io.fire(t.mark, false); t.io.fire(t.mark, true);
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
@@ -236,5 +238,45 @@ function setup({ reduce = false, session = memoryStorage(), start = 1000000, roo
assert.strictEqual(t.running(span), true, 'its own intro');
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); });
})().catch((e) => { console.error(e); process.exit(1); });