@curless/brand-fonts
v0.3.0
Published
Open-source (SIL OFL 1.1) brand fonts for Curless cards, self-hosted: variable Montserrat, Playfair Display, Cabin, Jost, Inter, Noto Sans SC and Noto Serif SC, Chinese cut into unicode-range slices so a page fetches only the characters it shows. A catalo
Readme
@curless/brand-fonts
The fonts a Curless card draws a group's own type in - open fonts only
(SIL OFL 1.1), served by us, never by Google (Google Fonts does not load in
mainland China). Variable fonts, so one file covers every weight a card asks
for; Chinese cut into ~100 unicode-range slices, so a page fetches only the
slices its characters are in.
| Name (in server.fonts) | CSS family | From | Size |
|---|---|---|---|
| Montserrat | Montserrat Variable | @fontsource-variable/montserrat (Latin) | ~0.1 MB |
| Playfair Display | Playfair Display Variable | @fontsource-variable/playfair-display (Latin) | ~0.1 MB |
| Cabin | Cabin Variable | @fontsource-variable/cabin (Latin) - moody lenses' headings | ~0.1 MB |
| Jost | Jost Variable | @fontsource-variable/jost (Latin) - moody lenses' body and figures | ~0.1 MB |
| Inter | Inter Variable | @fontsource-variable/inter (Latin) - Elspet | ~0.2 MB |
| Noto Sans SC | Noto Sans SC Variable | @fontsource-variable/noto-sans-sc | ~4.5 MB in 101 slices |
| Noto Serif SC | Noto Serif SC Variable | @fontsource-variable/noto-serif-sc | ~5.9 MB in 101 slices |
A new font: add it to BRAND_FONTS in src/index.ts (an @fontsource-variable
package, pinned in devDependencies), build, and bump the version - the version
is in every font's address, so files are cached for good.
How a card uses them (the mechanism - copy it for another card)
The brand names its fonts in its config, for each use, in order:
"server": { "fonts": { "display": ["Playfair Display", "Noto Serif SC"], "body": ["Montserrat", "Noto Sans SC"], "numeric": ["Montserrat"] } }A Latin font, then a Chinese one: each character is drawn in the first font that has it. Where the group's site uses a font it licenses, name the nearest open one here and say which and why in the brand repository's
SOURCE.md.The server serves the files under its own origin, at
<publicUrl>/fonts/<BRAND_FONTS_VERSION>/<path>:import { BRAND_FONTS_VERSION, fontAsset, fontHeaders, weightsOf } from '@curless/brand-fonts'; app.get('/fonts/:version/*', (c) => { if (c.req.param('version') !== BRAND_FONTS_VERSION) return c.notFound(); const asset = fontAsset(c.req.path.slice(`/fonts/${BRAND_FONTS_VERSION}/`.length), { weights: weightsOf(c.req.query('w')), }); return asset ? c.body(new Uint8Array(asset.body), 200, fontHeaders(asset.type)) : c.notFound(); });To draw the group's own weights rather than the card's finer ones (robin, 2026-10-14: 字重一律跟官网), pass them on:
fontAsset(path, { weights: weightsOf(c.req.query('w')) }), and ask for…/montserrat.css?w=400,500,700. Each face is then declared at those weights only, all from the same variable file - a card asking for 650 is drawn at 700, as the group's site would draw it. Withoutw, the whole range.fontAssetreads only what this package built (no.., no other files);fontHeaderssendsAccess-Control-Allow-Origin: *- without it the browser drops the font silently, because a card is an iframe on the host's origin - and an immutable cache.The card's CSP names that origin:
_meta.ui.csp.resourceDomains(Claude) andopenai/widgetCSP.resource_domains(ChatGPT). Without it the host blocks every font and the card reads in system fonts.Every answer hands the card the fonts (an optional output field, declared in the output schema only where the brand names fonts):
"fonts": { "css": ["https://…/fonts/0.1.0/playfair-display.css", "…"], "display": "\"Playfair Display Variable\", \"Noto Serif SC Variable\"", "body": "\"Montserrat Variable\", \"Noto Sans SC Variable\"", "numeric": "\"Montserrat Variable\"" }(
familiesOf(names)makes each list;BRAND_FONTS[name].csseach sheet.)The card puts the families IN FRONT OF its system fonts, never instead: it links each stylesheet and sets its font variables to
<brand families>, <its own system stack>. Every stylesheet saysfont-display: swap, so a slice still loading, a font the CSP refused, a character the font lacks are all drawn in the system's - never blank. And it accepts only https stylesheets and quoted plain family names: this is CSS put into the page.
A brand that names no fonts gets no field, and its card is drawn exactly as
before. The hotel card does all five: @curless/hotel-storefront
(server.ts fontsOf and /fonts, card.ts applyFonts).
One shared host for every service instead of each serving its own: set
CURLESS_FONTS_URL (hotel-storefront) to its root; the version is appended,
and that origin goes into the CSP instead.
