Skip to content

Fonts & base reset

The token contract can name a font family (--font-sans), but a name only renders if the font files are loaded in the document — and each Synapse app runs in its own iframe, a separate document that inherits no @font-face from the host page. A host sending only tokens is therefore naming a typeface the app has no way to render.

The library ships no fonts. Font tokens fall back to web-safe system stacks, so an app renders correctly with no host, no network, and no font files. A host that wants its own typeface sends the faces alongside the tokens.

A host supplies its typeface as @font-face CSS in the host context, under the MCP Apps spec’s styles.css.fonts. The SDK loads that CSS into the app document.

// Host side: the ui/initialize hostContext, or a host-context-changed
{
theme: "dark",
styles: {
variables: { "--font-sans": "'Your Sans', system-ui, sans-serif" },
css: {
fonts: "@font-face { font-family: 'Your Sans'; src: url('/fonts/your-sans.woff2') format('woff2'); font-weight: 400 700; font-display: swap; }",
},
},
}
  • The font-family in each rule must match the family the host names in its --font-* variable.
  • connect() applies the CSS with the spec’s own applyHostFonts; the cross-host client injects it the same way. Both use one style element, __mcp-host-fonts.
  • The browser fetches a face only when the app’s CSS first uses it, so an unused weight costs nothing. Set font-display: swap in the rule so text paints in the fallback first.

Sending no fonts is supported, not degraded

Section titled “Sending no fonts is supported, not degraded”

Send no styles.css.fonts and the app renders in the web-safe fallbacks the token contract defines — system-ui for fontSans, ui-monospace for fontMono, and the same system-ui stack for fontHeading — one family, hierarchy from weight and size.

Write "'Your Sans', system-ui, sans-serif", never "'Your Sans'". A bare family name with no matching face falls through to the browser default rather than to your intended stack.

A host that mounts apps in a srcdoc iframe without allow-same-origin — the usual choice, since granting it to third-party app HTML is a sandbox escape — gives the frame an opaque origin. CSP 'self' then matches nothing. Under a typical font-src 'self' data:, a data: URI is the only form that works unconditionally; any http(s) URL, self-hosted or CDN, needs the host to add that origin to the frame’s policy. Check the host’s CSP before choosing.

If a face is blocked, nothing breaks — the app falls back to the neutral system stack. It just looks unbranded.

The font CSS is injected once and stays loaded. A later host-context-changed without styles.css — a dark-mode toggle, say — leaves the typeface in place. A host that changes typeface mid-session is not supported; send the fonts once, in the handshake.

Import @nimblebrain/synapse/ui/base for its side effect to establish the root-height chain a full-pane app needs:

import "@nimblebrain/synapse/ui/base";

It injects two rules: the height chain

html, body, #root { height: 100% }

so a height: 100% app shell resolves against the iframe’s allocated pane, plus

body { margin: 0 }

so the pane has no user-agent margin gap. A percentage chain, not a viewport unit, is the correct mechanism here: the app sizes to the pane the host gave it, not to the screen.

The module also exports the function directly:

import { injectBaseReset } from "@nimblebrain/synapse/ui/base";

AppFrame calls injectBaseReset() automatically when it renders, so apps built on it get the chain for free. Import the module explicitly in your entry when you want the chain established before React mounts (which avoids a first-paint layout jump) or when you render a full-pane root without AppFrame.