openvibers/openvibe.shared Commits

Shared 1.30.0: pages never show unstyled (web-runtime inserts a fragment once its feature's and dependencies' stylesheets have loaded, all started together), rt.enter(section, work, { label }) hides a page until its route is ready, and route-transition.js (fetched on the first move) adds the top progress bar, a 'Loading …' line with the phase, and an opacity-only fade-in. Runtime docs moved to the README (6.2 KB brotli). npm test 42/42.

OP OpenVibers committed 29 Sept 2026 · fabec422e5 · 8 files, +189 −38


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c70efed..de74bf6 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.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.
++
## 1.29.2 — 2026-09-28
**Navbar dropdowns open again.** A top link's dropdown (Live's "Go Live" → Dashboard) opened but was cut off: it hangs below the links row, which scrolls sideways (`overflow-x: auto`) and so clips anything below it. The menu is now fixed-positioned under its link whenever it can show (hover, keyboard focus, a tap), measured from the box a filtered or transformed bar makes for fixed children. The usage notes at the top of `navbar.js` moved to the README ("The navbar"), which keeps the file inside its 29 KB brotli budget (28.7 KB).
ddiff --git a/README.md b/README.md
iindex fc58b11..9e3fc2b 100644
---- a/README.md
++++ b/README.md
@@@ -345,6 +345,40 @@ can't load, those icons fall back to `<i class="fa-solid …">`. Two options cha
- A Content-Security-Policy refusal, a signed-out 401/403, or Network's `REALTIME_TICKETS=off` (503) stop the stream, and the bell polls.
- **The site's CSP** needs `connect-src https://events.openvibe.network` (and `https://openvibe.network` for the ticket, which the bell already calls). `OpenVibeNotifications.realtimeState()` reports the feed's state.
++### The web runtime (`web-runtime.js`, `route-transition.js`)
++
++```text
++openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
++OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.
++
++ const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"
++ rt.load('broadcast') → the feature's stylesheets (its dependencies' too) and markup fragment together, the markup
++ inserted once the styles are in (never unstyled), then its dependencies and scripts (in
++ order) and its `after` hook; once, cached; a failed load can be retried.
++ rt.route(path) → every feature the path's routes name, as one promise.
++ rt.enter(section, work, { label })
++ → a page while its route loads: hidden (keeping its space) until its styles and code are in;
++ route-transition.js (loaded on the first move) adds the top bar, a "Loading <label>…" line
++ after 150 ms with the phase, and the fade-in. rt.status(text) changes that line.
++ rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
++ of memory, and within a budget (count, and bytes where sizes are known).
++ rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
++ → route generations: a loader that awaited something can tell whether the visitor moved on.
++ rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
++ route, released by the next nextRoute(). Anything registered on a scope whose route has
++ ended is refused (and counted), never left running.
++ rt.diagnostics(), rt.leaks()
++ → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
++ current route holds, and the prefetch budget spent.
++
++Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
++every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
++again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
++kept, so nothing runs twice.
++Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
++handlers working before their feature has loaded.
++```
++
### The navbar (`navbar.js`)
```text
ddiff --git a/STATUS.json b/STATUS.json
iindex 27e28d1..29f9565 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.29.2",
++ "version": "1.30.0",
"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/files.js b/files.js
iindex f576b42..df37cce 100644
---- a/files.js
++++ b/files.js
@@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
'sso-client.js', // link hand-off, FedCM
'panels.js', // one-open-at-a-time panel coordinator
'ui.js', // toasts and page notices
++ 'route-transition.js', // page-move bar, loading line and fade-in; web-runtime.js fetches it on the first move
'island.js', // activity island
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
ddiff --git a/package.json b/package.json
iindex 445bfbc..e7e97f9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.29.2",
++ "version": "1.30.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -62,7 +62,8 @@
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js",
-- "./web-runtime": "./web-runtime.js"
++ "./web-runtime": "./web-runtime.js",
++ "./route-transition": "./route-transition.js"
},
"files": [
"*.js",
ddiff --git a/route-transition.js b/route-transition.js
nnew file mode 100644
iindex 0000000..c7e04bb
---- /dev/null
++++ b/route-transition.js
@@@ -0,0 +1,74 @@
++/*
++ * openvibe-shared/route-transition.js — what a page move looks like (roadmap WS-P): web-runtime.js's rt.enter() fetches
++ * this on the first in-site move. A thin progress bar at the top of the window; after 150 ms a centred line in the
++ * page, "Loading Content…" with the current phase under it ("applying styles", "starting up", or the site's own
++ * rt.status("Loading clips…")); then the page fades in (opacity only: a transform would make the page the containing
++ * block of its position:fixed children, and a bottom sheet or a floating button would jump while it ran). Theme tokens colour it (--accent, --accent-light,
++ * --text-muted); reduced motion gets no animation.
++ *
++ * const h = OVRouteFx.start(section, { label, prefix }) → { status(text), done() }; follows <prefix>:phase events
++ * OVRouteFx.bar('run' | 'done') → the top bar alone (a site's own loads)
++ */
++(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}
++.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}
++.ovrt-bar{position:fixed;left:0;top:0;height:2px;width:100%;z-index:2147483000;pointer-events:none;transform-origin:0 50%;transform:scaleX(0);opacity:0;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa));box-shadow:0 0 8px color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}
++.ovrt-bar.is-run{opacity:1;transform:scaleX(.85);transition:transform 2.6s cubic-bezier(.1,.7,.2,1)}
++.ovrt-bar.is-done{opacity:0;transform:scaleX(1);transition:transform .18s ease,opacity .35s .12s}
++.ovrt-enter{animation:ovrtEnter .28s cubic-bezier(.22,1,.36,1) both}
++@keyframes ovrtEnter{from{opacity:0}to{opacity:1}}
++@keyframes ovrtIn{from{opacity:0}to{opacity:1}}
++@keyframes ovrtSlide{from{transform:translateX(-100%)}to{transform:translateX(265%)}}
++@media (prefers-reduced-motion:reduce){.ovrt-enter,.ovrt-wait,.ovrt-wait i::after{animation:none}.ovrt-bar.is-run,.ovrt-bar.is-done{transition:none}}`;
++ const st = document.createElement('style');
++ st.id = 'ovrt-css';
++ st.textContent = css;
++ document.head.appendChild(st);
++
++ let barEl = null, barTimer = 0;
++ function bar(state) {
++ if (!barEl) { barEl = document.createElement('div'); barEl.className = 'ovrt-bar'; barEl.setAttribute('aria-hidden', 'true'); document.body.appendChild(barEl); }
++ root.clearTimeout(barTimer);
++ if (state === 'run') { barEl.classList.remove('is-done', 'is-run'); void barEl.offsetWidth; barEl.classList.add('is-run'); }
++ else { barEl.classList.add('is-done'); barTimer = root.setTimeout(() => barEl.classList.remove('is-run', 'is-done'), 600); }
++ }
++ const reduced = () => { try { return root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
++
++ const PHASES = { styles: 'applying styles', code: 'starting up' };
++ function start(section, { label, prefix = 'ov' } = {}) {
++ bar('run');
++ let wait = null, open = true;
++ const onPhase = (e) => { if (wait && e.detail && PHASES[e.detail.phase]) wait.querySelector('small').textContent = PHASES[e.detail.phase]; };
++ document.addEventListener(`${prefix}:phase`, onPhase);
++ const t = root.setTimeout(() => {
++ if (!open) return;
++ wait = document.createElement('div');
++ wait.className = 'ovrt-wait';
++ wait.setAttribute('role', 'status');
++ wait.innerHTML = '<i></i><span></span><small></small>';
++ wait.querySelector('span').textContent = `Loading ${label || 'page'}…`;
++ section.appendChild(wait);
++ }, 150);
++ return {
++ status(text) { if (wait) wait.querySelector('small').textContent = String(text || ''); },
++ done() {
++ open = false;
++ root.clearTimeout(t);
++ document.removeEventListener(`${prefix}:phase`, onPhase);
++ if (wait) wait.remove();
++ bar('done');
++ if (reduced()) return;
++ section.classList.remove('ovrt-enter');
++ void section.offsetWidth;
++ section.classList.add('ovrt-enter');
++ root.setTimeout(() => section.classList.remove('ovrt-enter'), 400);
++ },
++ };
++ }
++
++ root.OVRouteFx = { start, bar };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/test/web-runtime.test.js b/test/web-runtime.test.js
iindex fc95c1b..1df5807 100644
---- a/test/web-runtime.test.js
++++ b/test/web-runtime.test.js
@@@ -1,6 +1,7 @@
'use strict';
// web-runtime.js (roadmap WS-P task 6), in a linkedom page where appended <script>/<link> tags "load" or "fail" on cue:
--// - a feature loads its fragment first, then deps, stylesheets and scripts (versioned, in order), then its hook, once;
++// - a feature loads its stylesheets (deps' too) with its fragment, inserts the fragment once they are in, then deps and
++// scripts (versioned, in order), then its hook, once; enter() hides a page until its route is ready;
// tags the server already wrote count as loaded; nothing is added twice;
// - asset groups are transactional: a failed script withdraws the stylesheets that attempt added (not one another
// feature wants), removes its own tag, runs no hook, and a retry fetches only what is missing;
@@@ -117,6 +118,29 @@ const ROUTES = [{ path: '^/player', features: ['player'] }, { path: '^/ops', fea
assert.strictEqual(p.RT.assetKey('https://cdn.example/x/a.js?v=1'), 'https://cdn.example/x/a.js', 'another origin keeps its origin');
}
++ // ── Never unstyled: the markup waits for the feature's (and its deps') styles; enter() hides until ready ──
++ {
++ const p = page();
++ const rt = p.RT.create({ features: FEATURES, routes: ROUTES });
++ p.behave['/css/base.css'] = 'hold'; // a dependency's stylesheet is slow
++ const phases = [];
++ p.document.addEventListener('ov:phase', (e) => phases.push(`${e.detail.name}:${e.detail.phase}`));
++ const section = p.document.getElementById('page-player');
++ const done = rt.enter(section, rt.load('player'), { label: 'Player' });
++ assert.ok(section.hasAttribute('data-ov-loading'), 'hidden while it loads');
++ for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));
++ assert.strictEqual(section.dataset.fragmentLoaded, undefined, 'markup fetched but not inserted before its styles');
++ deq(p.out.appended.slice(0, 3), ['/css/base.css', '/css/player.css', '/css/shared.css'], 'every stylesheet starts with the markup, deps first');
++ p.fire(p.held.shift(), 'load');
++ 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(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');
++ }
++
// ── Transactional groups: a failed script rolls the attempt back; a retry fetches only what is missing ──
{
const p = page();
ddiff --git a/web-runtime.js b/web-runtime.js
iindex ad1273f..3280413 100644
---- a/web-runtime.js
++++ b/web-runtime.js
@@@ -1,33 +1,11 @@
--/*
-- * openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P task 6), taken out of
-- * OpenVibe.Live's public/js/ov-loader.js so every OpenVibe site loads its code the same way.
-- *
-- * const rt = OVWebRuntime.create({ features, routes, versions, ... }); // registry: see README "Web runtime"
-- * rt.load('broadcast') → the feature's markup fragment, its dependencies, stylesheets and scripts (in order), then
-- * its `after` hook; once, cached; a failed load can be retried.
-- * rt.route(path) → every feature the path's routes name, as one promise.
-- * rt.prefetch('channel') → download only (link rel=prefetch), never execute; skipped on Save-Data, 2g and < 2 GB
-- * of memory, and within a budget (count, and bytes where sizes are known).
-- * rt.nextRoute() / rt.gen() / rt.isCurrent(gen)
-- * → route generations: a loader that awaited something can tell whether the visitor moved on.
-- * rt.scope() → timers, listeners, observers, fetches (AbortSignal) and child scopes owned by the current
-- * route, released by the next nextRoute(). Anything registered on a scope whose route has
-- * ended is refused (and counted), never left running.
-- * rt.diagnostics(), rt.leaks()
-- * → loaded, failed and rolled-back features, duplicate tags, late registrations, what the
-- * current route holds, and the prefetch budget spent.
-- *
-- * Asset groups are transactional: a feature counts as loaded, runs its hook and fires `<prefix>:feature` only when
-- * every script loaded. When one fails, the stylesheets that attempt added (and no other feature wants) are taken out
-- * again and the failed script's tag is removed, so a retry fetches exactly what is missing. Scripts that did run are
-- * kept, so nothing runs twice.
-- * Tags the server already put in the document count as loaded. Global stubs (installStubs) keep inline onclick
-- * handlers working before their feature has loaded.
-- */
++/* openvibe-shared/web-runtime.js — a site's feature loader and route lifecycle (roadmap WS-P). API and rules: README "Web runtime". */
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OVWebRuntime) return;
++ // Where this file was loaded from: route-transition.js is fetched from next to it.
++ const selfSrc = document.currentScript && document.currentScript.src;
++
/** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */
function assetKey(src) {
try {
@@@ -127,14 +105,18 @@
// ── Markup fragments ─────────────────────────────────────────────────────────────────────
const fragments = Object.create(null);
-- function loadFragment(name, sectionId) {
++ function loadFragment(name, sectionId, stylesReady) {
const section = document.getElementById(sectionId);
if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
if (fragments[name]) return fragments[name];
section.setAttribute('aria-busy', 'true');
-- fragments[name] = root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' })
-- .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })
-- .then((html) => {
++ // Markup and styles download together; the markup goes in once its styles have loaded (or timed out),
++ // so a page is never shown unstyled for a moment.
++ fragments[name] = Promise.all([
++ root.fetch(url(fragmentPath(name)), { credentials: 'same-origin' }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); }),
++ stylesReady || null,
++ ])
++ .then(([html]) => {
// Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.
if (section.dataset.fragmentLoaded !== '1') {
section.innerHTML = html;
@@@ -163,11 +145,14 @@
const css = (f.css || []).map(assetKey);
css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
const added = [];
-- // The feature's markup goes in before its dependencies and scripts run: modules bind to it when they load.
-- const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`) : Promise.resolve();
++ // Every stylesheet the feature and its dependencies need starts at once, with the markup; the markup goes
++ // in once they have loaded, before the dependencies and scripts run (modules bind to it when they load).
++ emit(`${prefix}:phase`, { name, phase: 'styles' });
++ const stylesReady = Promise.all(stylesOf(name).map((p) => loadStyle(p, added)));
++ const markup = f.fragment ? loadFragment(f.fragment, f.section || `page-${f.fragment}`, stylesReady) : stylesReady;
features[name] = markup
-- .then(() => Promise.all((f.deps || []).map(load)))
-- .then(() => Promise.all((f.css || []).map((p) => loadStyle(p, added)).concat((f.js || []).map((p) => loadScript(p)))))
++ .then(() => { emit(`${prefix}:phase`, { name, phase: 'code' }); return Promise.all((f.deps || []).map(load)); })
++ .then(() => Promise.all((f.js || []).map((p) => loadScript(p))))
.then(() => {
loaded[name] = true;
delete d.failed[name];
@@@ -191,6 +176,33 @@
return features[name];
}
++ /** The stylesheets of a feature and everything it depends on, in order, each once. */
++ function stylesOf(name, seen = new Set(), out = []) {
++ const f = FEATURES[name];
++ if (!f || seen.has(name)) return out;
++ seen.add(name);
++ for (const dep of f.deps || []) stylesOf(dep, seen, out);
++ for (const p of f.css || []) if (!out.includes(p)) out.push(p);
++ return out;
++ }
++
++ // ── Page transitions ─────────────────────────────────────────────────────────────────────
++ // rt.enter(section, work, { label }): the section's content stays hidden (keeping its space) until `work` is done;
++ // route-transition.js (fetched next to this file on the first in-site move) draws the bar, line and fade-in.
++ 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');
++ function enter(section, work, opts = {}) {
++ const p = Promise.resolve(work), attr = `data-${prefix}-loading`, first = !enters++;
++ 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); }
++ 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 }); }, () => {});
++ const done = () => { section.removeAttribute(attr); if (h) h.done(); };
++ return p.then((v) => { done(); return v; }, (e) => { done(); throw e; });
++ }
++
function featuresFor(path) {
const out = [];
ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
@@@ -427,6 +439,7 @@
gen: () => generation,
isCurrent: (g) => g === generation,
scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
++ enter, status: (t) => fx && fx.status(t), stylesOf,
isLoaded: (name) => !!features[name],
/** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
boot(b = {}) {