openvibers/openvibe.live Commits

Live's icons arrive drawn: the OpenVibe glyphs in the HTML, not Font Awesome swapped later (#497)

OP OpenVibers committed 12 days ago · f523ae14f6 · 4 files, +115 −1

Plan L54 K4, the first site off Font Awesome (the owner, 2026-11-01: 'why Font Awesome? We should be using OpenVibe
Design's icon system… get rid of Font Awesome completely'). Every <i class="fa-…"> in Live's own HTML (index.html and
the fragments, through assets.rewriteHtml) is drawn on the server as the glyph ov-glyphs.js would draw: same classes,
data-ovg and SVG (line, and solid where the glyph has one), so the first paint has the network's icons and nothing
flashes. Brands, moving icons, unknown names and icons with content stay with the browser. A small first-paint style
sizes them and honours the person's icon style (openvibe-shared 4.71.0).

Changes

ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex ca0665bb..2da7fa4c 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -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 before
htmlBrotliKB: 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)
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 0b7a08c1..4b4b6d58 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -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();
ddiff --git a/server/web/glyphs-html.js b/server/web/glyphs-html.js
nnew file mode 100644
iindex 00000000..9603e27f
---- /dev/null
++++ b/server/web/glyphs-html.js
@@@ -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 };
ddiff --git a/test/glyphs-html.test.js b/test/glyphs-html.test.js
nnew file mode 100644
iindex 00000000..38521b19
---- /dev/null
++++ b/test/glyphs-html.test.js
@@@ -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');