openvibers/openvibe.shared Commits

4.106.0: the ghost cursor (motion.js [data-ov-ghost], MOTION-1) (#270)

OP OpenVibers committed 11 days ago · b506c4a3b7 · 7 files, +146 −5

* 4.106.0: the ghost cursor (motion.js [data-ov-ghost], MOTION-1)

A scene shows how it is used: an arrow walks to each [data-ov-ghost-step=N] in order, clicks (a ring that widens and
fades), marks the step data-ov-ghost-on for the page to draw chosen, and starts over. Only while in view, never under
reduced motion, gone for good the moment the reader touches the scene; aria-hidden, no focus, no pointer events.

* The ghost cursor's Chrome test asks for motion explicitly (macOS runners reduce motion)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8bfaa28..5cb52c9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ 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).
++## 4.106.0 — 2026-11-02
++
++- **The ghost cursor** (`motion.js` `[data-ov-ghost]`, MOTION-1): a scene shows how it is used. An arrow walks to each
++ `[data-ov-ghost-step="N"]` inside it in order, "clicks" (a ring that widens and fades, never a spinner), marks that
++ step `data-ov-ghost-on` so the page can draw it chosen, and starts over after the last. It runs only while the scene
++ is in view, never under reduced motion (the OS setting or `data-ov-motion="reduced"`), and is gone for good the
++ moment the reader touches, clicks, types or focuses inside the scene. The cursor is aria-hidden and takes no focus or
++ pointer events. `test/motion-ghost-chrome.test.js` checks it in Chrome.
++
## 4.105.1 — 2026-11-02
- **OpenVibe Connect draws the plug in the launcher** (`ov-icons.js` ALIASES `connect: 'plug'`), not the fallback mark.
ddiff --git a/README.md b/README.md
iindex bef945b..f737ec1 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.105.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.106.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 9e6edc5..bed2429 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.105.1",
++ "version": "4.106.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/motion.js b/motion.js
iindex ba5aa7f..9c77b61 100644
---- a/motion.js
++++ b/motion.js
@@@ -8,6 +8,12 @@
* [data-ov-ticker="1204"] counts up to its number when seen (the text the server wrote is the end state)
* input[data-ov-typed="a|b|c"] types each hint into the placeholder in turn, erases it, moves on; stops for good
* once the box is focused or has text
++ * [data-ov-ghost] a scene's ghost cursor (4.106.0, MOTION-1): an arrow walks to each [data-ov-ghost-step="N"]
++ * inside it in order, "clicks" (a ring that widens and fades) and marks that step
++ * data-ov-ghost-on (the page draws what "chosen" looks like), dwells, moves on, and starts
++ * over after the last. Only while the scene is in view; never under reduced motion; gone for
++ * good the moment the reader touches, clicks, types or focuses inside the scene. The
++ * cursor is aria-hidden and takes no focus or pointer events.
*
* Runs by itself when it loads; OpenVibeMotion.enhance(container) again for markup added later. Idempotent.
*/
@@@ -53,6 +59,63 @@
timer = setTimeout(run, 900);
}
++ const GHOST_CSS = `
++.ov-ghost-host{position:relative}
++.ov-ghost{position:absolute;left:0;top:0;z-index:5;width:22px;height:22px;pointer-events:none;opacity:0;transition:transform .55s cubic-bezier(.3,.8,.3,1),opacity .3s ease;filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))}
++.ov-ghost.is-on{opacity:1}
++.ov-ghost svg{display:block;width:22px;height:22px}
++.ov-ghost-ring{position:absolute;left:-9px;top:-9px;width:18px;height:18px;border-radius:50%;border:2px solid var(--room-accent,var(--accent,#3b82f6));opacity:0}
++.ov-ghost.is-click .ov-ghost-ring{animation:ovGhostRing .5s ease-out}
++@keyframes ovGhostRing{from{opacity:.9;transform:scale(.4)}to{opacity:0;transform:scale(1.8)}}`;
++ const GHOST_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3l14 8.2-6.1 1.3 3.6 6.8-2.6 1.4-3.6-6.8L5 18.6z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/></svg><span class="ov-ghost-ring"></span>';
++
++ function ghost(scene) {
++ if (still()) return;
++ const steps = Array.prototype.slice.call(scene.querySelectorAll('[data-ov-ghost-step]'))
++ .sort((a, b) => Number(a.getAttribute('data-ov-ghost-step')) - Number(b.getAttribute('data-ov-ghost-step')));
++ if (!steps.length) return;
++ if (!document.getElementById('ov-ghost-css')) { const st = document.createElement('style'); st.id = 'ov-ghost-css'; st.textContent = GHOST_CSS; document.head.appendChild(st); }
++ if (getComputedStyle(scene).position === 'static') scene.classList.add('ov-ghost-host');
++ const cur = document.createElement('span');
++ cur.className = 'ov-ghost';
++ cur.setAttribute('aria-hidden', 'true');
++ cur.innerHTML = GHOST_SVG;
++ scene.appendChild(cur);
++ let i = 0, timer = 0, visible = false, gone = false;
++ const mark = (el) => { steps.forEach((x) => x.removeAttribute('data-ov-ghost-on')); if (el) el.setAttribute('data-ov-ghost-on', ''); };
++ const place = (el) => {
++ const a = scene.getBoundingClientRect(), b = el.getBoundingClientRect();
++ // Toward the left of the step (where a radio or an icon usually is), so the arrow does not cover its words.
++ const x = b.left - a.left + Math.min(b.width * 0.18, 26), y = b.top - a.top + b.height * 0.6;
++ cur.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
++ };
++ const step = () => {
++ if (gone || !visible) return;
++ const el = steps[i];
++ place(el);
++ cur.classList.add('is-on');
++ timer = setTimeout(() => {
++ if (gone) return;
++ cur.classList.remove('is-click'); void cur.offsetWidth; cur.classList.add('is-click');
++ mark(el);
++ i = (i + 1) % steps.length;
++ timer = setTimeout(step, i === 0 ? 2600 : 1300);
++ }, 650);
++ };
++ const stop = () => {
++ if (gone) return;
++ gone = true; clearTimeout(timer); mark(null); cur.remove();
++ ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.removeEventListener(ev, stop, true));
++ };
++ ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.addEventListener(ev, stop, { capture: true, passive: true }));
++ if (typeof IntersectionObserver === 'function') {
++ new IntersectionObserver((es) => es.forEach((e) => {
++ const was = visible; visible = e.isIntersecting;
++ if (visible && !was) { clearTimeout(timer); timer = setTimeout(step, 500); } else if (!visible) { clearTimeout(timer); cur.classList.remove('is-on'); }
++ }), { threshold: 0.4 }).observe(scene);
++ } else { visible = true; timer = setTimeout(step, 500); }
++ }
++
function enhance(scope) {
const box = scope || document;
const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel));
@@@ -60,6 +123,7 @@
all('[data-ov-reveal]').forEach((el) => { if (once(el, 'ovReveal')) { if (still()) el.classList.add('is-in'); else seen(el, (x) => x.classList.add('is-in')); } });
all('[data-ov-ticker]').forEach((el) => { if (once(el, 'ovTicker')) seen(el, tick, '0px'); });
all('input[data-ov-typed]').forEach((el) => { if (once(el, 'ovTyped')) typed(el); });
++ all('[data-ov-ghost]').forEach((el) => { if (once(el, 'ovGhost')) ghost(el); });
}
html.classList.add('ov-motion');
ddiff --git a/package-lock.json b/package-lock.json
iindex 49a4c39..c6194e0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.105.1",
++ "version": "4.106.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.105.1",
++ "version": "4.106.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex f6dcb99..c065e36 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.105.1",
++ "version": "4.106.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/motion-ghost-chrome.test.js b/test/motion-ghost-chrome.test.js
nnew file mode 100644
iindex 0000000..c5196ed
---- /dev/null
++++ b/test/motion-ghost-chrome.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// The ghost cursor (motion.js [data-ov-ghost], 4.106.0, MOTION-1) in a real headless Chrome: it walks a scene's steps in
++// order and marks each one chosen (data-ov-ghost-on) in turn; the page stays its width; it is gone for good the moment
++// the reader touches the scene; and under reduced motion there is no cursor at all. Skipped (exit 0) without Chrome or
++// with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('motion-ghost-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const scene = (extra = '') => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>ghost</title><meta name="viewport" content="width=device-width,initial-scale=1">
++<style>body{margin:0;padding:40px 16px;font:15px system-ui}.pick{max-width:340px;display:grid;gap:8px}.row{display:block;padding:12px;border:1px solid #888}</style>
++<script src="/motion.js" defer></script></head><body${extra}><div class="pick" data-ov-ghost>
++<span class="row" data-ov-ghost-step="2">Second</span><span class="row" data-ov-ghost-step="1">First</span><span class="row" data-ov-ghost-step="3">Third</span>
++</div></body></html>`;
++const server = http.createServer((req, res) => {
++ if (req.url === '/motion.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'motion.js'))); }
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
++ return res.end(req.url === '/still' ? scene(' data-x="1"').replace('<html lang="en">', '<html lang="en" data-ov-motion="reduced">') : scene());
++});
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ghost-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const chosen = 'Array.from(document.querySelectorAll("[data-ov-ghost-on]")).map((e) => e.textContent).join(",")';
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 390 });
++ // A runner may have reduce motion set (macOS CI does): this page asks for motion explicitly.
++ await p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ const seen = [];
++ for (let k = 0; k < 40 && seen.length < 3; k++) {
++ await wait(250);
++ const now = await p.evaluate(chosen);
++ if (now && now !== seen[seen.length - 1]) seen.push(now);
++ }
++ assert.deepStrictEqual(seen, ['First', 'Second', 'Third'], 'the steps in their numbered order, one chosen at a time');
++ assert.strictEqual(await p.evaluate('!!document.querySelector(".ov-ghost[aria-hidden=true]")'), true, 'the cursor is there, hidden from assistive tech');
++ assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'the page stays its width');
++ await p.evaluate('document.querySelector(".row").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); 1');
++ await wait(200);
++ assert.strictEqual(await p.evaluate(`!document.querySelector(".ov-ghost") && ${chosen} === ""`), true, 'a touch ends it for good');
++
++ const still = await h.openPage(browser, context, { width: 390 });
++ await still.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
++ await still.goto(`${base}/still`, { maxMs: 5000 });
++ await wait(1500);
++ assert.strictEqual(await still.evaluate('!document.querySelector(".ov-ghost") && document.querySelectorAll("[data-ov-ghost-on]").length === 0'), true, 'reduced motion: no cursor at all');
++ const errors = p.state.errors.concat(still.state.errors).filter((e) => !/Failed to load resource/.test(e.text));
++ assert.deepStrictEqual(errors, [], JSON.stringify(errors));
++ console.log('motion-ghost-chrome: all checks passed');
++ } finally {
++ await browser.close().catch(() => {});
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });