From 42dd9f4ca72f3097b530d7da6f48d30711aff28a Mon Sep 17 00:00:00 2001 From: Memel <255658710+mml-studio@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:37:53 +0200 Subject: [PATCH] perf: subset the icon font, and drop the icon family nothing uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading the app pulls 442,061 bytes from Google Fonts over six requests. 330,256 of them are the complete Material Symbols Outlined variable font, served so the interface can draw 28 glyphs. Measured in headless Chromium against `npm run dev`, cold profile, 2026-09-09. Google will serve a subset instead: `icon_names=` returns the same 28 glyphs in 4,044 bytes. The app now pulls 115,319 bytes over five requests — 326,742 fewer, and a third of a megabyte that no longer sits in front of the first paint. The sixth request was `Material+Icons+Round`, a second icon family no source uses; `src/cockpitMarkup.test.mjs` already asserts the markup must not use it. Its 173 kB font never downloads today, because nothing activates the family, but the stylesheet is fetched and render-blocking on every boot. Subsetting is the one change here that can break the interface SILENTLY. A glyph the subset does not carry gets no missing-glyph box: the ligature never forms and the element renders the literal word. `right_panel_open` is exactly the case a hand-kept list misses: src/ui.js:1992 finds the element by its class and picks the glyph in a ternary, so the name is nowhere in the markup. So the list is not kept by hand. `src/materialSymbolsSubset.test.mjs` extracts the glyph names from the sources and fails when one is absent from `icon_names`, naming the file that asks for it. It errs wide on purpose: Google answers 200 and ignores a name it does not know (verified against the live endpoint), so a false positive costs nothing while a false negative costs the cockpit. A literal to the left of a `?` is a condition, not a rendered string, and is dropped. Verified: `npm run build`; `npm test` (2,708 pass, 0 fail — the two allocation microbenchmarks skip on Node 26.0.0, as the runner intends). In the live page each of the 28 glyphs measures about one em wide via canvas measureText, against 369 px for a name deliberately left out of the subset. `npm run test:track` does not finish on this machine, on main as much as on this branch: three runs, zero FAIL lines, stopping on a Puppeteer timeout at track-regression.mjs:647 and once on a ProtocolError. Nothing in it touches fonts, and the branch never behaved differently from its base. --- CHANGELOG.md | 10 ++++ index.html | 7 ++- src/materialSymbolsSubset.test.mjs | 88 ++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/materialSymbolsSubset.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..17a3f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Changed + +- The Material Symbols icon font is now requested as a subset of the glyphs the + app actually renders, and the unused Material Icons Round stylesheet is gone. + Loading the app pulled 442 kB from Google Fonts over six requests, 330 kB of + it the complete icon font served for the 28 glyphs on screen; it now pulls + 115 kB over five. `src/materialSymbolsSubset.test.mjs` fails when a source + names a glyph the subset is missing, because an absent glyph renders as its + own name rather than as a missing-glyph box. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/index.html b/index.html index e8b0b8d..50a7951 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,11 @@ - - + +
diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs new file mode 100644 index 0000000..3349e74 --- /dev/null +++ b/src/materialSymbolsSubset.test.mjs @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const REPO_ROOT = path.resolve(SRC_ROOT, '..'); +const INDEX_HTML = path.join(REPO_ROOT, 'index.html'); + +/** The glyph written as element text: `radar`. */ +const SPAN_TEXT = /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*} glyph -> the first file that names it. + */ +function referencedGlyphs() { + const found = new Map(); + for (const file of sourceFiles()) { + const source = readFileSync(file, 'utf8'); + const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/'); + const add = (glyph) => { if (!found.has(glyph)) found.set(glyph, relative); }; + for (const match of source.matchAll(SPAN_TEXT)) add(match[1]); + for (const match of source.matchAll(TEXT_ASSIGNMENT)) { + const statement = match[1].replaceAll('?.', '.'); + const assigned = statement.includes('?') ? statement.slice(statement.indexOf('?') + 1) : statement; + for (const literal of assigned.matchAll(STRING_LITERAL)) add(literal[1]); + } + } + return found; +} + +/** The `icon_names` list index.html asks Google for. */ +function subsettedGlyphs(html = readFileSync(INDEX_HTML, 'utf8')) { + const match = /Material\+Symbols\+Outlined[^"]*[?&]icon_names=([a-z0-9_,]+)/.exec(html); + assert.ok(match, 'index.html must request Material Symbols with an icon_names subset'); + return new Set(match[1].split(',')); +} + +test('every glyph the sources render is in the icon_names subset', () => { + const subset = subsettedGlyphs(); + const missing = [...referencedGlyphs()] + .filter(([glyph]) => !subset.has(glyph)) + .map(([glyph, file]) => `${glyph} (${file})`); + + assert.deepEqual( + missing, + [], + 'Glyphs named by the sources but absent from the index.html icon_names list. ' + + 'Add them there — an unlisted glyph renders as its own name on screen: ' + + missing.join(', '), + ); +}); + +test('the unused Material Icons Round family is not loaded', () => { + // A second icon font, 173 kB, for a family no source ever uses — and + // src/cockpitMarkup.test.mjs already asserts the markup must not use it. + const html = readFileSync(INDEX_HTML, 'utf8'); + assert.doesNotMatch(html, /Material\+Icons\+Round/, 'index.html loads an icon font nothing renders'); +});