@tiledev/tile-fonts
v0.1.2
Published
Runtime font loading for Tile apps. Registers font files published through the Live Layer so Brand text styles render in the chosen typeface. Ships no font data.
Readme
@tiledev/tile-fonts
Runtime font loading for Tile apps. Registers the font files published through the Live Layer so Brand text styles render in the typeface a merchant chose.
This package ships no font data. The editor holds the font catalogue (~1,998 families) and publishes only the handful of URLs an app actually needs. Bundling that table here would put ~1.09 MB of JS into every merchant app, and a runtime lookup by a dynamic key cannot be tree-shaken away.
Install
npm install @tiledev/tile-fontsPeers: expo-font >=14, react >=18, react-native >=0.73. No runtime deps.
Use
In the app's ThemeProvider, alongside the existing brand overlay:
import { useBrandFonts } from '@tiledev/tile-fonts';
const brand = useLiveLayer<BrandOverride>(BRAND_CATS, ['brand']);
const { resolve } = useBrandFonts(brand);
// per typography slot
const key = resolve(slot.fontFamily, slot.fontWeight);
const style = key ? { ...slot, fontFamily: key, fontWeight: undefined } : slot;resolve returns null until the file is registered, so text renders in the
platform face and swaps when it lands. That is the intended fallback, not a
failure state — a font must never delay first paint.
Dropping fontWeight and fontStyle is not optional. The registered file
already is that weight, so leaving the weight on causes synthetic
double-bolding on iOS and web.
fontStyle matters more, and only on Android. The catalogue publishes upright
faces only, and Android resolves family+style as a unit — an unsatisfiable
pair falls back to the system family instead of synthesising a slant. An
inherited fontStyle: "italic" silently discards the font: it downloads,
caches, registers, resolve() returns its key, and the text still renders in
Roboto. This is the hardest failure in the whole feature to diagnose, because
every log and cache check says the font is fine.
Payload shape
The editor publishes font files as a sibling of typography, because the
app spreads each typography patch straight into a React Native style object
where a URL would land as an unknown key on a Text:
{
"brand": {
"typography": { "heading": { "fontFamily": "Oswald", "fontWeight": 400 } },
"fonts": { "Oswald": { "400": "https://fonts.gstatic.com/s/oswald/…ttf" } }
}
}Weight snapping is driven by what the payload contains, not by a table here: the editor may have snapped 300 → 400 because a family ships no Light, and the app has to land on the same key without knowing why.
The key contract
fontKey(family, weight) → PlayfairDisplay-700.
This must match catalogFontKey in the editor
(theme-panel/font-catalog.ts). Fonts are registered under this key and styled
by it; a mismatch means the file loads and nothing uses it.
Spaces are stripped because a space is unsafe in a web font-family token, and
the weight is part of the key because one registered name can only point at one
file while heading and body may share a family at different weights.
Notes
- Files must be TrueType or OpenType. Android cannot render WOFF2, which is what Google Fonts serves to a browser User-Agent. The editor's catalogue is TTF-only for this reason.
- Caching is not implemented here, on purpose.
expo-fonthands the URL toexpo-asset, whose native module keys a cache file by MD5 of the URL and reuses it unconditionally on both platforms. Fonts download once per device, not once per launch. - The
loadAsynckey becomes thefontFamily. Styling with the file's own family name works on Android and silently fails on iOS, where the internal PostScript name would have had to match.
Local development
Unpublished, the package resolves from the workspace sibling. Three things must line up for the preview to reach it:
| Where | What |
| --- | --- |
| tile-run-engine/metro.config.js | watchFolders + resolver.extraNodeModules → the sibling |
| tile-run-engine/src/aliases/common.tsx | guarded require of the real implementation, not a stub |
| tile-web-v2/…/session/utils/dependencies.ts | PREVIEW_PROVIDED_MODULES entry |
That last one is mandatory, and it stays mandatory now that the package is on the public registry. The reason changed, the requirement did not:
- Before publishing, the bundler simply could not fetch a private Artifact
Registry package. The preview sat on "Loading…" while the engine looped
RESEND_CODE— indistinguishable from a slow cold build. - Now, the bundler can fetch it, and that is worse. It would build its own
copy along with its own
expo-fontandreact, giving the preview two of each — the duplicate-React failure described in the next section, arriving by a different route.
The preview must get this package from the run-engine's alias, never from the bundler.
A real device build resolves it from npm like any other dependency.
Peers must never resolve from this package
react, react-native and expo-font are peers, and Metro resolves a linked
sibling's imports from the sibling's own node_modules first. If a copy of
react lives here, the package's hooks run against a dispatcher belonging to a
different React than the one rendering the app, and every render dies with:
Invalid hook call … Cannot read properties of null (reading 'useMemo')Confirmed by inspecting the served bundle, which registered both
node_modules/react/index.js and ../tile-fonts/node_modules/react/index.js.
A Metro blockList on this package's node_modules is configured in the
run-engine as a guard, but do not rely on it — it did not take effect in
practice. The reliable fix is that the copies simply are not here:
npm install # peers land here as transitive deps of expo-font
npm run build:local # build, then delete react / react-native / expo-fontdist requires only react and expo-font, so with those pruned Metro falls
through to the host's copies. This is a local-development concern only: files
excludes node_modules, so an installed copy of the package cannot reproduce it.
Restart the run-engine with --clear after changing any of this; Metro caches
resolution and a plain restart will not pick it up.
Scripts
npm run build # tsc → dist (CommonJS; the run-engine `require`s this)
npm run lint # tsc --noEmitCommonJS is deliberate: the run-engine's alias table uses require(), and an
ESM dist without "type": "module" would throw Unexpected token 'export'.
