openvibers/openvibe.shared soft.js

soft.js

main 18.4 KB · 325 lines · javascript

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 'use strict';
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