Font Library Developer Reference
A source-level map of tool/font/index.html and the remote engine
font-library.js: the page/engine split, the catalogue data contracts, the FontFace
registration model, the scoring, pairing and recommender maths, the in-browser ZIP writer, the
AuricFonts loader API, and the four site-side layers wrapped around it.
Overview
The Font Library is the only tool on the site whose interface is not in this repository. The route
/tool/font/ ships a 518-line static shell: a hero, a three-view rail, a filter rail with
six empty facet containers, the specimen controls, an empty #fl-grid, a drawer skeleton, a
tray and a toast. Every
card, every pane, every byte of catalogue behaviour is rendered by a 2,004-line ES module served from
https://fonts.auricartisan.com, an R2 bucket fronted by a custom domain. The repository
copy of that module lives at --future--/font/font-library.js and the deployed file is
byte-identical to it, so every line number quoted below is the shipping code.
The catalogue itself is a static build snapshot: 2,213 families, 4,883 font files, 550 variable fonts, 60 unicode script blocks, built once on 2026-06-02 and uploaded wholesale. Nothing about it updates at runtime. The engine fetches three JSON files on boot, one 3–4 MB detail group per alphabet range when you open a family, and one licence body per licence text you read. Everything else — filtering, sorting, scoring, pairing, recommending, packaging a ZIP — happens in the browser with no dependencies and no server.
Because the whole interface arrives from one external host, site-side layers exist only to hold it in place — besides the view, dock and pane scripts that lay the shell out, there are four: a DOM normaliser that re-skins markup it does not own, a power-tools layer that drives the engine's controls through a command palette, two independent outage watchdogs, and a navigation interceptor that empties the grid before any link click because tearing the live grid down crashed the renderer. This reference covers the engine first, then those layers, then the publishing pipeline and the limits worth knowing before you change anything.
1. Runtime overview and file map
+Two halves. The page half is committed to this repository and is a thin, indexable shell. The engine half is built out of tree, uploaded to R2, and served from a custom domain. They meet at a set of element ids and one meta tag.
| File | Role |
|---|---|
tool/font/index.html |
The route. Hero, the Specimens / Collections / Selected view rail, the filter rail with
empty facet containers, the specimen controls, empty #fl-grid, drawer
skeleton, tray, toast, and the ten script tags below. 518 lines. |
--future--/font/font-library.js |
The engine. ES module, 2,004 lines, 107,696 bytes. Grid, facets, drawer, tray, compare, recommender, ZIP writer. Exports nothing; runs on import. Git-ignored; served from R2. |
--future--/font/font-cdn.js |
The loader. Classic script defining window.AuricFonts. Usable on any page,
not just this one. |
--future--/font/font-library.css |
The engine's own stylesheet, loaded from the CDN as a plain <link>.
|
--future--/font/data/ |
meta.json, stats.json, index.json,
collections.json, detail/<GROUP>.json,
licenses/<bodyId>.txt. |
--future--/font/css/<id>.css |
2,213 generated per-family stylesheets — the thing a <link> or
@import embed snippet points at. |
--future--/font/font/<GROUP>/<FOLDER>/ |
The 4,883 font files themselves, plus each family's original licence file. |
js/tool/font-home-nav.js |
Capture-phase link interceptor that quiesces the grid before any same-origin navigation. |
js/tool/library-reference.js |
MutationObserver that post-processes the remote engine's markup into site classes. |
js/tool/library-power.js |
Command palette, shortcuts, recently-viewed strip, engine-health watchdog. Shared with the Icon Library. |
js/tool/library/power-core.js |
The pure logic behind the above — hotkey parsing, fuzzy ranking, recent store. Unit-tested. |
js/tool/cdn-fallback.js |
Six-second outage check specific to the CDN stylesheet. |
css/font-icon-panel.css |
1,086 lines re-skinning the remote drawer to the site's panel tokens. |
scripts/upload-fonts-r2-api.mjs |
Publishes --future--/font to the R2 bucket fonts. |
Script load order
The shell ends with ten script tags, in this order. Order matters twice: the classic scripts run
before the modules, and font-cdn.js must define AuricFonts before anything
calls it.
<script src="/js/tool/font-home-nav.js"></script>
<script src="/js/tool/font-views.js" defer></script>
<script src="/js/tool/font-dock.js" defer></script>
<script src="/js/tool/font-panes.js" defer></script>
<script src="/js/tool/library-reference.js"></script>
<script type="module" src="/js/tool/library-power.js"></script>
<script type="module" src="/js/unified.js"></script>
<script src="https://fonts.auricartisan.com/font-cdn.js"></script>
<script type="module" src="https://fonts.auricartisan.com/font-library.js"></script>
<script type="module" src="/js/tool/cdn-fallback.js"></script>
The head also loads https://fonts.auricartisan.com/font-library.css and then
/css/font-icon-panel.css and /css/font-shell.css, in that order, so the site skin
wins the cascade. font-views.js switches the three views and runs the filter rail,
font-dock.js docks the drawer to the right edge (a bottom sheet below 1,180 px),
and font-panes.js sorts and de-emojis the Suitability scores.
2. Base resolution, boot sequence and CDN URL shape
+Where the engine thinks its assets are
Both remote scripts resolve an asset base before doing anything else. They agree on the configured sources and differ on the fallback.
// font-library.js:10
const DEFAULT_BASE = location.pathname.replace(/\/[^/]*$/, '') || '.';
function configuredBase() {
const globalBase = window.AURIC_FONT_BASE || window.AURIC_FONT_ASSET_BASE;
const metaBase = document.querySelector('meta[name="auric-font-base"]')?.content;
const base = String(globalBase || metaBase || '').trim();
return base ? base.replace(/\/+$/, '') : '';
}
const BASE = configuredBase() || DEFAULT_BASE;
const abs = (rel) => new URL(rel, new URL(baseHref(), location.href)).href;
So the engine's fallback is the page's own directory, which is why the shell pins the base explicitly on line 17:
<meta name="auric-font-base" content="https://fonts.auricartisan.com" />
font-cdn.js resolves the same three configured sources plus a data-base
attribute on its own tag, and falls back to its own script directory via
detectBase() (document.currentScript.src with
/font-cdn.js stripped). That difference is the reason the loader works when dropped on
a third-party page and the engine does not.
init()
init() is called immediately at module scope with a .catch attached. The
sequence is fixed:
init().catch(err => { /* writes "Could not load the catalogue" into #fl-grid */ });
async function init() {
loadSelectedFromStore(); // localStorage 'fl-selected'
const [meta, stats, index] = await Promise.all([
fetch(`${BASE}/data/meta.json`).then(r => r.json()).catch(() => null),
fetch(`${BASE}/data/stats.json`).then(r => r.json()).catch(() => null),
fetch(`${BASE}/data/index.json`).then(r => r.json()), // no catch — rejects init
]);
S.meta = meta; S.stats = stats; S.index = index;
hydrateSelectedFromIndex();
paintHeroStats();
buildFacets();
wireControls();
setupCardObserver();
applyFilters();
renderTray();
buildRecommenderPanel();
loadCollections();
}
meta.json and stats.json degrade to null; only
index.json is load-bearing. Losing stats.json costs you the facet chips
and the script counter but leaves the grid working. loadCollections() is fired without
await, so the featured shelf arrives after the grid.
Hero counters
paintHeroStats() fills four ids from the two optional payloads. Current catalogue
values are in brackets.
| Element | Source | Value |
|---|---|---|
#stat-families | meta.families, falling back to
index.length | 2,213 |
#stat-files | meta.files | 4,883 |
#stat-variable | meta.variable | 550 |
#stat-scripts | Object.keys(stats.scripts).length |
60 |
CDN URL shape
| Path under the base | What it is | Fetched when |
|---|---|---|
/data/meta.json | Build manifest | Boot |
/data/stats.json | Facet histograms | Boot |
/data/index.json | 2,213 grid records, 1.85 MB | Boot |
/data/collections.json | Six featured shelves | Boot, not awaited |
/data/detail/<GROUP>.json | Full metadata for one alphabet
range; GROUP is A-F, G-L, M-R or
S-Z | First time a family in that group is opened; cached in
S.details |
/data/licenses/<bodyId>.txt | One of 314 deduplicated licence bodies | Licence tab, or ZIP packaging; cached in S.licenses |
/css/<id>.css | Generated per-family stylesheet | Never
by the engine — only quoted in snippets and injected by
AuricFonts.load() |
/font/<GROUP>/<FOLDER>/<file> | A font file. Paths
are URL-encoded, e.g.
font/A-F/ADVENTPRO/AdventPro%5Bwdth%2Cwght%5D.ttf | Card enters view, family opened, or ZIP built |
/font-cdn.js, /font-library.js,
/font-library.css | The engine itself | Page load |
The family id contract
A family's slug is computed from its name in three separate places, and they must stay in step,
because the client turns a name into css/<id>.css without asking the server:
// font-library.js:1777 — flSlug()
// font-cdn.js:51 — slugify() ("mirror of the build-time slugify")
String(s).toLowerCase()
.replace(/['’"]/g, '') // apostrophes and quotes vanish
.replace(/[^a-z0-9]+/g, '-') // everything else collapses to a hyphen
.replace(/^-+|-+$/g, '') // trimmed
|| 'font'; // never empty
The same function also produces ZIP folder names via safePackageName(), which slugs and
then strips anything outside [a-z0-9-].
3. Catalogue data contracts
+meta.json
{
"name": "Auric Artisan Font Library",
"built": "2026-06-02T09:12:47.101Z",
"families": 2213,
"files": 4883,
"variable": 550,
"groups": ["A-F", "G-L", "M-R", "S-Z"],
"licenseBodies": 314,
"includesSystem": true,
"buildMs": 11223
}
stats.json
Nine keys: total, files, variable, withItalic,
categories, licenses, sources, scripts,
groups. The last five are objects mapping a value to a count, and
buildFacets() reads them directly to build every chip and tab.
| Facet | Built from | Current distribution |
|---|---|---|
| Category tabs | stats.categories, sorted by count |
serif 1,172 · sans-serif 780 · handwriting 97 · monospace 83 · display 81 |
| Licence chips | stats.licenses |
ofl 1,929 · unknown 191 · apache-2.0 45 · cc 40 · mit 7 · custom 1 |
| Source chips | stats.sources | library 2,023 · system 190 |
| Script chips | stats.scripts, top 18 by count | 60 script blocks in total |
index.json — the grid driver
One flat array of 2,213 records, 1.85 MB, fetched whole on boot and never re-fetched. Every filter, every sort, every recommendation and every pairing runs against this array; the detail files are only for the drawer.
{
"id": "inter", // flSlug(name)
"name": "Inter",
"group": "G-L", // which detail/<GROUP>.json holds the rest
"category": "sans-serif", // sans-serif | serif | display | handwriting | monospace
"source": "library", // library | system
"weights": [100,200,300,400,500,600,700,800,900],
"weightCount": 9,
"variable": true,
"axes": ["opsz", "wght"], // tags only
"italic": true,
"monospace": false,
"license": "ofl", // ofl | apache-2.0 | ufl | mit | cc | gpl | custom | unknown
"designer": "Rasmus Andersson",
"scriptCount": 22,
"scripts": ["Basic Latin", "Latin-1 Supplement", …],
"charCount": 2849,
"styleCount": 2,
"sample": null, // family-supplied sample text, usually null
"created": "2024-05-24",
"scores": { "accessibility": 81, "ui": 85, "developer": 26,
"editorial": 74, "readability": 83 },
"xh": 0.546, // x-height in em
"feat": ["dlig","calt","tnum","pnum","frac","zero","salt", …],
"tr": [90, 10, 10, 24, 11, 57, 52, 75], // trait vector, TRAIT_KEYS order, 0–100
"preview": {
"file": "font/G-L/INTER/Inter%5Bopsz%2Cwght%5D.ttf",
"local": null, // '|'-separated local() aliases for system fonts
"weight": 400, "italic": false, "variable": true, "format": "truetype"
}
}
tr is positional and its order is TRAIT_KEYS:
const TRAIT_KEYS = ['modern','classic','elegant','playful',
'technical','friendly','bold','minimal'];
A system-installed family has source: "system", preview.file: null and a
preview.local alias list such as "Agency FB|AgencyFB-Reg". 190 of the
2,213 records are of this kind.
detail/<GROUP>.json
An object keyed by family id. A-F.json holds 625 families in 3.26 MB;
M-R.json is about 4.0 MB. Opening one family downloads its whole group.
{
"advent-pro": {
"name": "Advent Pro", "folderName": "ADVENTPRO", "group": "A-F",
"source": "library", "category": "sans-serif",
"serif": false, // detected from the outlines, not the category
"designer": "VivaRado, Andreas Kalpakidis",
"designers": ["VivaRado", "Andreas Kalpakidis"],
"manufacturer": "…", "vendorUrl": "…", "designerUrl": "…",
"description": null, "copyright": "…", "trademark": null,
"version": "Version 3.000", "created": "2019-02-13", "modified": "2022-11-25",
"sampleText": null,
"isVariable": true, "hasItalic": true, "monospace": false,
"outline": "truetype", // truetype | cff
"weights": [100, …, 900],
"axes": [{ "tag":"wdth", "min":100, "default":100, "max":200,
"hidden":false, "name":"Width" },
{ "tag":"wght", "min":100, "default":400, "max":900,
"hidden":false, "name":"Weight" }],
"instances": [{ "name":"SemiBold", "coords":[100, 600] }, …],
"scripts": ["Basic Latin", …],
"charCount": 630, "charTrunc": false,
"glyphSet": [[0], [13], [32,126], …], // single codepoints or [start,end] ranges
"scores": { accessibility, ui, developer, editorial, readability },
"traits": { modern, classic, elegant, playful,
technical, friendly, bold, minimal }, // 0–100, named not positional
"suitability": {
"xHeightEm": 0.501, "xHeightScore": 74, "distinction": 76,
"width": 100, "weightFlexibility": 100,
"screenRendering": 96, "strokeContrast": 100,
"signals": { "ilDistinct":0.128, "oneDistinct":1, "zeroDistinct":0,
"iDot":1, "hasRegular":true, "hasBold":true, "weightCount":9 }
},
"features": ["kern","liga"], "featureCount": 2, "featKeys": ["liga","kern"],
"styleCount": 2,
"styles": [{ file, local, name, format, outline, size, style, postScriptName,
fullName, weight, weightName, width, widthName, italic, monospace,
glyphCount, charCount, embedding, isVariable, axes, instances }],
"preview": { … },
"license": {
"id": "ofl", "name": "…", "file": "OFL.txt", "url": "…",
"copyright": "…", "bodyId": "<content hash>",
"permissions": { commercialUse, modification, distribution,
privateUse, sellFontAlone, includeLicense, note },
"embedding": { level, label, installable, noSubset, bitmapOnly }
}
}
}
Two shapes to keep straight: index.tr is a positional array,
detail.traits is a named object. Both carry the same eight numbers.
collections.json
Six sections of hand-picked ids, rendered as the featured shelf.
| Section id | Title | Ids |
|---|---|---|
popular | Popular Fonts | 36 |
new | New Fonts | 28 |
ui | Best UI Fonts | 30 |
a11y | Best Accessibility Fonts | 30 |
dev | Best Developer Fonts | 30 |
editorial | Best Editorial Fonts | 30 |
renderFeatured() shows the first 14 resolvable ids per row as mini cards;
updateFeaturedVisibility() hides the whole shelf as soon as
S.q is non-empty, any filter set has members, or S.recoActive is true.
Do not confuse this file with the repository's own data/collections.json, which is the
site-wide tool directory and merely lists /tool/font/ as an entry.
Generated per-family CSS
css/<id>.css opens with a comment naming the family, its licence and its weight
list, then declares one @font-face per file with font-display: swap and a
relative src of ../font/<GROUP>/<FOLDER>/<file>.
Variable files get font-weight: 100 900 and
format('truetype-variations'), plus a font-stretch range when the
family has a wdth axis. There are 2,213 of these in the bucket, one per family;
the engine mirrors their content client-side in fontFaceSnippet() for the
@font-face embed tab.
4. Application state and the DOM contract
+The state object
One module-level object. There is no store, no framework, no reactivity: mutate
S, then call the render function yourself.
const S = {
index: [], filtered: [], stats: null, meta: null,
details: new Map(), // group → parsed detail JSON
licenses: new Map(), // bodyId → licence text (or null)
page: 0, pageSize: 48,
q: '', sort: 'name',
filters: {
category: new Set(), props: new Set(), weights: new Set(),
scripts: new Set(), license: new Set(), source: new Set(),
features: new Set(),
},
preview: '', size: 36, view: 'grid',
faceKeys: new Set(), // dedup keys for registered FontFaces
registeredFamily: new Set(), // family ids whose every style is registered
selected: new Map(), // id → { id, name, group, category }
current: null, // the index entry the drawer is showing
cardObserver: null,
collections: null,
recoActive: false,
};
Five more properties are attached lazily as panes render: S._snippets (embed tab),
S._devExports and S._scaleCss (develop tab), S._pairs and
S._pairFam (pairings tab), plus S.recommendable, the memoised
recommendable id set.
One inconsistency worth knowing: S.size initialises to 36 while the
#fl-size input ships with value="40", so cards render at 36 px until
the slider is first moved.
DOM contract
The engine reads fixed ids out of the shell. Renaming one in
tool/font/index.html breaks the engine silently, and the engine is not in this
repository, so nothing will fail a build.
| Group | Ids |
|---|---|
| Hero | fl-hero-stats, stat-families,
stat-files, stat-variable, stat-scripts |
| App root | font-app (also the flag
library-power.js uses to decide it is on the font page) |
| Specimen controls | fl-preview-text, fl-shuffle,
fl-size, fl-size-val, plus
.anz-mode-btn[data-view] |
| Guide and workflow (optional) | fl-guide, fl-guide-dismiss,
fl-start-here-panel, fl-flow,
fl-progress-meter — the engine still looks these up, but the current shell
keeps only fl-start-here-panel, which font-views.js uses for the
Selected view; the rest are absent and their lookups do nothing |
| Results toolbar | fl-cat-tabs, fl-search,
fl-sort, fl-filter-toggle, fl-reco-toggle,
fl-download-selected, fl-count |
| Facets | fl-filters-panel, facet-props,
facet-weights, facet-features, facet-scripts,
facet-license, facet-source, fl-reset |
| Results | fl-featured, fl-recommend-panel,
fl-grid, fl-sentinel |
| Drawer | fl-scrim, fl-drawer, fl-d-name,
fl-d-sub, fl-d-add, fl-d-download,
fl-d-close, fl-d-tabs, fl-d-body |
| Tray | fl-tray-fab, fl-tray-count,
fl-tray, fl-tray-head-count, fl-tray-close,
fl-tray-list, fl-tray-embed, fl-tray-download,
fl-tray-compare, fl-tray-clear |
| Feedback | fl-toast |
Two data attributes cross the boundary as well. data-fl-step and
data-fl-focus on the workflow buttons name the step and the id to focus;
data-fl-action (values filters, tray,
download-selected) is read by handleWorkflowAction() anywhere on the page,
which is how a Start Here button would share the workflow's download path. The current shell has no
workflow buttons and no data-fl-action element, so both paths are dormant.
5. FontFace registration and lazy specimens
+
Nothing is loaded through a stylesheet. Every specimen on the page is a
FontFace object constructed in JavaScript, added to document.fonts, and
named under an internal prefix so it can never collide with a real page font.
function addFace(family, url, desc, key, local) {
key = key || family + '|' + (url || local);
if (S.faceKeys.has(key)) return Promise.resolve(family); // dedup
S.faceKeys.add(key);
let src;
if (url) src = `url("${abs(url)}")`;
else if (local) src = local.split('|').map(l => `local("${l}")`).join(', ');
else return Promise.resolve(family);
let ff;
try { ff = new FontFace(family, src, { display: 'swap', ...desc }); }
catch (err) { return Promise.reject(err); }
document.fonts.add(ff);
return ff.load().then(() => family);
}
| Convention | Value |
|---|---|
| Internal family name | 'AAF_' + entry.id |
| Preview face key | 'prev:' + entry.id — one per family,
shared by cards, mini cards, pairing previews and recommender rows |
| Full-style face key | 'f:' + (style.file || style.local) |
| System font source | local("…") list built by splitting
preview.local on | |
| Display | always swap |
ensureFamilyFaces(fam) registers every style of a family once, guarded by
S.registeredFamily, and converts variable-font axis ranges into CSS descriptors: a
wght axis becomes font-weight: "<min> <max>" and a
wdth axis becomes font-stretch: "<min>% <max>%". It returns
the internal family name, which openDrawer() stashes on the detail record as
fam._family and every pane then interpolates into inline font-family declarations.
Two observers
| Observer | rootMargin | Does |
|---|---|---|
S.cardObserver (setupCardObserver()) | 300px | On first intersection, unobserves the card and calls loadCardFont(), which
registers the preview face and swaps the specimen's
font-family, then removes .is-loading. |
Sentinel observer (in wireControls()) | 600px | Watches #fl-sentinel; while
S.page * S.pageSize < S.filtered.length it increments
S.page and calls renderPage(). That is the infinite
scroll. |
Card specimens are sized by clampCardSize(), which is
Math.min(48, Math.max(22, S.size)) — the header slider ranges 12–120 px
but cards never leave 22–48 px. repaintSpecimens() walks every
.fl-card, rewrites its text content to S.preview || entry.name and reapplies
the clamped size; it does not re-register any face, which is why retyping the preview text is cheap.
6. Filtering, sorting and pagination
+applyFilters()
One pass over S.index, then a sort, then a full grid rebuild. The predicate semantics
differ per facet and that difference is deliberate.
| Facet | Field | Semantics |
|---|---|---|
| Category | e.category | Set membership; the tabs are single-select so the set holds at most one value |
| Licence | e.license | OR within the facet |
| Source | e.source | OR within the facet |
| Properties | e.variable, e.italic,
e.monospace | Each selected chip is a hard requirement |
| Weights | e.weights | every — AND. Selecting
300 and 700 keeps only families that have both. |
| Scripts | e.scripts | every — AND |
| OpenType | e.feat | every over the chip, then
some over that chip's tag family |
| Search | name + ' ' + designer + ' ' + category |
Lower-cased substring, debounced 160 ms |
The eight feature chips are not raw tags; each expands through FEAT_FILTER:
const FEAT_FILTER = {
liga: ['liga', 'dlig', 'calt'], smcp: ['smcp', 'c2sc'], onum: ['onum'],
tnum: ['tnum'], frac: ['frac'], zero: ['zero'], ss: ['ss'],
swsh: ['swsh', 'cswh'],
};
The match is e.feat.includes(tag), so ss works because the build writes a
bare "ss" marker into feat alongside the numbered tags.
Every filter change sets S.page = 0, empties #fl-grid, writes the count
into #fl-count, calls renderPage(true) and then
updateFeaturedVisibility(). renderPage() slices
S.filtered at S.page * 48, builds each card into a
DocumentFragment and appends once. An empty first page renders the
"No fonts match these filters" panel instead.
Sort modes
#fl-sort value | Comparator |
|---|---|
name (default) | localeCompare(…, 'en', {sensitivity:'base'}) |
accessibility / ui / readability |
Descending on that score, ties broken alphabetically |
weights | Descending weightCount |
glyphs | Descending charCount |
variable | Variable first, then alphabetical |
random ("Surprise me") | () => Math.random() - 0.5 |
Note that random is an inconsistent comparator rather than a uniform shuffle, and it
re-rolls on every filter change because the sort is re-run inside
applyFilters(). The Shuffle button simply sets S.sort = 'random', syncs
the select and re-applies.
Card markup
card(e) builds an <article class="fl-card"> with
role="button", tabIndex = 0 and data-id. It carries a name, a
designer/style-count line, a .fl-card-specimen and a badge row: category, a
⚡ Variable badge, an accessibility .fl-score pill, the first word of the
licence label, and an Installed badge for system families. Clicking the
.fl-card-fav star toggles selection; clicking anywhere else, or pressing Enter, opens
the drawer.
7. The detail drawer and its eleven panes
+
openDrawer(entry) opens the panel and scrim immediately, paints skeletons, then awaits
getDetail(entry). On success it calls ensureFamilyFaces(), writes the
subtitle (category, designers, style count, axis count, glyph count), renders the tab bar from
TABS, and shows the specimen pane. On failure the body reads "Details unavailable."
const TABS = [
['specimen','✍','Specimen'], ['suitability','📊','Suitability'],
['pairings','🔗','Pairings'], ['glyphs','🔡','Glyphs'],
['features','𝒻','Features'], ['styles','🅰','Styles'],
['about','ⓘ','About'], ['license','⚖','License'],
['charset','🌐','Charset'], ['develop','⌨','Develop'],
['embed','⟨⟩','Use & Embed'],
];
showPane(fam, tab) is a flat if/else chain: it assigns
pane<Tab>(fam, ff) to #fl-d-body's
innerHTML, then calls the matching wire<Tab>() for the eight panes
that need listeners, then resets body.scrollTop. ff is the font stack
string '<internal family>', <category fallback>, single-quoted so it is safe
inside an HTML style attribute.
| Pane | Render | Wire | What it does |
|---|---|---|---|
| Specimen | paneSpecimen() | wireSpecimen() |
Contenteditable tester, axis sliders, instance chips, OpenType chips, seven presets |
| Suitability | paneSuitability() | — | Five score rings, six signal bars, Best-for chips, eight-trait fingerprint |
| Pairings | panePairings() | wirePairings() |
Eight ranked partners with a live heading/body preview |
| Glyphs | paneGlyphs() | wireGlyphs() |
Codepoint grid, 600 at a time, click to copy |
| Features | paneFeatures() | wireFeatures() |
Default → enabled comparison per tag; click copies the CSS |
| Styles | paneStyles() | wireStyles() |
Every weight rendered, then the file table and the kit download |
| About | paneAbout() | — | Fourteen provenance rows, description, copyright, trademark |
| License | paneLicense() | wireLicense() |
Badge, links, permissions grid, full text fetched by bodyId |
| Charset | paneCharset() | — | Script chips, coverage counts, first 40 unicode ranges, sample line |
| Develop | paneDevelop() | wireDevelop() |
Font stack, five export formats, modular type scale |
| Use & Embed | paneEmbed() | wireEmbed() |
Six copy-paste snippets over selectable weight chips |
The specimen editor
#fl-specimen is a contenteditable block starting at 64 px. The
toolbar drives it two ways. Style controls write inline styles directly:
#fl-ed-weight sets fontWeight (and pushes the value into the
wght slider if there is one), #fl-ed-size,
#fl-ed-lh and #fl-ed-ls set fontSize,
lineHeight and letterSpacing. Text formatting goes through
document.execCommand on mousedown with the default prevented so the
selection survives: bold, italic, underline,
strikeThrough, and justifyLeft/justifyCenter/justifyRight
built from the button's data-align. The colour input calls
styleWithCSS then foreColor when there is a live selection inside the
specimen, and otherwise sets spec.style.color on the whole block.
execCommand is deprecated; this is the one part of the engine with real
cross-browser variance.
resetSpecimen() rewrites cssText wholesale back to the family stack, the
starting weight and 64 px, restores the three number inputs and snaps each axis slider to its
default.
Variable axes and named instances
paneSpecimen() renders a slider per non-hidden fvar axis via
axisSlider(a): label, tag · min–max, a range whose step is 1 when the
range exceeds 50 and 0.1 otherwise, and an <output>. The shared
apply() closure reads every [data-axis-input], joins them into
"tag" value pairs and writes spec.style.fontVariationSettings. An instance
chip maps instance.coords[i] onto fam.axes[i].tag — positional, so
the two arrays must stay aligned — then applies, sets the weight, and syncs the weight select
through closestWeight(w, weights).
OpenType handling
Tags are classified by OT_META (tag → [label, group]) into the four
OT_GROUPS: Ligatures, Letterforms, Figures & numbers, Spacing.
otLabel() handles the numbered families with two regexes, turning
ss01 into "Stylistic Set 1" and cv01 into "Character Variant 1"; those get
their own two groups. Toggling any chip re-runs applyFeatures(), which collects every
.fl-otf-chip.is-active and writes
font-feature-settings: "tag" 1, … or normal.
The Code preset is the one preset with side effects: it swaps in CODE_SAMPLE, sets
white-space: pre-wrap, caps the size at 24 px in both the style and the number
input, and programmatically clicks the calt and liga chips if they exist
and are off.
The separate Features gallery renders one row per tag with a sample drawn from
FEAT_SAMPLE (with fallbacks 'Hamburgefonstiv 0123 ag' for
ssNN and 'agyl 0123 IJ' for cvNN), shown twice — once
plain, once with font-feature-settings applied inline. Clicking a row copies
font-feature-settings: "tag" 1;.
Glyph map
expandRanges(fam.glyphSet, cap = 8000) flattens the range array into codepoints and
stops at the cap, so CJK-scale fonts are truncated without a notice.
wireGlyphs() renders in steps of STEP = 600 behind the "Show more glyphs"
button; each cell carries data-cp as U+XXXX and copies its character on
click. The size slider spans 18–80 px.
Styles, About, License, Charset
paneStyles() renders every weight (plus an italic row per weight when
hasItalic), a download card, and a table of every file: style, weight with its name,
width name, ⚡ Variable or the uppercased format, byte size via
fmtBytes(), glyph count, and either a direct <a download> or an
Installed badge when style.file is null.
paneAbout() builds a fixed list of fourteen rows and filters out the empty ones. URLs
pass through fixUrl(), which prefixes https:// when the stored value has
no scheme — the raw data holds bare hosts such as www.vivarado.com.
paneLicense() renders the licence name from license.name or the
LICENSE_LABEL map, a link to license.url, a link to the original bundled
file built by familyLicenseFileUrl() (the licence sits beside the fonts, so the path is
derived from styles[0].file), the plain-language permissions.note, and a
six-cell grid: commercial use, modification, redistribution, private use, web embedding (from
embedding.installable, defaulting to true) and sell-font-alone. Each cell renders
✔, ✘ or – for true, false and unknown.
wireLicense() then awaits getLicenseBody(bodyId) and drops the text in.
paneCharset() lists every script chip, three coverage figures (characters mapped, glyphs
in the first font file, first and last codepoint), the first 40 unicode ranges formatted by
hex(), and a sample line in sampleText or the pangram.
Compare mode
openCompare() takes the first four selected ids, requires at least two, and reuses the
drawer as a presenter with no tabs. It awaits getDetail() per family (falling back to
ensurePreviewFamily() when detail fails) and builds a 14-row table: specimen, category,
designer, weights, variable axes, x-height, glyphs, OpenType feature count, the five scores through
scoreCell(), and licence. Column headers are buttons that reopen that family's drawer.
8. Scores, pairings and the recommender maths
+Score presentation
The five scores are computed at build time from outlines and metrics and shipped in the data; the engine only presents them. One threshold function drives every colour in the interface:
function scoreClass(v) {
return v >= 85 ? 'is-great'
: v >= 70 ? 'is-good'
: v >= 50 ? 'is-ok'
: 'is-low';
}
The ring is pure CSS: paneSuitability() emits
<div class="fl-score-ring" style="--p:${v}"> and
font-library.css draws
conic-gradient(currentColor calc(var(--p) * 1%), …). Note that the site-side
normaliser applies its own, different buckets on top — 80 and 45 — when it tags
the pane; see section 11.
| Bar | Field | Note shown beside it |
|---|---|---|
| x-height | suitability.xHeightScore | xHeightEm
+ " em" |
| Character distinction (I l 1 · 0 O) | suitability.distinction |
"distinct zero" when signals.zeroDistinct |
| Proportion / width | suitability.width | — |
| Weight flexibility | suitability.weightFlexibility |
signals.weightCount + " weights" |
| Screen rendering | suitability.screenRendering | — |
| Low stroke-contrast | suitability.strokeContrast | — |
The "Best for" chips are derived in the pane, not stored: UI & product at
ui ≥ 78, Body text at readability ≥ 78, Code at
developer ≥ 75, Headlines at editorial ≥ 78,
Accessibility-critical at accessibility ≥ 85, and "Display & accents" when nothing
qualifies.
The recommendable pool
Both the pairing engine and the recommender rank inside one curated set, not the whole catalogue.
RECOMMENDABLE_SEED is 134 hand-listed mainstream family names (133 unique —
Lora appears twice) grouped by
category; recommendablePool() slugs each name, intersects with the ids actually present
in index.json, and memoises the result on S.recommendable. In the current catalogue 131 of them
resolve; Albert Sans and Ubuntu Mono are not in the build. A separate
predicate clientMainstream(f) (requires Basic Latin coverage, rejects script-specific
names by regex) exists alongside it in the source.
The practical consequence: most of the 2,213 families can never appear as a recommendation or as a suggested partner. They are reachable only through browsing, filtering, search and the featured shelves.
Pairing algorithm
computePairings(fam, n = 8) decides whether the subject reads as a heading —
category === 'display' || category === 'serif' || editorial ≥ 72 — and picks the
complementary role for the partner. It then scores every eligible candidate (in the pool, not
itself, has scores, not handwriting):
raw = 0.32·categoryFit
+ 0.24·roleFit
+ 0.20·xHeightHarmony
+ 0.16·quality
+ superFamilyBonus // 0.18 or 0
categoryFit = PAIR_CAT[[a.category, b.category].sort().join('+')] ?? 0.4
roleFit = partner.scores[famHeading ? 'readability' : 'editorial'] / 100
xHeightHarmony = 1 - min(1, |fam.xHeightEm - partner.xh| / 0.12)
quality = (partner.accessibility + partner.ui) / 200
superFamilyBonus = 0.18 when firstToken(names) match AND categories differ
score = clamp(round(raw · 100), 60, 99)
PAIR_CAT is keyed on the alphabetically sorted category pair, so it is symmetric:
| Pair | Fit | Pair | Fit |
|---|---|---|---|
| sans-serif + serif | 1.00 | sans-serif + sans-serif | 0.55 |
| display + sans-serif | 1.00 | monospace + sans-serif | 0.55 |
| display + serif | 0.82 | monospace + serif | 0.55 |
| handwriting + sans-serif | 0.62 | serif + serif | 0.45 |
| handwriting + serif | 0.62 | display + display | 0.40 |
| display + monospace | 0.35 | monospace + monospace | 0.30 |
| Anything not listed falls through to 0.4. | |||
A super-family match earns a "Super-family" badge on the card. The preview is built by
setPreview(partner), which registers the partner's preview face through
ensurePreviewFamily() and renders a heading line and a body paragraph in the right
order for the subject's role, plus the two action buttons.
Recommender algorithm
The recommender turns an intent into an eight-dimensional desired trait vector, then ranks the pool by cosine similarity against each font's own trait vector.
desiredFromIntent({ industry, styles, accessibility, paletteMood })
industry traits × 1.2 // also sets role and category bias
each style chip × 1.0
palette traits × 1.1 // sets role/cats too, but only if no industry chosen
if the vector is all zeros → { modern: 1 }
a11yW = essential 0.60 | balanced 0.32 | any 0.12
scoreFontForIntent(f, desired)
sim = cosine(f.tr.map(x => x / 100), desired.vec)
role = f.scores[desired.role] / 100
a11y = f.scores.accessibility / 100
catBonus = desired.cats ? (desired.cats.includes(f.category) ? 1 : 0.55) : 1
raw = (0.46·sim + 0.30·role + a11yW·a11y) · catBonus
match = clamp(round(100 · raw / (0.46 + 0.30 + a11yW)), 40, 99)
Thirteen industries are defined in INDUSTRY, each with a trait weighting, a role
(ui, developer, editorial, readability or
accessibility) and a category list. STYLE_OPTIONS holds the same
eight keys as TRAIT_KEYS in a different order, rendered as eight chips. recommend() returns the top 12.
reasonFor(f, desired) writes the one-line explanation: the font's two highest traits
that reach 45, then "<score> a11y" when accessibility is 80 or better.
Palette → typography
parsePalette(text) matches #rgb and #rrggbb (the hash is
optional for the six-digit form) and maps each through hexToHsl(), which expands
three-digit hex, validates, and returns {h, s, l, hex} with s and
l on 0–1. paletteMood(cols) then reduces the set:
avgL = mean(l)
avgS = mean(s)
contrast = max(l) - min(l)
warm = count(s > 0.15 && (h < 50 || h >= 330))
- count(s > 0.15 && h >= 180 && h <= 275)
warm ≤ 0 && contrast > 0.50 → modern 1.0, minimal 0.8 (+ technical 0.5 if avgS < 0.45)
warm > 0 && avgS > 0.45 → playful 0.8, friendly 0.7, bold 0.4
avgL < 0.35 && avgS < 0.50 → elegant 0.9, classic 0.5
avgS < 0.22 && contrast < 0.55 → minimal 0.8, classic 0.4
avgS > 0.60 → bold 0.5
nothing matched → modern 1.0, minimal 0.5
The rules are cumulative, not exclusive — a dark, cool, high-contrast palette collects both the
first and the third. The function also returns a role (editorial only for the dark
low-saturation case, otherwise ui), a category bias
(['serif','sans-serif'] when avgL < 0.35 && avgS < 0.45,
otherwise ['sans-serif']) and the human tags that
renderRecoResults() prints as "Palette reads …".
9. Embed snippets, developer exports and the ZIP writer
+Embed snippets
paneEmbed() builds an object of six strings, stores it on S._snippets, and
renders the keys as tabs. Every snippet is run through highlight(), a five-regex
tokeniser that runs after esc() and colours comments, tags, attributes, strings and a
fixed list of font properties.
| Tab | Emits |
|---|---|
| Link | <link rel="stylesheet" href="<base>/css/<id>.css"> |
| Import | @import url('<base>/css/<id>.css'); |
| Script | <script src="<base>/font-cdn.js" data-families="<Name>"></script> |
| JS | AuricFonts.load('<Name>').then(…) |
| @font-face | The first four styles through fontFaceSnippet() |
| Usage | A .headline rule; for a family with a wght axis
the weight is written as the variable range <first> <last> |
fontFaceSnippet(fam, style) mirrors what the generated CSS does, but with an absolute
src: a wght axis becomes a weight range, a wdth axis adds
font-stretch, and the format string gains the -variations suffix.
The tray's Embed button takes a different path. openSelectionEmbed() reuses the drawer
as a presenter and produces one block containing every family's <link>, one
equivalent <script> tag whose data-families is the names joined with
|, and a :root block of --font-<id> custom properties.
Develop exports
paneDevelop() computes a role — mono for monospace,
serif for serif, sans for everything else — and a stack of the family
name plus the category's entry in FALLBACK_STACK, then builds five exports onto
S._devExports.
| Export | Shape |
|---|---|
| CSS variables | :root { --font-<role>: <stack> } plus a
body rule |
| Tailwind | A tailwind.config.js with
theme.extend.fontFamily.<role> as an array |
| SCSS | $font-<role>: <stack>; plus a
body rule |
| Design tokens | {"font.family.<role>": {"value": …, "type": "fontFamily"}} |
| CSS @import | The @import plus the custom property |
The same pane carries the modular type scale.
const SCALE_STEPS = [['xs',-2],['sm',-1],['base',0],['md',1],['lg',2],
['xl',3],['2xl',4],['3xl',5],['4xl',6]];
function buildScale(base, ratio) {
return SCALE_STEPS.map(([name, step]) => {
const px = base * Math.pow(ratio, step);
const rem = px / 16;
const lh = step <= 0 ? 1.5 : step <= 2 ? 1.3 : 1.15;
return { name, px: Math.round(px * 100) / 100,
rem: Math.round(rem * 1000) / 1000, lh };
});
}
Base is a number input clamped 10–24 px; ratio is a select of seven named ratios from
1.125 (Major Second) to 1.618 (Golden Ratio), defaulting to 1.2. The preview renders the steps in
reverse with the sample size capped at 60 px, and Copy CSS emits
S._scaleCss, a :root block of --text-<name> values in
rem.
The self-hosting ZIP kit
downloadFontPackage(fams, filename) drops any family with no
style.file (system fonts cannot be redistributed), then for each remaining family
fetches its font files one at a time and assembles a flat entry list.
<family-slug>/fonts/<file> one per bundled style, name-collision-safe
<family-slug>/css/<id>.css @font-face blocks with src: url('../fonts/…')
plus :root { --font-<id>: … }
<family-slug>/demo.html standalone page
<family-slug>/README.txt file list and three usage steps
<family-slug>/LICENSE.txt the fetched licence body, when there is one
DOWNLOAD_NOTES.txt only if some file failed to fetch
A single family downloads as <slug>-font-kit.zip; a multi-family selection
downloads as auric-font-selection-kit.zip. Filenames are made unique by
uniqueFileName(), which strips characters illegal on Windows and appends
-2, -3 and so on; CSS src paths are escaped by
cssUrlPath(), which is encodeURI plus quote and parenthesis escaping.
makeStoredZip(entries) writes the archive by hand with no library. It is store-only
— compression method 0, so the ZIP is the sum of the font files plus headers.
| Record | Signature | Size | Notes |
|---|---|---|---|
| Local file header | 0x04034b50 | 30 bytes + name | version 20, general-purpose flag 0x0800 (UTF-8 names), method 0, CRC-32,
compressed size = uncompressed size |
| Central directory entry | 0x02014b50 | 46 bytes + name | Carries the local header offset at byte 42 |
| End of central directory | 0x06054b50 | 22 bytes | Entry count twice, central size, central offset |
Timestamps come from zipDosStamp(date), which packs the DOS time as
(h << 11) | (m << 5) | floor(s / 2) and the DOS date as
((y - 1980) << 9) | ((M + 1) << 5) | d. crc32(bytes) builds a
256-entry table on first use with polynomial 0xEDB88320 and runs the standard
reflected loop. The result is a Blob of type application/zip, handed to
triggerDownload(), which clicks a hidden object-URL anchor and revokes it 800 ms
later.
Two costs follow from this design: the whole archive is assembled in memory, and
fetchBytes() is called in a serial for loop, so a large multi-family
selection is slow and memory-heavy.
10. The AuricFonts CDN loader
+
font-cdn.js is an IIFE with no dependencies that exposes one global under two names:
window.AuricFonts and the alias window.AuricFontLoader. It is independent
of font-library.js — the library page loads it because the JS embed snippet tells
people to use it, and because the Design System Generator and any third-party page can use it on its own.
| Member | Signature | Behaviour |
|---|---|---|
base | getter / setter | Reads or replaces the asset base;
the setter runs normalizeBase() |
slugify | (name) → id | The mirror of the build-time slug |
load | (spec | spec[], opts) → Promise<{id, family}> |
Injects css/<id>.css via ensureLink(), then resolves
through whenReady(). Arrays map to Promise.all. |
face | ({family, url, weight, style, display, stretch, format, local}) → Promise<family> |
Registers one explicit FontFace, deduped on
family|weight|style|url |
injectCss | (cssText, id) → HTMLStyleElement |
Appends a <style>, tagged data-auric-font |
cssUrl / css | (spec) → string |
The family's stylesheet href |
list | () → Promise<index[]> |
Lazily fetches data/index.json and returns a copy |
search | (q) → Promise<index[]> |
Substring over name, designer and category |
get | (idOrName) → Promise<detail | null> |
Resolves the index entry, then loads and caches that entry's detail group |
whenReady | (family, weights) → Promise |
document.fonts.load() per weight (default 400), then
document.fonts.ready |
index | getter | The cached index array, or
null |
parseSpec()
A spec may be an object ({family|name|id, weights, italic}) or a string. The string
grammar is Google-Fonts-shaped:
'Inter' → { id:'inter', family:'Inter', weights:null, italics:false }
'Inter:wght@400;700' → { id:'inter', family:'Inter', weights:[400,700], italics:false }
'Roboto:400,700i' → { id:'roboto', family:'Roboto', weights:[400,700], italics:true }
'Lora:400..900' → { id:'lora', family:'Lora', weights:[400], italics:false }
It splits on the first colon, strips a leading <axis>@ prefix, splits the rest on
; or ,, parseInts each token and sets
italics when any token ends in i. Note that a Google-style range is not understood: there is no split on
., so 400..900 becomes the single token 400..900 and
parseInt yields just 400 — the upper endpoint is dropped. Note also that
weights only ever affects the readiness probes — the injected stylesheet always
contains the whole family.
ensureLink(id) deduplicates twice: against its own state.links set and
against document.styleSheets, so a stylesheet already present in the page is not
injected again. Each injected link carries data-auric-font="<id>".
Declarative boot
boot() runs at the end of the IIFE, finds its own script tag, and reads three data
attributes (data-families also answering to data-fonts).
| Attribute | Effect |
|---|---|
data-families (or data-fonts) | A
|-separated list of specs; each is passed to load() |
data-display | Sets the default font-display used by
face() |
data-base | Overrides the asset base, ahead of the globals and the meta tag |
<script src="https://fonts.auricartisan.com/font-cdn.js"
data-families="Alegreya|Roboto Slab|Open Sans:wght@400;700"
data-display="swap"></script>
One discrepancy to be careful with
The repository copy of font-cdn.js is 13,209 bytes and ends with an idle-scheduled
navigator.sendBeacon to https://api.auricartisan.com/cdn/beacon, gated on
the host being exactly fonts. or icons.auricartisan.com and suppressed
under DNT, Global Privacy Control and a data-telemetry="off" attribute. The file
currently served from the CDN is 9,045 bytes and ends at the AuricFonts export —
it contains none of that block. Any reasoning about what the shipping page actually sends must be
based on the deployed file, not the source.
11. Site-side layers: power tools, normaliser, fallbacks, CSP
+library-power.js
A shared layer for the Font and Icon libraries. It detects which page it is on by looking for
#font-app or #icon-app and returns immediately if neither exists, then
selects a configuration block of ids. It never touches the engine's internals — every command
either focuses an element, clicks one, or dispatches synthetic input and
change events at a range.
| Command id | Title | Hotkey | Drives |
|---|---|---|---|
search | Focus search | / | Focus
#fl-search |
shuffle | Shuffle results | s | Click
#fl-shuffle |
grid | Grid view | g | Click
[data-view="grid"] |
list | List view | l | Click
[data-view="list"] |
filters | Toggle filters | f | Click
#fl-filter-toggle |
reco | Find your font | r | Click
#fl-reco-toggle |
tray | Open selected tray | t | Click
#fl-tray-fab, else #fl-tray-close |
download | Download selected | — | Click
#fl-download-selected |
size-up / size-down | Preview size | ] / [ | Nudge #fl-size and dispatch
input + change |
top | Scroll to top | — |
window.scrollTo |
theme | Toggle light / dark | — | Clicks the site theme toggle if it finds one |
preview | Focus preview text | — | Focus
#fl-preview-text |
tabCommands() turns every button in #fl-cat-tabs into a
"Category · …" jump command, with the trailing count stripped from the label. The palette itself
opens on ? and ranks with rankCommands() from
power-core.js; arrow keys move, Enter runs, Escape closes.
A recently-viewed strip is inserted before .anz-main inside
#font-app. A MutationObserver on #fl-d-name and on the
drawer's attributes calls recordOpen(), which stores the drawer title. Clicking a chip
does not reopen the drawer directly — it writes the name into the search box and dispatches an
input event, so the engine re-searches for it.
Finally, startWatchdog() waits 4 s, then polls
engineLooksAlive() eight times at 1.5 s intervals — roughly 16 s in the
worst case. The liveness test is generous: a non-empty grid, or a hero counter that is not just
dashes and ellipses, or a digit in the count. Failure replaces the grid with a fallback panel whose
copy differs depending on navigator.onLine.
Two watchdogs, deliberately
cdn-fallback.js is the narrower of the two and fires first, at
DEADLINE_MS = 6000. It requires both conditions to hold: the CDN stylesheet's
link.sheet is null (the request failed) and
#fl-grid still has no children. A slow-but-working CDN only trips the first; a
legitimately empty result set only trips the second. When both hold it replaces the grid with an
"<kind> library is temporarily unavailable" panel and removes the sentinel. The comment in the
file explains why detection is post-hoc rather than an onerror attribute: CSP sets
script-src-attr 'none' on this route, so inline handlers cannot run.
library-reference.js
A classic script with a MutationObserver over document.documentElement
(childList, subtree, characterData, and the class, hidden,
style, aria-hidden attributes). It re-shapes markup the site does not own:
| Function | Does |
|---|---|
normalizeScorePills() | Trims every .fl-score to the
bare number, dropping the leading emoji |
syncDetailPanelEmptyState() | Adds .aa-panel-empty to
the drawer and scrim while the title is a placeholder or the body has under 12 characters
and no interactive element — that is what keeps the skeleton state from flashing as a
full-viewport panel |
normalizeAlignmentButtons() | Detects the three alignment buttons by
attribute, class or arrow glyph and rewrites them into
.aa-align-icon three-bar glyphs with real aria-labels |
markSuitabilityReport() | Tags #fl-d-body as
.aa-suitability-report and classifies its rows into
.aa-suit-score-card, .aa-suit-meter-row and
.aa-suit-chip, with aa-score-high|mid|low from
scoreBucket() |
function scoreBucket(value) { // library-reference.js:104
const n = Number(value);
if (!Number.isFinite(n)) return "";
if (n >= 80) return "high";
if (n >= 45) return "mid";
return "low";
}
These thresholds (80 / 45) are the site's, and they are not the engine's (85 / 70 / 50). Both classes end up on the page at once. If you change one, look at the other.
font-home-nav.js
A capture-phase click listener on document.
isQuiesceLink() accepts only same-origin, same-tab, non-download anchors that change
the path or query — it returns null for hash links, mailto:,
tel:, javascript:, blob:, data:, cross-origin
URLs, and any click with a modifier held. For everything else it calls
preventDefault() and stopImmediatePropagation(), runs
quiesceHeavyPage() — replaceChildren() on
#fl-grid and #ic-grid, then
pointer-events: none and visibility: hidden on
<html> — and finally navigates with location.assign.
The reason is in the file's comment: tearing down the live specimen grid while observers were still
firing and font fetches were in flight crashed the renderer with
STATUS_BREAKPOINT. The side effect is that ordinary anchor behaviour on this page is
pre-empted, and any other listener on those clicks never runs.
Content-Security-Policy
_headers gives /tool/font and /tool/font/* a dedicated policy
that whitelists https://fonts.auricartisan.com in script-src,
style-src, font-src, img-src and
connect-src, alongside 'self', the AdSense hosts and
https://api.auricartisan.com for connect. script-src-attr is
'none', so no inline event-handler attribute can run on this route.
js/server.js keeps a byte-for-byte mirror of the policy as
FONT_CSP for the dev server; the two must be edited together.
The Design System Generator route carries its own variant whitelisting both
fonts. and icons.auricartisan.com, because it loads both library engines.
Theming seam
css/font-icon-panel.css opens by resetting
--color-gold-rgb: inherit on .fl and .ic. The remote
stylesheet declares --color-gold-rgb: var(--color-gold-rgb, …), which is a
self-referential cycle; the browser discards the declaration and the site accent along with it.
Everything after that repaints the drawer with the --aa-panel-* tokens;
css/font-shell.css then docks it to the right edge and hides the scrim on wide
screens.
Route opt-outs
The shell sets <meta name="aa-url-state" content="off" />, which turns off the
site-wide #p= scroll and control recorder in
js/common/url-scroll-state.js. The comment above it says why: generic control scraping
over a page this large bloats the URL and crashes the renderer on back/forward.
12. Storage, publishing, tests and known limits
+Storage keys
| Key | Owner | Shape | Written |
|---|---|---|---|
fl-selected | font-library.js |
JSON array of {id, name, group, category} |
Every selection change, via saveSelected(); read back by
loadSelectedFromStore() before the fetches and refreshed against the fresh
index by hydrateSelectedFromIndex() |
aa_fl_recent_v1 | library-power.js |
Up to 12 {id, label, t}, newest first |
Whenever the drawer title changes; the store falls back to an in-memory object when
localStorage throws |
Both writes are wrapped so a blocked or full localStorage is silent. Neither key is
versioned by catalogue build, which is why the tray is re-hydrated from the index on every boot
rather than trusted.
Globals
| Global | Set by | Contents |
|---|---|---|
window.AuricFonts | font-cdn.js | The loader API of section 10 |
window.AuricFontLoader | font-cdn.js | Alias of the same object |
window.AALibraryPower | library-power.js |
{ open, commands, recents } |
window.AURIC_FONT_BASE / AURIC_FONT_ASSET_BASE |
You, if you want to | Read by both remote scripts before the meta tag |
font-library.js is a module and exports nothing. There is no supported way to call into
the engine from another script; the extension point is the DOM, which is exactly what
library-power.js and library-reference.js use.
Keyboard bindings
Two independent document-level listeners bind overlapping keys.
| Listener | Phase | Guards | Keys |
|---|---|---|---|
Engine (wireControls()) | Bubble | Skips inputs, textareas, selects and contenteditable; skips when Meta, Ctrl or Alt is held | /, r, f, g, l,
c (only with 2+ selected) |
| Engine, second listener | Bubble | None | Escape always calls closeDrawer() |
library-power.js | Capture | Same typing guard; explicitly yields Ctrl/⌘ combinations so the site spotlight keeps Ctrl-K | ?, /, s, f, r,
t, g, l, [, ],
Escape |
Neither listener stops propagation, so the shared keys fire twice. For g and
l that is harmless — the same view button is clicked twice. For f
and r it is not: both handlers toggle the same panel, so the second toggle undoes the
first.
Publishing pipeline
scripts/upload-fonts-r2-api.mjs pushes --future--/font to the R2 bucket
fonts through the Cloudflare API. The token comes from
CLOUDFLARE_API_TOKEN or the wrangler OAuth file.
| Flag | Default | Effect |
|---|---|---|
--bucket | fonts | Target bucket |
--source | ./--future--/font | Local root |
--concurrency | 24 (capped at 64) | Parallel uploads |
--attempts | 4 | Retries per object |
--skipExisting | off | Lists remote keys first and uploads only what is missing |
--retryFile | — | Re-uploads only the keys in a previous failure log |
--dryRun, --limit, --delayMs | — | Print the first 20 keys and exit; cap the file count; throttle |
scripts/r2-fonts-cors.json restricts the bucket to GET and
HEAD from https://auricartisan.com,
https://www.auricartisan.com and http://localhost:3001, exposes
ETag and sets a 24-hour maxAgeSeconds. Local development therefore has to
run on port 3001 or the CDN will refuse the requests.
Cross-tool seams
| Seam | Where |
|---|---|
| Tool directory entry (category Typography; tags fonts, typography, specimens, embed, cdn) | data/collections.json |
openFontLibrary() → /tool/font/?q=<heading family> |
js/tool/design-engine/design-engine.js |
| Split-view registry entry | js/common/split/split.v3.js |
| Header navigation entry | the generated inline nav bundle |
| Combined CSP for both library engines | _headers, the
/tool/design-engine block |
The Design System Generator deep link is currently a dead end: font-library.js never reads
location.search or location.hash, so the ?q= is ignored and
you land on the unfiltered grid. Wiring it up means reading the query in
init() before applyFilters() and seeding S.q and
#fl-search.
Tests
Only the pure logic of the power layer is covered:
tests/library/power-core.test.js exercises
parseHotkey, eventMatchesHotkey, formatHotkey,
shouldIgnoreTyping, rankCommands and createRecentStore
against js/tool/library/power-core.js, including a throwing storage backend. The remote
engine has no test in this repository, and cannot easily have one while it is fetched at runtime
from another host.
Known limits
- The search placeholder says "Family, designer or foundry" but the haystack is
name + designer + category. Themanufacturerfield is never searched. - 190 of the 2,213 families are the build machine's installed system fonts, catalogued with
local()aliases and no served file. On the public site they render only if the visitor happens to have them, their downloads read "Installed only" and are disabled, and they still count toward the headline figure. - Opening any family downloads that family's entire detail group — 3.3 MB for
A-F, about 4.0 MB forM-R— on top of the 1.85 MBindex.jsonalready fetched at boot. Nothing is paginated server-side. - The glyph map caps at 8,000 codepoints and the charset tab lists only the first 40 ranges, so CJK-scale fonts are silently truncated in both.
- Recommendations and pairings can only ever surface the ~130 seeded families that exist in the catalogue.
- The catalogue is a build snapshot dated 2026-06-02. Adding fonts means re-running the build and re-uploading; nothing refreshes at runtime.
- The specimen editor is built on
document.execCommand, which is deprecated and behaves differently across browsers. - The ZIP is store-only, assembled in memory, with font files fetched serially.
EscapecallscloseDrawer()unconditionally from a listener attached at wire time, on top of the power layer's own Escape handling.- Everything on the page comes from one external host, and the route CSP whitelists only that host. If it is blocked, the catalogue disappears — which is why there are two independent outage fallbacks rather than one.