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.
Why defer the brand
Section titled “Why defer the brand”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.
How the host fills the tokens
Section titled “How the host fills the tokens”Under the ext-apps protocol, the host injects a :root block of CSS variables into
your app’s iframe. That payload arrives as hostContext.styles.variables during the
handshake, and the host re-sends it whenever the theme changes (the
host-context-changed notification). Every var(--token, fallback) in the library
re-resolves against that block, so filling the variables is all it takes to brand
the whole component.
Who wins: the resolution order
Section titled “Who wins: the resolution order”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:
- The host, over the protocol —
hostContext.styles.variables, applied as an inline style on:root. Inline outranks every stylesheet, so this always wins. - Any ordinary
:rootrule — both the stylesheet a host injects into your app document and your app’s own CSS land here. Hosts need that channel becausestyles.variablesis a closed enum, so a design system larger than the enum delivers the remainder as a stylesheet. @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.
Theming resolves in CSS, not React
Section titled “Theming resolves in CSS, not React”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.
Standalone, the library still renders
Section titled “Standalone, the library still renders”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
inlineadapter still applies a theme, so color tokens resolve through thesynapse-defaultslayer above and stay theme-correct in dark as well as light. Same forconnect(),createSynapse()andSynapseProvideragainst 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 tolight, so wrapping in aSynapseProviderinstalls 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.
What this buys you
Section titled “What this buys you”- 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.
Related
Section titled “Related”- Tokens reference: every token group and how the host fills it.
- Fonts: loading the brand faces into the iframe.
- Match the host theme: tracking the host’s theme reactively in your own code.