Skip to main content
Auric Artisan · Documentation

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.

Updated: September 3, 2026 Route: /tool/font/ Engine: fonts.auricartisan.com/font-library.js Reading time: about 30 minutes Author: Chirag Bansal
Back to Documentation Auric Artisan Home

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.

Table of contents

  1. 1. Runtime overview and file map
  2. 2. Base resolution, boot sequence and CDN URL shape
  3. 3. Catalogue data contracts
  4. 4. Application state and the DOM contract
  5. 5. FontFace registration and lazy specimens
  6. 6. Filtering, sorting and pagination
  7. 7. The detail drawer and its eleven panes
  8. 8. Scores, pairings and the recommender maths
  9. 9. Embed snippets, developer exports and the ZIP writer
  10. 10. The AuricFonts CDN loader
  11. 11. Site-side layers: power tools, normaliser, fallbacks, CSP
  12. 12. Storage, publishing, tests and known limits

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.

ElementSourceValue
#stat-familiesmeta.families, falling back to index.length2,213
#stat-filesmeta.files4,883
#stat-variablemeta.variable550
#stat-scriptsObject.keys(stats.scripts).length 60

CDN URL shape

Path under the baseWhat it isFetched when
/data/meta.jsonBuild manifestBoot
/data/stats.jsonFacet histogramsBoot
/data/index.json2,213 grid records, 1.85 MBBoot
/data/collections.jsonSix featured shelvesBoot, not awaited
/data/detail/<GROUP>.jsonFull metadata for one alphabet range; GROUP is A-F, G-L, M-R or S-ZFirst time a family in that group is opened; cached in S.details
/data/licenses/<bodyId>.txtOne of 314 deduplicated licence bodiesLicence tab, or ZIP packaging; cached in S.licenses
/css/<id>.cssGenerated per-family stylesheetNever 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.ttfCard enters view, family opened, or ZIP built
/font-cdn.js, /font-library.js, /font-library.cssThe engine itselfPage 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.

FacetBuilt fromCurrent distribution
Category tabsstats.categories, sorted by count serif 1,172 · sans-serif 780 · handwriting 97 · monospace 83 · display 81
Licence chipsstats.licenses ofl 1,929 · unknown 191 · apache-2.0 45 · cc 40 · mit 7 · custom 1
Source chipsstats.sourceslibrary 2,023 · system 190
Script chipsstats.scripts, top 18 by count60 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 idTitleIds
popularPopular Fonts36
newNew Fonts28
uiBest UI Fonts30
a11yBest Accessibility Fonts30
devBest Developer Fonts30
editorialBest Editorial Fonts30

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.

GroupIds
Herofl-hero-stats, stat-families, stat-files, stat-variable, stat-scripts
App rootfont-app (also the flag library-power.js uses to decide it is on the font page)
Specimen controlsfl-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 toolbarfl-cat-tabs, fl-search, fl-sort, fl-filter-toggle, fl-reco-toggle, fl-download-selected, fl-count
Facetsfl-filters-panel, facet-props, facet-weights, facet-features, facet-scripts, facet-license, facet-source, fl-reset
Resultsfl-featured, fl-recommend-panel, fl-grid, fl-sentinel
Drawerfl-scrim, fl-drawer, fl-d-name, fl-d-sub, fl-d-add, fl-d-download, fl-d-close, fl-d-tabs, fl-d-body
Trayfl-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
Feedbackfl-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);
}
ConventionValue
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 sourcelocal("…") list built by splitting preview.local on |
Displayalways 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

ObserverrootMarginDoes
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.

FacetFieldSemantics
Categorye.categorySet membership; the tabs are single-select so the set holds at most one value
Licencee.licenseOR within the facet
Sourcee.sourceOR within the facet
Propertiese.variable, e.italic, e.monospaceEach selected chip is a hard requirement
Weightse.weightsevery — AND. Selecting 300 and 700 keeps only families that have both.
Scriptse.scriptsevery — AND
OpenTypee.featevery over the chip, then some over that chip's tag family
Searchname + ' ' + 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 valueComparator
name (default)localeCompare(…, 'en', {sensitivity:'base'})
accessibility / ui / readability Descending on that score, ties broken alphabetically
weightsDescending weightCount
glyphsDescending charCount
variableVariable 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.

PaneRenderWireWhat it does
SpecimenpaneSpecimen()wireSpecimen() Contenteditable tester, axis sliders, instance chips, OpenType chips, seven presets
SuitabilitypaneSuitability()— Five score rings, six signal bars, Best-for chips, eight-trait fingerprint
PairingspanePairings()wirePairings() Eight ranked partners with a live heading/body preview
GlyphspaneGlyphs()wireGlyphs() Codepoint grid, 600 at a time, click to copy
FeaturespaneFeatures()wireFeatures() Default → enabled comparison per tag; click copies the CSS
StylespaneStyles()wireStyles() Every weight rendered, then the file table and the kit download
AboutpaneAbout()— Fourteen provenance rows, description, copyright, trademark
LicensepaneLicense()wireLicense() Badge, links, permissions grid, full text fetched by bodyId
CharsetpaneCharset()— Script chips, coverage counts, first 40 unicode ranges, sample line
DeveloppaneDevelop()wireDevelop() Font stack, five export formats, modular type scale
Use & EmbedpaneEmbed()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.

BarFieldNote shown beside it
x-heightsuitability.xHeightScorexHeightEm + " em"
Character distinction (I l 1 · 0 O)suitability.distinction "distinct zero" when signals.zeroDistinct
Proportion / widthsuitability.width—
Weight flexibilitysuitability.weightFlexibility signals.weightCount + " weights"
Screen renderingsuitability.screenRendering—
Low stroke-contrastsuitability.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:

PairFitPairFit
sans-serif + serif1.00sans-serif + sans-serif0.55
display + sans-serif1.00monospace + sans-serif0.55
display + serif0.82monospace + serif0.55
handwriting + sans-serif0.62serif + serif0.45
handwriting + serif0.62display + display0.40
display + monospace0.35monospace + monospace0.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.

TabEmits
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>
JSAuricFonts.load('<Name>').then(…)
@font-faceThe first four styles through fontFaceSnippet()
UsageA .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.

ExportShape
CSS variables:root { --font-<role>: <stack> } plus a body rule
TailwindA 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 @importThe @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.

RecordSignatureSizeNotes
Local file header0x04034b5030 bytes + name version 20, general-purpose flag 0x0800 (UTF-8 names), method 0, CRC-32, compressed size = uncompressed size
Central directory entry0x02014b5046 bytes + name Carries the local header offset at byte 42
End of central directory0x06054b5022 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.

MemberSignatureBehaviour
basegetter / setterReads or replaces the asset base; the setter runs normalizeBase()
slugify(name) → idThe 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
indexgetterThe 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).

AttributeEffect
data-families (or data-fonts)A |-separated list of specs; each is passed to load()
data-displaySets the default font-display used by face()
data-baseOverrides 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 idTitleHotkeyDrives
searchFocus search/Focus #fl-search
shuffleShuffle resultssClick #fl-shuffle
gridGrid viewgClick [data-view="grid"]
listList viewlClick [data-view="list"]
filtersToggle filtersfClick #fl-filter-toggle
recoFind your fontrClick #fl-reco-toggle
trayOpen selected traytClick #fl-tray-fab, else #fl-tray-close
downloadDownload selected—Click #fl-download-selected
size-up / size-downPreview size ] / [Nudge #fl-size and dispatch input + change
topScroll to top— window.scrollTo
themeToggle light / dark—Clicks the site theme toggle if it finds one
previewFocus 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:

FunctionDoes
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

KeyOwnerShapeWritten
fl-selectedfont-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_v1library-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

GlobalSet byContents
window.AuricFontsfont-cdn.jsThe loader API of section 10
window.AuricFontLoaderfont-cdn.jsAlias of the same object
window.AALibraryPowerlibrary-power.js { open, commands, recents }
window.AURIC_FONT_BASE / AURIC_FONT_ASSET_BASE You, if you want toRead 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.

ListenerPhaseGuardsKeys
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 listenerBubbleNone Escape always calls closeDrawer()
library-power.jsCapture 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.

FlagDefaultEffect
--bucketfontsTarget bucket
--source./--future--/fontLocal root
--concurrency24 (capped at 64)Parallel uploads
--attempts4Retries per object
--skipExistingoffLists 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

SeamWhere
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 entryjs/common/split/split.v3.js
Header navigation entrythe 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. The manufacturer field 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 for M-R — on top of the 1.85 MB index.json already 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.
  • Escape calls closeDrawer() 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.