The problem, in detail
Why icon-font codepoints change, and how to stop it
A generated icon font is an application-facing API. The codepoint behindAppIcons.back is as much a contract as a method signature, and it breaks the same way: quietly, at a distance, in code nobody touched.
The mechanism
Icon fonts place glyphs in a Unicode private use area, most often starting at U+E000. Somebody has to decide which icon gets which value, and most generators decide it the simplest way available: walk the current list of source files, hand out consecutive codepoints, stop.
That makes the assignment a function of the file list rather than a stored decision. Nothing anywhere records thatback.svg was U+E000 last Tuesday. Re-derive the mapping from a different file list and you get a different mapping.
Start with two icons:
arrows/back.svg -> 0xE000 status/verified.svg -> 0xE001
Now add one icon, actions/add.svg. It sorts before everything else, so under a list-derived scheme it takes the first codepoint and pushes both existing icons along:
actions/add.svg -> 0xE000 <- was back.svg arrows/back.svg -> 0xE001 <- was verified.svg status/verified.svg -> 0xE002 <- moved
The new font is perfectly valid and internally consistent. Itscmap agrees with its glyph table, it passes validation, and it renders. The damage is entirely outside the font, in the code that still says 0xE000 and now gets a plus sign where a back arrow used to be.
This is why the failure is expensive. There is no crash, no exception, and no failing test. A reviewer reading the diff sees a regenerated binary font and a changed constant, both of which look like the expected result of adding an icon. The wrong glyph ships, and it gets found in a screenshot.
The same shift happens when a file is renamed, when a directory is reorganised, when an icon is deleted from the middle of a set, or when a designer reorders a selection in a GUI. None of those are unusual events. All of them silently rewrite the mapping.
The fix is to store the decision
If instability comes from re-deriving the mapping, stability comes from recording it. GlyphPact writes every assignment toiconfont.lock.json in the generated output directory and reads that file back on every subsequent build. An assignment already present is reused exactly; only sources with no recorded assignment draw a new codepoint.
The lock is a plain, documented JSON file. It is meant to be committed, read, and reviewed in a pull request.
{
"schemaVersion": 1,
"generator": "glyphpact",
"generatorVersion": "1.0.0",
"fontFamily": "AppIcons",
"className": "AppIcons",
"fontPackage": null,
"startCodepoint": "0xE000",
"unitsPerEm": 1000,
"glyphs": [
{
"source": "arrows/back.svg",
"name": "back",
"codepoint": "0xE000",
"sourceSha256": "38121f4ed8a1e2ab...",
"matchTextDirection": true,
"geometrySha256": "a86146b122485cd9...",
"metadata": {
"author": "GlyphPact contributors",
"license": "MIT"
}
}
],
"retired": []
}Each entry carries more than a number. sourceSha256 andgeometrySha256 let a later build tell the difference between a file that was renamed and a file whose artwork actually changed, which is what makes a content-preserving rename keep its codepoint instead of being treated as a deletion plus an addition.
The lock file JSON schema is published alongside the compiler, so other tooling can read it without guessing.
The same change, with a lock
What actually happens
This is a real build. A two-icon pack gained four icons, one of which sorts before everything that was already there.
Before: two icons
U+E000arrows/back.svgU+E001status/verified.svg
After: four added, in discovery order
U+E002actions/add.svgU+E004arrows/arrow_down.svgU+E000arrows/back.svgunchangedU+E001status/verified.svgunchangedU+E005status/warning.svg
actions/add.svg is now the first source alphabetically, and it holds U+E002. arrows/back.svg is now third, and it still holds the U+E000 it was given when it was first. Position in the pack stopped mattering the moment the assignment was written down.
"unitsPerEm": 1000, "glyphs": [ {+ "source": "actions/add.svg",+ "name": "actionsAdd",+ "codepoint": "0xE002",+ "sourceSha256": "a169819fd451...",+ "matchTextDirection": false,+ "geometrySha256": "2025c5f8dff2..."+ },+ {+ "source": "arrows/arrow_down.svg",+ "name": "arrowsArrowDown",+ "codepoint": "0xE004",+ "sourceSha256": "b6e87581e2f2...",+ "matchTextDirection": false,+ "geometrySha256": "48a927141186..."+ },+ { "source": "arrows/back.svg", "name": "back", "codepoint": "0xE000", "sourceSha256": "38121f4ed8a1...", "matchTextDirection": true, }, { "source": "status/verified.svg", "name": "verified", "codepoint": "0xE001", },+ {+ "source": "status/warning.svg",+ "name": "statusWarning",+ "codepoint": "0xE005",+ } ], "retired": []Deleting an icon does not free its codepoint
Reuse is the second way this breaks, and it is worse than shifting, because a recycled codepoint is one that already shipped. If0xE003 pointed at an archive box in a released app, handing it to a different icon means old builds, cached fonts, and any code still referencing the constant now render the new picture.
So GlyphPact tombstones instead. A removed source moves to theretired array with its codepoint intact, and the value is never allocated again. The active sequence is left with a permanent gap, which is the correct outcome.
"glyphs": [
{ "source": "actions/add.svg", "codepoint": "0xE002" },
{ "source": "arrows/arrow_down.svg", "codepoint": "0xE004" },
{ "source": "arrows/back.svg", "codepoint": "0xE000" },
{ "source": "status/verified.svg", "codepoint": "0xE001" },
{ "source": "status/warning.svg", "codepoint": "0xE005" }
],
"retired": [
{ "source": "actions/archive.svg", "codepoint": "0xE003" }
]Restoring the file later reactivates the same slot rather than allocating a fresh one, so a revert is a real revert.
Tombstones consume allocation slots. Both active icons and retired ones draw from the range, because that is what permanence costs. The default BMP private use area,U+E000 - U+F8FF, holds 6,400 lifetime slots. For a bigger catalogue, start in a supplementary area with--start-codepoint 0xF0000, which provides 65,534 slots.
A lock only helps if something enforces it
A stored mapping still fails if the committed font stops matching the committed sources. Someone edits an SVG without regenerating, or regenerates without committing, and the repository now holds a font built from an older pack. Nothing in a normal test suite notices.
--check closes that gap. It rebuilds a candidate artifact set from current sources and compares it against what is committed, without writing to the owned output directory. Exit code3 means stale.
- name: Verify generated icon output is current run: glyphpact --config icon_font.json --check
With that step in place, the three failure modes are all covered by something mechanical: the lock prevents reassignment, tombstones prevent reuse, and the check prevents drift. None of them depend on anybody remembering a procedure.
Try it
Start with your own pack
uv tool install glyphpactInstalls the latest stable release from PyPI, currently v1.0.1. Already installed? Run uv tool upgrade glyphpact. Release notes.
Compiling is read-only with respect to your sources, and the strict default policy will tell you about anything in the pack an icon font cannot hold before it writes a font.