FontPicker is the one canonical font-choosing UI: a dropdown that lists the document's own fonts[] lane, a lazily-loaded Google Fonts catalog, and a "upload .ttf/.otf" row, all reporting the chosen font through a single host.onPick callback. It is host-pure — it never writes the model, never talks to the Store, never touches the network beyond the Google Fonts API fetch — so every host (builder arg field, configurator, a future SketchUp palette) owns what "picking a font" actually does downstream.
Import
import { FontPicker } from "@parametron/ui"
import type { FontPickerHost, FontRecord } from "@parametron/ui"
import { loadCatalog, orderedVariants, variantRecordName } from "@parametron/ui"
import type { WebfontFamily } from "@parametron/ui"
import { fontFullName, recordFromFontFile } from "@parametron/ui"Exported from the package root (packages/ui/src/font/index.ts → the root barrel src/index.ts); there is no /font subpath.
The contract
type FontRecord = {
name: string
url: string
}
type FontPickerHost = {
/** The document's current fonts lane (the "In model" section). */
fonts: () => FontRecord[]
/** The ONE exit: the picked/uploaded record. The caller owns everything
* that happens next (write the lane, set the arg, pre-load, re-render). */
onPick: (record: FontRecord) => void
/** Google Fonts Developer API key (referrer-restricted, public-safe).
* Absent → the catalog section is hidden and the picker degrades to
* "in model + upload". */
apiKey?: string
/** Currently selected font name (highlights the active row). */
current?: () => string
}FontRecord deliberately mirrors the engine's FontJSON ({ name, url }, see Schema & validation) but is a local type — the package stays host-pure by dealing in plain records instead of importing engine document types.
FontPicker
function FontPicker(host: FontPickerHost): DomphyElementA closed row (current font name + ▾) that expands into a search box and a scrollable list on click:
- "In model" —
host.fonts()filtered by the search query, one row per record. Clicking a row callshost.onPick(record)unchanged. - "Google Fonts" — only rendered when
host.apiKeyis set. Opening the picker triggersloadCatalog(apiKey); while it resolves the section shows a spinner, and a rejected fetch collapses to"Google Fonts unavailable — use upload"with no retry UI (the picker still works via in-model + upload). A single-variant family picks straight from its row; a multi-variant family expands into variant chips (variantRecordName-labelled, e.g. "Bold", "Italic") on click, each chip picking{ name: variantRecordName(family, variant), url: family.files[variant] }. - Upload row — a hidden
<input type="file" accept=".ttf,.otf,font/ttf,font/otf">behind a label. On change it callsrecordFromFontFile(file)and picks the result; a parse failure shows"<name>" is not a readable font fileinline instead of picking.
The rendered catalog list is capped at 60 rows (MAX_ROWS — the catalog is ~1,500 families and search narrows fast; virtualization wasn't worth it). Picking anything (any of the three sources) closes the dropdown and collapses any expanded family before calling host.onPick.
Font catalog (Google Fonts adapter)
type WebfontFamily = {
family: string
category: string
variants: string[]
subsets: string[]
/** variant ("regular" | "italic" | "700" | "700italic" | …) → TTF url. */
files: Record<string, string>
}
function loadCatalog(apiKey: string): Promise<WebfontFamily[]>
function orderedVariants(family: WebfontFamily): string[]
function variantRecordName(family: string, variant: string): stringloadCatalog fetches https://www.googleapis.com/webfonts/v1/webfonts?key=...&sort=popularity once per session (module-level promise cache) and normalizes any http:// file URLs to https://. A failed fetch clears the cache so the next picker open retries rather than sticking on a stale rejection.
variantRecordName(family, variant) builds the canonical record name a variant would use — "Roboto" (regular), "Roboto Bold" (700), "Roboto Bold Italic" (700italic) — via a weight-label table (Thin/ExtraLight/Light/Medium/SemiBold/Bold/ExtraBold/Black). This is the identity used both for the picked FontRecord.name and for the chip label in the picker (with the family prefix stripped).
orderedVariants(family) sorts a family's variants for display: upright weights ascending (default weight 400 when unlabelled), then italics after all uprights.
// adapted from packages/ui/src/font/__tests__/catalog.test.ts
variantRecordName("Roboto", "regular") // "Roboto"
variantRecordName("Roboto", "700") // "Roboto Bold"
variantRecordName("Roboto", "700italic") // "Roboto Bold Italic"
orderedVariants({ variants: ["regular", "italic", "700", "700italic", "300"], /* … */ })
// ["300", "regular", "700", "italic", "700italic"]Upload
function fontFullName(buffer: ArrayBuffer, fallback: string): string
function recordFromFontFile(file: File): Promise<FontRecord>recordFromFontFile reads the file as an ArrayBuffer, parses it with the engine's font domain (font.parse from @parametron/parametric/compute — see Compute (pure geometry)) so a non-font file fails loudly instead of embedding garbage, then builds a data:font/ttf;base64,... URI record. Embedding is the only option for an uploaded file — there is no CDN to hand it to — so licensing responsibility for a shared, embedded commercial font stays with the uploader.
fontFullName reads the font's own name table (fullName, falling back to fontFamily + fontSubfamily combined, then postScriptName) so the record name is the font's canonical identity, never an invented alias; it falls back to the filename (extension stripped) if parsing throws or no name is found.
Wiring a host
FontPicker only reports a pick — writing the model's fonts[] lane, setting the arg expression, and pre-loading the font into the Store (see Store) is the host's job. The reference wiring is the builder's font-typed arg field (packages/ui/src/builder/blocks/NodeArgs.ts, see Builder panel):
const applyPick = (record: FontRecord) => {
// 1. Declare the record in the document's fonts lane (idempotent).
if (!scope.fonts.some(font => font.name === record.name)) {
scope.fonts.push({ name: record.name, url: record.url })
}
// 2. The arg's expression is the record NAME as a quoted string literal.
const expr = `'${record.name.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
triggerRebuild(arg, expr) // renders now with the fallback font
// 3. Pre-load the real glyphs into the Store, then rebuild again.
scope.assets?.registerFont(record.name, record.url)
void scope.assets?.loadFonts().then(() => triggerRebuild(arg, expr))
}
FontPicker({
fonts: () => scope.fonts,
onPick: applyPick,
apiKey: import.meta.env.VITE_GOOGLE_FONTS_API_KEY,
current: () => fontArgName(arg), // parses the arg's quoted-literal expression back to a name
})A font-typed arg's value is always a single-quoted expression string holding the record name ('Roboto Bold') — the picker is the one gateway that also declares the record, so using a font never requires a separate "manage fonts" step first. See Registry & contracts for how a font-typed arg resolves through Store.getFont at evaluation time, and Model authoring reference for textCurve's font arg.
See also
- Store —
registerFont/loadFonts/getFont, the async pre-load phase the host runs after a pick. - Schema & validation — the document's static
fonts[]lane (FontJSON = { name, url }). - Compute (pure geometry) — the
fontdomain (parse,textClean) this package's upload path calls into. - Builder panel — the reference host that wires
FontPickerinto afont-typed node arg. - Material panels — the sibling asset-lane editor (
materials[]instead offonts[]).