windo/Concepts/How it works
How it works

How it works

windo runs your component in a window of its own — literally. The workbench you click around in and the component you're previewing live in two separate browsing contexts, talking over a small, typed message channel. That split is the whole design, and once you see why it's there, the rest of windo falls into place.

Two windows, one conversation

A windo session is two documents, not one.

The chrome is the top-level page: the sidebar, the controls panel, the toolbar, the schema and console views — the workbench. The preview is an <iframe> nested inside it, and that iframe is the only place your component is ever rendered.

They can't call each other's functions. They share no variables. Everything that passes between them is plain JSON sent with postMessage. The chrome asks for things ("show me this windo", "here are new props", "run this action"); the preview reports back ("here's the manifest", "props parsed", "state changed", "render failed").

TS
// chrome -> iframe
{ type: 'select', id: 'button' }
{ type: 'set-props', id: 'button', json: '{ "label": "Save" }' }
{ type: 'invoke-action', id: 'button', actionId: '0' }

// iframe -> chrome
{ type: 'manifest', entries: [...], groups: [...], contexts: [...] }
{ type: 'parse-ok', id: 'button' }
{ type: 'state', id: 'button', state: {...}, actions: [...] }

Why a separate window at all

The obvious approach — render the component right next to the toolbar in one page — fails quietly. A component's CSS is global by default. A reset, a body rule, a stray * { box-sizing }, a font import — any of it would reach out and restyle the workbench, and the workbench's own styles would just as easily bleed into your component. You'd be debugging a preview that doesn't look like your real app.

The iframe is a hard wall. Styles, the DOM, even the React tree are scoped to the inside. Your component renders against a blank document, exactly as if it were the only thing on the page — which is the point of a canvas. The toolbar can sit in a fully styled workbench without a single rule leaking across.

The isolation goes the other way too. Component-local state lives entirely in the preview. When you switch windos or edit props, the preview re-renders in place; the chrome never holds a copy of your render tree, so there's nothing to fall out of sync.

The schema never crosses the boundary

windo validates live props with a zod schema. That schema is a tree of functions — transforms, coercions, refinements. Functions can't be cloned by postMessage, and even if they could, sending them to the chrome would be pointless: the chrome can't run them safely or meaningfully.

So the schema stays put. It lives in the preview iframe and never travels.

What the chrome needs is enough to draw the controls — which fields exist, their kinds, whether they're optional, enum options, numeric bounds. windo derives exactly that into a flat, serialisable descriptor and sends the descriptor instead. The chrome renders its controls from the descriptor; the live schema stays behind to do the actual parsing.

TS
// Inside the iframe: the real schema does the work.
const schema = z.object({
label: z.string(),
size: z.enum(['sm', 'md', 'lg']),
})

// What the chrome receives: a JSON descriptor, no functions.
{
fields: [
  { key: 'label', kind: 'string', optional: false },
  { key: 'size', kind: 'enum', optional: false, options: ['sm', 'md', 'lg'] },
],
}

The round-trip makes the division of labour concrete. You edit a value in the chrome's controls; the chrome serialises the candidate object to JSON and posts it down as set-props. The iframe parses that JSON against the live schema with safeParse. On success it keeps the parsed value and re-renders, then replies parse-ok. On failure it flattens zod's error into per-field messages and replies parse-error, and the chrome shows them next to the offending fields.

The chrome never validates. It can't — it doesn't have the schema. It only ever displays what the preview tells it.

How windos are discovered

windo doesn't ask you to register anything. The catalogue is built by scanning the filesystem.

The Vite plugin exposes a virtual module, virtual:windo-registry, that the preview imports at startup. Behind that module the plugin emits an import.meta.glob over your *.windo.tsx files, so Vite hands the preview a map of every matching file — each entry a lazy import. The same module re-exports your windo.config (found by walking the chosen root for a windo.config.ts and its siblings).

On boot the preview resolves that glob, loads each file's default export, and turns every one into a manifest entry — its id derived from the filename (Button.windo.tsxbutton). That manifest, plus the config's groups and contexts, is the first thing posted up to the chrome. Add a new *.windo.tsx file and it appears; no list to maintain.

TSvirtual:windo-registry
// What the plugin generates (simplified)
import * as __cfg from '/abs/path/windo.config.ts'
export const config = __cfg.config ?? __cfg.default
export const modules = import.meta.glob('/**/*.windo.tsx')

The plugin also owns the two HTML documents themselves. Because it runs Vite in custom app mode, it serves the chrome document at / and the iframe document at /__windo/iframe directly — two tiny shells, one importing @westopp/windo/client, the other @westopp/windo/preview. You never write either page.

The shape of it

Putting the pieces together:

  • The chrome is the workbench — UI only, no component code, no schema.
  • The preview iframe renders your component, holds the live schema, owns component-local state.
  • A typed postMessage protocol is the only link; every message is plain JSON.
  • A serialisable descriptor travels in the schema's place so the chrome can draw controls without ever holding the schema.
  • The registry is built by import.meta.glob over *.windo.tsx, with config and components discovered from the chosen root.

Where next

  • Configurable props — define the zod schema that becomes the live controls panel.
  • State & actions — the per-windo state machine the preview owns and the chrome drives.
  • The Vite plugin — wiring windo() into your build and pointing it at the right root.