openvibers/openvibe.live Commits
Live's icons arrive drawn: the OpenVibe glyphs in the HTML, not Font Awesome swapped later (#497)
Changes
@@@ -19,7 +19,7 @@ const ROOT = path.join(__dirname, '..', '..');// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),// "css" the render-blocking stylesheets.const BUDGETS = {-- htmlRawKB: 120, // measured 94.9++ htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")jsRawKB: 1250, // 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)@@@ -35,6 +35,7 @@ const HASH_LEN = 12;const MANIFEST_DIRS = ['js', 'css', 'fragments'];// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.const critical = require('./critical-css');++const glyphHtml = require('./glyphs-html');const CORE_CSS = '/css/style.core.css';const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;const MANIFEST_MARK = '<!--ov:asset-manifest-->';@@@ -244,6 +245,9 @@ function rewriteHtml(html) {const h = hashOf(urlPath);return h ? `${q}${urlPath}?v=${h}${q}` : m;});++ // The network's icons, drawn here rather than swapped in the browser (plan L49; server/web/glyphs-html.js).++ out = glyphHtml.render(out);++ if (out.includes('</head>') && out.includes(' ovg" data-ovg=') && !out.includes('id="ov-glyphs-first"')) out = out.replace('</head>', () => `<style id="ov-glyphs-first">${glyphHtml.STYLE}</style>\n</head>`);if (out.includes(LOOK_BOOT_MARK)) out = out.replace(LOOK_BOOT_MARK, () => `<script>${LOOK_BOOT}</script>`);if (out.includes(MANIFEST_MARK)) {const reg = features();nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * Icons drawn on the server (plan L49 step 2; the owner, 2026-11-01: "why Font Awesome? We should be using OpenVibe++ * Design's icon system for all our icons"). Every <i class="fa-solid fa-…"></i> in Live's own HTML (index.html and the++ * fragments, through assets.rewriteHtml) arrives as the OpenVibe glyph itself, exactly as openvibe-shared's++ * ov-glyphs.js would have drawn it in the browser: same classes (`ovg`), same data-ovg and data-ovg-fa, same SVG. So++ * the first paint has the network's icons (no Font Awesome glyph flashing first, no wait for a script or a font), and++ * ov-glyphs.js, when it arrives, sees each one is already drawn and leaves it alone. Markup built in the browser is++ * still swapped there.++ *++ * Left to the browser: brand logos (fa-brands), animated icons (fa-spin, fa-beat… and the busy dots), an icon with a++ * title or a transform, anything with content of its own, and a name the glyphs do not have.++ *++ * render(html) → html with the icons drawn++ * STYLE the few rules that size and stroke a drawn icon before ov-glyphs.js's own stylesheet arrives++ */++const glyphs = require('openvibe-shared/glyphs');++++// An empty <i class="…fa-…"></i>, with only attributes that are safe to keep as they are.++const ICON_RE = /<i class="([^"<>]*\bfa-[a-z0-9-]+[^"<>]*)"((?:\s+(?:aria-hidden="true"|id="[a-z0-9_-]+"|style="[^"<>]*"))*)\s*><\/i>/g;++// Font Awesome's style and size classes: not names.++const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|li|ul|border|inverse|pull-left|pull-right|rotate-90|rotate-180|rotate-270|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto)$/;++// Moving icons stay with the browser's swap, which knows how each one moves (and that nothing spins).++const MOVES = /^fa-(spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter)$/;++++function render(html) {++ if (!html || html.indexOf('fa-') === -1) return html;++ return html.replace(ICON_RE, (whole, cls, attrs) => {++ const classes = cls.trim().split(/\s+/);++ if (classes.includes('ovg') || classes.includes('fa-brands') || classes.includes('fab')) return whole;++ if (classes.some((c) => MOVES.test(c))) return whole;++ const names = classes.filter((c) => /^fa-/.test(c) && !MOD.test(c));++ if (names.length !== 1) return whole;++ const g = glyphs.fromFa(names[0]);++ if (!g || g === 'busy') return whole;++ const hidden = /aria-hidden=/.test(attrs) ? '' : ' aria-hidden="true"';++ // The line drawing, and the solid one for small sizes when the glyph has one (openvibe-shared 4.69.0).++ const solid = typeof glyphs.solid === 'function' && glyphs.solid(g);++ const inner = typeof glyphs.inner === 'function' ? glyphs.inner(g) : glyphs.path(g);++ return `<i class="${cls.trim()} ovg" data-ovg="${g}" data-ovg-fa="${names[0]}"${attrs}${hidden}><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${solid ? ' class="has-s"' : ''}>${inner}</svg></i>`;++ });++}++++// ov-glyphs.js's own first rules (it adds the rest, sizes and motion, when it runs).++const STYLE = '.ovg::before,.ovg::after{content:none!important;display:none!important}'++ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-style:normal!important}'++ // Optical sizes, as ov-glyphs.js 4.69.0: heavier lines when small, and the solid drawing at 18 px and under.++ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}'++ // The person's icon style (openvibe-shared 4.71.0, plan L56: Auto, Line or Solid, <html data-ov-icons>) wins over the++ // switch point, as in ov-glyphs.js.++ + 'html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'++ + '.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25), max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}'++ + '.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}'++ + '.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}'++ + '.ovg.fa-fw{width:1.25em;text-align:center}';++++module.exports = { render, STYLE };nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++// Icons drawn on the server (plan L49 step 2, L54 K4: Font Awesome out of Live). Live's own HTML arrives with the++// OpenVibe glyphs already drawn, exactly as ov-glyphs.js draws them, so the first paint has the network's icons; what++// the browser must handle (brands, moving icons, unknown names, icons with content) is left to it.++const assert = require('assert');++const glyphs = require('openvibe-shared/glyphs');++const { render, STYLE } = require('../server/web/glyphs-html');++++const one = (html) => render(html);++++// A known name becomes its glyph, line and (where it has one) solid, keeping its classes and safe attributes.++const live = one('<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>');++assert.ok(live.startsWith('<i class="fa-solid fa-tower-broadcast ovg" data-ovg="live" data-ovg-fa="fa-tower-broadcast" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"'), live);++assert.ok(live.includes(glyphs.path('live')), 'the line drawing');++if (glyphs.solid('live')) assert.ok(live.includes('class="has-s"') && live.includes('<g class="ovg-s">'), 'and the solid one');++assert.ok(live.endsWith('</svg></i>'));++++// An icon without aria-hidden gets it (it is decoration beside its label).++assert.match(one('<i class="fa-solid fa-house"></i>'), /data-ovg-fa="fa-house" aria-hidden="true">/);++++// Left to the browser: brands, moving icons, unknown names, two names, icons already drawn, icons with content.++for (const keep of [++ '<i class="fa-brands fa-discord"></i>',++ '<i class="fa-solid fa-spinner fa-spin"></i>',++ '<i class="fa-solid fa-not-a-glyph-name"></i>',++ '<i class="fa-solid fa-house fa-video"></i>',++ '<i class="fa-solid fa-house ovg" data-ovg="home"></i>',++ '<i class="fa-solid fa-house">x</i>',++ '<i class="fa-solid fa-house" title="Home"></i>',++]) assert.strictEqual(one(keep), keep, keep);++++// Idempotent: drawing twice changes nothing.++const page = '<p><i class="fa-solid fa-scissors"></i> Clips</p><p><i class="fa-solid fa-video"></i> VODs</p>';++assert.strictEqual(render(render(page)), render(page));++assert.strictEqual((render(page).match(/ ovg" data-ovg=/g) || []).length, 2);++++// Text with no icon is returned as it is.++assert.strictEqual(render('<p>no icons</p>'), '<p>no icons</p>');++++// The first-paint rules: the size, the optical switch, and the person's icon style (openvibe-shared 4.71.0).++assert.ok(STYLE.includes('.ovg::before,.ovg::after{content:none!important'), 'Font Awesome\'s glyph never shows');++assert.ok(STYLE.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the person\'s preference');++assert.ok(STYLE.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'wins over the switch point');++++// assets.rewriteHtml draws them and adds the rules once.++const assets = require('../server/web/assets');++if (typeof assets.rewriteHtml === 'function') {++ const out = assets.rewriteHtml('<html><head><title>t</title></head><body><i class="fa-solid fa-house"></i></body></html>');++ assert.ok(out.includes(' ovg" data-ovg="home"'), 'the page\'s icons are drawn');++ assert.strictEqual((out.match(/id="ov-glyphs-first"/g) || []).length, 1, 'the first-paint rules, once');++}++++console.log('glyphs-html: known names drawn on the server (line and solid), the rest left to the browser, idempotent, first-paint rules');
@@ -19,7 +19,7 @@ const ROOT = path.join(__dirname, '..', '..');
@@ -19,7 +19,7 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
// "css" the render-blocking stylesheets.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 120, // measured 94.9
+ htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1250, // 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsRawKB: 1250, // 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
@@ -35,6 +35,7 @@ const HASH_LEN = 12;
@@ -35,6 +35,7 @@ const HASH_LEN = 12;
const MANIFEST_DIRS = ['js', 'css', 'fragments'];
const MANIFEST_DIRS = ['js', 'css', 'fragments'];
// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.
// The first-paint subset of style.css (server/web/critical-css.js): generated, not a file in public/.
const critical = require('./critical-css');
const critical = require('./critical-css');
+const glyphHtml = require('./glyphs-html');
const CORE_CSS = '/css/style.core.css';
const CORE_CSS = '/css/style.core.css';
const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;
const REF_RE = /(["'])(\/(?:js|css|shared|fragments)\/[A-Za-z0-9_./-]+?\.(?:js|css|html))(?:\?v=[A-Za-z0-9_.-]*)?\1/g;
const MANIFEST_MARK = '<!--ov:asset-manifest-->';
const MANIFEST_MARK = '<!--ov:asset-manifest-->';
@@ -244,6 +245,9 @@ function rewriteHtml(html) {
@@ -244,6 +245,9 @@ function rewriteHtml(html) {
const h = hashOf(urlPath);
const h = hashOf(urlPath);
return h ? `${q}${urlPath}?v=${h}${q}` : m;
return h ? `${q}${urlPath}?v=${h}${q}` : m;
});
});
+ // The network's icons, drawn here rather than swapped in the browser (plan L49; server/web/glyphs-html.js).
+ out = glyphHtml.render(out);
+ if (out.includes('</head>') && out.includes(' ovg" data-ovg=') && !out.includes('id="ov-glyphs-first"')) out = out.replace('</head>', () => `<style id="ov-glyphs-first">${glyphHtml.STYLE}</style>\n</head>`);
if (out.includes(LOOK_BOOT_MARK)) out = out.replace(LOOK_BOOT_MARK, () => `<script>${LOOK_BOOT}</script>`);
if (out.includes(LOOK_BOOT_MARK)) out = out.replace(LOOK_BOOT_MARK, () => `<script>${LOOK_BOOT}</script>`);
if (out.includes(MANIFEST_MARK)) {
if (out.includes(MANIFEST_MARK)) {
const reg = features();
const reg = features();
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * Icons drawn on the server (plan L49 step 2; the owner, 2026-11-01: "why Font Awesome? We should be using OpenVibe
+ * Design's icon system for all our icons"). Every <i class="fa-solid fa-…"></i> in Live's own HTML (index.html and the
+ * fragments, through assets.rewriteHtml) arrives as the OpenVibe glyph itself, exactly as openvibe-shared's
+ * ov-glyphs.js would have drawn it in the browser: same classes (`ovg`), same data-ovg and data-ovg-fa, same SVG. So
+ * the first paint has the network's icons (no Font Awesome glyph flashing first, no wait for a script or a font), and
+ * ov-glyphs.js, when it arrives, sees each one is already drawn and leaves it alone. Markup built in the browser is
+ * still swapped there.
+ *
+ * Left to the browser: brand logos (fa-brands), animated icons (fa-spin, fa-beat… and the busy dots), an icon with a
+ * title or a transform, anything with content of its own, and a name the glyphs do not have.
+ *
+ * render(html) → html with the icons drawn
+ * STYLE the few rules that size and stroke a drawn icon before ov-glyphs.js's own stylesheet arrives
+ */
+const glyphs = require('openvibe-shared/glyphs');
+
+// An empty <i class="…fa-…"></i>, with only attributes that are safe to keep as they are.
+const ICON_RE = /<i class="([^"<>]*\bfa-[a-z0-9-]+[^"<>]*)"((?:\s+(?:aria-hidden="true"|id="[a-z0-9_-]+"|style="[^"<>]*"))*)\s*><\/i>/g;
+// Font Awesome's style and size classes: not names.
+const MOD = /^fa-(solid|regular|light|thin|duotone|sharp|sharp-duotone|classic|fw|lg|xl|xs|sm|2xs|2xl|[0-9]+x|li|ul|border|inverse|pull-left|pull-right|rotate-90|rotate-180|rotate-270|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto)$/;
+// Moving icons stay with the browser's swap, which knows how each one moves (and that nothing spins).
+const MOVES = /^fa-(spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter)$/;
+
+function render(html) {
+ if (!html || html.indexOf('fa-') === -1) return html;
+ return html.replace(ICON_RE, (whole, cls, attrs) => {
+ const classes = cls.trim().split(/\s+/);
+ if (classes.includes('ovg') || classes.includes('fa-brands') || classes.includes('fab')) return whole;
+ if (classes.some((c) => MOVES.test(c))) return whole;
+ const names = classes.filter((c) => /^fa-/.test(c) && !MOD.test(c));
+ if (names.length !== 1) return whole;
+ const g = glyphs.fromFa(names[0]);
+ if (!g || g === 'busy') return whole;
+ const hidden = /aria-hidden=/.test(attrs) ? '' : ' aria-hidden="true"';
+ // The line drawing, and the solid one for small sizes when the glyph has one (openvibe-shared 4.69.0).
+ const solid = typeof glyphs.solid === 'function' && glyphs.solid(g);
+ const inner = typeof glyphs.inner === 'function' ? glyphs.inner(g) : glyphs.path(g);
+ return `<i class="${cls.trim()} ovg" data-ovg="${g}" data-ovg-fa="${names[0]}"${attrs}${hidden}><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${solid ? ' class="has-s"' : ''}>${inner}</svg></i>`;
+ });
+}
+
+// ov-glyphs.js's own first rules (it adds the rest, sizes and motion, when it runs).
+const STYLE = '.ovg::before,.ovg::after{content:none!important;display:none!important}'
+ + '.ovg{display:inline-block;width:1.15em;height:1.15em;line-height:1;vertical-align:-.18em;font-style:normal!important}'
+ // Optical sizes, as ov-glyphs.js 4.69.0: heavier lines when small, and the solid drawing at 18 px and under.
+ + '.ovg>svg{display:block;width:100%;height:100%;fill:none;stroke:currentColor;stroke-width:calc(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25));stroke-linecap:round;stroke-linejoin:round;overflow:visible}'
+ // The person's icon style (openvibe-shared 4.71.0, plan L56: Auto, Line or Solid, <html data-ov-icons>) wins over the
+ // switch point, as in ov-glyphs.js.
+ + 'html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'
+ + '.ovg>svg.has-s{stroke-width:min(var(--ovg-stroke,2) * 1px + max(0px, (18.4px - 1.15em) * .25), max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}'
+ + '.ovg>svg>.ovg-s{fill:currentColor;stroke:none;clip-path:inset(max(0px, (1.15em - var(--ovg-pref, var(--ovg-solid-below, 18.4px))) * 999))}'
+ + '.ovg>svg>.ovg-s .ln{fill:none;stroke:currentColor;stroke-width:2.6px}'
+ + '.ovg.fa-fw{width:1.25em;text-align:center}';
+
+module.exports = { render, STYLE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+// Icons drawn on the server (plan L49 step 2, L54 K4: Font Awesome out of Live). Live's own HTML arrives with the
+// OpenVibe glyphs already drawn, exactly as ov-glyphs.js draws them, so the first paint has the network's icons; what
+// the browser must handle (brands, moving icons, unknown names, icons with content) is left to it.
+const assert = require('assert');
+const glyphs = require('openvibe-shared/glyphs');
+const { render, STYLE } = require('../server/web/glyphs-html');
+
+const one = (html) => render(html);
+
+// A known name becomes its glyph, line and (where it has one) solid, keeping its classes and safe attributes.
+const live = one('<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>');
+assert.ok(live.startsWith('<i class="fa-solid fa-tower-broadcast ovg" data-ovg="live" data-ovg-fa="fa-tower-broadcast" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"'), live);
+assert.ok(live.includes(glyphs.path('live')), 'the line drawing');
+if (glyphs.solid('live')) assert.ok(live.includes('class="has-s"') && live.includes('<g class="ovg-s">'), 'and the solid one');
+assert.ok(live.endsWith('</svg></i>'));
+
+// An icon without aria-hidden gets it (it is decoration beside its label).
+assert.match(one('<i class="fa-solid fa-house"></i>'), /data-ovg-fa="fa-house" aria-hidden="true">/);
+
+// Left to the browser: brands, moving icons, unknown names, two names, icons already drawn, icons with content.
+for (const keep of [
+ '<i class="fa-brands fa-discord"></i>',
+ '<i class="fa-solid fa-spinner fa-spin"></i>',
+ '<i class="fa-solid fa-not-a-glyph-name"></i>',
+ '<i class="fa-solid fa-house fa-video"></i>',
+ '<i class="fa-solid fa-house ovg" data-ovg="home"></i>',
+ '<i class="fa-solid fa-house">x</i>',
+ '<i class="fa-solid fa-house" title="Home"></i>',
+]) assert.strictEqual(one(keep), keep, keep);
+
+// Idempotent: drawing twice changes nothing.
+const page = '<p><i class="fa-solid fa-scissors"></i> Clips</p><p><i class="fa-solid fa-video"></i> VODs</p>';
+assert.strictEqual(render(render(page)), render(page));
+assert.strictEqual((render(page).match(/ ovg" data-ovg=/g) || []).length, 2);
+
+// Text with no icon is returned as it is.
+assert.strictEqual(render('<p>no icons</p>'), '<p>no icons</p>');
+
+// The first-paint rules: the size, the optical switch, and the person's icon style (openvibe-shared 4.71.0).
+assert.ok(STYLE.includes('.ovg::before,.ovg::after{content:none!important'), 'Font Awesome\'s glyph never shows');
+assert.ok(STYLE.includes('html[data-ov-icons=solid]{--ovg-pref:99999px}html[data-ov-icons=line]{--ovg-pref:0px}'), 'the person\'s preference');
+assert.ok(STYLE.includes('var(--ovg-pref, var(--ovg-solid-below, 18.4px))'), 'wins over the switch point');
+
+// assets.rewriteHtml draws them and adds the rules once.
+const assets = require('../server/web/assets');
+if (typeof assets.rewriteHtml === 'function') {
+ const out = assets.rewriteHtml('<html><head><title>t</title></head><body><i class="fa-solid fa-house"></i></body></html>');
+ assert.ok(out.includes(' ovg" data-ovg="home"'), 'the page\'s icons are drawn');
+ assert.strictEqual((out.match(/id="ov-glyphs-first"/g) || []).length, 1, 'the first-paint rules, once');
+}
+
+console.log('glyphs-html: known names drawn on the server (line and solid), the rest left to the browser, idempotent, first-paint rules');