Parametron

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): DomphyElement

A closed row (current font name + ) that expands into a search box and a scrollable list on click:

  1. "In model"host.fonts() filtered by the search query, one row per record. Clicking a row calls host.onPick(record) unchanged.
  2. "Google Fonts" — only rendered when host.apiKey is set. Opening the picker triggers loadCatalog(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] }.
  3. Upload row — a hidden <input type="file" accept=".ttf,.otf,font/ttf,font/otf"> behind a label. On change it calls recordFromFontFile(file) and picks the result; a parse failure shows "<name>" is not a readable font file inline 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): string

loadCatalog 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

  • StoreregisterFont/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 font domain (parse, textClean) this package's upload path calls into.
  • Builder panel — the reference host that wires FontPicker into a font-typed node arg.
  • Material panels — the sibling asset-lane editor (materials[] instead of fonts[]).
📖 4 min read