Configurable props
A windo edits props as JSON. Two fields make that work: configurableProps hands the canvas a zod schema that defines the editable surface and validates every keystroke, while defaultProps carries the full props — functions, JSX, everything JSON can't hold. The JSON you type merges on top of the defaults, so the schema only needs to describe the parts a person should reach for.
The schema is the editable surface
configurableProps<Props>()(schema) is an identity helper — it returns the same schema you pass in, unchanged at runtime. Its only job is at the type level: the first call captures your component's prop type, and the second constrains the schema to a Partial<Props>. If the schema describes a field your component doesn't accept, or types one wrongly, the line stops compiling. The schema can never drift from the component.
The two-call shape exists because TypeScript can't infer the prop type and the schema type in one set of parentheses. You name the props once; zod infers the rest.
import { configurableProps } from '@westopp/windo' import { z } from 'zod' import type { ButtonProps } from './components/Button' const schema = configurableProps<ButtonProps>()( z.object({ children: z.string().default('Create project'), variant: z.enum(['primary', 'secondary', 'outline', 'ghost', 'destructive']).default('primary'), size: z.enum(['sm', 'md', 'lg']).default('md'), loading: z.boolean().default(false), disabled: z.boolean().default(false), }) )
Each key becomes one editable field. A z.enum turns into a dropdown; a z.boolean into a toggle; strings and numbers into text inputs. You don't list controls anywhere — the canvas derives them from the schema's input shape.
From schema to the Schema panel
The live zod schema can't cross the iframe boundary — it holds closures (refinements, transforms) that don't serialise. So windo flattens it into a plain descriptor with describeSchema, and that travels to the chrome to render the Schema panel: one row per field, each with its kind, whether it's optional, enum options, and any min/max bound.
The descriptor leans on z.toJSONSchema for the input shape, then reads the zod node's own type tag for kinds JSON Schema can't express — a z.date() or z.set() comes back as an empty object in JSON Schema, but its zod tag is exact, so the panel still labels it date or array. The result is a faithful map of what you can edit, derived entirely from the schema you already wrote.
{
"fields": [
{ "key": "children", "kind": "string", "optional": true },
{ "key": "variant", "kind": "enum", "optional": true,
"options": ["primary", "secondary", "outline", "ghost", "destructive"] },
{ "key": "size", "kind": "enum", "optional": true, "options": ["sm", "md", "lg"] },
{ "key": "loading", "kind": "boolean", "optional": true },
{ "key": "disabled", "kind": "boolean", "optional": true }
]
}The fields read as optional here because every one carries a .default() — a defaulted field is one JSON can leave out. Drop the default and require the value, and that field's optional flips to false.
Editing JSON, validated live
The descriptor renders the panel, but it never validates anything. The real schema stays inside the iframe, and when you edit the JSON, that live schema parses it — transforms, coerce, and refine all run, exactly as they would in your app. Two outcomes:
- Parse succeeds — the canvas re-renders the component with the parsed output.
- Parse fails — the canvas surfaces the zod error against the field that produced it, and the last good render stays on screen.
So the JSON editor is your component's actual validation contract, not a loose approximation. If coerce would turn the string "3" into the number 3, you'll see the coercion; if a refine rejects an empty label, you'll see that rejection inline.
// coerce + refine run when you edit the JSON, just like at runtime const schema = configurableProps<InputProps>()( z.object({ label: z.string().min(1, 'Label is required').default('Email'), maxLength: z.coerce.number().int().min(1).default(40), size: z.enum(['sm', 'md']).default('md'), }) )
Type { "maxLength": "12" } and the component receives the number 12 — coerce ran. Type { "label": "" } and the panel shows Label is required against label, because min(1) rejected it. Nothing reaches the component until the parse clears.
defaultProps carries what JSON can't
A schema describes the JSON-shaped slice of your props. But most components also take props JSON can never hold — a ReactNode icon, an onClick, a render function. Those live in defaultProps, which is the full prop object the component starts from.
export default windo<ButtonProps>(() => ({ title: 'Button', group: 'actions', configurableProps: schema, // the FULL props — including JSX and handlers the schema can't express defaultProps: { variant: 'primary', size: 'md', loading: false, disabled: false, children: 'Create project', iconLeft: <PlusIcon />, onClick: () => {}, }, component: props => <Button {...props} />, }))
At render time, windo starts from defaultProps, then merges the validated JSON over it. The JSON owns the keys you edit; defaultProps owns everything else — and everything the JSON omits. Your iconLeft and onClick survive untouched because the schema never mentions them, so the editor never has to.
This is why the schema can stay small. It only needs to cover the props worth tweaking by hand; the rest ride along in defaultProps.
defaultProps can read ctx
defaultProps can also be a function of the live ctx — ctx => Props — so a default can derive from the current environment instead of being fixed. Pull a localized label out of ctx.ctxState.language, swap a default icon by ctx.colorScheme, size something off ctx.viewport — the resolver runs at render time, not in the static factory body, so it sees the real ctx.
Keep the resolver a pure read of ctx: return props from what you find there, and don't call setState, setCtxState, or setColorScheme inside it. See State & actions for ctxState and the render-time rule.
// defaultProps as a function of ctx — the label follows the shared language defaultProps: ctx => ({ variant: 'primary', children: ctx.ctxState.language === 'fr' ? 'Créer un projet' : 'Create project', iconLeft: <PlusIcon />, onClick: () => {}, }),
Precedence: the editor draft wins per key
A ctx-aware default only shows if nothing overwrites it. The render merge is { ...resolvedDefaultProps, ...currentValues } — currentValues is the editor's working draft, and it overrides defaultProps key by key.
The catch is where that draft comes from: windo seeds it from the schema's static defaults — the .default() values on configurableProps — not from defaultProps. So if a prop lives in both defaultProps (ctx-aware) and configurableProps, the schema's default lands in the draft and shadows the ctx-derived value. The label you computed from ctx.ctxState.language never reaches the component, because the editor already filled that key with its static default.
The fix is to split the props by who should own them:
- Genuinely ctx-driven props — keep them out of
configurableProps. LetdefaultPropsown the key alone, with no schema field competing for it, and the ctx value flows through. - Hand-editable props — put them in
configurableProps. These are the ones a person should reach for in the JSON editor; a static default is exactly what you want seeding the draft.
In the example above, children is ctx-driven, so it stays off the schema. variant and size are knobs to tweak, so they live in configurableProps. A prop sits in one column or the other, not both.
The authored props table
configurableProps documents the editable slice. To document the whole component — including the props that only live in defaultProps — author a props table. It's plain data, written by hand, not derived from the schema, so it can describe types JSON can't represent and props the editor never shows.
props: [ { name: 'variant', type: '"primary" | "secondary" | "outline" | "ghost" | "destructive"', default: '"primary"', desc: 'Visual emphasis of the button.' }, { name: 'size', type: '"sm" | "md" | "lg"', default: '"md"', desc: 'Control height and typography scale.' }, { name: 'iconLeft', type: 'ReactNode', default: '—', desc: 'Optional icon rendered before the label.' }, { name: 'loading', type: 'boolean', default: 'false', desc: 'Shows a spinner and disables interaction.' }, { name: 'onClick', type: '() => void', default: '—', desc: 'Click handler.' }, ]
Each row is one prop. iconLeft and onClick appear here even though no JSON field exists for them — the table is for the reader, the schema is for the editor, and they cover different ground on purpose.
| Name | Type | Description |
|---|---|---|
| name | string | Prop name as the component declares it. |
| type | string | The TypeScript type, written as a display string. |
| default | string | Default value, or '—' when there isn't one. Optional. |
| desc | string | One-line explanation shown in the table. Optional. |
Where next
- State & actions — drive a windo's behaviour over time with component-local state and toolbar/pointer actions.
- Variants — pin labelled prop patches into a click-to-apply gallery, built from the same
Partial<Props>shape.