openvibers/openvibe.space Commits
Font Awesome out, the OpenVibe glyphs in (plan L54 K4) (#157)
Changes
@@@ -42,7 +42,7 @@ function home({ projects, csrf, canCreate = true }) {</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });return `<header class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p><h1>Your projects</h1><p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></header>--${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder-plus')}<p>You have no projects yet.</p></div>`}++${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder')}<p>You have no projects yet.</p></div>`}${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;}@@@ -128,7 +128,7 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></header>${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources"><fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload"><input type="hidden" name="strip" value="folder"></fieldset>--<fieldset class="hd-source"><legend>${icon('fa-file-zipper')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)"></fieldset>++<fieldset class="hd-source"><legend>${icon('fa-box-archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)"></fieldset></div><label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label><fieldset class="hd-choices"><legend>When it is ready</legend>@@@ -140,9 +140,10 @@ function renderPage(o) {...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),`<link rel="stylesheet" href="${asset('css/space.css')}">`,++ // The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01).++ `<script id="ov-glyphs-loader" src="${ovServe.url('ov-glyphs.js')}" defer></script>`,// Page-only stylesheets of this site (the dashboard's hosting.css), after space.css; the home page loads none....(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),-- '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,// The rules that show the picked part of a pick block are written for the page, because there is one per part.@@@ -169,7 +169,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem};// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.const claim = guest ? (guest.signedIn-- ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> Keep this site</button></form>`++ ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-book" aria-hidden="true"></i> Keep this site</button></form>`: `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';const body = `<div class="sp-editor" id="sp-editor" data-view="preview"><header class="sp-ed-bar">@@@ -13,7 +13,7 @@ const TEMPLATES = [{ id: 'guestbook', name: 'Guestbook', icon: 'fa-book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },{ id: 'ai-buddy', name: 'AI buddy', icon: 'fa-robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },-- { id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },++ { id: 'poll', name: 'Quick poll', icon: 'fa-chart-column', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },{ id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++// No Font Awesome (plan L54 K4; the owner, 2026-11-01: "get rid of Font Awesome completely and just use our own++// openvibe.design system"). OpenVibe.Space's icons are the OpenVibe glyphs (openvibe-shared/glyphs, drawn in the browser by++// /shared/ov-glyphs.js, which the Shared navbar loads on a page that names an icon) and the brand logos from++// openvibe-shared/brands. This holds it there:++// - no page or stylesheet links Font Awesome, and nothing draws with Font Awesome's font;++// - every page that names an icon loads the navbar or the glyph drop-in;++// - every fa- name OpenVibe.Space uses is one the glyphs or the brands draw (a name nobody draws would show nothing).++// node test/no-font-awesome.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const glyphs = require('openvibe-shared/glyphs');++const brands = require('openvibe-shared/brands');++++const ROOT = path.join(__dirname, '..');++const walk = (dir, ok, out = []) => {++ if (!fs.existsSync(dir)) return out;++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ if (e.name === 'node_modules' || e.name === 'vendor' || e.name.startsWith('.')) continue;++ const p = path.join(dir, e.name);++ if (e.isDirectory()) walk(p, ok, out); else if (ok.test(e.name)) out.push(p);++ }++ return out;++};++const rel = (p) => path.relative(ROOT, p);++++// The pages (whole HTML files) and the code that draws them.++const PAGE_ROOTS = [];++const CODE_ROOTS = ["public","server"];++const LAYOUTS = ["server/render/layout.js"]; // the server-rendered pages: their layout brings the navbar or the glyphs++++// A Font Awesome stylesheet, wherever it is written.++const FA_CSS = /fontawesome[^"'\s]*\.css|font-awesome[^"'\s]*\.css|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i;++++const pages = [];++for (const r of PAGE_ROOTS) pages.push(...walk(path.join(ROOT, r), /\.html$/));++for (const p of pages) {++ const html = fs.readFileSync(p, 'utf8');++ assert.ok(!/fontawesome|font-awesome/i.test(html), `${rel(p)} links Font Awesome`);++ // A whole page (with a <head>; fragments are parts of a page) that names an icon loads what draws it.++ if (/<head[\s>]/i.test(html) && /\bfa-[a-z]/.test(html) && !/\/shared\/navbar|ov-glyphs\.js/.test(html)) {++ assert.fail(`${rel(p)} has fa- icons but loads neither the navbar nor /shared/ov-glyphs.js`);++ }++}++++// Nothing draws with Font Awesome's font.++for (const r of CODE_ROOTS) {++ for (const p of walk(path.join(ROOT, r), /\.css$/)) {++ assert.ok(!/font-family:\s*["']?Font ?Awesome/i.test(fs.readFileSync(p, 'utf8')), `${rel(p)} draws with Font Awesome's font`);++ }++ for (const p of walk(path.join(ROOT, r), /\.(html|js|mjs|cjs)$/)) {++ const t = fs.readFileSync(p, 'utf8');++ assert.ok(!/['"]\d+px FontAwesome|Font Awesome 6 Free/.test(t), `${rel(p)} draws with Font Awesome's font`);++ assert.ok(!FA_CSS.test(t), `${rel(p)} links Font Awesome`);++ }++}++++// The server-rendered pages get the glyphs from the Shared navbar (shell.page) or from the drop-in.++for (const f of LAYOUTS) {++ const t = fs.readFileSync(path.join(ROOT, f), 'utf8');++ assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);++}++++// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.++const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;++const files = [];++for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));++const missing = new Map();++for (const f of files) {++ for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {++ const n = m[1];++ if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;++ if (!missing.has(n)) missing.set(n, rel(f));++ }++}++assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');++++console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);
@@ -42,7 +42,7 @@ function home({ projects, csrf, canCreate = true }) {
@@ -42,7 +42,7 @@ function home({ projects, csrf, canCreate = true }) {
</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });
</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });
return `<header class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p><h1>Your projects</h1>
return `<header class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p><h1>Your projects</h1>
<p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></header>
<p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></header>
-${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder-plus')}<p>You have no projects yet.</p></div>`}
+${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder')}<p>You have no projects yet.</p></div>`}
${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;
${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;
}
}
@@ -128,7 +128,7 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo
@@ -128,7 +128,7 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo
<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></header>
<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></header>
${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources">
${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources">
<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload"><input type="hidden" name="strip" value="folder"></fieldset>
<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload"><input type="hidden" name="strip" value="folder"></fieldset>
-<fieldset class="hd-source"><legend>${icon('fa-file-zipper')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)"></fieldset>
+<fieldset class="hd-source"><legend>${icon('fa-box-archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)"></fieldset>
</div>
</div>
<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
<fieldset class="hd-choices"><legend>When it is ready</legend>
<fieldset class="hd-choices"><legend>When it is ready</legend>
@@ -140,9 +140,10 @@ function renderPage(o) {
@@ -140,9 +140,10 @@ function renderPage(o) {
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...feeds.map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
`<link rel="stylesheet" href="${asset('css/space.css')}">`,
+ // The OpenVibe glyphs draw every fa- icon here (no Font Awesome since 2026-11-01).
+ `<script id="ov-glyphs-loader" src="${ovServe.url('ov-glyphs.js')}" defer></script>`,
// Page-only stylesheets of this site (the dashboard's hosting.css), after space.css; the home page loads none.
// Page-only stylesheets of this site (the dashboard's hosting.css), after space.css; the home page loads none.
...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
...(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
- '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">',
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<meta name="ov-boost" content="space@${escapeHtml(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
// The rules that show the picked part of a pick block are written for the page, because there is one per part.
// The rules that show the picked part of a pick block are written for the page, because there is one per part.
@@ -169,7 +169,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
@@ -169,7 +169,7 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
};
};
// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
const claim = guest ? (guest.signedIn
const claim = guest ? (guest.signedIn
- ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-bookmark" aria-hidden="true"></i> Keep this site</button></form>`
+ ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-book" aria-hidden="true"></i> Keep this site</button></form>`
: `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';
: `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';
const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
<header class="sp-ed-bar">
<header class="sp-ed-bar">
@@ -13,7 +13,7 @@ const TEMPLATES = [
@@ -13,7 +13,7 @@ const TEMPLATES = [
{ id: 'guestbook', name: 'Guestbook', icon: 'fa-book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },
{ id: 'guestbook', name: 'Guestbook', icon: 'fa-book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },
{ id: 'ai-buddy', name: 'AI buddy', icon: 'fa-robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],
{ id: 'ai-buddy', name: 'AI buddy', icon: 'fa-robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],
instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
- { id: 'poll', name: 'Quick poll', icon: 'fa-square-poll-vertical', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
+ { id: 'poll', name: 'Quick poll', icon: 'fa-chart-column', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
{ id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
{ id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },
{ id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },
{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
{ id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+// No Font Awesome (plan L54 K4; the owner, 2026-11-01: "get rid of Font Awesome completely and just use our own
+// openvibe.design system"). OpenVibe.Space's icons are the OpenVibe glyphs (openvibe-shared/glyphs, drawn in the browser by
+// /shared/ov-glyphs.js, which the Shared navbar loads on a page that names an icon) and the brand logos from
+// openvibe-shared/brands. This holds it there:
+// - no page or stylesheet links Font Awesome, and nothing draws with Font Awesome's font;
+// - every page that names an icon loads the navbar or the glyph drop-in;
+// - every fa- name OpenVibe.Space uses is one the glyphs or the brands draw (a name nobody draws would show nothing).
+// node test/no-font-awesome.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const glyphs = require('openvibe-shared/glyphs');
+const brands = require('openvibe-shared/brands');
+
+const ROOT = path.join(__dirname, '..');
+const walk = (dir, ok, out = []) => {
+ if (!fs.existsSync(dir)) return out;
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name === 'vendor' || e.name.startsWith('.')) continue;
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) walk(p, ok, out); else if (ok.test(e.name)) out.push(p);
+ }
+ return out;
+};
+const rel = (p) => path.relative(ROOT, p);
+
+// The pages (whole HTML files) and the code that draws them.
+const PAGE_ROOTS = [];
+const CODE_ROOTS = ["public","server"];
+const LAYOUTS = ["server/render/layout.js"]; // the server-rendered pages: their layout brings the navbar or the glyphs
+
+// A Font Awesome stylesheet, wherever it is written.
+const FA_CSS = /fontawesome[^"'\s]*\.css|font-awesome[^"'\s]*\.css|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i;
+
+const pages = [];
+for (const r of PAGE_ROOTS) pages.push(...walk(path.join(ROOT, r), /\.html$/));
+for (const p of pages) {
+ const html = fs.readFileSync(p, 'utf8');
+ assert.ok(!/fontawesome|font-awesome/i.test(html), `${rel(p)} links Font Awesome`);
+ // A whole page (with a <head>; fragments are parts of a page) that names an icon loads what draws it.
+ if (/<head[\s>]/i.test(html) && /\bfa-[a-z]/.test(html) && !/\/shared\/navbar|ov-glyphs\.js/.test(html)) {
+ assert.fail(`${rel(p)} has fa- icons but loads neither the navbar nor /shared/ov-glyphs.js`);
+ }
+}
+
+// Nothing draws with Font Awesome's font.
+for (const r of CODE_ROOTS) {
+ for (const p of walk(path.join(ROOT, r), /\.css$/)) {
+ assert.ok(!/font-family:\s*["']?Font ?Awesome/i.test(fs.readFileSync(p, 'utf8')), `${rel(p)} draws with Font Awesome's font`);
+ }
+ for (const p of walk(path.join(ROOT, r), /\.(html|js|mjs|cjs)$/)) {
+ const t = fs.readFileSync(p, 'utf8');
+ assert.ok(!/['"]\d+px FontAwesome|Font Awesome 6 Free/.test(t), `${rel(p)} draws with Font Awesome's font`);
+ assert.ok(!FA_CSS.test(t), `${rel(p)} links Font Awesome`);
+ }
+}
+
+// The server-rendered pages get the glyphs from the Shared navbar (shell.page) or from the drop-in.
+for (const f of LAYOUTS) {
+ const t = fs.readFileSync(path.join(ROOT, f), 'utf8');
+ assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
+}
+
+// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.
+const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;
+const files = [];
+for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
+const missing = new Map();
+for (const f of files) {
+ for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {
+ const n = m[1];
+ if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;
+ if (!missing.has(n)) missing.set(n, rel(f));
+ }
+}
+assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
+
+console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);