diff --git a/README.md b/README.md index 0c6d075..d336eb6 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,34 @@ portal and console each pin its SHA-256. `scripts/check-styles.mjs` holds the da prints that hash. **A change here must be followed by the portal and the console taking the file again and moving their pins to the new hash.** Until they do, their checks fail. +## Type + +`src/data/type.ts` is the source for the house font set and the twelve pairings a client picks +from. It holds sixteen families: fifteen Fontsource packages and the device's own sans. Each +family records its board class (a `FontKey` from styles.ts), package, licence, designer, axes, +the weights the pairings use, a metric-matched fallback with its `size-adjust` and overrides, and +the kilobytes of its Latin woff2. Each pairing records a heading, a body and an optional label +family, a feel, recommended heading and body settings, what it suits, and the style boards it +serves. `familyFor(key)` gives the family a board class renders in. It is plain data with no +imports, so any app can copy it. **No font file is committed here.** A site installs the +package, imports the Latin subset and serves it from its own origin. + +The rules are strict. Only SIL OFL 1.1 and Apache 2.0 are allowed, because both let the files be +redistributed and self-hosted. A pairing's families weigh at most 120 KB together. "System" +costs nothing and stays on the list. A pairing serves a board only when its families match the +board's heading and body classes, and its label class when the board has one, so the board's +`type.note` stays true. `kb` and `metrics` were measured from the Fontsource 5.3.0 files with +Capsize. The header of `type.ts` says how. + +It is consumed by: + +- **SuperheroPortal**, `src/brand/type.data.ts`: the brand guide's typography and hearts. +- **superherotech.ai**, which vendors it for the public `/type/` page and to render `/styles/`. + +The pin rule is the same as for style boards. Only the file header may differ between copies, each +copy pins the SHA-256 of the body, and `scripts/check-type.mjs` prints it. **A change here must +be followed by the copies taking the file again and moving their pins to the new hash.** + ## Licence MIT, Ecropolis LLC. Elements are copied into client sites; see `LICENSE`. diff --git a/package.json b/package.json index 9eabc47..4529f8c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "preview": "astro preview", "astro": "astro", "build:icon-data": "node scripts/build-icon-data.mjs", - "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-categories.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-mega-menu.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-styles.mjs" + "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-categories.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-mega-menu.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-styles.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-type.mjs" }, "dependencies": { "astro": "^7.2.1" diff --git a/scripts/check-type.mjs b/scripts/check-type.mjs new file mode 100644 index 0000000..df63e6c --- /dev/null +++ b/scripts/check-type.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * The house font set and its pairings in src/data/type.ts -- one file in several repos. + * + * Downstream copies (the portal's src/brand/type.data.ts, the site's vendored + * copy) are byte-identical below the file header and pin the SHA-256 of that + * body. This check holds the data's own rules and prints the hash those pins + * record: + * + * - every FontKey in styles.ts resolves through `familyFor` to a family of that class, + * and the file's written-out FontKey and StyleId unions match styles.ts exactly + * - family and pairing ids are unique, slug-shaped and listed in the same order + * as their id arrays; twelve pairings, one of them "System" at zero bytes + * - every web family is a Fontsource package (`@fontsource-variable/*` when + * variable, `@fontsource/*` when not) under OFL-1.1 or Apache-2.0 and nothing else, + * with its licence URL, designer, 1-4 weights, fallback and metrics + * - the fallback's first face is one the metrics were computed against + * - every pairing names real families, and together they weigh at most TYPE_BUDGET_KB + * - the heading weight is one of the heading family's weights; body text is at 400 + * - every family is used by a pairing, and every style board is served by one + * - a pairing serves a board only if its families match the board's heading and body + * classes, and its label class when the board names one, so `type.note` stays true + * - the file imports nothing (framework-free) and its header carries the pin rule + * + * When you change the body, the hash changes: downstream copies must take the + * file again and move their pins in the same change, or their checks fail. + * + * node --experimental-strip-types scripts/check-type.mjs + */ +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const FILE = join(root, 'src/data/type.ts'); +const STYLES = join(root, 'src/data/styles.ts'); + +const PIN_RULE = 'Only this header may differ; below it the file is one file in every repo.'; +const HEADER_END = '\n */\n'; +const LICENCES = ['OFL-1.1', 'Apache-2.0']; +// The local faces the metrics are computed against (Capsize's system-font metrics). +const METRIC_FACES = ['Arial', 'Times New Roman', 'Courier New']; +const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const PERCENT = /^\d+(\.\d+)?%$/; + +const failures = []; +const fail = (msg) => failures.push(msg); + +const src = readFileSync(FILE, 'utf8'); +const at = src.indexOf(HEADER_END); +if (!src.startsWith('/**') || at === -1) fail('type.ts must open with a block-comment header: the pinned hash is taken below it'); +const header = at === -1 ? '' : src.slice(0, at + HEADER_END.length); +const body = at === -1 ? src : src.slice(at + HEADER_END.length); +if (!header.replace(/\n \* ?/g, ' ').includes(PIN_RULE)) fail(`the header must say "${PIN_RULE}"`); +if (/^\s*import\s/m.test(body) || /\brequire\(/.test(body)) fail('type.ts must import nothing: downstream copies it as plain data'); + +const T = await import(pathToFileURL(FILE).href); +const S = await import(pathToFileURL(STYLES).href); +const fontKeys = Object.keys(S.FONT_STACKS); +const boards = S.STYLE_BOARDS; +const families = T.TYPE_FAMILIES; +const pairings = T.TYPE_PAIRINGS; + +// The file's written-out unions match styles.ts. +const unionOf = (name) => { + const m = body.match(new RegExp(`type ${name} =([^;]+);`)); + return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : []; +}; +for (const [name, want] of [['FontKey', fontKeys], ['StyleId', [...S.STYLE_IDS]]]) { + const got = unionOf(name); + if (JSON.stringify(got) !== JSON.stringify(want)) fail(`the ${name} union in type.ts must list, in order, ${want.join(', ')}; it lists ${got.join(', ') || 'nothing'}`); +} + +const short = (what, s, max) => { + if (typeof s !== 'string' || !s.trim()) fail(`${what} is empty`); + else if (s.length > max) fail(`${what} is ${s.length} characters; at most ${max}`); +}; +const idsOk = (what, list, declared) => { + const ids = list.map((x) => x.id); + if (new Set(ids).size !== ids.length) fail(`${what} ids repeat: ${ids.join(', ')}`); + for (const id of ids) if (!SLUG.test(id)) fail(`${what} id "${id}" is not slug-shaped`); + if (JSON.stringify([...declared]) !== JSON.stringify(ids)) fail(`${what}: the id array and the list name different ids or orders`); + return ids; +}; + +// Families +const fids = idsOk('family', families, T.TYPE_FAMILY_IDS); +const byId = new Map(families.map((f) => [f.id, f])); +for (const f of families) { + const w = `family ${f.id}`; + short(`${w}: name`, f.name, 40); + short(`${w}: designer`, f.designer, 120); + short(`${w}: fallback`, f.fallback, 200); + if (!fontKeys.includes(f.class)) fail(`${w}: class "${f.class}" is not a FONT_STACKS key`); + const weights = f.weights ?? []; + if (weights.length < 1 || weights.length > 4) fail(`${w}: ${weights.length} weights; want one to four`); + for (const n of weights) if (!Number.isInteger(n) || n < 100 || n > 900 || n % 100) fail(`${w}: weight ${n} is not one of 100..900`); + if (JSON.stringify([...weights]) !== JSON.stringify([...weights].sort((a, b) => a - b))) fail(`${w}: weights must be ascending`); + + if (f.source === 'system') { + if (f.package !== null || f.licence !== null || f.licenceUrl !== null) fail(`${w}: a system family has no package, licence or licence URL`); + if (f.kb !== 0) fail(`${w}: a system family weighs 0 KB, not ${f.kb}`); + if (f.metrics) fail(`${w}: a system family has nothing to swap from, so no metrics`); + continue; + } + if (f.source !== 'fontsource') { fail(`${w}: source "${f.source}"; want fontsource or system`); continue; } + if (!LICENCES.includes(f.licence)) fail(`${w}: licence "${f.licence}"; only ${LICENCES.join(' or ')}`); + const scope = f.variable ? '@fontsource-variable/' : '@fontsource/'; + if (typeof f.package !== 'string' || !f.package.startsWith(scope) || !SLUG.test(f.package.slice(scope.length))) { + fail(`${w}: package "${f.package}"; a ${f.variable ? 'variable' : 'static'} family comes from ${scope}`); + } + let url = null; + try { url = new URL(f.licenceUrl); } catch {} + if (!url || url.protocol !== 'https:' || !f.licenceUrl.includes(f.package)) fail(`${w}: licenceUrl "${f.licenceUrl}" must be an https URL of the package's own licence`); + if (f.variable && !(f.axes ?? []).includes('wght')) fail(`${w}: a variable family lists its axes, wght among them`); + if (!f.variable && f.axes !== undefined) fail(`${w}: a static family has no axes`); + if (typeof f.kb !== 'number' || !(f.kb > 0) || Math.round(f.kb * 10) !== f.kb * 10) fail(`${w}: kb "${f.kb}" must be a positive number to one decimal`); + const first = f.fallback.split(',')[0].trim().replace(/^'|'$/g, ''); + if (!METRIC_FACES.includes(first)) fail(`${w}: the fallback must start with a face the metrics are computed against (${METRIC_FACES.join(', ')}); it starts with ${first}`); + if (!f.metrics) fail(`${w}: no metrics for the fallback @font-face`); + for (const [k, v] of Object.entries(f.metrics ?? {})) if (!PERCENT.test(v)) fail(`${w}: metrics.${k} "${v}" is not a percentage`); +} + +// Every board class resolves. +for (const k of fontKeys) { + const f = T.familyFor(k); + if (!f) fail(`FontKey "${k}" resolves to no family`); + else if (f.class !== k) fail(`familyFor("${k}") gives ${f.id}, of class ${f.class}`); +} + +// Pairings +if (pairings.length !== 12) fail(`twelve pairings, not ${pairings.length}`); +idsOk('pairing', pairings, T.TYPE_PAIRING_IDS); +const used = new Set(); +const served = new Map(boards.map((b) => [b.id, []])); +const report = []; +for (const p of pairings) { + const w = `pairing ${p.id}`; + short(`${w}: name`, p.name, 40); + short(`${w}: feel`, p.feel, 120); + const fam = {}; + for (const role of ['heading', 'body', 'label']) { + if (p[role] === undefined && role === 'label') continue; + const f = byId.get(p[role]); + if (!f) { fail(`${w}: ${role} "${p[role]}" is not a family`); continue; } + fam[role] = f; + used.add(f.id); + } + const h = p.headings ?? {}; + if (!(h.scale >= 0.5 && h.scale <= 2)) fail(`${w}: headings.scale ${h.scale}; want 0.5 to 2`); + if (fam.heading && !fam.heading.weights.includes(h.weight)) fail(`${w}: headings.weight ${h.weight} is not one of ${fam.heading.id}'s weights (${fam.heading.weights.join(', ')})`); + if (!/^(0|-?\d*\.?\d+em)$/.test(h.tracking ?? '')) fail(`${w}: headings.tracking "${h.tracking}"; want 0 or a length in em`); + if (typeof h.upper !== 'boolean') fail(`${w}: headings.upper must be true or false`); + if (!(h.lineHeight >= 0.8 && h.lineHeight <= 1.6)) fail(`${w}: headings.lineHeight ${h.lineHeight}; want 0.8 to 1.6`); + const b = p.bodySettings ?? {}; + if (!/^\d*\.?\d+rem$/.test(b.size ?? '')) fail(`${w}: bodySettings.size "${b.size}"; want rem`); + if (!(b.lineHeight >= 1.3 && b.lineHeight <= 1.9)) fail(`${w}: bodySettings.lineHeight ${b.lineHeight}; want 1.3 to 1.9`); + const measure = /^(\d+)ch$/.exec(b.measure ?? ''); + if (!measure || +measure[1] < 45 || +measure[1] > 80) fail(`${w}: bodySettings.measure "${b.measure}"; want 45ch to 80ch`); + if (fam.body && !fam.body.weights.includes(400)) fail(`${w}: body text is set at 400, which ${fam.body.id} does not list`); + if (!Array.isArray(p.suits) || p.suits.length < 1 || p.suits.length > 4) fail(`${w}: suits names one to four businesses`); + for (const s of p.suits ?? []) short(`${w}: suits entry`, s, 60); + + const kb = T.pairingKb(p); + if (kb > T.TYPE_BUDGET_KB) fail(`${w}: its families weigh ${kb} KB; the budget is ${T.TYPE_BUDGET_KB}`); + + if (!p.pairsWith?.length) fail(`${w}: pairsWith names no board`); + for (const id of p.pairsWith ?? []) { + const board = boards.find((x) => x.id === id); + if (!board) { fail(`${w}: pairsWith "${id}" is not a board`); continue; } + served.get(id).push(p.id); + const t = board.type; + for (const role of ['heading', 'body', 'label']) { + if (role === 'label' && t.label === undefined) continue; + const got = fam[role]?.class; + if (got !== t[role]) fail(`${w}: serves ${id}, whose ${role} is ${t[role]} ("${t.note}"), but its ${role} is ${got ?? 'missing'}`); + } + } + report.push(`${p.id} ${kb}KB`); +} +for (const id of fids) if (!used.has(id)) fail(`family ${id} is in no pairing: drop it or pair it`); +for (const [id, ps] of served) if (!ps.length) fail(`board ${id} is served by no pairing`); +const system = pairings.find((p) => p.id === 'system'); +if (!system || T.pairingKb(system) !== 0) fail('the "system" pairing must be on the list, at zero bytes'); + +// Constants and helpers +for (const [k, v] of [['TYPE_PICKS_MAX', 2], ['TYPE_BUDGET_KB', 120]]) { + if (T[k] !== v) fail(`${k} is ${T[k]}; want ${v}`); +} +if (T.pairingById('nope') !== null || T.pairingById(pairings[0]?.id) !== pairings[0]) fail('pairingById must find a pairing by id and give null for anything else'); +if (T.isPairingId('nope') || !T.isPairingId(pairings[0]?.id)) fail('isPairingId must accept exactly the pairing ids'); + +if (failures.length) { + console.error('check-type failed:'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +const sha = createHash('sha256').update(body).digest('hex'); +const licences = LICENCES.map((l) => `${families.filter((f) => f.licence === l).length} ${l}`).join(', '); +console.log(`check-type: ${families.length} families (${licences}, 1 system), ${pairings.length} pairings: ${report.join(', ')}`); +console.log(`check-type: body SHA-256 ${sha} (downstream pins record this)`); diff --git a/src/data/type.ts b/src/data/type.ts new file mode 100644 index 0000000..1aad3a2 --- /dev/null +++ b/src/data/type.ts @@ -0,0 +1,639 @@ +/** + * Type: the house font set and its pairings -- SuperheroLibrary, src/data/type.ts. THE SOURCE. + * + * Copies live downstream, each with its own header and this file's body: + * + * ecropolis/SuperheroPortal src/brand/type.data.ts (the brand guide's typography, hearts) + * + * superherotech.ai vendors it too, for the public /type/ page and for /styles/, + * which renders each board's type in its real family through `familyFor`. + * + * Only this header may differ; below it the file is one file in every repo. + * + * Each downstream repo pins the SHA-256 of everything after this comment, the + * way it pins src/data/styles.ts. `npm run check` here prints the hash + * (scripts/check-type.mjs). Change the body here first, then take it again + * downstream in changes that move their pins to the new hash. A pin is moved + * only together with its copy -- otherwise it records the drift instead of + * guarding against it. + * + * This file is data only. No font file is committed to this repo: a site + * installs the family's Fontsource `package`, imports its Latin subset and + * serves it from its own origin. + */ + +// Framework-free on purpose: no imports, no DOM, no Astro. The portal is a +// Worker, the site is Astro, and both read this as plain data. +// Rendering stays in each app; this file only says what the type IS. + +/** + * The board classes from src/data/styles.ts (`keyof typeof FONT_STACKS`), + * written out rather than imported so this file stays importless. + * scripts/check-type.mjs holds the two lists equal. + */ +type FontKey = + | "system" + | "geometric" + | "mono" + | "serif" + | "didone" + | "humanist" + | "rounded" + | "hand" + | "slab" + | "heavy" + | "script"; + +/** The style board ids from src/data/styles.ts, written out for the same reason. */ +type StyleId = + | "high-tech" + | "minimal" + | "classic" + | "natural" + | "luxurious" + | "playful" + | "childlike" + | "retro" + | "bold" + | "handcrafted"; + +/** The family ids, grouped by class in FONT_STACKS order. A class's first family is its default. */ +export const TYPE_FAMILY_IDS = [ + "system-ui", + "inter", + "source-sans-3", + "nunito-sans", + "jost", + "jetbrains-mono", + "source-serif-4", + "lora", + "playfair-display", + "fraunces", + "eb-garamond", + "nunito", + "comic-neue", + "roboto-slab", + "archivo", + "dancing-script", +] as const; + +export type FamilyId = (typeof TYPE_FAMILY_IDS)[number]; + +/** The twelve pairing ids, in the order every page shows them. Hearts store these. */ +export const TYPE_PAIRING_IDS = [ + "system", + "quiet", + "precise", + "counsel", + "atelier", + "harvest", + "old-style", + "friendly", + "crayon", + "diner", + "loud", + "workshop", +] as const; + +export type PairingId = (typeof TYPE_PAIRING_IDS)[number]; + +/** A client hearts up to two pairings. */ +export const TYPE_PICKS_MAX = 2; + +/** The most a pairing's families may weigh together, in KB of Latin woff2. */ +export const TYPE_BUDGET_KB = 120; + +/** The only two licences a family may carry. Both allow the files to be redistributed and self-hosted. */ +export type TypeLicence = "OFL-1.1" | "Apache-2.0"; + +/** + * A family. Every web family is a Fontsource package under OFL-1.1 or + * Apache-2.0, checked against the package's own LICENSE at the version below. + * + * One family is not a download: `system-ui`, the device's own sans. It has + * `source: "system"`, no package, no licence and zero bytes, so the "System" + * pairing costs nothing and stays a legitimate choice. + * + * - `kb` is the Latin-subset upright woff2 a site loads, measured from + * Fontsource 5.3.0 (every package here is at 5.3.0), in KB of 1024 bytes to + * one decimal. For a variable family that is the `wght` file its default + * import (`index.css`) serves, which covers every weight; for a static one + * it is the sum of the listed `weights`. Italics are extra and load only + * where a page uses them. + * - `axes` lists the axes the font has; the `wght` file carries only `wght`. + * The other axes (opsz, SOFT, wdth, ...) need the package's larger files. + * - `weights` is what the pairings use, at most four. + * - `fallback` is a local stack. `metrics` is for a fallback `@font-face` + * whose `src` is `local()` of the FIRST face in `fallback`, so the swap to + * the web font does not move the layout. The numbers are Capsize's + * (`@capsizecss/core` 4.1.3 `createFontStack`) with the web font measured + * from the Fontsource woff2 itself by `@capsizecss/unpack` 4.0.1 and the + * fallback's from `@capsizecss/metrics` 4.3.0; rounded to two decimals. + */ +export interface TypeFamily { + id: FamilyId; + /** The family's own name: "Inter". The CSS name is `cssFamily(f)`. */ + name: string; + /** The board class it stands in for. Every FontKey has at least one family. */ + class: FontKey; + source: "fontsource" | "system"; + /** "@fontsource-variable/inter"; null only for the system family. */ + package: string | null; + licence: TypeLicence | null; + /** The LICENSE file shipped in the package, at the version measured. */ + licenceUrl: string | null; + designer: string; + variable: boolean; + axes?: readonly string[]; + weights: readonly number[]; + fallback: string; + metrics?: { sizeAdjust: string; ascentOverride?: string; descentOverride?: string; lineGapOverride?: string }; + kb: number; +} + +export interface TypePairing { + id: PairingId; + name: string; + heading: FamilyId; + body: FamilyId; + /** A third face for small labels, eyebrows or figures, when the pairing has one. */ + label?: FamilyId; + /** One line, at most 120 characters: what the pairing feels like. */ + feel: string; + /** + * The recommended heading settings. `scale` multiplies the site's heading + * sizes; `tracking` is CSS letter-spacing; `lineHeight` is unitless. + */ + headings: { scale: number; weight: number; tracking: string; upper: boolean; lineHeight: number }; + /** Body text: CSS font-size, unitless line-height, and max-width in ch. */ + bodySettings: { size: string; lineHeight: number; measure: string }; + /** The kinds of business it suits. */ + suits: readonly string[]; + /** + * The style boards it serves. A board listed here has its heading and body + * classes matched by this pairing's families, and its label class too when + * the board names one, so the board's `type.note` stays true of it. + */ + pairsWith: readonly StyleId[]; +} + +const SANS = "Arial, Helvetica, sans-serif"; +const SERIF = "'Times New Roman', Times, serif"; +const MONO = "'Courier New', Courier, monospace"; +const LICENCE = (pkg: string) => `https://cdn.jsdelivr.net/npm/${pkg}@5.3.0/LICENSE`; + +export const TYPE_FAMILIES: readonly TypeFamily[] = [ + /* ------------------------------------------------------------- system */ + { + id: "system-ui", + name: "System UI", + class: "system", + source: "system", + package: null, + licence: null, + licenceUrl: null, + designer: "The device's own: San Francisco, Segoe UI, Roboto", + variable: false, + weights: [400, 500, 600, 700], + fallback: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", + kb: 0, + }, + { + id: "inter", + name: "Inter", + class: "system", + source: "fontsource", + package: "@fontsource-variable/inter", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/inter"), + designer: "Rasmus Andersson", + variable: true, + axes: ["wght", "opsz"], + weights: [400, 500, 700], + fallback: SANS, + metrics: { sizeAdjust: "107.12%", ascentOverride: "90.44%", descentOverride: "22.52%", lineGapOverride: "0%" }, + kb: 47.1, + }, + { + id: "source-sans-3", + name: "Source Sans 3", + class: "system", + source: "fontsource", + package: "@fontsource-variable/source-sans-3", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/source-sans-3"), + designer: "Paul D. Hunt for Adobe", + variable: true, + axes: ["wght"], + weights: [400, 600], + fallback: SANS, + metrics: { sizeAdjust: "89.28%", ascentOverride: "114.7%", descentOverride: "44.8%", lineGapOverride: "0%" }, + kb: 28.1, + }, + { + id: "nunito-sans", + name: "Nunito Sans", + class: "system", + source: "fontsource", + package: "@fontsource-variable/nunito-sans", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/nunito-sans"), + designer: "Vernon Adams, Jacques Le Bailly, Manvel Shmavonyan, Alexei Vanyashin", + variable: true, + axes: ["wght", "wdth", "opsz", "YTLC"], + weights: [400, 700], + fallback: SANS, + metrics: { sizeAdjust: "97.8%", ascentOverride: "103.37%", descentOverride: "36.09%", lineGapOverride: "0%" }, + kb: 30.3, + }, + /* ---------------------------------------------------------- geometric */ + { + id: "jost", + name: "Jost", + class: "geometric", + source: "fontsource", + package: "@fontsource-variable/jost", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/jost"), + designer: "Owen Earl (indestructible type*)", + variable: true, + axes: ["wght"], + weights: [400, 500, 600], + fallback: SANS, + metrics: { sizeAdjust: "96.01%", ascentOverride: "111.45%", descentOverride: "39.06%", lineGapOverride: "0%" }, + kb: 26, + }, + /* --------------------------------------------------------------- mono */ + { + id: "jetbrains-mono", + name: "JetBrains Mono", + class: "mono", + source: "fontsource", + package: "@fontsource-variable/jetbrains-mono", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/jetbrains-mono"), + designer: "Philipp Nurullin and Konstantin Bulenkov for JetBrains", + variable: true, + axes: ["wght"], + weights: [400, 500], + fallback: MONO, + metrics: { sizeAdjust: "99.98%", ascentOverride: "102.02%", descentOverride: "30%", lineGapOverride: "0%" }, + kb: 39.5, + }, + /* -------------------------------------------------------------- serif */ + { + id: "source-serif-4", + name: "Source Serif 4", + class: "serif", + source: "fontsource", + package: "@fontsource-variable/source-serif-4", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/source-serif-4"), + designer: "Frank Grießhammer for Adobe", + variable: true, + axes: ["wght", "opsz"], + weights: [400, 600, 700], + fallback: SERIF, + metrics: { sizeAdjust: "117.91%", ascentOverride: "87.87%", descentOverride: "28.41%", lineGapOverride: "0%" }, + kb: 49.6, + }, + { + id: "lora", + name: "Lora", + class: "serif", + source: "fontsource", + package: "@fontsource-variable/lora", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/lora"), + designer: "Olga Karpushina for Cyreal", + variable: true, + axes: ["wght"], + weights: [400, 500, 600], + fallback: SERIF, + metrics: { sizeAdjust: "115.2%", ascentOverride: "87.33%", descentOverride: "23.78%", lineGapOverride: "0%" }, + kb: 36.9, + }, + /* ------------------------------------------------------------- didone */ + { + id: "playfair-display", + name: "Playfair Display", + class: "didone", + source: "fontsource", + package: "@fontsource-variable/playfair-display", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/playfair-display"), + designer: "Claus Eggers Sørensen", + variable: true, + axes: ["wght"], + weights: [400, 600], + fallback: SERIF, + metrics: { sizeAdjust: "111.26%", ascentOverride: "97.25%", descentOverride: "22.56%", lineGapOverride: "0%" }, + kb: 37.5, + }, + /* ----------------------------------------------------------- humanist */ + { + id: "fraunces", + name: "Fraunces", + class: "humanist", + source: "fontsource", + package: "@fontsource-variable/fraunces", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/fraunces"), + designer: "Undercase Type (Phaedra Charles, Flavia Zimbardi)", + variable: true, + axes: ["wght", "opsz", "SOFT", "WONK"], + weights: [400, 600], + fallback: SERIF, + metrics: { sizeAdjust: "127.02%", ascentOverride: "77%", descentOverride: "20.08%", lineGapOverride: "0%" }, + kb: 35.8, + }, + { + id: "eb-garamond", + name: "EB Garamond", + class: "humanist", + source: "fontsource", + package: "@fontsource-variable/eb-garamond", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/eb-garamond"), + designer: "Georg Duffner and Octavio Pardo", + variable: true, + axes: ["wght"], + weights: [400, 500, 600], + fallback: SERIF, + metrics: { sizeAdjust: "94.77%", ascentOverride: "106.26%", descentOverride: "31.44%", lineGapOverride: "0%" }, + kb: 43.3, + }, + /* ------------------------------------------------------------ rounded */ + { + id: "nunito", + name: "Nunito", + class: "rounded", + source: "fontsource", + package: "@fontsource-variable/nunito", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/nunito"), + designer: "Vernon Adams, Cyreal and Jacques Le Bailly", + variable: true, + axes: ["wght"], + weights: [400, 700, 800], + fallback: SANS, + metrics: { sizeAdjust: "97.8%", ascentOverride: "103.37%", descentOverride: "36.09%", lineGapOverride: "0%" }, + kb: 38.2, + }, + /* --------------------------------------------------------------- hand */ + { + id: "comic-neue", + name: "Comic Neue", + class: "hand", + source: "fontsource", + package: "@fontsource/comic-neue", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource/comic-neue"), + designer: "Craig Rozynski", + variable: false, + weights: [700], + fallback: SANS, + metrics: { sizeAdjust: "98.47%", ascentOverride: "91.39%", descentOverride: "25.39%", lineGapOverride: "0%" }, + kb: 18.8, + }, + /* --------------------------------------------------------------- slab */ + { + id: "roboto-slab", + name: "Roboto Slab", + class: "slab", + source: "fontsource", + package: "@fontsource-variable/roboto-slab", + licence: "Apache-2.0", + licenceUrl: LICENCE("@fontsource-variable/roboto-slab"), + designer: "Christian Robertson", + variable: true, + axes: ["wght"], + weights: [400, 700], + fallback: SERIF, + metrics: { sizeAdjust: "116.83%", ascentOverride: "89.69%", descentOverride: "23.2%", lineGapOverride: "0%" }, + kb: 33.4, + }, + /* -------------------------------------------------------------- heavy */ + { + id: "archivo", + name: "Archivo", + class: "heavy", + source: "fontsource", + package: "@fontsource-variable/archivo", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/archivo"), + designer: "Héctor Gatti for Omnibus-Type", + variable: true, + axes: ["wght", "wdth"], + weights: [700, 900], + fallback: SANS, + metrics: { sizeAdjust: "102.29%", ascentOverride: "85.84%", descentOverride: "20.53%", lineGapOverride: "0%" }, + kb: 34.1, + }, + /* ------------------------------------------------------------- script */ + { + id: "dancing-script", + name: "Dancing Script", + class: "script", + source: "fontsource", + package: "@fontsource-variable/dancing-script", + licence: "OFL-1.1", + licenceUrl: LICENCE("@fontsource-variable/dancing-script"), + designer: "Pablo Impallari", + variable: true, + axes: ["wght"], + weights: [500], + fallback: SERIF, + metrics: { sizeAdjust: "89.35%", ascentOverride: "102.96%", descentOverride: "31.34%", lineGapOverride: "0%" }, + kb: 41.7, + }, +]; + +export const TYPE_PAIRINGS: readonly TypePairing[] = [ + { + id: "system", + name: "System", + heading: "system-ui", + body: "system-ui", + feel: "Nothing to download: the device's own sans, quick and familiar on every screen.", + headings: { scale: 1, weight: 500, tracking: "-0.01em", upper: false, lineHeight: 1.15 }, + bodySettings: { size: "1rem", lineHeight: 1.55, measure: "68ch" }, + suits: ["Any site where speed matters most", "Portals and internal tools"], + pairsWith: ["minimal"], + }, + { + id: "quiet", + name: "Quiet", + heading: "inter", + body: "inter", + feel: "One neutral sans at light weights; the work and the white space do the talking.", + headings: { scale: 0.95, weight: 500, tracking: "-0.02em", upper: false, lineHeight: 1.15 }, + bodySettings: { size: "1rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Architects and designers", "Studios and consultancies"], + pairsWith: ["minimal"], + }, + { + id: "precise", + name: "Precise", + heading: "jost", + body: "inter", + label: "jetbrains-mono", + feel: "Geometric headings over a neutral text face, with a monospace detail for figures and labels.", + headings: { scale: 1, weight: 600, tracking: "-0.02em", upper: false, lineHeight: 1.1 }, + bodySettings: { size: "1rem", lineHeight: 1.6, measure: "68ch" }, + suits: ["Software and tech services", "Engineering firms"], + pairsWith: ["high-tech"], + }, + { + id: "counsel", + name: "Counsel", + heading: "source-serif-4", + body: "source-sans-3", + feel: "A serif and a sans drawn as one family: steady, legible and unfussy.", + headings: { scale: 1, weight: 600, tracking: "0", upper: false, lineHeight: 1.2 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Accountants, lawyers and advisers", "Charities and member bodies"], + pairsWith: ["classic"], + }, + { + id: "atelier", + name: "Atelier", + heading: "playfair-display", + body: "source-serif-4", + feel: "A high-contrast display serif, spaced out in capitals, over a calm text serif.", + headings: { scale: 0.85, weight: 400, tracking: "0.08em", upper: true, lineHeight: 1.2 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.65, measure: "64ch" }, + suits: ["Jewellers and fine goods", "Boutique hotels and restaurants"], + pairsWith: ["luxurious"], + }, + { + id: "harvest", + name: "Harvest", + heading: "fraunces", + body: "nunito-sans", + feel: "A soft, warm serif with a gentle sans: grown rather than built.", + headings: { scale: 1, weight: 600, tracking: "-0.01em", upper: false, lineHeight: 1.15 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Farms and food producers", "Cafés and bakeries"], + pairsWith: ["natural"], + }, + { + id: "old-style", + name: "Old style", + heading: "eb-garamond", + body: "nunito-sans", + feel: "A Renaissance book face over a soft sans: warm, literate and unhurried.", + headings: { scale: 1.1, weight: 500, tracking: "0", upper: false, lineHeight: 1.15 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Wineries and heritage estates", "Bookshops and publishers"], + pairsWith: ["natural"], + }, + { + id: "friendly", + name: "Friendly", + heading: "nunito", + body: "nunito", + feel: "One round-ended sans, heavy for headings and light for text: cheerful and easy to read.", + headings: { scale: 1.05, weight: 800, tracking: "-0.01em", upper: false, lineHeight: 1.1 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.6, measure: "64ch" }, + suits: ["Local shops and repairs", "Pet care and family services"], + pairsWith: ["playful"], + }, + { + id: "crayon", + name: "Crayon", + heading: "comic-neue", + body: "nunito", + feel: "Big hand-lettered headings over a round sans that children and parents both find easy.", + headings: { scale: 1.1, weight: 700, tracking: "0", upper: false, lineHeight: 1.1 }, + bodySettings: { size: "1.125rem", lineHeight: 1.6, measure: "60ch" }, + suits: ["Nurseries and children's services", "Schools and clubs"], + pairsWith: ["childlike"], + }, + { + id: "diner", + name: "Diner", + heading: "roboto-slab", + body: "inter", + feel: "A sturdy slab for signs and headings, a plain sans for the menu.", + headings: { scale: 1, weight: 700, tracking: "0.02em", upper: true, lineHeight: 1.1 }, + bodySettings: { size: "1rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Diners, cafés and bars", "Hardware stores and trades"], + pairsWith: ["retro"], + }, + { + id: "loud", + name: "Loud", + heading: "archivo", + body: "inter", + feel: "Huge, heavy, tight capitals over a plain sans that stays out of the way.", + headings: { scale: 1.35, weight: 900, tracking: "-0.03em", upper: true, lineHeight: 0.95 }, + bodySettings: { size: "1rem", lineHeight: 1.55, measure: "64ch" }, + suits: ["Gyms and fitness", "Events and promoters"], + pairsWith: ["bold"], + }, + { + id: "workshop", + name: "Workshop", + heading: "lora", + body: "source-sans-3", + label: "dancing-script", + feel: "A brushed, calligraphic serif and a clear sans, with a script for the maker's signature.", + headings: { scale: 1, weight: 600, tracking: "0", upper: false, lineHeight: 1.2 }, + bodySettings: { size: "1.0625rem", lineHeight: 1.6, measure: "66ch" }, + suits: ["Makers and craft studios", "Florists and gift shops"], + pairsWith: ["handcrafted"], + }, +]; + +/* ------------------------------------------------------------------- helpers */ + +export function isFamilyId(v: unknown): v is FamilyId { + return typeof v === "string" && (TYPE_FAMILY_IDS as readonly string[]).includes(v); +} + +export function isPairingId(v: unknown): v is PairingId { + return typeof v === "string" && (TYPE_PAIRING_IDS as readonly string[]).includes(v); +} + +/** A family by id, or null for anything that is not one. */ +export function familyById(id: string | null | undefined): TypeFamily | null { + if (typeof id !== "string") return null; + return TYPE_FAMILIES.find((f) => f.id === id) ?? null; +} + +/** A pairing by id, or null for anything that is not one of the twelve. Never the nearest pairing. */ +export function pairingById(id: string | null | undefined): TypePairing | null { + if (typeof id !== "string") return null; + return TYPE_PAIRINGS.find((p) => p.id === id) ?? null; +} + +/** + * The family a board class renders in: the first family of that class, in + * TYPE_FAMILIES order. Every FontKey has one (scripts/check-type.mjs holds it), + * and `system` resolves to the device's own sans, as the boards always drew it. + */ +export function familyFor(key: FontKey): TypeFamily { + return TYPE_FAMILIES.find((f) => f.class === key)!; +} + +/** + * The CSS name Fontsource registers the family under: "Inter Variable" for a + * variable package, "Comic Neue" for a static one. Null for the system family, + * which is its `fallback` stack alone. + */ +export function cssFamily(f: TypeFamily): string | null { + if (f.source === "system") return null; + return f.variable ? `${f.name} Variable` : f.name; +} + +/** The families a pairing loads -- heading, body and label, each once. */ +export function pairingFamilies(p: TypePairing): TypeFamily[] { + const ids = [p.heading, p.body, ...(p.label ? [p.label] : [])]; + return [...new Set(ids)].map((id) => familyById(id)!); +} + +/** What a pairing costs to load, in KB of Latin woff2, to one decimal. */ +export function pairingKb(p: TypePairing): number { + return Math.round(pairingFamilies(p).reduce((sum, f) => sum + f.kb, 0) * 10) / 10; +}