openvibers/openvibe.shared Commits

v1.16.1: the OV mark moves for its intro, on hover/focus and while busy, not forever (browser check, WS-Q task 3). Its endless SVG animations (ovmFloat, ovmGlow, ovmComet, ovmDot, the V's SMIL light sweep and the orbiting dots) restyled and re-laid-out every frame and kept every idle page with the navbar 12-29% busy. Now a mark runs for the first 8 s of a visit (kept in sessionStorage, so the next page of a multi-page site finishes the intro instead of starting another), while it or its link is hovered or focused (plus 1.2 s) and while island.js shows activity (data-state busy/ok); otherwise its CSS animations are paused where they are and its SMIL clock stopped, so it keeps its look. Never while the page is hidden, while it is off-screen (IntersectionObserver), with data-static, under prefers-reduced-motion or html[data-ov-motion=reduced] (the V sweep used to run under all of those). A removed mark is unobserved and let go. Headless Chrome after five page loads: 12 running animations and 16.5% CPU before, none and 0% after. test/ov-mark.test.js. Not tagged

OP OpenVibers committed 26 Sept 2026 · e40dbb81a7 · 5 files, +312 −9


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5773ab7..1c10513 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.16.1 — 2026-09-26
++
++Fix: the **OV mark** (`ov-mark.js`) no longer animates forever. Its SVG animations restyle and re-lay-out on the main thread every frame, which kept every idle page with the navbar 12–29% busy (the browser check counted 8–19 endless animations per page). A mark now moves for the intro (the first 8 s of a visit in a tab, from the first time a mark is on screen; the next page of a multi-page site finishes that intro rather than starting another), while it or its link is hovered or focused (plus 1.2 s), and while it shows activity (`data-state` busy/ok from `island.js`). Otherwise its CSS animations are paused where they are and its SMIL clock is 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]`); the V's light sweep, a SMIL animation, used to keep running in all of those. Measured in headless Chrome after five page loads: 12 running animations and 16.5% CPU before, none and 0% after. `test/ov-mark.test.js`. Sites pick it up with their `openvibe-shared` pin (the file is `/shared/ov-mark.js`); pages that load it from openvibe.network get it when Network's pin moves.
++
## 1.16.0 — 2026-09-26
**`openvibe-shared/browser-harness`** (roadmap WS-Q task 3, WS-T tasks 3 and 4): Live's browser smoke suite, generalised for any site. It drives headless Chrome over the DevTools protocol with Node's own WebSocket (Node 22+, no dependency), one browser context per route. `run({ base, routes, widths, checks, navigation, … })` checks every route at 390, 768 and 1280 px: the HTTP status (a route can expect 404), console errors (exceptions, `console.error`, failed loads, CSP), horizontal overflow (naming the elements) and scripts requested twice. With JavaScript off it checks the visible text (200 characters), exactly one absolute canonical link (a noindex page may have none), and that JSON-LD parses and its headline or name is in the visible text. For an ItemList, at least 80% of the item names must be visible. axe-core runs at the widest width: serious and critical violations fail, moderate is reported. Once per site, A → B → A navigation runs five times, clicking a link when there is one so a single-page app navigates in place. After a forced GC it samples heap, DOM nodes, listeners and documents (`Performance.getMetrics`), plus live intervals, pending timeouts and open WebSockets (a probe installed before page scripts). A measure fails when it grows past its budget from lap 2 to the last lap and is still growing (`BUDGETS`). CPU, requests and bytes over 5 idle seconds are reported, not failed. `ignoreErrors` rules are counted per label, never dropped silently. `format()` gives text or Markdown, and `summarize()` gives per-check counts. axe-core is not vendored: an installed `axe-core` is used, else 4.13.0 comes from jsDelivr, checked against a pinned sha384 (the same bytes as the npm tarball) and cached. `test/browser-harness-chrome.test.js` runs it in real Chrome against a fixture and skips without Chrome. Additive.
ddiff --git a/ov-mark.js b/ov-mark.js
iindex 3bcf4a9..1cb2e62 100644
---- a/ov-mark.js
++++ b/ov-mark.js
@@@ -6,6 +6,16 @@
* Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
* The O is a ring with a comet arc running around it, the V is drawn inside with a light
* sweep, and a dot orbits the ring. 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).
*/
(function () {
'use strict';
@@@ -48,6 +58,7 @@
.ov-mark:hover .c{animation-duration:.9s}
.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
.ov-mark[data-static] .o{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]) .c,.ov-mark[data-ov]:not([data-ovm-run]) .d,.ov-mark[data-ov][data-variant=codes]:not([data-ovm-run]) .v{animation-play-state:paused!important}
.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (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] .c,.ov-mark[data-progress] .o{opacity:.25}
@@@ -73,18 +84,107 @@
<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
}
++ const INTRO_MS = 8000, LINGER_MS = 1200;
++ const state = new WeakMap(); // mark → { introEnd, lingerEnd, hover, focus, onScreen, timer }
++ let io = null, rm = null;
++ // One intro per visit (tab): the deadline is kept in sessionStorage so the next page of a
++ // multi-page site finishes the same intro instead of starting another.
++ const INTRO_KEY = 'ov-mark-intro-end';
++ function introDeadline(now) {
++ let end = 0;
++ try { end = Number(sessionStorage.getItem(INTRO_KEY)) || 0; } catch { /* no storage */ }
++ if (!end || end > now + INTRO_MS) { end = now + INTRO_MS; try { sessionStorage.setItem(INTRO_KEY, String(end)); } catch { /* */ } }
++ return end;
++ }
++ function reduced() {
++ return !!(rm && rm.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced';
++ }
++ function update(el) {
++ const st = state.get(el); if (!st) return;
++ const svgEl = el.querySelector('svg');
++ if (!el.isConnected || !svgEl) { forget(el); return; }
++ const now = Date.now();
++ const may = !reduced() && !el.hasAttribute('data-static') && !document.hidden && st.onScreen === true;
++ if (may && !st.introEnd) st.introEnd = introDeadline(now);
++ const s = el.getAttribute('data-state');
++ const run = may && (now < st.introEnd || st.hover || st.focus || now < st.lingerEnd || s === 'busy' || s === 'ok');
++ 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 */ }
++ 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);
++ }
++ // A mark taken out of the page stops and is let go (a navbar re-render replaces its mark);
++ // mount() picks it up again if it comes back.
++ function forget(el) {
++ const st = state.get(el); if (!st) return;
++ clearTimeout(st.timer); state.delete(el);
++ if (io) try { io.unobserve(el); } catch { /* */ }
++ el.removeAttribute('data-ovm-run');
++ try { el.querySelector('svg').pauseAnimations(); } catch { /* */ }
++ }
++ function updateAll() { document.querySelectorAll('.ov-mark[data-ov]').forEach(update); }
++ const bound = new WeakSet();
++ function track(el) {
++ state.set(el, { introEnd: 0, lingerEnd: 0, hover: false, focus: false, onScreen: io ? null : true, timer: 0 });
++ if (!bound.has(el)) {
++ bound.add(el);
++ const host = el.closest('a,button') || el;
++ const set = (k, v) => () => {
++ const st = state.get(el); if (!st) return;
++ st[k] = v; if (!v) st.lingerEnd = Date.now() + LINGER_MS;
++ update(el);
++ };
++ host.addEventListener('pointerenter', set('hover', true)); host.addEventListener('pointerleave', set('hover', false));
++ host.addEventListener('focusin', set('focus', true)); host.addEventListener('focusout', set('focus', false));
++ }
++ if (io) io.observe(el);
++ update(el);
++ }
function mount(root) {
-- (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
-- 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'; }
-- el.innerHTML = svg('ovm' + (++n) + '_');
++ (root || document).querySelectorAll('.ov-mark').forEach(el => {
++ if (!el.hasAttribute('data-ov')) {
++ el.setAttribute('data-ov', '1');
++ const size = parseInt(el.getAttribute('data-size'), 10);
++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
++ el.innerHTML = svg('ovm' + (++n) + '_');
++ }
++ if (!state.has(el) && el.isConnected) track(el);
});
}
function init() {
if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
++ try {
++ io = new IntersectionObserver((entries) => entries.forEach((e) => {
++ const st = state.get(e.target); if (!st) return;
++ st.onScreen = e.isIntersecting; update(e.target);
++ }));
++ } catch { io = null; }
++ try { rm = window.matchMedia('(prefers-reduced-motion: reduce)'); rm.addEventListener('change', updateAll); } catch { rm = null; }
++ document.addEventListener('visibilitychange', updateAll);
mount();
-- try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
++ try {
++ new MutationObserver((records) => {
++ let added = false;
++ for (const r of records) {
++ if (r.type === 'attributes') { if (r.target === document.documentElement) updateAll(); else if (state.has(r.target)) update(r.target); continue; }
++ if (r.addedNodes.length) added = true;
++ r.removedNodes.forEach((node) => {
++ if (node.nodeType !== 1) return;
++ if (state.has(node) && !node.isConnected) forget(node);
++ if (node.querySelectorAll) node.querySelectorAll('.ov-mark[data-ov]').forEach((m) => { if (!m.isConnected) forget(m); });
++ });
++ }
++ if (added) mount();
++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-state', 'data-static', 'data-ov-motion'] });
++ } catch { /* */ }
}
window.ovMarkMount = mount;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
ddiff --git a/package-lock.json b/package-lock.json
iindex a3f66e4..97ccd01 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.16.0",
++ "version": "1.16.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.16.0",
++ "version": "1.16.1",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex acb8fe0..a87fab7 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.16.0",
++ "version": "1.16.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/test/ov-mark.test.js b/test/ov-mark.test.js
nnew file mode 100644
iindex 0000000..e97136b
---- /dev/null
++++ b/test/ov-mark.test.js
@@@ -0,0 +1,199 @@
++'use strict';
++// ov-mark.js 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.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { parseHTML } = require('linkedom');
++
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-mark.js'), 'utf8');
++
++function memoryStorage() {
++ const m = new Map();
++ return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => { m.set(k, String(v)); }, removeItem: (k) => { m.delete(k); } };
++}
++
++function setup({ reduce = false, session = memoryStorage(), start = 1000000, html = '<a href="/" class="brand"><span class="ov-mark" data-size="28"></span></a><span class="ov-mark" id="foot" data-static="1"></span>' } = {}) {
++ const { window, document, MutationObserver, Event } = parseHTML(`<!doctype html><html><head></head><body>${html}</body></html>`);
++ let now = start, timers = [];
++ const clock = {
++ now: () => now,
++ advance(ms) {
++ now += ms;
++ for (;;) {
++ const due = timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at)[0];
++ if (!due) break;
++ timers = timers.filter((t) => t !== due); due.fn();
++ }
++ },
++ };
++ const FakeDate = { now: () => now };
++ const setT = (fn, ms) => { const t = { fn, at: now + ms }; timers.push(t); return t; };
++ const clearT = (t) => { timers = timers.filter((x) => x !== t); };
++ const observers = [];
++ class IO {
++ constructor(cb) { this.cb = cb; this.targets = new Set(); observers.push(this); }
++ observe(el) { this.targets.add(el); }
++ unobserve(el) { this.targets.delete(el); }
++ fire(el, isIntersecting) { this.cb([{ target: el, isIntersecting }]); }
++ }
++ const mq = { matches: reduce, listeners: [], addEventListener(_t, fn) { this.listeners.push(fn); } };
++ window.matchMedia = () => mq;
++ window.__ovMark = false; // linkedom windows share their expando properties
++ let hidden = false;
++ Object.defineProperty(document, 'hidden', { get: () => hidden, configurable: true });
++ const smil = new Map(); // svg → { paused, t }
++ const probe = document.createElement('div'); probe.innerHTML = '<svg></svg>';
++ const proto = Object.getPrototypeOf(probe.firstChild);
++ proto.pauseAnimations = function () { const s = smil.get(this) || { t: 0 }; s.paused = true; smil.set(this, s); };
++ proto.unpauseAnimations = function () { const s = smil.get(this) || { t: 0 }; s.paused = false; smil.set(this, s); };
++ proto.getCurrentTime = function () { return (smil.get(this) || { t: 0 }).t; };
++ proto.setCurrentTime = function (t) { const s = smil.get(this) || {}; s.t = t; smil.set(this, s); };
++ const run = new Function('window', 'document', 'IntersectionObserver', 'MutationObserver', 'setTimeout', 'clearTimeout', 'Date', 'sessionStorage', SRC);
++ run(window, document, IO, MutationObserver, setT, clearT, FakeDate, session);
++ const io = observers[0];
++ const mark = document.querySelector('.brand .ov-mark');
++ return {
++ window, document, Event, clock, io, mq, mark,
++ running: (el = mark) => el.hasAttribute('data-ovm-run'),
++ smilPaused: (el = mark) => (smil.get(el.querySelector('svg')) || {}).paused,
++ smilTime: (el = mark) => (smil.get(el.querySelector('svg')) || {}).t,
++ setHidden(v) { hidden = v; document.dispatchEvent(new Event('visibilitychange')); },
++ flush: () => new Promise((r) => setImmediate(r)),
++ };
++}
++
++(async () => {
++ // ── the CSS pauses a mounted mark's endless animations unless it is running ─────
++ {
++ 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\]\) \.c,[^{]*\{animation-play-state:paused!important\}/, 'paused rule');
++ 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.
++ assert.strictEqual(t.running(), false);
++ assert.strictEqual(t.smilPaused(), true);
++ assert.strictEqual(t.smilTime(), 1.6);
++ }
++
++ // ── intro: runs once on screen, stops by itself after about 8 s ─────────────────
++ {
++ const t = setup();
++ t.io.fire(t.mark, true);
++ assert.strictEqual(t.running(), true, 'intro runs');
++ 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.strictEqual(t.smilPaused(), true);
++ // Scrolling away and back does not replay the intro.
++ t.io.fire(t.mark, false); t.io.fire(t.mark, true);
++ assert.strictEqual(t.running(), false, 'no second intro');
++ }
++
++ // ── one intro per visit: the next page of a multi-page site finishes it, never restarts it ─
++ {
++ const session = memoryStorage();
++ const first = setup({ session });
++ first.io.fire(first.mark, true);
++ assert.strictEqual(first.running(), true);
++ const second = setup({ session, start: 1000000 + 3000 }); // a click 3 s into the intro
++ second.io.fire(second.mark, true);
++ assert.strictEqual(second.running(), true, 'the intro carries on');
++ second.clock.advance(5100);
++ assert.strictEqual(second.running(), false, 'and ends 8 s after the visit began');
++ const third = setup({ session, start: 1000000 + 20000 });
++ third.io.fire(third.mark, true);
++ assert.strictEqual(third.running(), false, 'a later page opens with the mark at rest');
++ assert.strictEqual(third.smilPaused(), true);
++ third.mark.closest('a').dispatchEvent(new third.Event('pointerenter'));
++ assert.strictEqual(third.running(), true, 'and still answers hover');
++ }
++
++ // ── hover and focus on the mark's link run it, with a short linger ─────────────
++ {
++ const t = setup();
++ t.io.fire(t.mark, true); t.clock.advance(9000);
++ const link = t.mark.closest('a');
++ link.dispatchEvent(new t.Event('pointerenter'));
++ assert.strictEqual(t.running(), true, 'hover runs');
++ t.clock.advance(60000);
++ assert.strictEqual(t.running(), true, 'while hovered');
++ link.dispatchEvent(new t.Event('pointerleave'));
++ assert.strictEqual(t.running(), true, 'lingers');
++ t.clock.advance(1300);
++ assert.strictEqual(t.running(), false, 'stops after the linger');
++ link.dispatchEvent(new t.Event('focusin'));
++ assert.strictEqual(t.running(), true, 'focus runs');
++ link.dispatchEvent(new t.Event('focusout')); t.clock.advance(1300);
++ assert.strictEqual(t.running(), false);
++ }
++
++ // ── a hidden page or an off-screen mark never runs, even in the intro or hovered ─
++ {
++ const t = setup();
++ t.io.fire(t.mark, true);
++ t.setHidden(true);
++ assert.strictEqual(t.running(), false, 'hidden page pauses the intro');
++ assert.strictEqual(t.smilPaused(), true);
++ t.setHidden(false);
++ assert.strictEqual(t.running(), true, 'visible again inside the intro');
++ t.io.fire(t.mark, false);
++ assert.strictEqual(t.running(), false, 'off-screen pauses');
++ t.mark.closest('a').dispatchEvent(new t.Event('pointerenter'));
++ assert.strictEqual(t.running(), false, 'off-screen even when hovered');
++ }
++
++ // ── activity (island.js data-state) runs it after the intro ─────────────────────
++ {
++ const t = setup();
++ t.io.fire(t.mark, true); t.clock.advance(9000);
++ t.mark.setAttribute('data-state', 'busy'); await t.flush();
++ assert.strictEqual(t.running(), true, 'busy runs');
++ t.mark.removeAttribute('data-state'); await t.flush();
++ assert.strictEqual(t.running(), false, 'idle again');
++ }
++
++ // ── reduced motion (the OS setting or the network's own) and data-static: never ──
++ {
++ const t = setup({ reduce: true });
++ t.io.fire(t.mark, true);
++ t.mark.closest('a').dispatchEvent(new t.Event('pointerenter'));
++ assert.strictEqual(t.running(), false, 'prefers-reduced-motion');
++ assert.strictEqual(t.smilPaused(), true, 'SMIL stopped too (the V sweep used to run under reduced motion)');
++
++ const u = setup();
++ u.io.fire(u.mark, true);
++ u.document.documentElement.setAttribute('data-ov-motion', 'reduced'); await u.flush();
++ assert.strictEqual(u.running(), false, 'html[data-ov-motion=reduced]');
++ u.document.documentElement.removeAttribute('data-ov-motion'); await u.flush();
++ assert.strictEqual(u.running(), true, 'back inside the intro');
++
++ const foot = u.document.getElementById('foot');
++ u.io.fire(foot, true);
++ assert.strictEqual(u.running(foot), false, 'data-static');
++ assert.strictEqual(u.smilPaused(foot), true);
++ u.mq.matches = true; u.mq.listeners.forEach((fn) => fn());
++ assert.strictEqual(u.running(), false, 'reduced-motion change applies at once');
++ }
++
++ // ── a removed mark is let go; one added later mounts and gets its own intro ──────
++ {
++ const t = setup();
++ t.io.fire(t.mark, true);
++ t.mark.closest('a').remove(); await t.flush();
++ assert.ok(!t.io.targets.has(t.mark), 'unobserved');
++ assert.strictEqual(t.running(), false, 'a detached mark does not keep a running flag');
++ const span = t.document.createElement('span'); span.className = 'ov-mark';
++ t.document.body.appendChild(span); await t.flush();
++ assert.ok(span.querySelector('svg') && t.io.targets.has(span), 'new mark mounted and observed');
++ t.io.fire(span, true);
++ 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');
++})().catch((e) => { console.error(e); process.exit(1); });