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 comfortaaFinding 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#
- theme tokens — the
themeblock, and theFonttokens that name a family - control — foreign UI controls (charts, grids, maps) — the other kind of file an app ships with its compile, on the same rail
- app.osy —
osyrin.lock, where a registration is pinned - Finding a typeface by its character, and shipping it — find an open-source face by its character, and fetch it