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.
Changes
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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).@@@ -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@@@ -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)",@@@ -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@@@ -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",nnew file mode 100644@@@ -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);@@@ -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', feaassert.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();@@@ -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 = {}) {
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 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).
**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).
@@ -345,6 +345,40 @@ can't load, those icons fall back to `<i class="fa-solid …">`. Two options cha
@@ -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.
- 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 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`)
### The navbar (`navbar.js`)
```text
```text
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.29.2",
+ "version": "1.30.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
@@ -28,6 +28,7 @@ const BROWSER = Object.freeze([
'sso-client.js', // link hand-off, FedCM
'sso-client.js', // link hand-off, FedCM
'panels.js', // one-open-at-a-time panel coordinator
'panels.js', // one-open-at-a-time panel coordinator
'ui.js', // toasts and page notices
'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
'island.js', // activity island
'tooltip.js',
'tooltip.js',
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -62,7 +62,8 @@
@@ -62,7 +62,8 @@
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./user-card": "./user-card.js",
"./user-card": "./user-card.js",
- "./web-runtime": "./web-runtime.js"
+ "./web-runtime": "./web-runtime.js",
+ "./route-transition": "./route-transition.js"
},
},
"files": [
"files": [
"*.js",
"*.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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);
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
// web-runtime.js (roadmap WS-P task 6), in a linkedom page where appended <script>/<link> tags "load" or "fail" on cue:
// 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;
// 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
// - 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;
// 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
@@ -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');
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 ──
// ── Transactional groups: a failed script rolls the attempt back; a retry fetches only what is missing ──
{
{
const p = page();
const p = page();
@@ -1,33 +1,11 @@
@@ -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) {
(function (root) {
'use strict';
'use strict';
if (typeof document === 'undefined' || root.OVWebRuntime) return;
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. */
/** The key an asset is known by: its path on this site, origin + path elsewhere; the query (?v=) never counts. */
function assetKey(src) {
function assetKey(src) {
try {
try {
@@ -127,14 +105,18 @@
@@ -127,14 +105,18 @@
// ── Markup fragments ─────────────────────────────────────────────────────────────────────
// ── Markup fragments ─────────────────────────────────────────────────────────────────────
const fragments = Object.create(null);
const fragments = Object.create(null);
- function loadFragment(name, sectionId) {
+ function loadFragment(name, sectionId, stylesReady) {
const section = document.getElementById(sectionId);
const section = document.getElementById(sectionId);
if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
if (!section || section.dataset.fragmentLoaded === '1') return Promise.resolve();
if (fragments[name]) return fragments[name];
if (fragments[name]) return fragments[name];
section.setAttribute('aria-busy', 'true');
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.
// Inserted once, never replaced: players, previews and call tiles bind to these exact nodes.
if (section.dataset.fragmentLoaded !== '1') {
if (section.dataset.fragmentLoaded !== '1') {
section.innerHTML = html;
section.innerHTML = html;
@@ -163,11 +145,14 @@
@@ -163,11 +145,14 @@
const css = (f.css || []).map(assetKey);
const css = (f.css || []).map(assetKey);
css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
css.forEach((k) => (wanted[k] || (wanted[k] = new Set())).add(name));
const added = [];
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
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(() => {
.then(() => {
loaded[name] = true;
loaded[name] = true;
delete d.failed[name];
delete d.failed[name];
@@ -191,6 +176,33 @@
@@ -191,6 +176,33 @@
return features[name];
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) {
function featuresFor(path) {
const out = [];
const out = [];
ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
ROUTES.forEach((r) => { if (r.re.test(path)) r.features.forEach((n) => { if (out.indexOf(n) === -1) out.push(n); }); });
@@ -427,6 +439,7 @@
@@ -427,6 +439,7 @@
gen: () => generation,
gen: () => generation,
isCurrent: (g) => g === generation,
isCurrent: (g) => g === generation,
scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
scope, nextRoute, installStubs, showRouteError, adoptExisting, watchIntent, idlePrefetch, diagnostics, leaks,
+ enter, status: (t) => fx && fx.status(t), stylesOf,
isLoaded: (name) => !!features[name],
isLoaded: (name) => !!features[name],
/** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
/** Adopt the server's tags, load `initial` features, watch link intent, prefetch the idle features. */
boot(b = {}) {
boot(b = {}) {