Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

web fonts — shipping a typeface with your app

osy font add <file.woff2> --family "<CSS family>" [--weight 400] [--style normal]

Naming a font in your theme asks for it; `osy font add` ships it. The command pins a font file in your project's lock, every compile carries it to the server, and the app's stylesheet declares the matching `@font-face` — so the typeface renders on a machine that has never seen it, instead of falling through to the next name in your stack.

stable2 examples compiled by CIuithemefontscli

Summary#

A theme names your app's type:

theme Doc { Font { Sans = "Hedvig Letters Serif, ui-serif, Georgia, serif"; } }

That is a request, not a delivery. font-family is a preference list, and a browser walks it until it finds something it already has — so on any machine without Hedvig Letters Serif installed, the name is skipped silently and your app renders in ui-serif. Nothing errors. The page just isn't the design.

osy font add is the other half. It registers a font FILE with your app: the file is pinned by content hash in osyrin.lock, carried to the server by every compile, served back immutably, and declared as an @font-face in the app's stylesheet. The theme still names the family exactly as before — the two stay separate on purpose, because one family often needs several files (a regular, a bold, an italic) and the theme should name it once.

Signature#

osy font add <file>  --family "<CSS family name>"  [--weight <400|700|…>]  [--style <normal|italic>]
osy font add --from-kit <Kit>  --family "<CSS family name>"
osy font add --fontsource <id>  [--weight 400,700]  [--style italic]
osy font find "<character>"
osy font kits
osy font list
osy font remove "<CSS family name>"

Description#

Registering a font#

Put the file somewhere in your project and register it:

$ osy font add model/fonts/HedvigLettersSerif-Regular.woff2 --family "Hedvig Letters Serif"
✓ Registered Hedvig Letters Serif (0ab846d39150…) → model/fonts/HedvigLettersSerif-Regular.woff2
  Name it in your theme to use it. Shadow the kit's own token, so every
  control picks it up without touching a page:
    Font { Sans = "Hedvig Letters Serif, ui-serif, Georgia, serif"; }
  It ships on the next compile. You are responsible for its licence.

--family is required and never guessed. A filename is not a family name — HedvigLettersSerif-Regular.woff2 provides the family Hedvig Letters Serif — and a guess that is subtly wrong produces the exact failure this feature exists to remove: the font loads, nothing matches it, and the page renders in the fallback while looking healthy.

Accepted formats are .woff2, .woff, .ttf and .otf. Prefer woff2: it is the smallest by a wide margin and every current browser reads it.

osy font list shows what your app ships:

$ osy font list
╭──────────────────────┬────────┬────────┬─────────────────────────────────────╮
│ Family               │ Weight │ Style  │ File                                │
├──────────────────────┼────────┼────────┼─────────────────────────────────────┤
│ Hedvig Letters Serif │ 400    │ normal │ model/fonts/HedvigLettersSerif-Reg… │
╰──────────────────────┴────────┴────────┴─────────────────────────────────────╯

Naming it in your theme#

Registering a file does not decide where it is used — your theme does, unchanged:

theme Doc {
  Font {
    Sans    = "Hedvig Letters Serif, ui-serif, Georgia, serif";        // every control's text — the kit's own token
    Display = "Hedvig Letters Sans, ui-sans-serif, system-ui, sans-serif";  // a second face, applied by name
  }
}

Keep the fallbacks. They are what the reader sees during the moment before the file arrives, and on the rare browser that cannot use it at all.

Where it applies, without touching a page#

The page's own body takes its family from the theme, so naming one is all it takes — every control inherits it, and there is no root element to go and edit. Two token spellings are read, in this order, and the platform's system stack is the last resort:

font-family: var(--font-sans, var(--fonts-body, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif));

Name it Font { Sans = … } — the kit's own token, which every control reads, so shadowing it re-skins them all. The page body also reads --fonts-body as a fallback, but no CONTROL does, and Fonts { Body = … } collides with the FontSize { Body } most themes declare: a theme's leaf names are one flat namespace across its groups, so the compiler refuses Body twice ("duplicate token 'Body'"). Font { Sans } has neither problem. Monospaced chrome reads --font-mono, then --fonts-mono.

A family you name in neither place still applies wherever you spell it out: fontFamily: Font.Display on a Text, or FontFamily = Font.Display; in a variant. That is how a second face used only for headings is applied.

⚠ The last entry in the stack is the degrade path, and it is the reason to keep one. A face that fails to load — a lock that pins bytes the app no longer ships, a network that never answers — falls to the next name you wrote. With nothing after the family, it falls to whatever the browser defaults to, which is a different design on every machine.

More than one file per family#

A family is a set of faces, and each file provides one. Register each with the weight and style it covers:

$ osy font add fonts/Inter-Regular.woff2    --family "Inter"
$ osy font add fonts/Inter-Bold.woff2       --family "Inter" --weight 700
$ osy font add fonts/Inter-Italic.woff2     --family "Inter" --style italic

--weight defaults to 400 and --style to normal, so the first line above needs neither. Registrations are keyed by (family, weight, style), which is why the second and third lines ADD faces rather than replace the first. A variable font registers the range it covers by itself — osy font add reads the file's wght axis, so a 100–900 face is registered as 100 900 and every weight renders from it rather than being synthesised. --weight still overrides what the file says.

Your theme still names Inter once. The browser picks the right file per weight and style.

What ships, and when#

Every compile carries every registered font whose file has changed or that the server does not yet have, and re-verifies each file against its pinned hash first. A font edited on disk but never re-added aborts the compile rather than shipping bytes the lock does not describe:

$ osy compile
✗ font 'Inter': 'fonts/Inter-Regular.woff2' has changed since it was registered (osyrin.lock pins
  9f86d0818200…, file is 2c624232945…) — re-run `osy font add fonts/Inter-Regular.woff2 --family "Inter"` to re-pin it.

Files are stored by content address, so identical bytes are stored once no matter how many apps or versions reference them, and a recompile that changes nothing ships nothing.

The app's stylesheet then declares each face ahead of the rules that use it:

@font-face {
  font-family: "Hedvig Letters Serif";
  src: url("/_osy/font/0ab846d39150…") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

font-display: swap is deliberate: text is painted immediately in the fallback and re-painted when the file lands. The alternative is a first paint with nothing where the words should be.

The two faces every app already has#

The platform ships Mona Sans (GitHub's, variable weight 200–900) and Martian Mono (Evil Martians, variable weight 100–800), both under the SIL Open Font License, and declares them in every app's stylesheet: the Osysharp.Ui theme sets Font.Sans and Font.Mono in them, so an app on the kit's own look renders in them with no osy font add. A declared face costs nothing until text is set in it — a browser fetches a font only when a page uses it — so an app that names its own Sans never downloads Mona Sans. An app's own face of the same family wins.

A font a kit already ships#

A look kit carries its own faces, each with its licence beside it. osy font kits lists every font the kits in your project's scope ship — the family, the kit, and the licence file found next to it. To use one in your own theme, take it into the app:

osy font kits
osy font add --from-kit Studio --family "Hanken Grotesk"

Every file of that family is copied into model/fonts/ with its licence text, and pinned exactly as a file of your own would be. The kit may be named in full or by its last part.

An open-source face, by its character#

osy font find "<character>" searches the open-source families by what they are like — calm humanist, rounded geometric, sophisticated didone — and osy font add --fontsource <id> fetches the one you chose, vendors it with its licence and registers it under its real family name, with no file to download first:

osy font find "rounded geometric"
osy font add --fontsource comfortaa

Finding a typeface by its character, and shipping it has the whole of both.

Removing a font from the app#

osy font remove "<family>" takes every entry of the family out of osyrin.lock and deletes the files it vendored. The next compile reconciles the server to what your app now declares — the row, the stored bytes and the @font-face all go together, so a stylesheet can never keep naming a face you stopped shipping.

Am I allowed to self-host this font? — licensing#

osy font add ships a file you chose, and the platform makes no licence check. Web-font licences vary in ways tooling cannot infer — some permit self-hosting freely, some by domain, some not at all. Confirm you may self-host before you register a font.

Examples#

The whole loop, from a downloaded file to a page that renders in it:

$ osy font add model/fonts/HedvigLettersSerif-Regular.woff2 --family "Hedvig Letters Serif"
✓ Registered Hedvig Letters Serif (0ab846d39150…) → model/fonts/HedvigLettersSerif-Regular.woff2

$ osy compile
✓ Osyrin compiled and applied to MarkdownDemo (6 file(s)).

with the theme naming it:

theme Doc {
  Font { Sans = "Hedvig Letters Serif, ui-serif, Georgia, serif"; }
}

To confirm a font is really being used rather than silently falling back, measure rendered text width against a family you know does not exist. document.fonts.check() is not a test — it resolves through the fallback stack and answers true either way.

See also#

Related

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

control — foreign UI controls (charts, grids, maps)

A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript…

app.osy

The manifest at the root of every project. It names the app, says which files are model, seed, migrations and tests…