openvibers/openvibe.network Commits

Paint every page's look before the first frame (L42) (#461)

OP OpenVibers committed 13 days ago · 003b11241c · 10 files, +209 −25

* Paint every page's look before the first frame (L42)

Network's own bootstrap painted only ov_theme, so a page could show the wrong season, or flash, until the deferred
theme-loader.js ran. openvibe-shared's look-boot paints the whole look before the first paint: the colours, the
season, Calm, the text size and color-scheme, from the look this browser last painted here (ov_paint) or the one a
link from another OpenVibe page carried (?ov_look=).

The public pages carry a marker where the script belongs; server/look-boot.js puts it there — at serve time for the
home page the server renders, and for the pages served raw, read once at boot. A browser from before look-boot keeps
only its theme: that is painted once, colours only, and only when there is no painted look yet.

* The home page budget: 189 KB raw, 35.5 KB brotli, with look-boot inline

The look before first paint is a script in the page, above the stylesheets: it adds 1 KB raw and 0.6 KB brotli to the
home page, which sits at 188.5 KB (35.1 KB brotli). The budget moves with it, and the comment says why.

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex f7957273..cb9229b0 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -9,12 +9,22 @@
<meta name="color-scheme" content="dark light">
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="manifest" href="/manifest.webmanifest">
--<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
-- Paints the cached theme synchronously so the default palette never flashes. Reads
-- localStorage only: no network, nothing to block on. theme-loader.js still runs after
-- and reconciles with the account. -->
++<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
++ openvibe-shared's look-boot here: it paints the look this browser last painted here (ov_paint), or the one a
++ link from another OpenVibe page carried (?ov_look=), with the display settings (the season switch among them).
++ theme-loader.js loads with defer, so without it the default palette (or an old season) showed first. -->
++<!--ov:look-boot-->
<script>
--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++// A browser from before look-boot has only its theme (ov_theme), not yet a painted look (ov_paint): paint that once.
++(function () {
++ try {
++ if (localStorage.getItem('ov_paint')) return;
++ var t = JSON.parse(localStorage.getItem('ov_theme') || 'null'), v = t && t.variables, el = document.documentElement;
++ if (!v) return;
++ for (var k in v) if (k.charAt(0) === '-' && /^[#0-9a-z(),.%\s-]{1,80}$/i.test(String(v[k])) && !/url|expression/i.test(String(v[k]))) el.style.setProperty(k, v[k]);
++ if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
++})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/admin.css">
ddiff --git a/public/index.html b/public/index.html
iindex 7f9e1159..99c46024 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -28,12 +28,22 @@
<link rel="canonical" href="https://openvibe.network/">
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","alternateName":["OpenVibe Network","OpenVibers"],"url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","description":"OpenVibe is an open-source, community-run network of websites that share one free account: live streaming from the browser (OpenVibe.Live), more than 180 online tools (OpenVibe.Tools), chat and the Vibe assistant (OpenVibe.Chat), games, a wiki, news, reviews, deals and an open developer platform.","disambiguatingDescription":"The open-source network of websites at openvibe.network, made by OpenVibers; not the Openvibe mobile social-media aggregator app.","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.chat","https://openvibe.games","https://openvibe.media","https://openvibe.video","https://openvibe.community","https://openvibe.space","https://openvibe.wiki","https://openvibe.blog","https://openvibe.news","https://openvibe.services","https://openre.stream"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One free account for every OpenVibe site: go live from your browser, online tools, chat, games, videos, jobs and an open platform to build on.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
--<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
-- Paints the cached theme synchronously so the default palette never flashes. Reads
-- localStorage only: no network, nothing to block on. theme-loader.js still runs after
-- and reconciles with the account. -->
++<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
++ openvibe-shared's look-boot here: it paints the look this browser last painted here (ov_paint), or the one a
++ link from another OpenVibe page carried (?ov_look=), with the display settings (the season switch among them).
++ theme-loader.js loads with defer, so without it the default palette (or an old season) showed first. -->
++<!--ov:look-boot-->
<script>
--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++// A browser from before look-boot has only its theme (ov_theme), not yet a painted look (ov_paint): paint that once.
++(function () {
++ try {
++ if (localStorage.getItem('ov_paint')) return;
++ var t = JSON.parse(localStorage.getItem('ov_theme') || 'null'), v = t && t.variables, el = document.documentElement;
++ if (!v) return;
++ for (var k in v) if (k.charAt(0) === '-' && /^[#0-9a-z(),.%\s-]{1,80}$/i.test(String(v[k])) && !/url|expression/i.test(String(v[k]))) el.style.setProperty(k, v[k]);
++ if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
++})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/shared/showcase.css">
ddiff --git a/public/my.html b/public/my.html
iindex 6b06941f..b62ed189 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -22,12 +22,22 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
--<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
-- Paints the cached theme synchronously so the default palette never flashes. Reads
-- localStorage only: no network, nothing to block on. theme-loader.js still runs after
-- and reconciles with the account. -->
++<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
++ openvibe-shared's look-boot here: it paints the look this browser last painted here (ov_paint), or the one a
++ link from another OpenVibe page carried (?ov_look=), with the display settings (the season switch among them).
++ theme-loader.js loads with defer, so without it the default palette (or an old season) showed first. -->
++<!--ov:look-boot-->
<script>
--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++// A browser from before look-boot has only its theme (ov_theme), not yet a painted look (ov_paint): paint that once.
++(function () {
++ try {
++ if (localStorage.getItem('ov_paint')) return;
++ var t = JSON.parse(localStorage.getItem('ov_theme') || 'null'), v = t && t.variables, el = document.documentElement;
++ if (!v) return;
++ for (var k in v) if (k.charAt(0) === '-' && /^[#0-9a-z(),.%\s-]{1,80}$/i.test(String(v[k])) && !/url|expression/i.test(String(v[k]))) el.style.setProperty(k, v[k]);
++ if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
++})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/my.css">
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex 1d42e423..2927ccfb 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -10,8 +10,22 @@
<link rel="apple-touch-icon" href="/assets/logo-192.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="robots" content="noindex, nofollow">
++<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
++ openvibe-shared's look-boot here: it paints the look this browser last painted here (ov_paint), or the one a
++ link from another OpenVibe page carried (?ov_look=), with the display settings (the season switch among them).
++ theme-loader.js loads with defer, so without it the default palette (or an old season) showed first. -->
++<!--ov:look-boot-->
<script>
--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++// A browser from before look-boot has only its theme (ov_theme), not yet a painted look (ov_paint): paint that once.
++(function () {
++ try {
++ if (localStorage.getItem('ov_paint')) return;
++ var t = JSON.parse(localStorage.getItem('ov_theme') || 'null'), v = t && t.variables, el = document.documentElement;
++ if (!v) return;
++ for (var k in v) if (k.charAt(0) === '-' && /^[#0-9a-z(),.%\s-]{1,80}$/i.test(String(v[k])) && !/url|expression/i.test(String(v[k]))) el.style.setProperty(k, v[k]);
++ if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
++})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
ddiff --git a/public/verify-email.html b/public/verify-email.html
iindex c189cc3f..f6eb2f56 100644
---- a/public/verify-email.html
++++ b/public/verify-email.html
@@@ -11,8 +11,22 @@
<link rel="manifest" href="/manifest.webmanifest">
<meta name="description" content="Confirming the email address on your OpenVibe account.">
<meta name="robots" content="noindex, nofollow">
++<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
++ openvibe-shared's look-boot here: it paints the look this browser last painted here (ov_paint), or the one a
++ link from another OpenVibe page carried (?ov_look=), with the display settings (the season switch among them).
++ theme-loader.js loads with defer, so without it the default palette (or an old season) showed first. -->
++<!--ov:look-boot-->
<script>
--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
++// A browser from before look-boot has only its theme (ov_theme), not yet a painted look (ov_paint): paint that once.
++(function () {
++ try {
++ if (localStorage.getItem('ov_paint')) return;
++ var t = JSON.parse(localStorage.getItem('ov_theme') || 'null'), v = t && t.variables, el = document.documentElement;
++ if (!v) return;
++ for (var k in v) if (k.charAt(0) === '-' && /^[#0-9a-z(),.%\s-]{1,80}$/i.test(String(v[k])) && !/url|expression/i.test(String(v[k]))) el.style.setProperty(k, v[k]);
++ if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
++ } catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
++})();
</script>
<script src="/shared/theme-loader.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
ddiff --git a/server/home/render.js b/server/home/render.js
iindex b4315be9..f9075f2a 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -12,6 +12,9 @@ const showcase = require('openvibe-shared/showcase');
const toolsCatalog = require('../domains/catalog');
const activity = require('./activity');
const boost = require('../boost');
++// The look before first paint (openvibe-shared look-boot, plan L42): the shell carries the marker and this
++// puts the script there, above the stylesheets, in the page the server renders.
++const lookBoot = require('../look-boot');
const SHELL = path.join(__dirname, '..', '..', 'public', 'index.html');
const esc = seo.esc;
@@@ -418,7 +421,7 @@ function render({ releaseId = '' } = {}) {
})) + seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const summary = summaryOf(catalog);
-- const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
++ const html = lookBoot.apply(fs.readFileSync(SHELL, 'utf8')).replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return pageCache;
}
ddiff --git a/server/index.js b/server/index.js
iindex f5ab863f..407f8683 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -20,6 +20,9 @@ const fs = require('fs');
const bcrypt = require('bcryptjs');
const config = require('./config');
++// The look before first paint (openvibe-shared look-boot, plan L42): the public pages carry a marker and this
++// puts openvibe-shared's script there, so a page paints the kept or carried look before its first frame.
++const lookBoot = require('./look-boot');
const { initDb } = require('./db/database');
const urlRegistry = require('./url-registry');
const { BRAND } = require('openvibe-shared/brand');
@@@ -81,11 +84,11 @@ function redirectWithoutHtml(req, res, targetPath) {
}
function sendMyAccountApp(res) {
-- return res.sendFile(path.join(__dirname, '..', 'public', 'my.html'));
++ return lookBoot.send(res, 'my.html');
}
function sendLandingPage(res) {
-- return res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++ return lookBoot.send(res, 'index.html');
}
// ── Security ─────────────────────────────────────────────────
@@@ -844,7 +847,7 @@ app.get(require('./not-found').ACCOUNT_HUB_PATHS, (req, res) => {
app.get('/verify-email', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Robots-Tag', 'noindex');
-- res.sendFile(path.join(__dirname, '..', 'public', 'verify-email.html'));
++ lookBoot.send(res, 'verify-email.html');
});
// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js
@@@ -864,9 +867,12 @@ app.get('/sso/check', require('./auth/sso-check').createSsoCheckRoute(() => ({ d
app.get(['/sso/fanout', '/sso/fanout.html'], (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Robots-Tag', 'noindex');
-- res.sendFile(path.join(__dirname, '..', 'public', 'sso-fanout.html'));
++ lookBoot.send(res, 'sso-fanout.html');
});
++// The public pages carry the look before first paint; the ones the static file server would answer by their own
++// file name (public/*.html) get it here, ahead of express.static below (plan L42).
++app.use(lookBoot.middleware());
app.use(express.static(path.join(__dirname, '..', 'public'), { setHeaders: require('./static-headers').publicStaticHeaders }));
// openvibe-shared: the OpenVibe.Shared release package.json pins, from node_modules.
@@@ -940,7 +946,7 @@ app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
// Admin panel — serve the SPA for /admin and any client-routed sub-path
// (e.g. /admin/settings, /admin/analytics/overview). Auth is checked client-side.
app.get(['/admin', '/admin/*'], (req, res) => {
-- res.sendFile(path.join(__dirname, '..', 'public', 'admin.html'));
++ lookBoot.send(res, 'admin.html');
});
// Anything no route above answered is a 404: JSON under /api, /internal and /oauth, otherwise a small
ddiff --git a/server/look-boot.js b/server/look-boot.js
nnew file mode 100644
iindex 00000000..fd895d73
---- /dev/null
++++ b/server/look-boot.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * The look before first paint (openvibe-shared 4.64.0 look-boot, plan L42).
++ *
++ * Every OpenVibe site keeps a record of the look it painted (localStorage 'ov_paint'), and a link from
++ * another OpenVibe page carries that look in '?ov_look='. openvibe-shared's look-boot paints it before
++ * the first frame: the colours, the season, Calm, the text size and color-scheme. Network's own bootstrap
++ * painted only the old 'ov_theme' record, so a page could show the wrong season, or flash, until
++ * theme-loader.js (deferred) ran.
++ *
++ * The pages carry a marker (<!--ov:look-boot-->) where the script belongs; this module puts it there:
++ * at serve time for the page the server renders (the home page), and for the pages served raw, once, at boot.
++ * The marker never reaches a page.
++ */
++const fs = require('fs');
++const path = require('path');
++const { BOOT, tag } = require('openvibe-shared/look-boot');
++
++const MARK = '<!--ov:look-boot-->';
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++
++/** The marker replaced by the inline script. A page without the marker is left alone. */
++function apply(html) {
++ if (typeof html !== 'string' || !html.includes(MARK)) return html;
++ return html.replace(MARK, () => tag());
++}
++
++// The public pages that carry the marker, read once at boot: their look-boot is in the bytes from here on.
++const files = new Map();
++for (const name of fs.readdirSync(PUBLIC_DIR)) {
++ if (!name.endsWith('.html')) continue;
++ const html = fs.readFileSync(path.join(PUBLIC_DIR, name), 'utf8');
++ if (!html.includes(MARK)) continue;
++ files.set(name, apply(html));
++}
++
++/** The page with look-boot in it, or null when the file is not one of ours. */
++function page(name) {
++ return files.get(name) || null;
++}
++
++/** Send a public page with look-boot in it. The headers a caller set (no-store, a redirect) are kept. */
++function send(res, name) {
++ const html = page(name);
++ if (!html) return false;
++ res.set('Content-Type', 'text/html; charset=utf-8');
++ if (!res.get('Cache-Control')) res.set('Cache-Control', 'public, max-age=0');
++ res.send(html);
++ return true;
++}
++
++/**
++ * The pages the static file server would answer raw (public/*.html reached by their own file name):
++ * they get look-boot too, so no path serves a page without it.
++ */
++function middleware() {
++ return (req, res, next) => {
++ if (!req.path.endsWith('.html')) return next();
++ if (!page(path.basename(req.path))) return next();
++ send(res, path.basename(req.path));
++ };
++}
++
++module.exports = { MARK, BOOT, apply, page, send, middleware, files };
ddiff --git a/test/look-boot.test.js b/test/look-boot.test.js
nnew file mode 100644
iindex 00000000..28cb1466
---- /dev/null
++++ b/test/look-boot.test.js
@@@ -0,0 +1,53 @@
++'use strict';
++// The look before first paint (openvibe-shared 4.64.0 look-boot, plan L42; the owner, 10-31: seasonal turned off
++// on Network was still on, and orange, until the deferred theme-loader.js caught up). Every page that carried the
++// old ov_theme bootstrap now paints the look this browser last painted here (ov_paint), or the one a link from
++// another OpenVibe page carried (?ov_look=), before its first stylesheet: once, and the marker never reaches a page.
++// A browser from before look-boot (its theme only) is painted once from that theme, colours only.
++// node test/look-boot.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { bootServer } = require('./helpers/boot-server');
++const { BOOT } = require('openvibe-shared/look-boot');
++const { MARK } = require('../server/look-boot');
++
++const PAGES = [
++ ['/', 'the home page'],
++ ['/my', 'the account hub'],
++ ['/admin', 'the admin panel'],
++ ['/verify-email', 'the email verification page'],
++ ['/sso/fanout', 'the sign-out fanout'],
++];
++
++(async () => {
++ const srv = await bootServer();
++ try {
++ for (const [path, name] of PAGES) {
++ const r = await fetch(srv.base + path);
++ assert.strictEqual(r.status, 200, `${path} (${name}) answers 200`);
++ const html = await r.text();
++ assert.ok(!html.includes(MARK), `${path}: the marker never reaches the page`);
++ assert.strictEqual(html.split(BOOT).length, 2, `${path}: look-boot once`);
++ const at = html.indexOf(BOOT);
++ const firstSheet = html.search(/<link[^>]+rel="stylesheet"/);
++ assert.ok(at > 0 && firstSheet > 0 && at < firstSheet, `${path}: it paints before any stylesheet`);
++ // The one-time fallback for a browser from before look-boot: its theme only, colours only.
++ const legacy = html.slice(at, firstSheet);
++ assert.match(legacy, /if \(localStorage\.getItem\('ov_paint'\)\) return;/, `${path}: the fallback waits for a painted look`);
++ assert.match(legacy, /!\/url\|expression\/i\.test/, `${path}: the fallback takes colours only`);
++ }
++ // No public page keeps the old bootstrap (it painted the theme only, so the season stayed on until the loader ran).
++ const pub = path.join(__dirname, '..', 'public');
++ for (const name of fs.readdirSync(pub).filter((n) => n.endsWith('.html'))) {
++ const html = fs.readFileSync(path.join(pub, name), 'utf8');
++ assert.ok(!html.includes("var raw=localStorage.getItem('ov_theme')"), `${name}: the old theme-only bootstrap is gone`);
++ }
++ console.log('look boot: every page paints the kept or carried look before its first stylesheet');
++ } catch (err) {
++ console.error(srv.logs());
++ throw err;
++ } finally {
++ await srv.stop();
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 2f6873ca..9c95e5a0 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 188, // 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
-- htmlBrotliKB: 34.5, // 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
++ htmlRawKB: 189, // 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
++ htmlBrotliKB: 35.5, // 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsRawKB: 334, // 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
jsBrotliKB: 84, // 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9