openvibers/openvibe.shared Commits
4.111.1: the tab icon is the Pair on every site (#291)
Changes
@@@ -1,5 +1,16 @@# Changelog++## 4.111.1 — 2026-11-05++++- **The tab icon is the Pair on every site.** `theme-loader.js` repaints the favicon on each theme apply++ (`<link rel="icon" data-ov-icon>`), and it still drew the retired ring mark: every site's inline Pair favicon turned++ into the old ring once the page loaded. It now draws `app-icon.js`'s `favicon()` (the heavy form, the site's glow,++ Live's red light and Games' yellow one) with the theme's `--bg-primary` as the tile; the theme accent no longer++ recolours the mark. The drawing is generated into the loader's `FAVICON` block by `scripts/build-theme-loader.js`++ from `mark.js` and `app-icon.js` (`--check` fails when stale), so it cannot drift again.++- Tests: `test/favicon-pair.test.js` (each site equals `favicon()`, the lights, the tile follows the theme, the block++ is current).++## 4.111.0 — 2026-11-04- **`openvibe-shared/vibes-pay` (`vibes-pay.js`): Vibes Pay for any page** (MONEY.md, D12).@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.111.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.111.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer 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` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.111.0",++ "version": "4.111.1","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.111.0",++ "version": "4.111.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.111.0",++ "version": "4.111.1","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.111.0",++ "version": "4.111.1","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -20,6 +20,8 @@const fs = require('fs');const path = require('path');const { BUILTIN_THEMES, DEFAULT_VARS, SEASONS } = require('../builtin-themes');++const MARK = require('../mark');++const APP_ICON = require('../app-icon');const LOADER = path.join(__dirname, '..', 'theme-loader.js');const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';@@@ -63,6 +65,31 @@ ${seasons.join('\n')}${END}`;}++// The tab icon (syncBrowserUi) is app-icon.js's favicon(): the Pair, heavy form, with each site's glow and light. Its++// parts are written here so the loader draws exactly what the PNG and inline favicons show (test/favicon-pair.test.js).++const FAV_BEGIN = ' // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand';++const FAV_END = ' // FAVICON:END';++function renderFavicon() {++ const g = MARK.geo('heavy'), { defs, body } = MARK.art({ form: 'heavy', id: 'ov', depth: true });++ const q = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;++ const sites = Object.entries(APP_ICON.SITES).map(([id, c]) => `${id}: [${q(c.accent[0])}, ${q(c.dot && c.dot.toLowerCase() !== '#ffffff' ? c.dot : '')}]`).join(', ');++ return `${FAV_BEGIN}++ const FAV_DEFS = ${q(defs)};++ const FAV_BODY = ${q(body)};++ const FAV_LIGHT = { cx: ${g.cx}, cy: ${g.cy}, r: ${+((g.r - g.sw / 2) * 0.62).toFixed(2)} };++ const FAV_SITES = { ${sites} };++${FAV_END}`;++}++function faviconBlock(src) {++ const a = src.indexOf(FAV_BEGIN), b = src.indexOf(FAV_END);++ if (a >= 0 && b > a) return { a, b: b + FAV_END.length };++ // First run: the old ring mark's site lights.++ const old = " const DOTS = { live: '#ef4444', games: '#facc15' };";++ const at = src.indexOf(old);++ if (at < 0) throw new Error('theme-loader.js: FAVICON block not found');++ return { a: at, b: at + old.length };++}++function currentBlock(src) {const a = src.indexOf(BEGIN);const b = src.indexOf(END);@@@ -76,7 +103,9 @@ function currentBlock(src) {const src = fs.readFileSync(LOADER, 'utf8');const { a, b } = currentBlock(src);--const next = src.slice(0, a) + render() + src.slice(b);++const themed = src.slice(0, a) + render() + src.slice(b);++const fav = faviconBlock(themed);++const next = themed.slice(0, fav.a) + renderFavicon() + themed.slice(fav.b);const check = process.argv.includes('--check');if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++// The tab icon theme-loader.js paints (syncBrowserUi, on every theme apply) is the Pair: app-icon.js's favicon() with++// the theme's ground for the tile. Until 4.111.1 it redrew the retired ring mark over every site's inline Pair favicon++// as soon as the page loaded. theme-loader.js runs in a small fake browser, as in display-comfort.test.js.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { execFileSync } = require('child_process');++const APP_ICON = require('../app-icon');++++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');++++function paint(site, vars = {}) {++ const attrs = new Map();++ const link = { type: '', href: 'data:image/svg+xml,inline', getAttribute: (k) => (k === 'data-ov-icon' ? site : null) };++ const meta = { setAttribute() {} };++ const html = { style: { setProperty() {}, removeProperty() {} }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), classList: { add() {}, remove() {}, contains: () => false } };++ const sandbox = {++ JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date, encodeURIComponent, setTimeout() {}, clearTimeout() {},++ document: {++ documentElement: html, readyState: 'complete', cookie: '', head: { appendChild() {} }, body: null,++ querySelector: (sel) => (sel.includes('data-ov-icon') ? link : sel.includes('theme-color') ? meta : null),++ getElementById: () => null, createElement: () => ({ setAttribute() {}, style: {} }), addEventListener() {},++ },++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },++ sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },++ location: { hostname: `openvibe.${site}`, protocol: 'https:', search: '', href: `https://openvibe.${site}/` },++ history: { state: null, replaceState() {} },++ getComputedStyle: () => ({ getPropertyValue: (k) => vars[k] || '' }),++ matchMedia: () => ({ matches: false, addEventListener() {} }),++ fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve(null) }),++ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },++ addEventListener() {}, dispatchEvent() {},++ };++ sandbox.window = sandbox;++ sandbox.globalThis = sandbox;++ vm.runInNewContext(SRC, sandbox);++ assert.ok(link.href.startsWith('data:image/svg+xml,'), `${site}: the loader painted the tab icon`);++ return decodeURIComponent(link.href.slice('data:image/svg+xml,'.length));++}++++// app-icon.js's favicon() with the theme's ground in place of its own tile gradient.++const expected = (site, bg) => APP_ICON.favicon({ site })++ .replace(/\n<radialGradient id="bg"[\s\S]*?<\/radialGradient>/, '')++ .replace('fill="url(#bg)"', `fill="${bg}"`);++++// 1. Every site app-icon.js knows, and an unknown one (Network's), draw the same Pair as the build-time favicon.++for (const site of [...Object.keys(APP_ICON.SITES), 'chat']) {++ const got = paint(site, { '--bg-primary': '#0a0f1c' });++ const want = expected(APP_ICON.SITES[site] ? site : 'network', '#0a0f1c');++ assert.strictEqual(got, want, `${site}: the loader's tab icon is app-icon.js's favicon()`);++ assert.ok(!got.includes('M-9.5,-7 L0,10 L9.5,-7'), `${site}: not the retired ring mark`);++ assert.ok(got.includes('mask="url(#ovh)"'), `${site}: the Pair, with its mask gap`);++}++++// 2. Live keeps its red record light and Games its yellow one; Tools has none.++assert.ok(paint('live').includes('fill="#ef4444"'), 'Live: the red light in the O');++assert.ok(paint('games').includes('fill="#facc15"'), 'Games: the yellow light');++assert.ok(!/<circle cx="30\.09" cy="50" r="5\.58"/.test(paint('tools')), 'Tools: no light');++++// 3. The tile follows the theme's ground; the mark keeps its brand colours.++const light = paint('tools', { '--bg-primary': '#f8fafc', '--accent': '#0f766e' });++assert.ok(light.includes('rx="15" fill="#f8fafc"'), 'a light theme gets a light tile');++assert.ok(!light.includes('#0f766e'), 'the theme accent does not recolour the mark');++++// 4. The generated block is current.++execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'build-theme-loader.js'), '--check'], { stdio: 'pipe' });++++console.log('favicon pair: all checks passed');@@@ -49,7 +49,8 @@ for (const t of BUILTIN_THEMES) {}assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).--assert.ok(loader.length < 62000, `theme-loader.js stays small for first paint (${loader.length} characters)`);++// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.++assert.ok(loader.length < 63500, `theme-loader.js stays small for first paint (${loader.length} characters)`);assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');// 5. Derived tokens: text on the accent always reads, on every theme.@@@ -232,17 +232,24 @@// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),// and window 'ov:theme' { accent, accentLight, bg, mode }.-- const DOTS = { live: '#ef4444', games: '#facc15' };++ // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand++ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="24" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';++ const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';++ const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };++ const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };++ // FAVICON:ENDconst COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;function readColor(name, fallback) {try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }}-- function faviconSvg(site, accent, light, bg) {-- const e = encodeURIComponent;-- return 'data:image/svg+xml,' + e('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + light + '"/><stop offset="1" stop-color="' + accent + '"/></linearGradient></defs>'-- + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(1.22)"><circle r="18" fill="none" stroke="url(#r)" stroke-width="4" opacity=".38"/>'-- + '<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#r)" stroke-width="4.4" stroke-linecap="round"/><path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="' + (document.documentElement.getAttribute('data-theme-mode') === 'light' ? accent : '#fff') + '" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>'-- + '<circle cx="17.4" cy="4.6" r="3.2" fill="' + (DOTS[site] || light) + '"/></g></svg>');++ /** The tab icon: app-icon.js's favicon() (the Pair, heavy form, the site's glow and light) on the theme's ground. */++ function faviconSvg(site, bg) {++ const s = FAV_SITES[site] || FAV_SITES.network, a = s[0];++ const light = s[1] ? '<circle cx="' + FAV_LIGHT.cx + '" cy="' + FAV_LIGHT.cy + '" r="' + FAV_LIGHT.r + '" fill="' + s[1] + '"/>' : '';++ return 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs>' + FAV_DEFS++ + '\n<radialGradient id="glow"><stop offset="0" stop-color="' + a + '" stop-opacity=".3"/><stop offset="1" stop-color="' + a + '" stop-opacity="0"/></radialGradient></defs>'++ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(0.58) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>'++ + FAV_BODY + light + '</g></svg>');}let _browserUiKey = '';function syncBrowserUi() {@@@ -255,7 +262,7 @@if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }meta.setAttribute('content', bg);const link = document.querySelector('link[rel~="icon"][data-ov-icon]');-- if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), accent, light, readColor('--bg-primary', '#0a0f1c')); }++ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), readColor('--bg-primary', '#0a0f1c')); }} catch { /* cosmetic */ }try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }}
@@ -1,5 +1,16 @@
@@ -1,5 +1,16 @@
# Changelog
# Changelog
+## 4.111.1 — 2026-11-05
+
+- **The tab icon is the Pair on every site.** `theme-loader.js` repaints the favicon on each theme apply
+ (`<link rel="icon" data-ov-icon>`), and it still drew the retired ring mark: every site's inline Pair favicon turned
+ into the old ring once the page loaded. It now draws `app-icon.js`'s `favicon()` (the heavy form, the site's glow,
+ Live's red light and Games' yellow one) with the theme's `--bg-primary` as the tile; the theme accent no longer
+ recolours the mark. The drawing is generated into the loader's `FAVICON` block by `scripts/build-theme-loader.js`
+ from `mark.js` and `app-icon.js` (`--check` fails when stale), so it cannot drift again.
+- Tests: `test/favicon-pair.test.js` (each site equals `favicon()`, the lights, the tile follows the theme, the block
+ is current).
+
## 4.111.0 — 2026-11-04
## 4.111.0 — 2026-11-04
- **`openvibe-shared/vibes-pay` (`vibes-pay.js`): Vibes Pay for any page** (MONEY.md, D12).
- **`openvibe-shared/vibes-pay` (`vibes-pay.js`): Vibes Pay for any page** (MONEY.md, D12).
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.111.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.111.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.111.0",
+ "version": "4.111.1",
"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)",
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.111.0",
+ "version": "4.111.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.111.0",
+ "version": "4.111.1",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.111.0",
+ "version": "4.111.1",
"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",
"bin": {
"bin": {
@@ -20,6 +20,8 @@
@@ -20,6 +20,8 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { BUILTIN_THEMES, DEFAULT_VARS, SEASONS } = require('../builtin-themes');
const { BUILTIN_THEMES, DEFAULT_VARS, SEASONS } = require('../builtin-themes');
+const MARK = require('../mark');
+const APP_ICON = require('../app-icon');
const LOADER = path.join(__dirname, '..', 'theme-loader.js');
const LOADER = path.join(__dirname, '..', 'theme-loader.js');
const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
@@ -63,6 +65,31 @@ ${seasons.join('\n')}
@@ -63,6 +65,31 @@ ${seasons.join('\n')}
${END}`;
${END}`;
}
}
+// The tab icon (syncBrowserUi) is app-icon.js's favicon(): the Pair, heavy form, with each site's glow and light. Its
+// parts are written here so the loader draws exactly what the PNG and inline favicons show (test/favicon-pair.test.js).
+const FAV_BEGIN = ' // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand';
+const FAV_END = ' // FAVICON:END';
+function renderFavicon() {
+ const g = MARK.geo('heavy'), { defs, body } = MARK.art({ form: 'heavy', id: 'ov', depth: true });
+ const q = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+ const sites = Object.entries(APP_ICON.SITES).map(([id, c]) => `${id}: [${q(c.accent[0])}, ${q(c.dot && c.dot.toLowerCase() !== '#ffffff' ? c.dot : '')}]`).join(', ');
+ return `${FAV_BEGIN}
+ const FAV_DEFS = ${q(defs)};
+ const FAV_BODY = ${q(body)};
+ const FAV_LIGHT = { cx: ${g.cx}, cy: ${g.cy}, r: ${+((g.r - g.sw / 2) * 0.62).toFixed(2)} };
+ const FAV_SITES = { ${sites} };
+${FAV_END}`;
+}
+function faviconBlock(src) {
+ const a = src.indexOf(FAV_BEGIN), b = src.indexOf(FAV_END);
+ if (a >= 0 && b > a) return { a, b: b + FAV_END.length };
+ // First run: the old ring mark's site lights.
+ const old = " const DOTS = { live: '#ef4444', games: '#facc15' };";
+ const at = src.indexOf(old);
+ if (at < 0) throw new Error('theme-loader.js: FAVICON block not found');
+ return { a: at, b: at + old.length };
+}
+
function currentBlock(src) {
function currentBlock(src) {
const a = src.indexOf(BEGIN);
const a = src.indexOf(BEGIN);
const b = src.indexOf(END);
const b = src.indexOf(END);
@@ -76,7 +103,9 @@ function currentBlock(src) {
@@ -76,7 +103,9 @@ function currentBlock(src) {
const src = fs.readFileSync(LOADER, 'utf8');
const src = fs.readFileSync(LOADER, 'utf8');
const { a, b } = currentBlock(src);
const { a, b } = currentBlock(src);
-const next = src.slice(0, a) + render() + src.slice(b);
+const themed = src.slice(0, a) + render() + src.slice(b);
+const fav = faviconBlock(themed);
+const next = themed.slice(0, fav.a) + renderFavicon() + themed.slice(fav.b);
const check = process.argv.includes('--check');
const check = process.argv.includes('--check');
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }
if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+// The tab icon theme-loader.js paints (syncBrowserUi, on every theme apply) is the Pair: app-icon.js's favicon() with
+// the theme's ground for the tile. Until 4.111.1 it redrew the retired ring mark over every site's inline Pair favicon
+// as soon as the page loaded. theme-loader.js runs in a small fake browser, as in display-comfort.test.js.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { execFileSync } = require('child_process');
+const APP_ICON = require('../app-icon');
+
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
+
+function paint(site, vars = {}) {
+ const attrs = new Map();
+ const link = { type: '', href: 'data:image/svg+xml,inline', getAttribute: (k) => (k === 'data-ov-icon' ? site : null) };
+ const meta = { setAttribute() {} };
+ const html = { style: { setProperty() {}, removeProperty() {} }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), classList: { add() {}, remove() {}, contains: () => false } };
+ const sandbox = {
+ JSON, Object, String, Number, Math, Array, Promise, Error, URL, URLSearchParams, Date, encodeURIComponent, setTimeout() {}, clearTimeout() {},
+ document: {
+ documentElement: html, readyState: 'complete', cookie: '', head: { appendChild() {} }, body: null,
+ querySelector: (sel) => (sel.includes('data-ov-icon') ? link : sel.includes('theme-color') ? meta : null),
+ getElementById: () => null, createElement: () => ({ setAttribute() {}, style: {} }), addEventListener() {},
+ },
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ location: { hostname: `openvibe.${site}`, protocol: 'https:', search: '', href: `https://openvibe.${site}/` },
+ history: { state: null, replaceState() {} },
+ getComputedStyle: () => ({ getPropertyValue: (k) => vars[k] || '' }),
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
+ fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve(null) }),
+ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
+ addEventListener() {}, dispatchEvent() {},
+ };
+ sandbox.window = sandbox;
+ sandbox.globalThis = sandbox;
+ vm.runInNewContext(SRC, sandbox);
+ assert.ok(link.href.startsWith('data:image/svg+xml,'), `${site}: the loader painted the tab icon`);
+ return decodeURIComponent(link.href.slice('data:image/svg+xml,'.length));
+}
+
+// app-icon.js's favicon() with the theme's ground in place of its own tile gradient.
+const expected = (site, bg) => APP_ICON.favicon({ site })
+ .replace(/\n<radialGradient id="bg"[\s\S]*?<\/radialGradient>/, '')
+ .replace('fill="url(#bg)"', `fill="${bg}"`);
+
+// 1. Every site app-icon.js knows, and an unknown one (Network's), draw the same Pair as the build-time favicon.
+for (const site of [...Object.keys(APP_ICON.SITES), 'chat']) {
+ const got = paint(site, { '--bg-primary': '#0a0f1c' });
+ const want = expected(APP_ICON.SITES[site] ? site : 'network', '#0a0f1c');
+ assert.strictEqual(got, want, `${site}: the loader's tab icon is app-icon.js's favicon()`);
+ assert.ok(!got.includes('M-9.5,-7 L0,10 L9.5,-7'), `${site}: not the retired ring mark`);
+ assert.ok(got.includes('mask="url(#ovh)"'), `${site}: the Pair, with its mask gap`);
+}
+
+// 2. Live keeps its red record light and Games its yellow one; Tools has none.
+assert.ok(paint('live').includes('fill="#ef4444"'), 'Live: the red light in the O');
+assert.ok(paint('games').includes('fill="#facc15"'), 'Games: the yellow light');
+assert.ok(!/<circle cx="30\.09" cy="50" r="5\.58"/.test(paint('tools')), 'Tools: no light');
+
+// 3. The tile follows the theme's ground; the mark keeps its brand colours.
+const light = paint('tools', { '--bg-primary': '#f8fafc', '--accent': '#0f766e' });
+assert.ok(light.includes('rx="15" fill="#f8fafc"'), 'a light theme gets a light tile');
+assert.ok(!light.includes('#0f766e'), 'the theme accent does not recolour the mark');
+
+// 4. The generated block is current.
+execFileSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'build-theme-loader.js'), '--check'], { stdio: 'pipe' });
+
+console.log('favicon pair: all checks passed');
@@ -49,7 +49,8 @@ for (const t of BUILTIN_THEMES) {
@@ -49,7 +49,8 @@ for (const t of BUILTIN_THEMES) {
}
}
assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).
// 62,000 since 4.64.0: the look travels between sites (?ov_look=) and every copy is dated; brotli 15.45 KB (was 15.41) (2026-10-31).
-assert.ok(loader.length < 62000, `theme-loader.js stays small for first paint (${loader.length} characters)`);
+// 63,500 since 4.111.1 (2026-11-05): the FAVICON block draws the Pair (about 1.2 KB raw) instead of the retired ring.
+assert.ok(loader.length < 63500, `theme-loader.js stays small for first paint (${loader.length} characters)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
// 5. Derived tokens: text on the accent always reads, on every theme.
@@ -232,17 +232,24 @@
@@ -232,17 +232,24 @@
// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
// ── The browser's UI follows the theme on every apply: theme-color, the favicon (<link rel="icon" data-ov-icon>),
// and window 'ov:theme' { accent, accentLight, bg, mode }.
// and window 'ov:theme' { accent, accentLight, bg, mode }.
- const DOTS = { live: '#ef4444', games: '#facc15' };
+ // FAVICON:BEGIN — generated by scripts/build-theme-loader.js from mark.js and app-icon.js; do not edit by hand
+ const FAV_DEFS = '<mask id="ovh" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="#000" stroke-width="24" stroke-linecap="round"/></mask><linearGradient id="ovgo" gradientUnits="userSpaceOnUse" x1="3.09" y1="23" x2="57.09" y2="77"><stop offset="0" stop-color="#87a6ff"/><stop offset="1" stop-color="#4268d6"/></linearGradient><linearGradient id="ovgv" gradientUnits="userSpaceOnUse" x1="0" y1="31.5" x2="0" y2="68.5"><stop offset="0" stop-color="#ae8df9"/><stop offset="1" stop-color="#784fd4"/></linearGradient>';
+ const FAV_BODY = '<circle cx="30.09" cy="50" r="18" fill="none" stroke="url(#ovgo)" stroke-width="18" mask="url(#ovh)"/><path d="M70.59 68.5L53.31 31.5M70.59 68.5L88.41 31.5" fill="none" stroke="url(#ovgv)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>';
+ const FAV_LIGHT = { cx: 30.09, cy: 50, r: 5.58 };
+ const FAV_SITES = { network: ['#60a5fa', ''], live: ['#60a5fa', '#ef4444'], tools: ['#38bdf8', ''], community: ['#34d399', ''], games: ['#a78bfa', '#facc15'], media: ['#fbbf24', ''], space: ['#818cf8', ''], ai: ['#22d3ee', ''] };
+ // FAVICON:END
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
const COLOR_RE = /^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%/]+\)|hsla?\([\d\s.,%/deg]+\))$/i;
function readColor(name, fallback) {
function readColor(name, fallback) {
try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
try { const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return COLOR_RE.test(v) ? v : fallback; } catch { return fallback; }
}
}
- function faviconSvg(site, accent, light, bg) {
- const e = encodeURIComponent;
- return 'data:image/svg+xml,' + e('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs><linearGradient id="r" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + light + '"/><stop offset="1" stop-color="' + accent + '"/></linearGradient></defs>'
- + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(1.22)"><circle r="18" fill="none" stroke="url(#r)" stroke-width="4" opacity=".38"/>'
- + '<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#r)" stroke-width="4.4" stroke-linecap="round"/><path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="' + (document.documentElement.getAttribute('data-theme-mode') === 'light' ? accent : '#fff') + '" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>'
- + '<circle cx="17.4" cy="4.6" r="3.2" fill="' + (DOTS[site] || light) + '"/></g></svg>');
+ /** The tab icon: app-icon.js's favicon() (the Pair, heavy form, the site's glow and light) on the theme's ground. */
+ function faviconSvg(site, bg) {
+ const s = FAV_SITES[site] || FAV_SITES.network, a = s[0];
+ const light = s[1] ? '<circle cx="' + FAV_LIGHT.cx + '" cy="' + FAV_LIGHT.cy + '" r="' + FAV_LIGHT.r + '" fill="' + s[1] + '"/>' : '';
+ return 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64"><defs>' + FAV_DEFS
+ + '\n<radialGradient id="glow"><stop offset="0" stop-color="' + a + '" stop-opacity=".3"/><stop offset="1" stop-color="' + a + '" stop-opacity="0"/></radialGradient></defs>'
+ + '<rect x="-32" y="-32" width="64" height="64" rx="15" fill="' + bg + '"/><g transform="scale(0.58) translate(-50 -50)"><ellipse cx="50" cy="50" rx="52" ry="40" fill="url(#glow)"/>'
+ + FAV_BODY + light + '</g></svg>');
}
}
let _browserUiKey = '';
let _browserUiKey = '';
function syncBrowserUi() {
function syncBrowserUi() {
@@ -255,7 +262,7 @@
@@ -255,7 +262,7 @@
if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
if (!meta) { meta = document.createElement('meta'); meta.name = 'theme-color'; document.head.appendChild(meta); }
meta.setAttribute('content', bg);
meta.setAttribute('content', bg);
const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
const link = document.querySelector('link[rel~="icon"][data-ov-icon]');
- if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), accent, light, readColor('--bg-primary', '#0a0f1c')); }
+ if (link) { link.type = 'image/svg+xml'; link.href = faviconSvg(link.getAttribute('data-ov-icon'), readColor('--bg-primary', '#0a0f1c')); }
} catch { /* cosmetic */ }
} catch { /* cosmetic */ }
try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
try { window.dispatchEvent(new CustomEvent('ov:theme', { detail: { accent: accent, accentLight: light, bg: bg, mode: mode } })); } catch { /* */ }
}
}