Skip to content

The token model

The component library holds no brand. There is no palette baked into the bundle, no hex values in the components. Every color, font, radius, and shadow is a CSS custom-property reference with a neutral fallback, written var(--token, fallback). This one decision is what lets a single component adopt the look of whatever host it renders in.

A Synapse component can run in the NimbleBrain host, in a ChatGPT surface, in Claude, or standalone. If the brand were compiled into the bundle, you would need a build per host, or a runtime theme switch that rewrites styles. Neither scales.

By expressing every visual value as a reference to a variable the host owns, the component stops carrying an opinion about color and type. The host supplies the opinion at runtime, and the same bytes look native everywhere.

Under the ext-apps protocol, the host sends its CSS variables as hostContext.styles.variables during the handshake, and re-sends them whenever the theme changes (the host-context-changed notification). The SDK applies them to your app’s :root, and every var(--token, fallback) in the library re-resolves against them, so filling the variables is all it takes to brand the whole component.

styles.variables is a closed enum, so a NimbleBrain host sends the variables that change with light and dark and have no spec key (--color-text-accent and the processing pair) beside it, as hostContext["ai.nimblebrain/styles"].variables, on the same two messages. The SDK applies them the same way, so they follow a theme toggle too.

Scrollbars, form controls and the canvas behind a transparent body are drawn by the browser, not by your CSS, in the document’s color-scheme. The SDK sets it inline on :root to the host’s mode at the handshake and on every theme change, beside the variables, so a dark app never keeps light scrollbars after a toggle. A host can seed color-scheme in the stylesheet it writes at mount, but it cannot reach into a sandboxed frame afterwards, so keeping it current is the SDK’s job.

A var() fallback is a static literal, so it cannot branch on light versus dark. A token the host never fills would otherwise resolve to its single hardcoded light fallback in both themes — white-on-white the moment the theme flips. So the SDK also ships a neutral default for every color token, in both modes, as a cascade layer named synapse-defaults.

Because it is a layer, it loses to everything that actually declares the variable. For any token, the winner is:

  1. The host, over the protocol — hostContext.styles.variables, and a NimbleBrain host’s ai.nimblebrain/styles variables, applied as an inline style on :root. Inline outranks every stylesheet, so this always wins.
  2. Any ordinary :root rule — both the stylesheet a host injects into your app document and your app’s own CSS land here. A host uses that channel for tokens that look the same in light and dark, because styles.variables is a closed enum and a design system larger than it needs somewhere to put the remainder.
  3. @layer synapse-defaults — the SDK’s neutral value for the active mode, when nothing above declares the token.

Tier 2 is one tier, not two: both are unlayered :root rules of identical specificity with no !important, so nothing in the cascade separates them and whichever comes later in the document wins. The NimbleBrain host injects its block at the top of <head>, ahead of your app’s bundled styles — so against that host, your app’s rule wins. A host token delivered by stylesheet is yours to override; a host token delivered over the protocol is not.

If you are integrating a new host: tier 2 is only as current as the stylesheet behind it. A host that bakes its tokens into the app document once, at mount, and cannot rewrite them afterwards will hold every mode-varying token at its mount-time value — the SDK’s defaults no longer mask that by overriding them, which is the point of this change. Deliver anything that varies with light/dark on a channel you can update, and the app tracks the theme for free.

Overriding an SDK default from your app therefore needs nothing special:

/* Wins over the SDK default. No !important, no layer of your own. */
:root {
--color-text-accent: #ff6600;
}

If you declare your own cascade layers, they sort after synapse-defaults and so win over it too.

Components style with token-driven inline-style objects whose values are those var() references, never resolved colors. When the host swaps the :root variables, including a light-to-dark flip, every reference re-resolves in CSS and the pixels change. No component re-renders. Nothing in React state has to know the theme moved.

Keyframes and pseudo-state rules (hover, focus) are injected once, up front, rather than recomputed per theme. Brand values are never captured into component state, so there is nothing to invalidate. A theme change is a repaint, not a render pass.

With no host to inject anything, the component renders unbranded but complete, never blank. What backs the color tokens depends on whether a connection ran:

  • Connected, but hostless — the standalone inline adapter still applies a theme, so color tokens resolve through the synapse-defaults layer above and stay theme-correct in dark as well as light. Same for connect() and AppProvider against a host that ships no tokens.
  • Never connected — a Storybook story or a unit test that renders a component directly installs no layer, because nothing applied a theme. Color tokens fall back to their var() second argument, which is a static literal and therefore light-flavoured in both modes. Rendering such a story in dark mode needs a host that says so: with no host context the mode resolves to light, so wrapping in an AppProvider installs the layer but installs the light one.

Everything the layer never covers — fonts, radii, shadows — falls back to its var() second argument in both cases: system fonts and neutral geometry.

  • Light and dark come for free, because they are just two sets of variable values the host swaps.
  • The host’s brand comes for free, because you never hardcoded a competing one.
  • Components are testable and previewable in isolation, because the fallbacks make them self-sufficient.

For the full list of tokens, their CSS variable names, and their fallbacks, see the Tokens reference.