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.
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.