openvibers/openvibe.shared soft.js
soft.js
| 1 | /* |
| 2 | * openvibe-shared/soft.js — the small, safe part of "soft pages": the next page is already fetched on intent, the |
| 3 | * shared bar stays still while the page cross-fades, and the navbar's mark draws "a page on its way" from the press. |
| 4 | * |
| 5 | * <script src="/shared/soft.js"></script> in <head>, after theme-loader.js, not deferred |
| 6 | * |
| 7 | * The script has to run before the arriving page first renders, or that page cannot take part in the cross-document |
| 8 | * transition; a deferred copy still opts the *next* move in. It re-appends its one <style> at DOMContentLoaded so it |
| 9 | * wins the cascade over kit.css. |
| 10 | * |
| 11 | * It adds only what is safe on every site today: |
| 12 | * |
| 13 | * • Prefetch on intent: pointer down, or 80 ms of hover on a computer, for a same-site link. Never on a data saver |
| 14 | * or a 2G/3G connection, at most four in flight, and never sign-in/out, auth/API, downloads, query URLs, in-page |
| 15 | * anchors or data-ov-no-prefetch (see research/soft-pages.md, 2026-11-10). Speculation Rules cover the press in |
| 16 | * Chrome/Edge (conservative, prefetch only); the hover and the other browsers use <link rel=prefetch>. |
| 17 | * No prerender: our navbar's page-view beacon (countView) and visit record (recordVisit) do not defer under |
| 18 | * document.prerendering, and signed-in pages can be stale when a hidden prerender is activated. |
| 19 | * • The navbar's mark: OVMark.navStart('page') on the press, and navEnd('page') on the next page's first paint, so |
| 20 | * the ring is already drawing while the click is still on its way. It leaves a site without the mark alone. |
| 21 | * • Cross-document view transitions: the bar mount is named and held still, the rest of the page cross-fades. Calm |
| 22 | * (data-ov-motion=reduced) is shorter; Off gets no transition. A browser without CSSViewTransitionRule gets no |
| 23 | * transition styles and no error. |
| 24 | * • window.OpenVibeSoft = { on(), off(), exclude(selector) }. |
| 25 | * |
| 26 | * If a page already has its own <script type="speculationrules"> (the navbar adds one), soft.js leaves the rules to |
| 27 | * it rather than adding a second set. The navbar sees these rules first and skips its own, so loading soft.js early |
| 28 | * also turns off the navbar's default prerender. |
| 29 | */ |
| 30 | (function (root) { |
| 31 | ; |
| 32 | if (!root || typeof document === 'undefined' || root.OpenVibeSoft) return; |
| 33 | const doc = document; |
| 34 | const html = doc.documentElement; |
| 35 | const STYLE_ID = 'ov-soft-css'; |
| 36 | const RULES_ID = 'ov-soft-rules'; |
| 37 | const NAV_KEY = 'ov-soft-press'; |
| 38 | const SELF = doc.currentScript || null; |
| 39 | const state = { on: true, exclude: [] }; |
| 40 | const MAX_PREFETCH = 4, HOVER_MS = 80; |
| 41 | const prefetched = new Set(); // URLs already asked for on this page (never ask twice) |
| 42 | const inflight = new Map(); // URL -> link, only while the fetch is on its way |
| 43 | let hoverTimer = 0, hoverLink = null, pressedLink = null, navTimer = 0; |
| 44 | |
| 45 | const supportsRules = () => { |
| 46 | try { return typeof HTMLScriptElement !== 'undefined' && !!HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules'); } catch { return false; } |
| 47 | }; |
| 48 | // The cross-document feature, not just startViewTransition (Firefox 144 has same-document transitions only). |
| 49 | const supportsTransitions = () => typeof root.CSSViewTransitionRule !== 'undefined'; |
| 50 | // theme-loader.js's one signal: full | reduced (Calm) | off; without it, ask the device. Calm only shortens. |
| 51 | function motion() { |
| 52 | const m = html && html.getAttribute ? html.getAttribute('data-ov-motion') : null; |
| 53 | if (m === 'off') return 'off'; |
| 54 | if (m === 'reduced') return 'calm'; |
| 55 | if (m === 'full') return 'full'; |
| 56 | try { return root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'calm' : 'full'; } catch { return 'full'; } |
| 57 | } |
| 58 | |
| 59 | // A data saver or a slow mobile connection never gets a speculative fetch: the page would arrive either way. |
| 60 | function frugal() { |
| 61 | let c = null; |
| 62 | try { c = (root.navigator && (root.navigator.connection || root.navigator.mozConnection || root.navigator.webkitConnection)) || null; } catch { c = null; } |
| 63 | if (!c) return false; |
| 64 | if (c.saveData) return true; |
| 65 | return /(^|-)[23]g$/.test(String(c.effectiveType || '')); |
| 66 | } |
| 67 | // Hover is a computer's signal; a finger's pointerdown is handled everywhere. |
| 68 | function finePointer() { |
| 69 | try { return !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches); } catch { return false; } |
| 70 | } |
| 71 | |
| 72 | // The links a prefetch is never worth making: sign-in/out and auth (they can send mail, set a CSRF cookie or sign |
| 73 | // someone out), the API, downloads, query URLs (a server-rendered state change often hides in a query), in-page |
| 74 | // anchors, and anything the page marks with data-ov-no-prefetch (or the navbar's data-no-prefetch). |
| 75 | const DENY_PATHS = [ |
| 76 | '/auth/*', '/api/*', '/internal/*', '/oauth/*', '/sso/*', |
| 77 | '/login*', '/*login*', '/signin*', '/*signin*', '/sign-in*', '/*sign-in*', |
| 78 | '/signup*', '/*signup*', '/sign-up*', '/*sign-up*', '/register*', '/*register*', |
| 79 | '/logout*', '/*logout*', '/signout*', '/*signout*', '/sign-out*', '/*sign-out*', |
| 80 | '/verify*', '/*verify*', '/reset*', '/*reset*', |
| 81 | '/*.pdf', '/*.zip', '/*.gz', '/*.tgz', '/*.tar', '/*.7z', '/*.rar', '/*.dmg', '/*.exe', '/*.apk', '/*.iso', |
| 82 | '/*.png', '/*.jpg', '/*.jpeg', '/*.gif', '/*.webp', '/*.avif', '/*.svg', |
| 83 | '/*.mp4', '/*.webm', '/*.mov', '/*.mp3', '/*.wav', '/*.ogg', '/*.m4a', |
| 84 | '/*.doc', '/*.docx', '/*.xls', '/*.xlsx', '/*.ppt', '/*.pptx', |
| 85 | '/*.csv', '/*.ics', '/*.json', '/*.xml', |
| 86 | ]; |
| 87 | const DENY_SELECTOR = '[data-ov-no-prefetch], [data-no-prefetch], [download], [rel~=nofollow], [target=_blank], [href*="?"], [href^="#"], [data-method], [data-turbo-method]'; |
| 88 | const globCache = {}; |
| 89 | const glob = (p) => globCache[p] || (globCache[p] = new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')); |
| 90 | const deniedPath = (path) => DENY_PATHS.some((p) => glob(p).test(path)); |
| 91 | |
| 92 | function ruleSpec() { |
| 93 | const where = { |
| 94 | and: [ |
| 95 | { href_matches: '/*' }, |
| 96 | { not: { href_matches: DENY_PATHS } }, |
| 97 | { not: { selector_matches: DENY_SELECTOR } }, |
| 98 | ], |
| 99 | }; |
| 100 | for (const selector of state.exclude) where.and.push({ not: { selector_matches: selector } }); |
| 101 | // conservative is a pointerdown: the same "on intent" the intent prefetcher uses. Hover is the explicit 80 ms |
| 102 | // below, so Chrome does not fetch every link that scrolls into view. |
| 103 | const rules = { prefetch: [{ where, eagerness: 'conservative' }] }; |
| 104 | // The navbar's cross-site warm-up for another OpenVibe site (navbar.js's [data-ov-net-link] rule): soft.js owns |
| 105 | // the rule set when it runs first, so keep that prefetch instead of silently losing it. The browser itself only |
| 106 | // prefetches a cross-site URL when the person has no cookies on the destination. |
| 107 | const crossSite = { and: [{ selector_matches: '[data-ov-net-link]' }, { not: { selector_matches: DENY_SELECTOR } }] }; |
| 108 | for (const selector of state.exclude) crossSite.and.push({ not: { selector_matches: selector } }); |
| 109 | rules.prefetch.push({ where: crossSite, eagerness: 'moderate' }); |
| 110 | return rules; |
| 111 | } |
| 112 | |
| 113 | const nonce = () => { |
| 114 | if (SELF && SELF.nonce) return SELF.nonce; |
| 115 | try { return [].slice.call(doc.scripts || []).map((s) => s.nonce).find(Boolean) || ''; } catch { return ''; } |
| 116 | }; |
| 117 | |
| 118 | const ownRules = () => doc.getElementById(RULES_ID); |
| 119 | const otherRules = () => doc.querySelector('script[type="speculationrules"]:not(#' + RULES_ID + ')'); |
| 120 | function dropRules() { const el = ownRules(); if (el && el.remove) el.remove(); } |
| 121 | |
| 122 | function applyRules() { |
| 123 | if (!state.on || !supportsRules() || frugal()) return dropRules(); |
| 124 | // Someone else owns the rule set (the navbar, an inline block, a header): do not add a competing one. |
| 125 | if (otherRules()) return dropRules(); |
| 126 | dropRules(); |
| 127 | try { |
| 128 | const script = doc.createElement('script'); |
| 129 | script.type = 'speculationrules'; |
| 130 | script.id = RULES_ID; |
| 131 | const n = nonce(); |
| 132 | if (n) script.nonce = n; |
| 133 | script.textContent = JSON.stringify(ruleSpec()); |
| 134 | (doc.head || html).appendChild(script); |
| 135 | } catch { /* an old browser: no prefetch, pages load as before */ } |
| 136 | } |
| 137 | |
| 138 | // ── Prefetch on intent ─────────────────────────────────────────────────────────────────────── |
| 139 | function attr(el, name) { try { return el.getAttribute(name); } catch { return null; } } |
| 140 | function samePage(u) { |
| 141 | try { return u.origin === root.location.origin && u.pathname === root.location.pathname && u.search === root.location.search; } catch { return false; } |
| 142 | } |
| 143 | function navigates(a) { |
| 144 | if (!a || attr(a, 'download') !== null) return false; |
| 145 | const target = String(attr(a, 'target') || ''); |
| 146 | if (target && target !== '_self') return false; |
| 147 | let u; try { u = new URL(attr(a, 'href'), root.location.href); } catch { return false; } |
| 148 | return /^https?:$/.test(u.protocol) && !samePage(u); |
| 149 | } |
| 150 | function excluded(a) { |
| 151 | for (const selector of state.exclude.concat([DENY_SELECTOR])) { |
| 152 | try { if (a.matches && a.matches(selector)) return true; } catch { /* an old selector: ignore it */ } |
| 153 | } |
| 154 | return false; |
| 155 | } |
| 156 | function prefetchable(a) { |
| 157 | if (!state.on || frugal() || !navigates(a) || excluded(a)) return null; |
| 158 | let u; try { u = new URL(attr(a, 'href'), root.location.href); } catch { return null; } |
| 159 | if (u.origin !== root.location.origin || u.search) return null; |
| 160 | if (deniedPath(u.pathname)) return null; |
| 161 | return u.href; |
| 162 | } |
| 163 | function startPrefetch(a, press) { |
| 164 | // Our conservative rule already fetches a press in Chrome/Edge; a second link would only repeat the GET. A |
| 165 | // page's own rules are left alone and may not cover the press, so it still gets the link. |
| 166 | if (press && supportsRules() && ownRules()) return; |
| 167 | const href = prefetchable(a); |
| 168 | if (!href || prefetched.has(href) || inflight.size >= MAX_PREFETCH) return; |
| 169 | prefetched.add(href); |
| 170 | try { |
| 171 | const link = doc.createElement('link'); |
| 172 | link.rel = 'prefetch'; |
| 173 | link.setAttribute('href', href); |
| 174 | link.setAttribute('as', 'document'); |
| 175 | link.setAttribute('fetchpriority', 'low'); |
| 176 | const done = () => { if (inflight.get(href) === link) inflight.delete(href); }; |
| 177 | if (link.addEventListener) { link.addEventListener('load', done); link.addEventListener('error', done); } |
| 178 | inflight.set(href, link); |
| 179 | (doc.head || html).appendChild(link); |
| 180 | setTimeout(done, 15000); |
| 181 | } catch { prefetched.delete(href); } |
| 182 | } |
| 183 | |
| 184 | function linkFrom(e) { |
| 185 | try { return e && e.target && e.target.closest ? e.target.closest('a[href]') : null; } catch { return null; } |
| 186 | } |
| 187 | function onPress(e) { |
| 188 | if (!e || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 189 | const a = linkFrom(e); |
| 190 | if (!a || !navigates(a)) return; |
| 191 | pressedLink = a; |
| 192 | startPrefetch(a, true); |
| 193 | startNav(); |
| 194 | } |
| 195 | function onHover(e) { |
| 196 | if (!finePointer() || frugal()) return; |
| 197 | const a = linkFrom(e); |
| 198 | if (!a || a === hoverLink) return; |
| 199 | clearTimeout(hoverTimer); |
| 200 | hoverLink = a; |
| 201 | if (!navigates(a)) return; |
| 202 | hoverTimer = setTimeout(() => startPrefetch(a, false), HOVER_MS); |
| 203 | } |
| 204 | function onHoverOut(e) { |
| 205 | if (!hoverLink) return; |
| 206 | let to = e && e.relatedTarget; |
| 207 | try { if (to && hoverLink.contains && hoverLink.contains(to)) return; } catch { /* */ } |
| 208 | clearTimeout(hoverTimer); |
| 209 | hoverLink = null; |
| 210 | } |
| 211 | |
| 212 | // ── The navbar's mark between the press and the next page's first paint ────────────────────── |
| 213 | function startNav() { |
| 214 | if (!state.on) return; |
| 215 | clearTimeout(navTimer); |
| 216 | try { if (root.OVMark) root.OVMark.navStart('page'); } catch { /* */ } |
| 217 | try { root.sessionStorage.setItem(NAV_KEY, String(Date.now())); } catch { /* no storage: the arrival's own mark */ } |
| 218 | // A press that turned out to be a drag, a context menu or a download never leaves the page. |
| 219 | navTimer = setTimeout(() => { try { root.sessionStorage.removeItem(NAV_KEY); } catch { /* */ } endNav(); }, 10000); |
| 220 | } |
| 221 | function endNav(kind) { |
| 222 | clearTimeout(navTimer); |
| 223 | try { if (root.OVMark) root.OVMark.navEnd(kind); } catch { /* */ } |
| 224 | } |
| 225 | // This page came from a press: finish the ring here, at the first paint the mark exists for. The mark is loaded by |
| 226 | // the navbar (deferred), so wait for it rather than for a script order. |
| 227 | function arrival() { |
| 228 | let t = 0; |
| 229 | try { |
| 230 | t = Number(root.sessionStorage.getItem(NAV_KEY)) || 0; |
| 231 | root.sessionStorage.removeItem(NAV_KEY); |
| 232 | } catch { t = 0; } |
| 233 | if (!t || Date.now() - t > 20000) return false; |
| 234 | // ov-mark learns the pace from its own 'ov-nav-click' marker; it sees this flag and leaves the ending to us. |
| 235 | try { root.__ovSoftNavEnded = true; } catch { /* */ } |
| 236 | let tries = 0; |
| 237 | const finish = () => { |
| 238 | let marks = null; |
| 239 | try { marks = doc.querySelectorAll('.openvibe-navbar-brand .ov-mark, .navbar .nav-brand .ov-mark, .ov-mark[data-ov-nav]'); } catch { marks = null; } |
| 240 | if (!root.OVMark || !marks || !marks.length) { |
| 241 | if (++tries <= 100) setTimeout(finish, 50); // the navbar and its mark arrive after the first paint |
| 242 | return; |
| 243 | } |
| 244 | endNav('page'); |
| 245 | }; |
| 246 | finish(); |
| 247 | return true; |
| 248 | } |
| 249 | |
| 250 | // ── The view transition styles ─────────────────────────────────────────────────────────────── |
| 251 | const ON_CSS = '@view-transition{navigation:auto}' |
| 252 | + ':root{--ov-soft-fade:.18s}' |
| 253 | + 'html[data-ov-motion=reduced]{--ov-soft-fade:.12s}' |
| 254 | + 'html #navbar-mount{view-transition-name:ov-soft-bar}' |
| 255 | // kit.css names the mount and the nested bar the same, which skips the transition; only the mount keeps a name. |
| 256 | + 'html #navbar-mount .openvibe-navbar{view-transition-name:none}' |
| 257 | + '::view-transition-old(root),::view-transition-new(root){animation-duration:var(--ov-soft-fade,.18s)}' |
| 258 | + '::view-transition-group(ov-soft-bar),::view-transition-old(ov-soft-bar),::view-transition-new(ov-soft-bar){animation:none}'; |
| 259 | const OFF_CSS = '@view-transition{navigation:none}'; |
| 260 | |
| 261 | let styleEl = null; |
| 262 | function applyStyle() { |
| 263 | const css = !supportsTransitions() ? '' : state.on && motion() !== 'off' ? ON_CSS : OFF_CSS; |
| 264 | if (!css) { |
| 265 | if (styleEl && styleEl.remove) { styleEl.remove(); styleEl = null; } |
| 266 | return; |
| 267 | } |
| 268 | if (!styleEl) { |
| 269 | styleEl = doc.createElement('style'); |
| 270 | styleEl.id = STYLE_ID; |
| 271 | (doc.head || html).appendChild(styleEl); |
| 272 | } |
| 273 | if (styleEl.textContent !== css) styleEl.textContent = css; |
| 274 | // Last in <head>, so @view-transition:none beats the auto kit.css declares, and so a later stylesheet cannot |
| 275 | // take the bar's name back. |
| 276 | try { if (doc.head && doc.head.lastChild !== styleEl) doc.head.appendChild(styleEl); } catch { /* */ } |
| 277 | } |
| 278 | |
| 279 | function on() { state.on = true; applyStyle(); applyRules(); } |
| 280 | function off() { state.on = false; applyStyle(); dropRules(); } |
| 281 | function exclude(selector) { |
| 282 | const s = String(selector || '').trim(); |
| 283 | if (!s) return; |
| 284 | try { doc.querySelector(s); } catch { return; } // an invalid selector would break the whole rule set |
| 285 | if (state.exclude.indexOf(s) === -1) state.exclude.push(s); |
| 286 | if (state.on) applyRules(); |
| 287 | } |
| 288 | |
| 289 | root.OpenVibeSoft = { on, off, exclude }; |
| 290 | |
| 291 | applyStyle(); |
| 292 | applyRules(); |
| 293 | // theme-loader.js may set the motion attribute after this script, and the Look panel may change it later. |
| 294 | try { if (typeof MutationObserver === 'function' && html) new MutationObserver(applyStyle).observe(html, { attributes: true, attributeFilter: ['data-ov-motion'] }); } catch { /* */ } |
| 295 | try { const rm = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)'); if (rm && rm.addEventListener) rm.addEventListener('change', applyStyle); } catch { /* */ } |
| 296 | if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', applyStyle, { once: true }); |
| 297 | else applyStyle(); |
| 298 | |
| 299 | // Intent, then the mark, then the next page's first paint. |
| 300 | try { |
| 301 | if (doc.addEventListener) { |
| 302 | doc.addEventListener('pointerdown', onPress, true); |
| 303 | if (typeof root.PointerEvent === 'undefined') doc.addEventListener('mousedown', onPress, true); |
| 304 | doc.addEventListener('click', (e) => { // a keyboard's activation has no pointerdown |
| 305 | if (!e || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; |
| 306 | const a = linkFrom(e); |
| 307 | // A press already started it, at the real intent; do not reset the ring at the click. |
| 308 | if (!a || a === pressedLink || !navigates(a)) return; |
| 309 | startNav(); |
| 310 | }, true); |
| 311 | doc.addEventListener('mouseover', onHover, true); |
| 312 | doc.addEventListener('mouseout', onHoverOut, true); |
| 313 | root.addEventListener('pagereveal', () => setTimeout(arrival, 0)); |
| 314 | root.addEventListener('pageshow', (e) => { |
| 315 | if (!e || !e.persisted) return; |
| 316 | clearTimeout(navTimer); |
| 317 | try { root.sessionStorage.removeItem(NAV_KEY); } catch { /* */ } |
| 318 | }); |
| 319 | try { const c = root.navigator && (root.navigator.connection || root.navigator.mozConnection || root.navigator.webkitConnection); if (c && c.addEventListener) c.addEventListener('change', applyRules); } catch { /* */ } |
| 320 | if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', arrival, { once: true }); |
| 321 | else setTimeout(arrival, 0); |
| 322 | } |
| 323 | } catch { /* a page without events still loads normally */ } |
| 324 | })(typeof window !== 'undefined' ? window : globalThis); |
| 325 |