/**
* openvibe-shared/motion.js — the network's motion, small and opt-in (plan 2026-10-28 §3.11): a page marks what moves and
* this makes it move, once, when it is seen. The server always renders the finished state (the real number, the whole
* scene, the plain placeholder), so a page is complete without this file, with motion off (html[data-ov-motion=off])
* and for crawlers. Calm (html[data-ov-motion=reduced]: the person's choice, or the device asking for less motion)
* plays all of it, gentler: a reveal rises a third as far, a number counts up quicker, the ghost cursor glides slower
* and waits longer.
*
* [data-ov-reveal] fades and rises in the first time it scrolls into view (html.ov-motion hides it until then)
* [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.
*/
(function (root) {
'use strict';
if (typeof document === 'undefined') return;
const html = document.documentElement;
// 'full' | 'calm' | 'off': is the signal (theme-loader.js); without the loader, ask the device.
const motion = () => { const m = html.getAttribute('data-ov-motion'); return m === 'off' ? 'off' : m === 'reduced' || (!m && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'calm' : 'full'; };
const still = () => motion() === 'off';
const seen = typeof IntersectionObserver === 'function'
? (el, fn, margin) => { const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); fn(e.target); } }), { rootMargin: margin || '0px 0px -8% 0px' }); io.observe(el); }
: (el, fn) => fn(el);
function tick(el) {
const end = Number(el.getAttribute('data-ov-ticker'));
const text = el.textContent;
if (!Number.isFinite(end) || end <= 0 || still()) return;
const t0 = performance.now(), dur = Math.min(1400, 500 + Math.log10(end + 1) * 220) * (motion() === 'calm' ? 0.6 : 1);
const step = (t) => {
const k = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - k, 3);
el.textContent = k < 1 ? Math.round(end * e).toLocaleString('en-US') : text;
if (k < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
function typed(input) {
const hints = String(input.getAttribute('data-ov-typed') || '').split('|').map((s) => s.trim()).filter(Boolean);
const base = input.getAttribute('placeholder') || '';
if (!hints.length || still()) return;
let i = 0, n = 0, dir = 1, timer = 0, stopped = false;
const stop = () => { stopped = true; clearTimeout(timer); input.setAttribute('placeholder', base); };
input.addEventListener('focus', stop, { once: true });
input.addEventListener('input', stop, { once: true });
const run = () => {
if (stopped || input.value) return stop();
const h = hints[i];
n += dir;
input.setAttribute('placeholder', h.slice(0, n));
let wait = dir > 0 ? 45 + Math.random() * 40 : 22;
if (dir > 0 && n >= h.length) { dir = -1; wait = 1700; } else if (dir < 0 && n <= 0) { dir = 1; i = (i + 1) % hints.length; wait = 380; }
timer = setTimeout(run, wait);
};
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}
html[data-ov-motion=reduced] .ov-ghost{transition-duration:.9s,.3s}
.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 = '';
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 slow = () => (motion() === 'calm' ? 1.5 : 1);
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) * slow());
}, 650 * slow());
};
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));
const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
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');
root.OpenVibeMotion = { enhance };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document));
else enhance(document);
})(typeof globalThis !== 'undefined' ? globalThis : this);