openvibers/openvibe.shared Commits

Shared 1.30.1: the first in-site move animates (first page = first enter() at route generation ≤ 1), route-transition.js preloads after load, the loading line lives outside the section (re-renders cannot remove it), requested by path so the versions map hashes it. Measured on Live at 400 ms latency: bar, 'Loading Content…' → 'starting up', fade-in, never unstyled. npm test 42/42.

OP OpenVibers committed 29 Sept 2026 · 6ffa241ed3 · 6 files, +18 −7


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex de74bf6..b895852 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ 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.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.30.1 — 2026-09-28
++
++Page moves, measured on Live with a 400 ms network: the bar runs from the click, "Loading Content…" appears at 150 ms and follows the phase ("starting up"), the styled page fades in, and nothing shows unstyled in between. Fixes to 1.30.0: the first move after a server-rendered page is animated (the "first page" is the first `enter()` before any route change, route generation ≤ 1, not simply the first call); `route-transition.js` is fetched a few seconds after load, so it is ready for the first click; the loading line sits over the page's visible area but outside the section (a page script that re-renders its section while loading can no longer take it away); the file is requested by path, so the site's versions map hashes it.
++
## 1.30.0 — 2026-09-28
**Pages never show unstyled, and moving between them looks finished.** `web-runtime.js`: a feature's stylesheets (its dependencies' too, `rt.stylesOf(name)`) start downloading together with its markup fragment, and the fragment is inserted only once they have loaded (or timed out), so a route no longer flashes unstyled markup for a moment. New `rt.enter(section, work, { label })`: the page's content stays hidden while its route loads, keeping its space (no layout jump), and is shown when the work settles, failure included. On the first in-site move the runtime fetches the new **`route-transition.js`** from next to itself (a browser file in `files.js`, export `openvibe-shared/route-transition`): a progress bar across the top, after 150 ms a centred "Loading Content…" line with the current phase under it (`<prefix>:phase` events: "applying styles", "starting up", or the site's own `rt.status(text)`), then an opacity-only fade-in (a transform would make the page the containing block of its fixed children). Theme tokens colour it; reduced motion gets none of the animation. The first page of a visit is never animated (it arrived server-rendered). The runtime's usage notes moved to the README ("The web runtime"), keeping it at 6.2 KB brotli (budget 6.5); the transition layer is 1.6 KB and loads only when someone moves.
ddiff --git a/STATUS.json b/STATUS.json
iindex 29f9565..9d897dc 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.30.0",
++ "version": "1.30.1",
"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)",
ddiff --git a/package.json b/package.json
iindex e7e97f9..7716ead 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.30.0",
++ "version": "1.30.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/route-transition.js b/route-transition.js
iindex c7e04bb..db84f65 100644
---- a/route-transition.js
++++ b/route-transition.js
@@@ -12,7 +12,7 @@
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OVRouteFx) return;
-- const css = `.ovrt-wait{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:12px;padding:24px;color:var(--text-muted,#8a96a8);font:600 .84rem/1.35 system-ui,sans-serif;text-align:center;animation:ovrtIn .25s ease both}
++ const css = `.ovrt-wait{position:fixed;z-index:40;pointer-events:none;display:grid;place-content:center;justify-items:center;gap:12px;padding:24px;color:var(--text-muted,#8a96a8);font:600 .84rem/1.35 system-ui,sans-serif;text-align:center;animation:ovrtIn .25s ease both}
.ovrt-wait i{display:block;width:min(200px,50vw);height:3px;border-radius:3px;overflow:hidden;position:relative;background:color-mix(in srgb,var(--accent,#3b82f6) 18%,transparent)}
.ovrt-wait i::after{content:'';position:absolute;inset:0;width:38%;border-radius:3px;background:var(--accent,#3b82f6);animation:ovrtSlide 1.05s cubic-bezier(.45,0,.2,1) infinite}
.ovrt-wait small{font-weight:500;font-size:.76rem;opacity:.85;min-height:1.2em;transition:opacity .2s}
@@@ -51,7 +51,11 @@
wait.setAttribute('role', 'status');
wait.innerHTML = '<i></i><span></span><small></small>';
wait.querySelector('span').textContent = `Loading ${label || 'page'}…`;
-- section.appendChild(wait);
++ // Over the page's visible area, but outside it: a page script that re-renders its section while the
++ // route loads cannot take the line away.
++ const r = section.getBoundingClientRect(), top = Math.max(r.top, 0);
++ Object.assign(wait.style, { left: `${r.left}px`, width: `${r.width}px`, top: `${top}px`, height: `${Math.max(160, Math.min(r.bottom, root.innerHeight) - top)}px` });
++ document.body.appendChild(wait);
}, 150);
return {
status(text) { if (wait) wait.querySelector('small').textContent = String(text || ''); },
ddiff --git a/test/web-runtime.test.js b/test/web-runtime.test.js
iindex 1df5807..10a96bc 100644
---- a/test/web-runtime.test.js
++++ b/test/web-runtime.test.js
@@@ -135,7 +135,7 @@ const ROUTES = [{ path: '^/player', features: ['player'] }, { path: '^/ops', fea
await done;
assert.strictEqual(section.dataset.fragmentLoaded, '1');
assert.ok(!section.hasAttribute('data-ov-loading'), 'shown once ready');
-- assert.ok(p.document.querySelector('style').textContent.includes('[data-ov-loading]>:not(.ovrt-wait){visibility:hidden}'), 'keeps its space while hidden');
++ assert.ok(p.document.querySelector('style').textContent.includes('[data-ov-loading]>*{visibility:hidden}'), 'keeps its space while hidden');
assert.ok(phases.includes('player:styles') && phases.includes('player:code'), 'phases for the loading line');
await assert.rejects(rt.enter(section, Promise.reject(new Error('x'))), /x/);
assert.ok(!section.hasAttribute('data-ov-loading'), 'a failed route is not left hidden');
ddiff --git a/web-runtime.js b/web-runtime.js
iindex 3280413..b7bd168 100644
---- a/web-runtime.js
++++ b/web-runtime.js
@@@ -192,10 +192,13 @@
let enters = 0, fx = null;
// Its own URL, without web-runtime's ?v= (the site's versions map hashes it, as any asset).
const fxSrc = selfSrc && assetKey(selfSrc).replace(/web-runtime\.js$/, 'route-transition.js');
++ // Ready before the first click: fetched a few seconds after the page settles.
++ if (fxSrc) root.setTimeout(() => { if (!root.OVRouteFx) loadScript(fxSrc).catch(() => {}); }, 4000);
function enter(section, work, opts = {}) {
-- const p = Promise.resolve(work), attr = `data-${prefix}-loading`, first = !enters++;
++ // The page that arrived server-rendered: the first enter() before any in-site move (route generation ≤ 1).
++ const p = Promise.resolve(work), attr = `data-${prefix}-loading`, first = !enters++ && generation <= 1;
if (!section) return p;
-- if (first) { const st = document.createElement('style'); st.textContent = `[${attr}]{position:relative;min-height:40vh}[${attr}]>:not(.ovrt-wait){visibility:hidden}`; document.head.appendChild(st); }
++ if (!document.getElementById(`${prefix}-rt-css`)) { const st = document.createElement('style'); st.id = `${prefix}-rt-css`; st.textContent = `[${attr}]{position:relative;min-height:40vh}[${attr}]>*{visibility:hidden}`; document.head.appendChild(st); }
section.setAttribute(attr, '');
let h = null;
if (!first && fxSrc) (root.OVRouteFx ? p.constructor.resolve() : loadScript(fxSrc)).then(() => { if (section.hasAttribute(attr)) fx = h = root.OVRouteFx.start(section, { label: opts.label, prefix }); }, () => {});