@westopp/windo 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,10 @@ export function buildRenderContext<State>(
10
10
  contexts: WindoContextMap,
11
11
  postLog: (entry: WindoLogEntry) => void,
12
12
  state: State,
13
- setState: (patch: Partial<State>) => void
13
+ setState: (patch: Partial<State>) => void,
14
+ ctxState: Record<string, unknown>,
15
+ setCtxState: (patch: Record<string, unknown>) => void,
16
+ setColorScheme: (scheme: 'light' | 'dark') => void
14
17
  ): WindoRenderContext<State> {
15
18
  const logger = {
16
19
  log: (...args: unknown[]) => postLog({ ts: Date.now(), args }),
@@ -26,6 +29,10 @@ export function buildRenderContext<State>(
26
29
  state,
27
30
  setState,
28
31
  contexts: {},
32
+ ctxState,
33
+ setCtxState,
34
+ setColorScheme,
35
+ toggleTheme: () => setColorScheme(env.colorScheme === 'light' ? 'dark' : 'light'),
29
36
  }
30
37
 
31
38
  for (const name of Object.keys(contexts)) {
@@ -10,7 +10,7 @@ import type { z } from 'zod'
10
10
  import { describeSchema } from '../descriptor'
11
11
  import type { WindoHostMessage, WindoPreviewMessage } from '../protocol'
12
12
  import { isHostMessage, isWindoMessage, WINDO_MSG } from '../protocol'
13
- import type { WindoActionMeta, WindoContextMap, WindoDefinition, WindoEnvState, WindoFactoryArg, WindoGroup, WindoLogEntry, WindoManifestEntry, WindoRenderContext, WindoVariantMeta } from '../types'
13
+ import type { WindoActionMeta, WindoContextMap, WindoDefinition, WindoEnvState, WindoFactoryArg, WindoGroup, WindoInitContext, WindoLogEntry, WindoManifestEntry, WindoPlacement, WindoPropDoc, WindoRenderContext, WindoVariant, WindoVariantMeta } from '../types'
14
14
  import { buildRenderContext } from './ctx'
15
15
  import { buildContextMeta, flattenZodError, idFromPath, sortEntries, summarizeLogArg, toCloneable } from './registry'
16
16
  import { PreviewRoot } from './render'
@@ -46,7 +46,14 @@ let currentSchema: z.ZodType | undefined
46
46
  let currentValues: unknown = {}
47
47
  let env: WindoEnvState = DEFAULT_ENV
48
48
  let currentState: Record<string, unknown> = {}
49
+ // Shared, cross-component state. Seeded once from the config and — unlike
50
+ // `currentState` — never reset on selection, so it survives switching windos.
51
+ let ctxState: Record<string, unknown> = { ...((config.ctxState as Record<string, unknown>) ?? {}) }
49
52
  let root: Root | null = null
53
+ // Raised while a windo's full-ctx fields are being resolved (init state, defaultProps,
54
+ // placement). Resolution must be a pure read of the env/ctxState surface — so the
55
+ // mutating callbacks no-op while it's set, closing every re-entrant render path.
56
+ let resolving = false
50
57
 
51
58
  function postToParent(msg: WindoPreviewMessage) {
52
59
  window.parent.postMessage(msg, '*')
@@ -60,15 +67,57 @@ function postLog(entry: WindoLogEntry) {
60
67
 
61
68
  /** The live ctx, rebuilt on demand so render-time, toolbar, and stage events all share the current state. */
62
69
  function makeCtx(): WindoRenderContext {
63
- return buildRenderContext(env, contexts, postLog, currentState, setState)
70
+ return buildRenderContext(env, contexts, postLog, currentState, setState, ctxState, setCtxState, setColorScheme)
71
+ }
72
+
73
+ /**
74
+ * The init-scoped ctx handed to a `state` resolver. State is resolved exactly once,
75
+ * here, before any component-local state exists — so it exposes the env surface only
76
+ * with a no-op `setState`. The shared `setCtxState`/`setColorScheme` are still wired
77
+ * through but no-op while `resolving` is set, preventing an author from re-triggering
78
+ * resolution mid-init. Typed as `WindoInitContext` to drop the absent `state`/`setState`.
79
+ */
80
+ function makeInitCtx(): WindoInitContext {
81
+ return buildRenderContext(env, contexts, postLog, {}, () => {}, ctxState, setCtxState, setColorScheme)
64
82
  }
65
83
 
66
84
  function setState(patch: Partial<Record<string, unknown>>) {
85
+ // No-op while resolving a windo's full-ctx fields — those are pure reads and must
86
+ // never drive a render, which would re-enter resolution and loop.
87
+ if (resolving) return
67
88
  currentState = { ...currentState, ...patch }
68
89
  render()
69
90
  postState()
70
91
  }
71
92
 
93
+ /** Merge a patch into the shared state, re-render, and echo it up to the chrome. */
94
+ function setCtxState(patch: Record<string, unknown>) {
95
+ if (resolving) return
96
+ ctxState = { ...ctxState, ...patch }
97
+ render()
98
+ postCtxState()
99
+ }
100
+
101
+ /** Cloneable snapshot of the shared state (drops functions/JSX before postMessage). */
102
+ function cloneableCtxState(): Record<string, unknown> {
103
+ const out: Record<string, unknown> = {}
104
+ for (const key of Object.keys(ctxState)) out[key] = toCloneable(ctxState[key])
105
+ return out
106
+ }
107
+
108
+ function postCtxState() {
109
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'ctx-state', state: cloneableCtxState() })
110
+ }
111
+
112
+ /** Set the canvas colour scheme from a component/action and echo it so the chrome's toggle stays in sync. */
113
+ function setColorScheme(scheme: 'light' | 'dark') {
114
+ if (resolving) return
115
+ if (env.colorScheme === scheme) return
116
+ env = { ...env, colorScheme: scheme }
117
+ render()
118
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'color-scheme', colorScheme: scheme })
119
+ }
120
+
72
121
  /** Post the current state snapshot + each action's live `disabled` flag up to the chrome. */
73
122
  function postState() {
74
123
  if (!currentId || !currentDef) return
@@ -113,11 +162,13 @@ function manifestEntry(id: string, def: WindoDefinition): WindoManifestEntry {
113
162
  group: def.group,
114
163
  tags: def.tags ?? [],
115
164
  status: def.status ?? 'stable',
116
- placement: def.placement ?? 'center',
165
+ // The now-functionable fields can't be resolved here (no ctx at init): fall back
166
+ // to a safe static value, and assume the dynamic forms contribute a variant/state.
167
+ placement: typeof def.placement === 'function' ? 'center' : (def.placement ?? 'center'),
117
168
  uses: def.uses ?? [],
118
- hasVariants: (def.variants?.length ?? 0) > 0,
169
+ hasVariants: typeof def.variants === 'function' ? true : (def.variants?.length ?? 0) > 0,
119
170
  actions,
120
- hasState: !!def.state && Object.keys(def.state as object).length > 0,
171
+ hasState: typeof def.state === 'function' ? true : !!def.state && Object.keys(def.state as object).length > 0,
121
172
  }
122
173
  if (def.description !== undefined) entry.description = def.description
123
174
  if (def.deprecation !== undefined) entry.deprecation = def.deprecation
@@ -132,10 +183,85 @@ function computeDefaults(schema: z.ZodType | undefined): unknown {
132
183
 
133
184
  function safeCode(def: WindoDefinition, defaults: unknown): string | null {
134
185
  if (!def.code) return null
186
+ // `code` is a pure string producer — resolve it under `resolving` so it can't drive a
187
+ // render and post an inconsistent describe payload by mutating state mid-resolution.
188
+ resolving = true
135
189
  try {
136
- return def.code(defaults as never)
190
+ return def.code(defaults as never, makeCtx())
137
191
  } catch {
138
192
  return null
193
+ } finally {
194
+ resolving = false
195
+ }
196
+ }
197
+
198
+ /** Resolve the authored Props table — a static array or a `ctx => array` function — under `resolving` so it stays a pure read. */
199
+ function safeProps(def: WindoDefinition): WindoPropDoc[] {
200
+ const p = def.props
201
+ if (typeof p !== 'function') return p ?? []
202
+ resolving = true
203
+ try {
204
+ return p(makeCtx()) ?? []
205
+ } catch {
206
+ return []
207
+ } finally {
208
+ resolving = false
209
+ }
210
+ }
211
+
212
+ /** Resolve a windo's initial state — a static value or an init-ctx function — under `resolving`, degrading to `{}` if it throws. */
213
+ function safeState(def: WindoDefinition): Record<string, unknown> {
214
+ const state = def.state
215
+ if (typeof state !== 'function') return { ...((state as Record<string, unknown> | undefined) ?? {}) }
216
+ resolving = true
217
+ try {
218
+ return { ...((state as (c: WindoInitContext) => unknown)(makeInitCtx()) as Record<string, unknown>) }
219
+ } catch {
220
+ return {}
221
+ } finally {
222
+ resolving = false
223
+ }
224
+ }
225
+
226
+ /** Resolve `defaultProps` — a static value or a `ctx => props` function — under `resolving` so it can't drive a render. */
227
+ function safeDefaultProps(def: WindoDefinition, ctx: WindoRenderContext): unknown {
228
+ const dp = def.defaultProps
229
+ if (typeof dp !== 'function') return dp
230
+ resolving = true
231
+ try {
232
+ return (dp as (c: WindoRenderContext) => unknown)(ctx)
233
+ } catch {
234
+ return {}
235
+ } finally {
236
+ resolving = false
237
+ }
238
+ }
239
+
240
+ /** Resolve `placement` — a static value or a `ctx => placement` function — under `resolving`, degrading to `center` if it throws. */
241
+ function safePlacement(def: WindoDefinition, ctx: WindoRenderContext): WindoPlacement {
242
+ const p = def.placement
243
+ if (typeof p !== 'function') return p ?? 'center'
244
+ resolving = true
245
+ try {
246
+ return p(ctx)
247
+ } catch {
248
+ return 'center'
249
+ } finally {
250
+ resolving = false
251
+ }
252
+ }
253
+
254
+ /** Resolve the gallery variants — a static array or a `ctx => array` function — under `resolving` so it stays a pure read. */
255
+ function safeVariants(def: WindoDefinition): WindoVariant<unknown>[] {
256
+ const v = def.variants
257
+ if (typeof v !== 'function') return v ?? []
258
+ resolving = true
259
+ try {
260
+ return v(makeCtx()) ?? []
261
+ } catch {
262
+ return []
263
+ } finally {
264
+ resolving = false
139
265
  }
140
266
  }
141
267
 
@@ -149,6 +275,7 @@ function postManifest() {
149
275
  groups: [...config.groups],
150
276
  tags: config.tags ? [...config.tags] : [],
151
277
  contexts: buildContextMeta(contexts),
278
+ ctxState: cloneableCtxState(),
152
279
  })
153
280
  }
154
281
 
@@ -156,14 +283,14 @@ function postDescribe(id: string, def: WindoDefinition, schema: z.ZodType | unde
156
283
  const defaults = computeDefaults(schema)
157
284
  // variant patches and defaults may carry ReactNodes/functions that postMessage
158
285
  // cannot clone — reduce them to JSON-safe forms before they cross the boundary.
159
- const variants: WindoVariantMeta[] = (def.variants ?? []).map(v => ({ label: v.label, props: toCloneable(v.props) as Record<string, unknown> }))
286
+ const variants: WindoVariantMeta[] = safeVariants(def).map(v => ({ label: v.label, props: toCloneable(v.props) as Record<string, unknown> }))
160
287
  postToParent({
161
288
  source: WINDO_MSG,
162
289
  dir: 'preview',
163
290
  type: 'describe',
164
291
  id,
165
292
  descriptor: describeSchema(schema),
166
- props: def.props ?? [],
293
+ props: safeProps(def),
167
294
  variants,
168
295
  defaults: toCloneable(defaults),
169
296
  code: safeCode(def, defaults),
@@ -174,12 +301,18 @@ function render() {
174
301
  if (!currentDef || !root) return
175
302
  const ctx = makeCtx()
176
303
  const id = currentId ?? ''
304
+ // Resolve the full-ctx fields here, OUTSIDE the React render cycle, so a function
305
+ // field that touches a mutating callback can't re-enter render() synchronously.
306
+ const defaultProps = safeDefaultProps(currentDef, ctx)
307
+ const placement = safePlacement(currentDef, ctx)
177
308
  root.render(
178
309
  createElement(PreviewRoot, {
179
310
  def: currentDef,
180
311
  ctx,
181
312
  values: currentValues,
182
313
  contexts,
314
+ defaultProps,
315
+ placement,
183
316
  onStage: dispatchStage,
184
317
  onError: (message: string, stack?: string) => {
185
318
  postToParent({ source: WINDO_MSG, dir: 'preview', type: 'render-error', id, message, ...(stack === undefined ? {} : { stack }) })
@@ -195,8 +328,11 @@ function handleSelect(id: string) {
195
328
  currentDef = match.def
196
329
  currentSchema = match.def.configurableProps
197
330
  currentValues = computeDefaults(currentSchema)
198
- // Reset component-local state to the windo's declared initial state.
199
- currentState = { ...((match.def.state as Record<string, unknown>) ?? {}) }
331
+ // Reset component-local state to the windo's declared initial state. Resolve it
332
+ // ONCE here — a `state` function runs against the init ctx (no live state/setState)
333
+ // under `resolving`, degrading to `{}` on throw. It is never re-resolved in
334
+ // render/makeCtx/set-env/set-ctx-state, so the author cannot drive a resolution loop.
335
+ currentState = safeState(currentDef)
200
336
  render()
201
337
  postState()
202
338
  postDescribe(id, currentDef, currentSchema)
@@ -249,6 +385,12 @@ function handleMessage(msg: WindoHostMessage) {
249
385
  env = msg.env
250
386
  render()
251
387
  break
388
+ case 'set-ctx-state':
389
+ // Chrome is the source of this update (an editor edit or a reload re-sync),
390
+ // so adopt it and re-render — but don't echo it back, to avoid a ping-pong.
391
+ ctxState = { ...msg.state }
392
+ render()
393
+ break
252
394
  case 'invoke-action':
253
395
  invokeAction(msg.actionId)
254
396
  break
@@ -45,15 +45,18 @@ export interface PreviewRootProps {
45
45
  ctx: WindoRenderContext
46
46
  values: unknown
47
47
  contexts: WindoContextMap
48
+ /** `defaultProps` resolved outside the render cycle by `index.ts`, so a function field can't re-enter render here. */
49
+ defaultProps: unknown
50
+ /** `placement` resolved outside the render cycle by `index.ts`, for the same reason. */
51
+ placement: WindoPlacement
48
52
  onError: (message: string, stack?: string) => void
49
53
  /** Fires the windo's pointer-bound actions (`enter`/`exit`/`hover`) as the pointer crosses the stage. */
50
54
  onStage: (phase: 'enter' | 'leave') => void
51
55
  }
52
56
 
53
57
  export function PreviewRoot(props: PreviewRootProps) {
54
- const { def, ctx, values, contexts, onError, onStage } = props
58
+ const { def, ctx, values, contexts, defaultProps, placement, onError, onStage } = props
55
59
 
56
- const defaultProps = typeof def.defaultProps === 'function' ? (def.defaultProps as (c: WindoRenderContext) => unknown)(ctx) : def.defaultProps
57
60
  const finalProps = { ...(defaultProps as object), ...((values as object) ?? {}) }
58
61
 
59
62
  let tree: ReactNode = def.component(finalProps, ctx)
@@ -77,7 +80,6 @@ export function PreviewRoot(props: PreviewRootProps) {
77
80
  )
78
81
  }
79
82
 
80
- const placement: WindoPlacement = def.placement ?? 'center'
81
83
  // Placements render flush; the `-padding` suffix opts into frame padding.
82
84
  const padded = placement.endsWith('-padding')
83
85
  const align = padded ? placement.slice(0, -'-padding'.length) : placement
package/src/protocol.ts CHANGED
@@ -13,6 +13,7 @@ export type WindoHostMessage =
13
13
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'select'; id: string }
14
14
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-props'; id: string; json: string }
15
15
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-env'; env: WindoEnvState }
16
+ | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-ctx-state'; state: Record<string, unknown> }
16
17
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'invoke-action'; id: string; actionId: string }
17
18
 
18
19
  /** iframe -> chrome */
@@ -27,6 +28,8 @@ export type WindoPreviewMessage =
27
28
  groups: WindoGroup[]
28
29
  tags: string[]
29
30
  contexts: WindoContextMeta[]
31
+ /** Initial shared state from the config — seeds the chrome's editable strip. */
32
+ ctxState: Record<string, unknown>
30
33
  }
31
34
  | {
32
35
  source: typeof WINDO_MSG
@@ -43,6 +46,8 @@ export type WindoPreviewMessage =
43
46
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'parse-error'; id: string; errors: WindoFieldError[] }
44
47
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'log'; entry: WindoLogEntry }
45
48
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'state'; id: string; state: Record<string, unknown>; actions: { id: string; disabled: boolean }[] }
49
+ | { source: typeof WINDO_MSG; dir: 'preview'; type: 'ctx-state'; state: Record<string, unknown> }
50
+ | { source: typeof WINDO_MSG; dir: 'preview'; type: 'color-scheme'; colorScheme: 'light' | 'dark' }
46
51
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'render-error'; id: string; message: string; stack?: string }
47
52
 
48
53
  export type WindoMessage = WindoHostMessage | WindoPreviewMessage
package/src/types.ts CHANGED
@@ -131,6 +131,20 @@ export interface WindoRenderContext<State = unknown> {
131
131
  setState: (patch: Partial<State>) => void
132
132
  /** Resolved values of opted-in contexts, keyed by context name. */
133
133
  contexts: Record<string, unknown>
134
+ /**
135
+ * Shared, cross-component state seeded from the config's `ctxState`. Unlike
136
+ * `state` (per-windo, reset on selection), this persists across selection and
137
+ * is global: any component can read it and write it via `setCtxState`. Use it
138
+ * to drive providers that wrap every component — e.g. a theme provider toggled
139
+ * from any component on the canvas.
140
+ */
141
+ ctxState: Record<string, unknown>
142
+ /** Merge a patch into the shared `ctxState` and re-render every consumer. */
143
+ setCtxState: (patch: Record<string, unknown>) => void
144
+ /** Set the canvas colour scheme from a component or action. */
145
+ setColorScheme: (scheme: 'light' | 'dark') => void
146
+ /** Flip the canvas colour scheme between light and dark. */
147
+ toggleTheme: () => void
134
148
  }
135
149
 
136
150
  /* ------------------------------------------------------------------ *
@@ -151,7 +165,13 @@ export interface WindoPropDoc {
151
165
  desc?: string
152
166
  }
153
167
 
154
- export type WindoDefaultProps<Props, State = unknown> = Props | ((ctx: WindoRenderContext<State>) => Props)
168
+ /** A value that may be authored statically or as a context-aware `ctx => value` function resolved at render-time. */
169
+ export type Ctxual<T, State = unknown> = T | ((ctx: WindoRenderContext<State>) => T)
170
+
171
+ /** The ctx surface available while resolving a windo's initial state — the render-time `state`/`setState` pair is excluded because it does not exist yet. */
172
+ export type WindoInitContext<State = unknown> = Omit<WindoRenderContext<State>, 'state' | 'setState'>
173
+
174
+ export type WindoDefaultProps<Props, State = unknown> = Ctxual<Props, State>
155
175
 
156
176
  /**
157
177
  * The object returned by a `windo(...)` factory.
@@ -169,9 +189,10 @@ export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug ext
169
189
  status?: WindoStatus
170
190
  description?: string
171
191
  deprecation?: string
172
- placement?: WindoPlacement
173
- /** Initial component-local state. Its shape is the `State` generic; `ctx.state`/`ctx.setState` derive from it. */
174
- state?: State
192
+ /** Where the component anchors in the canvas frame. Either a static placement or a function resolved with the live `ctx`. */
193
+ placement?: Ctxual<WindoPlacement, State>
194
+ /** Initial component-local state. Its shape is the `State` generic; `ctx.state`/`ctx.setState` derive from it. Either a static value or a function resolved with the init `ctx` (no `state`/`setState`) when the component is selected. */
195
+ state?: State | ((ctx: WindoInitContext<State>) => State)
175
196
  /** Out-of-band actions that drive state: toolbar buttons (`click`) and stage pointer triggers (`enter`/`exit`/`hover`). */
176
197
  actions?: WindoAction<State>[]
177
198
  /** zod schema: validator + parser for the JSON-editable prop subset. `z.output ⊆ Props`. */
@@ -180,11 +201,12 @@ export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug ext
180
201
  defaultProps: WindoDefaultProps<Props, State>
181
202
  /** Names of provider contexts this component opts into. */
182
203
  uses?: string[]
183
- variants?: WindoVariant<Props>[]
184
- /** Authored documentation table (not derived from the schema). */
185
- props?: WindoPropDoc[]
186
- /** Optional authored code snippet for the Code tab. */
187
- code?: (values: Props) => string
204
+ /** Gallery variants. Either a static array or a function resolved with the live `ctx` when the component is selected. */
205
+ variants?: Ctxual<WindoVariant<Props>[], State>
206
+ /** Authored documentation table (not derived from the schema). Either a static array or a function resolved with the live `ctx` when the component is selected. */
207
+ props?: Ctxual<WindoPropDoc[], State>
208
+ /** Optional authored code snippet for the Code tab, resolved with the JSON-editable values and the live `ctx`. */
209
+ code?: (values: Props, ctx: WindoRenderContext<State>) => string
188
210
  /** A local provider wrapping just this windo (in addition to `uses`). */
189
211
  providers?: ComponentType<{ children: ReactNode; ctx: WindoRenderContext<State> }>
190
212
  component: (props: Props, ctx: WindoRenderContext<State>) => ReactNode
@@ -220,6 +242,8 @@ export interface WindoConfig<Groups extends readonly WindoGroup[] = readonly Win
220
242
  contexts?: Contexts
221
243
  /** The set of tags components may be assigned. A component's `tags` must be drawn from this list; the sidebar filters by them. */
222
244
  tags?: Tags
245
+ /** Initial shared state exposed on `ctx.ctxState`. Global across every component and persisted across selection — write it from any component via `ctx.setCtxState`. */
246
+ ctxState?: Record<string, unknown>
223
247
  /** Glob(s) for discovery, relative to project root. Default `**\/*.windo.tsx`. */
224
248
  include?: string | string[]
225
249
  /** Title shown in the workbench chrome. */