openvibers/openvibe.shared motion.js
motion.js
| 1 | /** |
| 2 | * openvibe-shared/motion.js — the network's motion, small and opt-in (plan 2026-10-28 §3.11): a page marks what moves and |
| 3 | * this makes it move, once, when it is seen. The server always renders the finished state (the real number, the whole |
| 4 | * scene, the plain placeholder), so a page is complete without this file, with motion off (html[data-ov-motion=off]) |
| 5 | * and for crawlers. Calm (html[data-ov-motion=reduced]: the person's choice, or the device asking for less motion) |
| 6 | * plays all of it, gentler: a reveal rises a third as far, a number counts up quicker, the ghost cursor glides slower |
| 7 | * and waits longer. |
| 8 | * |
| 9 | * [data-ov-reveal] fades and rises in the first time it scrolls into view (html.ov-motion hides it until then) |
| 10 | * [data-ov-ticker="1204"] counts up to its number when seen (the text the server wrote is the end state) |
| 11 | * input[data-ov-typed="a|b|c"] types each hint into the placeholder in turn, erases it, moves on; stops for good |
| 12 | * once the box is focused or has text |
| 13 | * [data-ov-ghost] a scene's ghost cursor (4.106.0, MOTION-1): an arrow walks to each [data-ov-ghost-step="N"] |
| 14 | * inside it in order, "clicks" (a ring that widens and fades) and marks that step |
| 15 | * data-ov-ghost-on (the page draws what "chosen" looks like), dwells, moves on, and starts |
| 16 | * over after the last. Only while the scene is in view; never under reduced motion; gone for |
| 17 | * good the moment the reader touches, clicks, types or focuses inside the scene. The |
| 18 | * cursor is aria-hidden and takes no focus or pointer events. |
| 19 | * |
| 20 | * Runs by itself when it loads; OpenVibeMotion.enhance(container) again for markup added later. Idempotent. |
| 21 | */ |
| 22 | (function (root) { |
| 23 | ; |
| 24 | if (typeof document === 'undefined') return; |
| 25 | const html = document.documentElement; |
| 26 | // 'full' | 'calm' | 'off': <html data-ov-motion> is the signal (theme-loader.js); without the loader, ask the device. |
| 27 | 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'; }; |
| 28 | const still = () => motion() === 'off'; |
| 29 | const seen = typeof IntersectionObserver === 'function' |
| 30 | ? (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); } |
| 31 | : (el, fn) => fn(el); |
| 32 | |
| 33 | function tick(el) { |
| 34 | const end = Number(el.getAttribute('data-ov-ticker')); |
| 35 | const text = el.textContent; |
| 36 | if (!Number.isFinite(end) || end <= 0 || still()) return; |
| 37 | const t0 = performance.now(), dur = Math.min(1400, 500 + Math.log10(end + 1) * 220) * (motion() === 'calm' ? 0.6 : 1); |
| 38 | const step = (t) => { |
| 39 | const k = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - k, 3); |
| 40 | el.textContent = k < 1 ? Math.round(end * e).toLocaleString('en-US') : text; |
| 41 | if (k < 1) requestAnimationFrame(step); |
| 42 | }; |
| 43 | requestAnimationFrame(step); |
| 44 | } |
| 45 | |
| 46 | function typed(input) { |
| 47 | const hints = String(input.getAttribute('data-ov-typed') || '').split('|').map((s) => s.trim()).filter(Boolean); |
| 48 | const base = input.getAttribute('placeholder') || ''; |
| 49 | if (!hints.length || still()) return; |
| 50 | let i = 0, n = 0, dir = 1, timer = 0, stopped = false; |
| 51 | const stop = () => { stopped = true; clearTimeout(timer); input.setAttribute('placeholder', base); }; |
| 52 | input.addEventListener('focus', stop, { once: true }); |
| 53 | input.addEventListener('input', stop, { once: true }); |
| 54 | const run = () => { |
| 55 | if (stopped || input.value) return stop(); |
| 56 | const h = hints[i]; |
| 57 | n += dir; |
| 58 | input.setAttribute('placeholder', h.slice(0, n)); |
| 59 | let wait = dir > 0 ? 45 + Math.random() * 40 : 22; |
| 60 | 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; } |
| 61 | timer = setTimeout(run, wait); |
| 62 | }; |
| 63 | timer = setTimeout(run, 900); |
| 64 | } |
| 65 | |
| 66 | const GHOST_CSS = ` |
| 67 | .ov-ghost-host{position:relative} |
| 68 | .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))} |
| 69 | .ov-ghost.is-on{opacity:1} |
| 70 | html[data-ov-motion=reduced] .ov-ghost{transition-duration:.9s,.3s} |
| 71 | .ov-ghost svg{display:block;width:22px;height:22px} |
| 72 | .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} |
| 73 | .ov-ghost.is-click .ov-ghost-ring{animation:ovGhostRing .5s ease-out} |
| 74 | @keyframes ovGhostRing{from{opacity:.9;transform:scale(.4)}to{opacity:0;transform:scale(1.8)}}`; |
| 75 | 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>'; |
| 76 | |
| 77 | function ghost(scene) { |
| 78 | if (still()) return; |
| 79 | const steps = Array.prototype.slice.call(scene.querySelectorAll('[data-ov-ghost-step]')) |
| 80 | .sort((a, b) => Number(a.getAttribute('data-ov-ghost-step')) - Number(b.getAttribute('data-ov-ghost-step'))); |
| 81 | if (!steps.length) return; |
| 82 | if (!document.getElementById('ov-ghost-css')) { const st = document.createElement('style'); st.id = 'ov-ghost-css'; st.textContent = GHOST_CSS; document.head.appendChild(st); } |
| 83 | if (getComputedStyle(scene).position === 'static') scene.classList.add('ov-ghost-host'); |
| 84 | const cur = document.createElement('span'); |
| 85 | cur.className = 'ov-ghost'; |
| 86 | cur.setAttribute('aria-hidden', 'true'); |
| 87 | cur.innerHTML = GHOST_SVG; |
| 88 | scene.appendChild(cur); |
| 89 | let i = 0, timer = 0, visible = false, gone = false; |
| 90 | const slow = () => (motion() === 'calm' ? 1.5 : 1); |
| 91 | const mark = (el) => { steps.forEach((x) => x.removeAttribute('data-ov-ghost-on')); if (el) el.setAttribute('data-ov-ghost-on', ''); }; |
| 92 | const place = (el) => { |
| 93 | const a = scene.getBoundingClientRect(), b = el.getBoundingClientRect(); |
| 94 | // Toward the left of the step (where a radio or an icon usually is), so the arrow does not cover its words. |
| 95 | const x = b.left - a.left + Math.min(b.width * 0.18, 26), y = b.top - a.top + b.height * 0.6; |
| 96 | cur.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; |
| 97 | }; |
| 98 | const step = () => { |
| 99 | if (gone || !visible) return; |
| 100 | const el = steps[i]; |
| 101 | place(el); |
| 102 | cur.classList.add('is-on'); |
| 103 | timer = setTimeout(() => { |
| 104 | if (gone) return; |
| 105 | cur.classList.remove('is-click'); void cur.offsetWidth; cur.classList.add('is-click'); |
| 106 | mark(el); |
| 107 | i = (i + 1) % steps.length; |
| 108 | timer = setTimeout(step, (i === 0 ? 2600 : 1300) * slow()); |
| 109 | }, 650 * slow()); |
| 110 | }; |
| 111 | const stop = () => { |
| 112 | if (gone) return; |
| 113 | gone = true; clearTimeout(timer); mark(null); cur.remove(); |
| 114 | ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.removeEventListener(ev, stop, true)); |
| 115 | }; |
| 116 | ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.addEventListener(ev, stop, { capture: true, passive: true })); |
| 117 | if (typeof IntersectionObserver === 'function') { |
| 118 | new IntersectionObserver((es) => es.forEach((e) => { |
| 119 | const was = visible; visible = e.isIntersecting; |
| 120 | if (visible && !was) { clearTimeout(timer); timer = setTimeout(step, 500); } else if (!visible) { clearTimeout(timer); cur.classList.remove('is-on'); } |
| 121 | }), { threshold: 0.4 }).observe(scene); |
| 122 | } else { visible = true; timer = setTimeout(step, 500); } |
| 123 | } |
| 124 | |
| 125 | function enhance(scope) { |
| 126 | const box = scope || document; |
| 127 | const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel)); |
| 128 | const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; }; |
| 129 | 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')); } }); |
| 130 | all('[data-ov-ticker]').forEach((el) => { if (once(el, 'ovTicker')) seen(el, tick, '0px'); }); |
| 131 | all('input[data-ov-typed]').forEach((el) => { if (once(el, 'ovTyped')) typed(el); }); |
| 132 | all('[data-ov-ghost]').forEach((el) => { if (once(el, 'ovGhost')) ghost(el); }); |
| 133 | } |
| 134 | |
| 135 | html.classList.add('ov-motion'); |
| 136 | root.OpenVibeMotion = { enhance }; |
| 137 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document)); |
| 138 | else enhance(document); |
| 139 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 140 |