Skip to main content

Maintaining Stratos Icon Fonts

Stratos renders icons through two self-hosted ligature fonts. An icon element contains the icon's name as text (<mat-icon>delete</mat-icon>); the font's GSUB ligature table substitutes that letter sequence with the glyph. When a name has no ligature in the bundled font, the browser renders the literal text (rocket_launch) instead of an icon — the failure is silent, per-icon, and easy to miss in review.

The two fonts

FontFamilyFilesSource of truth
Material Icons'Material Icons'src/frontend/packages/core/assets/fonts/iconfont/google/material-design-icons / Google Fonts
Stratos icons'stratos-icons'src/frontend/packages/core/assets/fonts/stratos/EOS icons (SUSE) + custom additions; generated with grunt-webfont

@font-face declarations live in src/frontend/packages/core/sass/icons.scss (declarations only — the .material-icons/.stratos-icons class rules live in theme/styles/main.scss inside @layer base; keep it that way, see #5539).

Hosting is a choice with trade-offs

Stratos self-hosts both fonts. That is not the only defensible choice — it is the one whose costs fit this project, and it comes with a maintenance duty:

Hotlink Google FontsSelf-host (current choice)
FreshnessAutomatic — always the current releaseManual — someone must refresh the files
CascadeThe linked stylesheet ships unlayered rules that beat @layer utilities (one of the four sources of #5539), cross-origin and invisible to CSSOM inspectionAll icon CSS is ours, layered where we put it
Air-gapped / private-cloud deploysNo icons at allWorks
Privacy / CSPEvery page load reaches Google (user IPs); two domains whitelisted foreverNo external requests
Version controlGoogle can reshape glyphs under us on any releasePinned until we choose to update

The duty that comes with self-hosting: the bundled Material font sat unchanged from ~2019 to 2026 and fell behind the icon names the code adopted in the meantime — three rendered as text until the refresh in #5545. If the hosting choice is ever revisited, weigh the whole column, not one row.

Rules when adding an icon

  1. Check the name exists in the bundled font first (see audit below, or grep src/frontend/packages/core/assets/fonts/iconfont/codepoints for Material names). The Material Icons index shows the current release — the bundled font may be older.
  2. Kubernetes entities default to iconFont: 'stratos-icons' (kubernetes-entity-generator.ts) — a Material name there won't resolve, and vice versa. Ligature names are exact: config_map renders, config_maps does not.
  3. If the name is missing from the bundled Material font, refresh the font (below) rather than picking a worse older icon.

Refreshing the Material Icons font

All files in src/frontend/packages/core/assets/fonts/iconfont/ come from Google; refresh them together so the binaries never disagree:

cd src/frontend/packages/core/assets/fonts/iconfont

# woff2 — the file Google Fonts currently serves (URL is versioned; extract it
# from the CSS response, which varies by requesting browser)
curl -s -A "Mozilla/5.0 ... Chrome/126.0" \
"https://fonts.googleapis.com/icon?family=Material+Icons" \
| grep -oE "https://fonts.gstatic.com/[^)]+\.woff2"
curl -o MaterialIcons-Regular.woff2 "<that url>"

# ttf + codepoints — canonical repo
curl -sLo MaterialIcons-Regular.ttf \
https://raw.githubusercontent.com/google/material-design-icons/master/font/MaterialIcons-Regular.ttf
curl -sLo codepoints \
https://raw.githubusercontent.com/google/material-design-icons/master/font/MaterialIcons-Regular.codepoints

Generate the woff from the new ttf and regenerate the ijmap (name-per-codepoint metadata) from codepoints — both are pure derivations; fonttools does the conversion:

from fontTools.ttLib import TTFont
f = TTFont('MaterialIcons-Regular.ttf'); f.flavor = 'woff'
f.save('MaterialIcons-Regular.woff')

Do not reintroduce local('Material Icons') sources in icons.scss: a stale font installed on a user's machine would shadow the bundled one and silently reintroduce missing glyphs on that machine only.

Auditing icon coverage

The check that caught #5545 — diff the icon names referenced in source against the ligature tables actually present in the bundled fonts:

  1. Collect used names — grep src/frontend for <mat-icon>…</mat-icon> text, fontIcon="…" attributes, and icon: '…' properties in TS.
  2. Extract ligatures from each font — with fonttools, walk font['GSUB'].table.LookupList.Lookup[*].SubTable[*].ligatures and join the component glyph names into the ligature string (glyph names are AGL names: underscore_, three3fontTools.agl.AGL2UV maps them).
  3. Diff: every used name must appear in the Material set or the stratos-icons set. A name in neither renders as literal text.

Quick single-name browser check: render the name at font-size: 24px with the font and font-feature-settings: 'liga'; a resolved ligature is one ~24px-wide glyph, a missing one is a wide run of text.

Maintaining coverage through CI

The audit above is mechanical, needs no network (both fonts are in the repo), and fails loud where a human review fails silent — which makes it a good CI lint. The shape:

# .github/workflows/... — icon-coverage job
icon-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx run --spec "fonttools[woff]" python scripts/check-icon-coverage.py

scripts/check-icon-coverage.py does the three audit steps in one pass:

# 1. used names: scan src/frontend for <mat-icon>NAME</mat-icon>,
# fontIcon="NAME", and icon: 'NAME' (same regexes as the audit section)
# 2. available names: GSUB ligature strings from both bundled fonts
# (MaterialIcons-Regular.woff2 + stratos-icons.woff2), AGL-mapping
# component glyph names via fontTools.agl.AGL2UV
# 3. exit 1 listing any used name present in neither font
sys.exit(f"icons missing from bundled fonts: {missing}" if missing else 0)

Operational notes:

  • Run it on every PR rather than path-filtering: a new icon name arrives through .html/.ts edits anywhere in src/frontend, so a narrow path filter would miss the common case. The check runs in seconds.
  • If it is ever made a required check with a path filter anyway, filter at the job level (if: on a changes job output), not the workflow level — a workflow-level path skip never reports its check and blocks the merge queue on "Expected".
  • Dynamic icon names (built from data at runtime) are invisible to the static scan — keep entity/action icon names as literals in source, which is also what makes them greppable.
  • The scan intentionally over-collects (any icon: '…' string). If it flags a non-icon string, tighten the regex or add it to a short allowlist in the script — do not weaken the failure into a warning; a warning here is a missing glyph in production later.

Sizing (don't regress #5539)

The icon-font 24px default is defined once, in theme/styles/main.scss, inside @layer base — so Tailwind text-size utilities can override it. Never add .material-icons { font-size: … } rules in component styles, icons.scss, or (especially) unlayered global CSS; that recreates the dead-utility bug in #5539, which existed in four places before it was consolidated.