Contexts
Real components rarely render in a vacuum — they read a theme, a locale, a feature flag, a current user. A context is windo's way of supplying that surrounding world: a named bundle of live values, the UI to edit them, and an optional React provider to wrap the components that need it. Declare it once in your config and every windo can read from it; opt a windo in and the provider mounts around it.
What a context is for
A context answers a single question: what does this component need from outside itself? That splits cleanly into two jobs, and a context can do either or both.
- Ambient values — light or dark,
enorfr, a flag that's on or off. These are values your component (or your other windo functions) read to decide how to render. They come with editable controls in the chrome, so you can flip them live. - A provider — when the component reaches for a value through React context (
useTheme(), an i18n provider, a query client), windo needs to actually mount that provider in the iframe. The context carries that wrapper, and components opt into it.
You define a context with defineContext, register it under a name in your config, and from then on it's part of the environment every windo runs inside.
Defining a context
defineContext takes one object. Every field is optional — what you supply decides which of the two jobs the context does.
import { defineContext } from '@westopp/windo' import { ThemeProvider } from './app/ThemeProvider' const theme = defineContext({ label: 'Theme', description: 'Colour scheme the component renders under.', controls: { mode: { type: 'enum', options: ['light', 'dark'], default: 'light' }, density: { type: 'enum', options: ['cosy', 'compact'], default: 'cosy' }, }, provider: ({ children, values }) => ( <ThemeProvider mode={values.mode} density={values.density}> {children} </ThemeProvider> ), resolve: (values) => ({ ...values, isDark: values.mode === 'dark' }), })
Reading the fields in order:
labelanddescriptionname the context in the chrome's Context tab —labelis the heading,descriptionthe supporting line. Both are optional; the registration key is used whenlabelis absent.controlsis a map of control key to spec. Each spec declares atype(enum,boolean,string, ornumber), adefault, and the metadata to render its toggle —optionsfor anenum,min/max/stepfor anumber, an optionallabel. The control map's defaults are what the context starts with; the Context tab edits them from there.provideris a React component windo mounts inside the iframe. It receiveschildren(the component being previewed), the livevaluesfrom the controls, and the renderctx. Use it to wrap the component in whatever real provider it expects.resolveshapes what consumers actually read. By default a context exposes its raw control values;resolvelets you derive something richer — combine values, compute a flag, return a whole client object — from(values, ctx). Its return is what lands onctx.contexts[name].
Registering contexts
A context isn't live until your config knows about it. The contexts map in defineWindoConfig is the registry — each key is the name the context is addressed by everywhere else: in a windo's uses, in ctx.contexts, and as the section heading in the chrome when no label is set.
import { defineContext, defineWindoConfig } from '@westopp/windo' const result = defineWindoConfig({ title: 'Acme UI', groups: [ { name: 'Actions', slug: 'actions' }, { name: 'Data display', slug: 'data-display' }, ], contexts: { theme: defineContext({ label: 'Theme', controls: { mode: { type: 'enum', options: ['light', 'dark'], default: 'light' } }, provider: ({ children, values }) => <ThemeProvider mode={values.mode}>{children}</ThemeProvider>, }), locale: defineContext({ label: 'Locale', controls: { lang: { type: 'enum', options: ['en', 'fr', 'de'], default: 'en' } }, }), }, }) export const windo = result.windo export const config = result.config
Here theme carries a provider and locale is ambient-only. Both appear in the Context tab the moment they're registered — you don't opt a windo in to see a context's controls, only to mount its provider.
Ambient values vs. opting in
This is the distinction that trips people up, so it's worth stating plainly. Registering a context makes its values ambient — every windo can read them off ctx.contexts, no opt-in required. What requires opting in is the context's provider: windo only mounts a wrapper around windos that explicitly ask for it.
A component reads an ambient value through ctx.contexts[name]:
export default windo<BannerProps>(() => ({ title: 'Banner', group: 'data-display', defaultProps: { message: 'Heads up' }, // No `uses` — but the locale values are still readable. component: (props, ctx) => { const { lang } = ctx.contexts.locale as { lang: string } return <Banner {...props} lang={lang} /> }, }))
A component opts into a provider with the uses field — a list of context names. windo mounts each named context's provider around this windo (and only this windo), in the order listed.
export default windo<CardProps>(() => ({ title: 'Card', group: 'data-display', // The Theme provider wraps this windo, so `useTheme()` inside Card works. uses: ['theme'], defaultProps: { title: 'Atlas redesign' }, component: (props) => <Card {...props} />, }))
Editing context values live
Each registered context that contributes controls gets a section in the chrome's Context tab. The controls render from the metadata you gave each spec — an enum becomes a select of its options, a boolean a switch, a number a stepper bounded by min / max / step, a string a text field — and they start at each control's default.
Editing a control pushes the new value into the iframe. Both surfaces update at once: the provider re-renders with the fresh values, and ctx.contexts[name] reflects the new resolve output. So flipping theme.mode from light to dark re-runs the ThemeProvider and updates anything reading ctx.contexts.theme.isDark — no reload, no remount of your component tree beyond the provider.
Shared state a component can write
Context controls are ambient: the chrome owns them, and a component reads them but never sets them. Sometimes you want the inverse — a value any component can flip from inside the canvas, that a provider then paints with. That's ctxState: a single global bag of shared state, seeded in your config and writable from any windo.
The contrast with per-windo state is the point. state is scoped to one windo and resets the moment you select a different one. ctxState is global and persists across selection — whatever a component writes stays put as you move from windo to windo. Seed its initial shape with the config's ctxState field.
const result = defineWindoConfig({ title: 'Acme UI', // Seeds `ctx.ctxState` — global, persists across selection. ctxState: { theme: 'light' }, })
The pattern that makes this useful: drive a provider from ctxState instead of from context controls. The provider reads ctx.ctxState, so when any component merges a patch with ctx.setCtxState, every consumer re-renders and the provider repaints around whichever windo is on the canvas.
export default windo<ToggleProps>(() => ({ title: 'Theme toggle', group: 'actions', // Repaint the shared provider from inside the component. providers: ({ children, ctx }) => ( <AppThemeProvider theme={ctx.ctxState.theme as string}>{children}</AppThemeProvider> ), component: (props, ctx) => ( <ThemeToggle {...props} theme={ctx.ctxState.theme as string} onToggle={() => ctx.setCtxState({ theme: ctx.ctxState.theme === 'dark' ? 'light' : 'dark' })} /> ), }))
setCtxState shallow-merges the patch into the shared state and re-renders every consumer — so a toggle in one component changes what a card, a button, or the same component's provider reads on the next render. The chrome also surfaces ctxState in its Shared strip, so you can edit the same bag by hand when you'd rather not click through a component.
Driving the colour scheme
The canvas light/dark toggle is its own built-in control, separate from ctxState — it lives on ctx.colorScheme. A component can drive it too: ctx.setColorScheme('dark') sets it outright, ctx.toggleTheme() flips it. Both move the same scheme the chrome topbar toggle controls, so a switch inside a component and the topbar stay in sync.
export default windo<SwitchProps>(() => ({ title: 'Dark mode switch', group: 'actions', component: (props, ctx) => ( <Switch {...props} checked={ctx.colorScheme === 'dark'} // Flips the same scheme the chrome topbar toggle controls. onChange={() => ctx.toggleTheme()} /> ), }))
Wrapping a single windo
Contexts are the shared, reusable layer. Sometimes one windo needs a wrapper that nothing else does — a router stub for a link component, a form store for a single field demo, a mock query client scoped to one example. Reaching for a full context is overkill.
The per-windo providers field is the local escape hatch: a single React component that wraps just this windo, in addition to anything uses brings in. It receives children and the render ctx, so it can read live state and environment.
import { MemoryRouter } from 'react-router-dom' export default windo<NavLinkProps>(() => ({ title: 'Nav link', group: 'navigation', // Shared theme provider for everyone… uses: ['theme'], // …plus a router that only this windo needs. providers: ({ children }) => <MemoryRouter>{children}</MemoryRouter>, defaultProps: { to: '/projects', label: 'Projects' }, component: (props) => <NavLink {...props} />, }))
providers and uses compose: the uses providers mount first, then the local providers wraps inside them, then your component. Use a registered context when more than one windo shares a wrapper; reach for providers when the wrapper belongs to exactly one example.
Where next
- Variants — pre-baked prop sets that render in the gallery and apply on click.
- State & actions — the per-windo state machine and the render-time rule that governs
providerstoo.