@quadrel-enterprise-ui/icons
v1.12.1
Published
Quadrel icon font — WOFF2, SCSS @font-face and icon definitions, generated from SVG sources.
Readme
@quadrel-enterprise-ui/icons
Icon font of the Quadrel design system by the Swiss Federal Office for Customs and Border Security (BAZG). The source are SVG files; the font is generated here and published to npm. The quadrel-framework consumes only the published artefacts. No icomoon needed.
Usage
npm install @quadrel-enterprise-ui/icons@use '@quadrel-enterprise-ui/icons/icon-font'; // @font-face Quadrel-Icon, font embedded as base64import { quadrelIcons, quadrelIconNames, type QuadrelIconName } from '@quadrel-enterprise-ui/icons';
quadrelIcons; // [{ name: ['trash', 'trashLight'], htmlEntity: '' }, …]
quadrelIconNames; // ['trash', 'trashLight', …] — every primary name and alias
let icon: QuadrelIconName = 'trash'; // the union of every primary name and aliasQuadrelIconName lets you type an icon input against the font instead of against string. To keep accepting foreign names (your own glyphs, say), use QuadrelIconName | (string & {}).
Each entry maps one codepoint to its primary name and aliases. Render an icon by setting font-family: Quadrel-Icon and inserting the codepoint, e.g. content: "\e005".
The package contains icon-font.scss (package root, so a plain Sass load path on node_modules finds it) and the files from dist/: Quadrel.woff2 (also exported as @quadrel-enterprise-ui/icons/Quadrel.woff2 for consumers that serve the font themselves), the definitions as .mjs + .d.ts, and — for consumers that copy the file into their sources — icon-definitions.ts. The full list of icons with glyph previews is in ICONS.md. Versions follow Versioning: ^1.10.0 brings new icons automatically, never a removed one.
Prerequisites
Node 22 and npm — nothing else. All font tooling comes as npm packages; neither icomoon nor any local font software is needed.
npm ci --ignore-scripts--ignore-scripts is not a detail: without it, ttf2woff2 builds its native addon via node-gyp — over a minute, and it needs a compiler and Python. The build only ever uses the bundled wasm binary anyway, which is what keeps it reproducible (see Build).
Preparing SVGs takes a vector editor, in practice Illustrator or Figma — see Requirements for delivered SVGs.
After every npm install that changes package-lock.json: npm run lock:public. A workstation with ~/.npmrc → repo.bit.admin.ch writes Nexus URLs into the lockfile, and the release workflow outside the BAZG network cannot install from those. CI rejects such a lockfile.
Adding an icon
cp newIcon.svg import/goodsReturn.svg # file name = icon name, camelCase
npm run add
git add -A && git commitadd validates, normalizes, assigns the next codepoint, builds the font and empties import/. Several files at once are fine — they are processed alphabetically so the codepoint assignment is reproducible.
Aliases are added by hand in icons.json afterwards, followed by a rebuild.
Replacing an icon
cp newVersion.svg update/trash.svg # file name = existing icon name
npm run update
git add -A && git commitupdate mirrors add: the name must already exist. Codepoint and aliases stay untouched, icons.json is not modified — that is the only way consumers already shipped keep the same glyph at the same position.
The separate folder is deliberate. Overwriting an existing icon is a conscious act and must not happen because someone accidentally drops a file under a taken name into import/. Both commands point to the other one when a file lands in the wrong folder.
Check the glyphs visually after an update before committing — the build validates structure, not looks.
Without a local setup: upload the file
On GitHub open the folder import/ (for a replacement: update/), Add file → Upload files, drop the SVG in — file name = icon name, camelCase. When committing choose "Create a new branch for this commit and start a pull request"; committing straight to main is not possible anyway.
In the pull request CI runs npm run add or npm run update as a dry run. Green: the file is fine, codepoint and new version are in the run summary, the normalized drawing is attached as the artifact glyphen. Red: the reason is in the summary, remedies under Requirements for delivered SVGs — replace the file under the same name in the PR.
After the merge the workflow Icons verarbeiten processes the file on main: the same command, this time with a commit — icons/, icons.json, ICONS.md, version. The PR itself only carries the raw SVG. That is why two open upload PRs can never collide: codepoints are assigned on main only, and there one after the other.
If processing on main fails (red run in the Actions tab, reason in the summary), the file stays in import/. Put the corrected file under the same name in a new PR — the merge triggers processing again.
Discarding a run
As long as nothing is committed, an add, update or delete can be taken back completely:
git checkout -- icons icons.json ICONS.md originals package.json package-lock.json
git clean -fdq icons originals import update
npm run buildcheckout restores modified files (including the bumped version), clean removes the newly created ones in icons/ and originals/. The final build restores dist/ and ICONS.md.
The paths are listed one by one on purpose: git checkout -- . would also discard unrelated changes in the working tree.
Contents
| | |
|---|---|
| import/ | Inbox for new SVGs. Normally empty. |
| update/ | Inbox for replacements of existing icons. Normally empty. |
| originals/ | The raw file as delivered — before normalization. For further editing. |
| icons/ | Normalized source: one <path>, viewBox="0 0 1024 1024". |
| icons.json | Source of truth: names, aliases, codepoints. |
| ICONS.md | Generated table: glyph, codepoint, name, aliases. |
| dist/, icon-font.scss | Contents of the npm package: Quadrel.woff2, icon-definitions.ts / .mjs / .d.ts, and icon-font.scss at the package root. Not committed (.gitignore) — npm run build creates it, publish.yml publishes it. |
| scripts/ | build.mjs, add.mjs, update.mjs, delete.mjs, normalize.mjs, ingest.mjs, release-check.mjs. |
ICONS.md, dist/ and icon-font.scss are generated — do not edit by hand. ICONS.md is committed (the table renders on GitHub) and CI checks it for drift; dist/ is not.
The set only changes on purpose
No script touches an existing icon unless explicitly asked to via update/. In particular there is no bulk run over icons/ — not even when the normalizer improves later.
Reason: the glyphs are in production through the framework. An icon that looks right today must not change just because a library rounds differently.
That is why the name check is strict in both directions: add aborts when the name is taken, update aborts when it is not — each against primary names, against aliases, and regardless of case.
Deleting an icon
npm run delete -- goodsReturn
git add -A && git commitRemoves the entry, icons/ and originals/, rebuilds. Several names at once are fine.
The codepoint is never reused — nextCodepoint stays where it is, the counter only grows. If a freed codepoint were handed out again, every consumer that has not caught up yet would silently show a different icon.
Aliases cannot be deleted this way: they are not icons of their own, delete points to the primary name. An alias is removed by hand in icons.json.
Whether a name is still used in the framework is something this repo cannot check — delete shows beforehand what disappears.
Versioning
The version in package.json describes the icon set from a consumer's point of view. The scripts set it themselves — nobody has to remember:
| Command | Bump | Reason |
|---|---|---|
| npm run add | minor | new name, new codepoint |
| npm run update | patch | same names and codepoints, different drawing |
| npm run delete | major | a name disappears — consumers using it break |
The bump happens before the build so the stamp in dist/ carries the new number. A run with several files bumps once. npm run build on its own never changes the version, and a failed build rolls it back. Every merge with a new number is a release (see Release); earlier states are git tags v<version> and versions on npm, nothing is archived in the repo.
Release
When a version lands on main that npmjs.org does not have yet, the workflow Release (publish.yml) publishes the contents of dist/ as @quadrel-enterprise-ui/icons, sets the git tag v<version> and creates a GitHub release. A merge with a version bump is a release — there is no separate step. Re-running is safe: what is already published is skipped.
What a version changes in the set is determined by scripts/release-check.mjs against the latest release tag v<version> — publish.yml sets it only after a successful publish, so it is the same state consumers get from npm, just without the network. Because dist/ is not committed, the script rebuilds the tagged state from its sources (git archive + build.mjs with QD_ICONS_ROOT); the build is byte-reproducible, so the result is exactly the package published back then. It reports: added, removed, moved, redrawn (drawings compared by the glyph outlines in the font, not by the SVGs). From that follows the required bump: removed or moved name → major, new name → minor, new drawing → patch.
The gate for it runs in the pull request's CI — if the bump is smaller than required, the PR is red and main never gets it. At publish time the same comparison only produces the list for the release notes on the tag (--no-gate).
Provenance is off: npm issues it only for public source repositories, this one is internal.
Authentication via trusted publishing (OIDC), as for the framework: no token in the repo, the package is bound to this workflow on npmjs.org. dist/ is built during the run; ICONS.md and icons.json must match it (the same drift check as in CI).
The package carries this README; publish.yml strips scripts and devDependencies from the package.json in the tarball so that consumers see metadata and exports only.
Requirements for delivered SVGs
The usual source is IBM Carbon, often adjusted in Illustrator. Normalization cleans up on its own: <style>/classes, transforms, <rect>/<circle>/<polygon>, scaling to 1024, Illustrator <switch> wrappers. It merges several paths as long as they share the same winding.
Rejected is whatever cannot be resolved unambiguously:
| Abort reason | Remedy |
|---|---|
| not a valid icon name | rename the file to camelCase — eventLocation.svg, not event--location.svg |
| a real stroke | convert to paths in Illustrator |
| fill-rule="evenodd" | redraw as a compound path with nonzero winding |
| yields several paths instead of one | the paths have different windings — unite them into one compound path in Illustrator |
| missing or non-square viewBox | make the artboard square |
| yields no path | everything was dropped as invisible — usually fill="none" on the <svg> element, as Figma exports it: remove the attribute |
| name already taken | choose another name — see above |
The two attribute cases are deceptive because the file looks perfectly normal in the editor: a fill="none" on the root element counts as "paints nothing" for the normalizer and takes the whole drawing with it, fill-rule="evenodd" prevents the paths from being united. Figma ships both regularly, and both can usually be removed safely — but check the holes afterwards, because the font renders nonzero.
The scale factor is computed from the viewBox, not assumed. A 24×24 file ends up correctly at 1024.
Build
npm run buildThe build is a pure function: icons.json + icons/ → dist/ + icon-font.scss + ICONS.md. It never writes to icons/ or icons.json. dist/ is not committed — whoever needs it builds it; ICONS.md is, so the table renders on GitHub.
It is byte-reproducible — building twice yields identical bytes. Two things are pinned for that: svg2ttf gets ts: 0 (otherwise the build time lands in the head table), and ttf2woff2 is pinned to its bundled wasm binary (otherwise the result would depend on whether the native build succeeded during npm ci). The drift check in CI relies on this.
Since the native addon is never loaded anyway, CI installs with npm ci --ignore-scripts — the same switch locally, see Prerequisites.
dist/
icon-font.scss the package contents — the version from package.json
dist/Quadrel.woff2
dist/icon-definitions.ts
dist/icon-definitions.mjs
dist/icon-definitions.d.tsNot committed, no archive. Earlier states live on npm (npm pack @quadrel-enterprise-ui/[email protected]) and as git tag v<version>, from which the state can be rebuilt at any time — exactly what release-check.mjs does for its comparison.
Every generated file carries the version in its first line. A hand-copied file in a consumer shows at a glance which state it is on.
Validation
npm run build aborts on: a missing SVG, an orphaned SVG without an entry, a duplicate codepoint, a codepoint ≥ nextCodepoint, a duplicate name or alias (case-insensitive too), a violated invariant in icons/ (viewBox, stroke, <style>/class, exactly one <path>), an empty glyph, a full-square glyph.
CI additionally checks that ICONS.md is committed up to date and that the lockfile points to npmjs.org. In pull requests it compares the set with the last release and checks the version bump (see Release), and runs add/update as a dry run for files in import/ and update/ (see Without a local setup).
Tests & lint
npm test # vitest run --coverage
npm run lint # eslint .normalize.mjs is pure and unit-tested directly. add/update/delete/build run as CLIs with process.exit and start the build as a subprocess — they are tested behaviourally against a throwaway repo.
For that, build.mjs and ingest.mjs understand the environment variable QD_ICONS_ROOT: when set, it redirects only the data (icons.json, icons/, dist/, import/, update/) to that directory — the code stays in the repo. Without it the scripts work on the repo root as before; CI never sets it.
Font metrics
Em 1024, ascent 960, descent 64 — matching the previous icomoon font so rendering in the framework stays unchanged.
License
Apache-2.0. The icons are derived from IBM Carbon (Apache-2.0) — see NOTICE and LICENSE.
