openvibers/openvibe.shared Commits

4.199.0: the shared files are served minified; avatars reserve their box (#433)

OP OpenVibers committed 3 days ago · fc3db6dda0 · 13 files, +285 −13

* Mount Dock panels inside a host sheet

* Serve shared JS/CSS minified, cached by content hash

* Reserve avatar boxes: width/height + lazy/async

* Merge main (4.197.0) into the menu's one sheet

* The agent view: an ended session's one button sits beside its two lines

* 4.198.0: on a phone the menu is one sheet; Messages and AI open inside it

Messages and AI in the row of places open the Dock's panels inside the
menu, under its own head (OpenVibeDock.mountInto), and AI carries a
Vibe · Agents switch. Feed, typing or closing hand the panel back. On a
computer they still open the floating Dock.

* Merge remote-tracking branch 'origin/perf/serve-minify-1110' into perf-served

* Merge remote-tracking branch 'origin/perf/shared-images-1110' into perf-served

* 4.199.0: the shared files are served minified; avatars reserve their box

CSS at clean-css level 1 only, so rules are never moved or merged.

* Merge remote-tracking branch 'origin/main' into perf-served

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 178cf6d..3d29ea7 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,24 @@
# Changelog
++## 4.199.0 — 2026-11-10
++
++**Every page downloads about 56 KB less: the shared files are served minified.** From the owner's notes of 2026-11-10:
++"Make sure everything loads … non-blocking asynchronously … minified".
++
++- **Served minified, cached by content hash (`serve.js`, new `minify.js`; OpenVibe.Shared #429).** Sites have no build
++ step by choice, so the shared browser files are minified where they are served: terser for JavaScript, clean-css for
++ stylesheets. It runs once per release per file, and the immutable `?v=` address does the rest. On 4.197.0's eager set
++ (the bar, the theme, the footer, the kit and the rest) that is 806 KB → 523 KB raw and 189 KB → 133 KB after brotli,
++ about 56 KB less per page view; `navbar.js` alone is 49.8 KB → 34.4 KB as delivered. A file the minifier cannot
++ handle, or one that would not come out smaller, is served as it is; a minifier error never fails a request. ETag and
++ `?v=` immutability are unchanged. Stylesheets are minified at clean-css level 1 only (inside each rule), never level
++ 2, which moves and merges rules and could change which rule wins. New dependencies: `terser`, `clean-css`.
++- **Avatars reserve their box (OpenVibe.Shared #430).** The bar's, the footer's, the account switcher's and the user
++ card's pictures carry their width and height and decode asynchronously (the footer's also load lazily, below the
++ fold), so a signed-in page no longer shifts when the picture arrives.
++- Checked on production openvibe.chat with every shared file served minified, on a phone and a computer: the bar, the
++ menu, Messages and AI inside it, the Dock; no page errors.
++
## 4.198.0 — 2026-11-10
**On a phone the menu is one sheet: Messages and AI open inside it.** From the owner's notes of 2026-11-10, with the menu
ddiff --git a/README.md b/README.md
iindex 3dcc69d..5e65e8f 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.198.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.199.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 0d3b839..0a01add 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.198.0",
++ "version": "4.199.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/account-switcher.js b/account-switcher.js
iindex a958f6a..036dba1 100644
---- a/account-switcher.js
++++ b/account-switcher.js
@@@ -469,7 +469,7 @@
<div class="openvibe-switcher-list">
${loggedAccounts.map(a => `
<div class="openvibe-switcher-item ${String(a.id) === String(activeId) ? 'active' : ''}" data-id="${a.id}">
-- <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="">
++ <img src="${a.avatar_url || '/data/avatars/default.png'}" alt="" width="40" height="40" decoding="async">
<div class="info">
<div class="name">${a.display_name || a.username}</div>
<div class="sub">${a.email || `@${a.username}`}</div>
ddiff --git a/footer.js b/footer.js
iindex 9514198..431bc23 100644
---- a/footer.js
++++ b/footer.js
@@@ -342,7 +342,7 @@
const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || '');
const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : '');
const avatar = sized
-- ? `<img src="${esc(sized)}" alt="">`
++ ? `<img src="${esc(sized)}" alt="" width="${media ? 64 : 32}" height="${media ? 64 : 32}" loading="lazy" decoding="async">`
: `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
const quick = [
service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
ddiff --git a/minify.js b/minify.js
nnew file mode 100644
iindex 0000000..1861296
---- /dev/null
++++ b/minify.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * openvibe-shared/minify — serve-time minification of the shared browser JS/CSS, cached by content
++ * hash. Sites have no build step by choice (plain files, content-hashed at serve time), so
++ * "minified" comes from the serve path (serve.js), not a bundler per site.
++ *
++ * const { minifyJs, minifyCss } = require('openvibe-shared/minify');
++ * const js = await minifyJs(src); // terser, names mangled, comments dropped
++ * const css = minifyCss(src); // clean-css (sync)
++ *
++ * Both fail open: a file the minifier cannot handle is returned unchanged, and serve.js falls back
++ * to the raw file on any error, so a minifier surprise can never 500 a page.
++ */
++const { minify: terserMinify } = require('terser');
++const CleanCSS = require('clean-css');
++
++// Level 1 only: it removes what is not needed inside each rule and never moves or merges rules, so the cascade's order
++// (and a later rule winning) cannot change (the orchestrator's review, 2026-11-10: level 2 restructures).
++const cleanCss = new CleanCSS({ level: { 1: { specialComments: 0 } } });
++
++/** Minify browser JS (terser, async). Input/output are strings. Throws on a parse error. */
++async function minifyJs(src) {
++ const out = await terserMinify(String(src), { compress: true, mangle: true, format: { comments: false } });
++ return out.code;
++}
++
++/** Minify browser CSS (clean-css, sync). Input/output are strings. */
++function minifyCss(src) {
++ return cleanCss.minify(String(src)).styles;
++}
++
++/** Minify by filename extension; anything else is returned unchanged. */
++async function minifyFile(name, data) {
++ const s = data.toString('utf8');
++ if (name.endsWith('.js')) return Buffer.from(await minifyJs(s));
++ if (name.endsWith('.css')) return Buffer.from(minifyCss(s));
++ return data;
++}
++
++module.exports = { minifyJs, minifyCss, minifyFile };
ddiff --git a/navbar.js b/navbar.js
iindex 976ec63..5c7070e 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -1992,7 +1992,7 @@
const retry = current && current !== src ? ` data-retry-src="${escapeAttr(current)}"` : '';
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
const alt = escapeAttr(user?.display_name || user?.username || 'Avatar');
-- return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr}>`;
++ return `<img class="${escapeAttr(className)}" src="${escapeAttr(src)}"${retry} data-fallback-src="${escapeAttr(fallback)}" alt="${alt}"${idAttr} width="${escapeAttr(size)}" height="${escapeAttr(size)}" decoding="async">`;
}
// A picture that does not load: the current one once (data-retry-src), then the initial.
ddiff --git a/package-lock.json b/package-lock.json
iindex 5ade31a..96e88da 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,16 +1,18 @@
{
"name": "openvibe-shared",
-- "version": "4.198.0",
++ "version": "4.199.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.198.0",
++ "version": "4.199.0",
"license": "MIT",
"dependencies": {
++ "clean-css": "^5.3.3",
"jsonwebtoken": "^9.0.0",
-- "linkedom": "^0.18.13"
++ "linkedom": "^0.18.13",
++ "terser": "^5.51.2"
},
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
@@@ -29,6 +31,51 @@
"dev": true,
"license": "Apache-2.0"
},
++ "node_modules/@jridgewell/gen-mapping": {
++ "version": "0.3.13",
++ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
++ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/sourcemap-codec": "^1.5.0",
++ "@jridgewell/trace-mapping": "^0.3.24"
++ }
++ },
++ "node_modules/@jridgewell/resolve-uri": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
++ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=6.0.0"
++ }
++ },
++ "node_modules/@jridgewell/source-map": {
++ "version": "0.3.11",
++ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
++ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/gen-mapping": "^0.3.5",
++ "@jridgewell/trace-mapping": "^0.3.25"
++ }
++ },
++ "node_modules/@jridgewell/sourcemap-codec": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
++ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
++ "license": "MIT"
++ },
++ "node_modules/@jridgewell/trace-mapping": {
++ "version": "0.3.31",
++ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
++ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/resolve-uri": "^3.1.0",
++ "@jridgewell/sourcemap-codec": "^1.4.14"
++ }
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -43,6 +90,18 @@
"node": ">= 0.6"
}
},
++ "node_modules/acorn": {
++ "version": "8.19.0",
++ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
++ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
++ "license": "MIT",
++ "bin": {
++ "acorn": "bin/acorn"
++ },
++ "engines": {
++ "node": ">=0.4.0"
++ }
++ },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@@ -129,6 +188,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/buffer-from": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
++ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
++ "license": "MIT"
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -170,6 +235,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/clean-css": {
++ "version": "5.3.3",
++ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
++ "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
++ "license": "MIT",
++ "dependencies": {
++ "source-map": "~0.6.0"
++ },
++ "engines": {
++ "node": ">= 10.0"
++ }
++ },
++ "node_modules/commander": {
++ "version": "2.20.3",
++ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
++ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
++ "license": "MIT"
++ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@@ -1375,6 +1458,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/source-map": {
++ "version": "0.6.1",
++ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
++ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
++ "license": "BSD-3-Clause",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/source-map-support": {
++ "version": "0.5.21",
++ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
++ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-from": "^1.0.0",
++ "source-map": "^0.6.0"
++ }
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -1385,6 +1487,24 @@
"node": ">= 0.8"
}
},
++ "node_modules/terser": {
++ "version": "5.51.2",
++ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
++ "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "@jridgewell/source-map": "^0.3.3",
++ "acorn": "^8.15.0",
++ "commander": "^2.20.0",
++ "source-map-support": "~0.5.20"
++ },
++ "bin": {
++ "terser": "bin/terser"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
ddiff --git a/package.json b/package.json
iindex 205bdf4..3a48926 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.198.0",
++ "version": "4.199.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -96,6 +96,7 @@
"./release-update": "./release-update.js",
"./release-watch": "./release-watch.js",
"./seo": "./seo.js",
++ "./minify": "./minify.js",
"./serve": "./serve.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
@@@ -185,8 +186,10 @@
],
"license": "MIT",
"dependencies": {
++ "clean-css": "^5.3.3",
"jsonwebtoken": "^9.0.0",
-- "linkedom": "^0.18.13"
++ "linkedom": "^0.18.13",
++ "terser": "^5.51.2"
},
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/serve.js b/serve.js
iindex c6500a2..aaf1fc9 100644
---- a/serve.js
++++ b/serve.js
@@@ -15,6 +15,10 @@
* cards (social/<service>.jpg, openvibe-shared/social), the installable app icons
* (icons/<service>[-maskable]-<size>.png, openvibe-shared/app-icons) and the web app manifest built
* per service at manifest/<service>.webmanifest.
++ *
++ * JS/CSS are minified at serve time (openvibe-shared/minify) and cached by content hash: the minify
++ * runs once per release per file, then the immutable ?v= URL does the rest. A file the minifier
++ * cannot handle is served raw; a minified body is used only when it is smaller than the raw file.
*/
const fs = require('fs');
const files = require('./files');
@@@ -23,6 +27,7 @@ const appIcons = require('./app-icons');
const appIcon = require('./app-icon');
const cache = require('./cache-policy');
const { hashAsset } = require('./assets');
++const { minifyFile } = require('./minify');
const hashes = new Map();
const fileOf = (name) => (social.isCard(name) ? social.path(name) : appIcons.isIcon(name) ? appIcons.path(name) : files.path(name));
@@@ -96,6 +101,35 @@ function cacheHeaders(res, name, hash, v) {
res.setHeader('X-Content-Type-Options', 'nosniff');
}
++// Minified bodies by `${name}:${hash}`: minification runs once per release, then the cache and the
++// immutable ?v= URL do the rest. Bounded, because a site never serves more than this package lists.
++const bodies = new Map();
++
++/** The bytes to serve for `name`: minified when JS/CSS, raw otherwise. Fail-open to raw. */
++async function bodyFor(name) {
++ const hash = hashOf(name);
++ const key = `${name}:${hash}`;
++ const hit = bodies.get(key);
++ if (hit) return hit;
++ if (bodies.size > 300) bodies.clear();
++ const raw = fs.readFileSync(fileOf(name));
++ if (!name.endsWith('.js') && !name.endsWith('.css')) {
++ const entry = { body: raw, minified: false };
++ bodies.set(key, entry);
++ return entry;
++ }
++ try {
++ const body = await minifyFile(name, raw);
++ const entry = body.length < raw.length ? { body, minified: true } : { body: raw, minified: false };
++ bodies.set(key, entry);
++ return entry;
++ } catch {
++ const entry = { body: raw, minified: false };
++ bodies.set(key, entry);
++ return entry;
++ }
++}
++
/** Express/connect handler for app.use('/shared', handler()). */
function handler() {
return function openvibeShared(req, res, next) {
@@@ -123,8 +157,8 @@ function handler() {
cacheHeaders(res, name, hash, v);
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.method === 'HEAD') return res.end();
-- fs.createReadStream(fileOf(name)).on('error', next).pipe(res);
++ bodyFor(name).then(({ body }) => res.end(body)).catch(next);
};
}
--module.exports = { handler, url, hashOf, manifestUrl };
++module.exports = { handler, url, hashOf, manifestUrl, _bodyFor: bodyFor };
ddiff --git a/test/avatar-sizes.test.js b/test/avatar-sizes.test.js
nnew file mode 100644
iindex 0000000..73d59eb
---- /dev/null
++++ b/test/avatar-sizes.test.js
@@@ -0,0 +1,27 @@
++'use strict';
++// Shared avatars reserve their box: width/height on every <img> the shared parts draw, lazy + async
++// where the image sits below the fold (footer), async alone in the header and click panels.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const dir = path.join(__dirname, '..');
++const src = (f) => fs.readFileSync(path.join(dir, f), 'utf8');
++
++const navbar = src('navbar.js');
++// avatarImg carries the requested size as width/height and decodes async; no lazy above the fold.
++const avatarImg = navbar.slice(navbar.indexOf('function avatarImg'), navbar.indexOf('function avatarImg') + 1200);
++assert.ok(avatarImg.includes('width="${escapeAttr(size)}"') && avatarImg.includes('height="${escapeAttr(size)}"'), 'navbar avatar reserves its size');
++assert.ok(avatarImg.includes('decoding="async"'), 'navbar avatar decodes async');
++assert.ok(!avatarImg.includes('loading="lazy"'), 'navbar avatar is above the fold: no lazy');
++
++const footer = src('footer.js');
++assert.ok(footer.includes('width="${media ? 64 : 32}"') && footer.includes('height="${media ? 64 : 32}"'), 'footer avatar reserves 64/32');
++assert.ok(footer.includes('loading="lazy"') && footer.includes('decoding="async"'), 'footer avatar is lazy + async (below the fold)');
++
++const switcher = src('account-switcher.js');
++assert.ok(switcher.includes('width="40" height="40"') && switcher.includes('decoding="async"'), 'switcher avatar is 40x40');
++
++const card = src('user-card.js');
++assert.ok(card.includes('width="56" height="56"') && card.includes('decoding="async"'), 'user-card avatar is 56x56');
++
++console.log('avatar sizes: navbar, footer, switcher and user-card reserve their boxes');
ddiff --git a/test/minify.test.js b/test/minify.test.js
nnew file mode 100644
iindex 0000000..d42c51d
---- /dev/null
++++ b/test/minify.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++// openvibe-shared/minify: JS/CSS minify smaller, still parses, fail-open via serve.js.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { minifyJs, minifyCss } = require('../minify');
++const files = require('../files');
++
++(async () => {
++ // JS: every browser file minifies smaller and still parses as a classic script.
++ for (const f of files.BROWSER) {
++ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');
++ const code = await minifyJs(raw);
++ assert.ok(code.length < raw.length, `${f}: minified (${code.length}) < raw (${raw.length})`);
++ assert.doesNotThrow(() => new vm.Script(code, { filename: f }), `${f}: minified parses`);
++ assert.ok(!code.includes("require('openvibe-shared/"), `${f}: no require leaked`);
++ }
++ // CSS: every stylesheet minifies to no more than the raw file.
++ for (const f of files.STYLES) {
++ const raw = fs.readFileSync(path.join(files.dir, f), 'utf8');
++ const code = minifyCss(raw);
++ assert.ok(code.length <= raw.length, `${f}: minified no larger (${code.length} <= ${raw.length})`);
++ assert.ok(code.trim().length > 0, `${f}: minified is not empty`);
++ }
++ // A parse error throws (serve.js catches and serves raw) rather than returning broken code.
++ await assert.rejects(() => minifyJs('function broken( {'), /Unexpected token/);
++ console.log(`minify: ${files.BROWSER.length} JS and ${files.STYLES.length} CSS files minify clean`);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/user-card.js b/user-card.js
iindex a15b478..ef1a2e8 100644
---- a/user-card.js
++++ b/user-card.js
@@@ -152,7 +152,7 @@
const avatarUrl = user.avatar_url || `${_config.apiBase}/data/avatars/default.png`;
card.innerHTML = `
<div class="openvibe-usercard-banner" style="${user.profile_color ? `background: linear-gradient(135deg, ${user.profile_color}88, ${user.profile_color})` : ''}"></div>
-- <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" onerror="this.style.display='none'">
++ <img class="openvibe-usercard-avatar" src="${avatarUrl}" alt="${user.username}" width="56" height="56" decoding="async" onerror="this.style.display='none'">
<div class="openvibe-usercard-body">
<div class="openvibe-usercard-name">
<span class="${nameClass}" style="${nameColor}">${user.display_name || user.username}</span>