Skip to content

Theming

The NimbleBrain platform injects CSS custom properties into every app iframe so your UI can match the host shell — including dark mode — without shipping your own color palette. The tokens follow the MCP ext-apps specification, with a small set of NimbleBrain-specific extensions prefixed --nb-.

When your app’s HTML loads in an iframe, the platform:

  1. Injects a <style> block into your <head> with the full token set (ext-apps --color-* / --font-* / --border-radius-* / --shadow-* plus the --nb-* extensions) for the current theme. This happens before your HTML renders — no flash of wrong colors.
  2. Sends ui/initialize via postMessage with the same tokens in params.theme.tokens (the NimbleBrain legacy notification path).
  3. Sends ui/notifications/host-context-changed when the user toggles dark mode, with updated token values under styles.variables.

Your CSS references these variables with var(--token, fallback). The fallback ensures your app still looks correct when running outside the platform (Claude Desktop, standalone, etc).

body {
font-family: var(--font-sans, system-ui, sans-serif);
background: var(--color-background-primary, #ffffff);
color: var(--color-text-primary, #1a1a1a);
}
button {
background: var(--color-text-accent, #2563eb);
color: var(--nb-color-accent-foreground, #ffffff);
border-radius: var(--border-radius-md, 0.5rem);
}
input {
border: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-background-secondary, #f9fafb);
color: var(--color-text-primary, #1a1a1a);
}
.muted-text {
color: var(--color-text-secondary, #6b7280);
}

The injected <style> block gives you the right tokens at load time. But when the user toggles dark mode mid-session, you need a JavaScript handler to apply the updated values:

function applyTokens(tokens) {
if (!tokens || typeof tokens !== 'object') return;
for (const [key, value] of Object.entries(tokens)) {
document.documentElement.style.setProperty(key, value);
}
}
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
// Apply tokens on initial load (legacy notification path)
if (msg.method === 'ui/initialize' && msg.params?.theme?.tokens) {
applyTokens(msg.params.theme.tokens);
}
// Apply tokens when host context changes (ext-apps spec)
if (msg.method === 'ui/notifications/host-context-changed') {
const vars = msg.params?.styles?.variables;
if (vars) applyTokens(vars);
}
});

applyTokens sets CSS variables on document.documentElement, which overrides the injected <style> values. Because your CSS uses var() references, the UI updates instantly.

The platform injects the following tokens into every iframe. Most ext-apps spec tokens also cross the protocol boundary and refresh live on theme changes; the --nb-* extensions — plus --color-text-accent, --font-text-base-size, and --font-text-base-line-height — are injected at load only and do not refresh on a toggle.

TokenLightDarkUse for
--color-background-primary#faf9f7#0a0a09Page background
--color-background-secondary#ffffff#141413Card / panel backgrounds
--color-background-tertiary#f3f2ef#1c1c1bSubtle / nested backgrounds
--color-text-primary#171717#e5e5e5Body text
--color-text-secondary#737373#a3a3a3Captions, metadata, placeholders
--color-text-tertiary#a3a3a3#737373De-emphasized text
--color-text-accent#0055FF#3b8effLinks, primary buttons, AI accents (load-only — does not refresh on toggle)
--color-border-primary#e5e5e5#262626Borders, dividers
--color-border-secondary#e5e5e5#262626Secondary borders
--color-ring-primary#0055FF#3b8effFocus rings
TokenValueUse for
--font-sans'Satoshi', system-ui, sans-serifBody font
--font-mono'JetBrains Mono Variable', ui-monospace, SFMono-Regular, monospaceCode / monospace font
--font-weight-normal400Normal weight
--font-weight-medium500Medium weight
--font-weight-semibold600Semibold weight
--font-weight-bold700Bold weight
--font-text-xs-size / -line-height0.75rem / 1remExtra-small body text
--font-text-sm-size / -line-height0.875rem / 1.25remSmall body text
--font-text-base-size / -line-height1rem / 1.5remBase body text
--font-text-lg-size / -line-height1.125rem / 1.75remLarge body text
--font-heading-sm-size / -line-height1.25rem / 1.75remSmall heading
--font-heading-md-size / -line-height1.5rem / 2remMedium heading
--font-heading-lg-size / -line-height2rem / 2.5remLarge heading
TokenValueUse for
--border-radius-xs0.25remTight corners
--border-radius-sm0.5remSmall corners
--border-radius-md0.75remDefault corners
--border-radius-lg1remLarge corners
--border-radius-xl1.5remExtra-large corners
--border-width-regular1pxStandard border width
TokenLightDarkUse for
--shadow-hairline0 0 0 1px rgba(0,0,0,0.06)0 0 0 1px rgba(255,255,255,0.06)Hairline outline
--shadow-sm0 1px 2px rgba(0,0,0,0.05)0 1px 2px rgba(0,0,0,0.3)Small elevation
--shadow-md0 4px 6px -1px rgba(0,0,0,0.1)0 4px 6px -1px rgba(0,0,0,0.4)Medium elevation
--shadow-lg0 10px 15px -3px rgba(0,0,0,0.1)0 10px 15px -3px rgba(0,0,0,0.4)Large elevation

These have no ext-apps spec equivalent. They are injected into the iframe’s <style> block at load but do not cross the protocol boundary, so they will not refresh on a live theme toggle (see the caveat above).

TokenLightDarkUse for
--nb-color-accent-foreground#ffffff#0a0a09Text on accent backgrounds
--nb-color-danger#dc2626#f87171Errors, destructive actions
--nb-color-success#059669#34d399Success indicators
--nb-color-warning#f59e0b#fbbf24Warning indicators
--nb-color-warm#d4620a#f59542Warm accent
--nb-color-warm-light#fef5ee#2a1a08Warm accent background
--nb-color-processing#7c3aed#a78bfaIn-progress / processing state
--nb-color-processing-light#f3eeff#1a0f2eProcessing background
--nb-color-info-light#eef4ff#0c1a33Info background
--nb-font-heading'Erode', Georgia, serifsameHeading font

Theme injection is non-breaking. The platform injects tokens into every iframe, but they have zero effect unless your CSS references them:

Your CSSWhat happens
background: whiteStays white. Injected tokens are ignored.
background: var(--my-bg)Uses your --my-bg variable. No collision.
background: var(--color-background-primary, white)Picks up the platform theme. Falls back to white outside the platform.

Existing apps require zero changes — theming is entirely opt-in.

The platform injects a <style> block as the first child of <head>, before your app’s own <style> tags. This means:

  • Platform tokens are declared on :root and are available to your CSS.
  • The injected block includes a minimal body reset (margin: 0, font-family: var(--font-sans), background: var(--color-background-primary), color: var(--color-text-primary)).
  • Your app’s <style> comes after and wins the CSS cascade for any conflicting declarations.

If you define your own body { background: ... }, it overrides the injected reset. This is by design — your app’s styles always win.

If your tokens aren’t applying, open DevTools and inspect the iframe:

  1. In Chrome DevTools, open the Elements panel.
  2. Expand the iframe’s document (click the #document node inside the <iframe>).
  3. Select <html> and check the Computed tab for --color-background-primary.

Common issues:

SymptomCauseFix
Background is white, not warm paperYour CSS uses background: #fff instead of var(--color-background-primary, #fff)Replace hardcoded colors with var() references
Tokens exist but aren’t appliedCSS specificity — a more specific selector overrides the tokenCheck that your selector isn’t more specific than the :root declaration
Dark mode doesn’t toggleMissing ui/notifications/host-context-changed handlerAdd the applyTokens message listener (see code above)
An --nb-* color doesn’t flip on toggle--nb-* tokens are injected at load only, not in the live payloadDrive light/dark-sensitive surfaces from the spec --color-* tokens
Tokens are stale after restartdeps/ directory contains an old bundled copyDelete deps/<your-package> during local development (see Local Development)

See the Hello World walkthrough for a complete app with theme integration, including both Python and TypeScript implementations.