openvibers/openvibe.shared Commits
4.106.0: the ghost cursor (motion.js [data-ov-ghost], MOTION-1) (#270)
Changes
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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.@@@ -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 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.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)",@@@ -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');@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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); });
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 4.105.1 — 2026-11-02
- **OpenVibe Connect draws the plug in the launcher** (`ov-icons.js` ALIASES `connect: 'plug'`), not the fallback mark.
- **OpenVibe Connect draws the plug in the launcher** (`ov-icons.js` ALIASES `connect: 'plug'`), not the fallback mark.
@@ -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.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
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.105.1",
+ "version": "4.106.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)",
@@ -8,6 +8,12 @@
@@ -8,6 +8,12 @@
* [data-ov-ticker="1204"] counts up to its number when seen (the text the server wrote is the end state)
* [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
* 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
* 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.
* Runs by itself when it loads; OpenVibeMotion.enhance(container) again for markup added later. Idempotent.
*/
*/
@@ -53,6 +59,63 @@
@@ -53,6 +59,63 @@
timer = setTimeout(run, 900);
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) {
function enhance(scope) {
const box = scope || document;
const box = scope || document;
const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel));
const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel));
@@ -60,6 +123,7 @@
@@ -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-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('[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('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');
html.classList.add('ov-motion');
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.105.1",
+ "version": "4.106.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.105.1",
+ "version": "4.106.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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); });