@westopp/windo 0.1.0 → 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,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'
@@ -21,6 +21,7 @@ const contexts = config.contexts ?? {}
21
21
  const w: WindoFactoryArg<readonly WindoGroup[], WindoContextMap> = {
22
22
  groups: Object.fromEntries(config.groups.map(g => [g.slug, g])) as Record<string, WindoGroup>,
23
23
  contexts,
24
+ tags: config.tags ? [...config.tags] : [],
24
25
  }
25
26
 
26
27
  interface ResolvedWindo {
@@ -45,7 +46,14 @@ let currentSchema: z.ZodType | undefined
45
46
  let currentValues: unknown = {}
46
47
  let env: WindoEnvState = DEFAULT_ENV
47
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>) ?? {}) }
48
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
49
57
 
50
58
  function postToParent(msg: WindoPreviewMessage) {
51
59
  window.parent.postMessage(msg, '*')
@@ -59,15 +67,57 @@ function postLog(entry: WindoLogEntry) {
59
67
 
60
68
  /** The live ctx, rebuilt on demand so render-time, toolbar, and stage events all share the current state. */
61
69
  function makeCtx(): WindoRenderContext {
62
- 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)
63
82
  }
64
83
 
65
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
66
88
  currentState = { ...currentState, ...patch }
67
89
  render()
68
90
  postState()
69
91
  }
70
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
+
71
121
  /** Post the current state snapshot + each action's live `disabled` flag up to the chrome. */
72
122
  function postState() {
73
123
  if (!currentId || !currentDef) return
@@ -110,12 +160,15 @@ function manifestEntry(id: string, def: WindoDefinition): WindoManifestEntry {
110
160
  id,
111
161
  title: def.title,
112
162
  group: def.group,
163
+ tags: def.tags ?? [],
113
164
  status: def.status ?? 'stable',
114
- 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'),
115
168
  uses: def.uses ?? [],
116
- hasVariants: (def.variants?.length ?? 0) > 0,
169
+ hasVariants: typeof def.variants === 'function' ? true : (def.variants?.length ?? 0) > 0,
117
170
  actions,
118
- 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,
119
172
  }
120
173
  if (def.description !== undefined) entry.description = def.description
121
174
  if (def.deprecation !== undefined) entry.deprecation = def.deprecation
@@ -130,10 +183,85 @@ function computeDefaults(schema: z.ZodType | undefined): unknown {
130
183
 
131
184
  function safeCode(def: WindoDefinition, defaults: unknown): string | null {
132
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
133
189
  try {
134
- return def.code(defaults as never)
190
+ return def.code(defaults as never, makeCtx())
135
191
  } catch {
136
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
137
265
  }
138
266
  }
139
267
 
@@ -145,7 +273,9 @@ function postManifest() {
145
273
  title: config.title ?? 'windo',
146
274
  entries,
147
275
  groups: [...config.groups],
276
+ tags: config.tags ? [...config.tags] : [],
148
277
  contexts: buildContextMeta(contexts),
278
+ ctxState: cloneableCtxState(),
149
279
  })
150
280
  }
151
281
 
@@ -153,14 +283,14 @@ function postDescribe(id: string, def: WindoDefinition, schema: z.ZodType | unde
153
283
  const defaults = computeDefaults(schema)
154
284
  // variant patches and defaults may carry ReactNodes/functions that postMessage
155
285
  // cannot clone — reduce them to JSON-safe forms before they cross the boundary.
156
- 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> }))
157
287
  postToParent({
158
288
  source: WINDO_MSG,
159
289
  dir: 'preview',
160
290
  type: 'describe',
161
291
  id,
162
292
  descriptor: describeSchema(schema),
163
- props: def.props ?? [],
293
+ props: safeProps(def),
164
294
  variants,
165
295
  defaults: toCloneable(defaults),
166
296
  code: safeCode(def, defaults),
@@ -171,12 +301,18 @@ function render() {
171
301
  if (!currentDef || !root) return
172
302
  const ctx = makeCtx()
173
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)
174
308
  root.render(
175
309
  createElement(PreviewRoot, {
176
310
  def: currentDef,
177
311
  ctx,
178
312
  values: currentValues,
179
313
  contexts,
314
+ defaultProps,
315
+ placement,
180
316
  onStage: dispatchStage,
181
317
  onError: (message: string, stack?: string) => {
182
318
  postToParent({ source: WINDO_MSG, dir: 'preview', type: 'render-error', id, message, ...(stack === undefined ? {} : { stack }) })
@@ -192,8 +328,11 @@ function handleSelect(id: string) {
192
328
  currentDef = match.def
193
329
  currentSchema = match.def.configurableProps
194
330
  currentValues = computeDefaults(currentSchema)
195
- // Reset component-local state to the windo's declared initial state.
196
- 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)
197
336
  render()
198
337
  postState()
199
338
  postDescribe(id, currentDef, currentSchema)
@@ -246,6 +385,12 @@ function handleMessage(msg: WindoHostMessage) {
246
385
  env = msg.env
247
386
  render()
248
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
249
394
  case 'invoke-action':
250
395
  invokeAction(msg.actionId)
251
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 */
@@ -25,7 +26,10 @@ export type WindoPreviewMessage =
25
26
  title: string
26
27
  entries: WindoManifestEntry[]
27
28
  groups: WindoGroup[]
29
+ tags: string[]
28
30
  contexts: WindoContextMeta[]
31
+ /** Initial shared state from the config — seeds the chrome's editable strip. */
32
+ ctxState: Record<string, unknown>
29
33
  }
30
34
  | {
31
35
  source: typeof WINDO_MSG
@@ -42,6 +46,8 @@ export type WindoPreviewMessage =
42
46
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'parse-error'; id: string; errors: WindoFieldError[] }
43
47
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'log'; entry: WindoLogEntry }
44
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' }
45
51
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'render-error'; id: string; message: string; stack?: string }
46
52
 
47
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.
@@ -161,15 +181,18 @@ export type WindoDefaultProps<Props, State = unknown> = Props | ((ctx: WindoRend
161
181
  * `actions`, `providers`, `component` — runs at render-time with the live `ctx`.
162
182
  * Never close over live values in the static factory body.
163
183
  */
164
- export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug extends string = string> {
184
+ export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug extends string = string, Tag extends string = string> {
165
185
  title: string
166
186
  group: GroupSlug
187
+ /** Tags this component carries. Each must be one of the config's declared `tags`. Drives the sidebar's tag filter. */
188
+ tags?: Tag[]
167
189
  status?: WindoStatus
168
190
  description?: string
169
191
  deprecation?: string
170
- placement?: WindoPlacement
171
- /** Initial component-local state. Its shape is the `State` generic; `ctx.state`/`ctx.setState` derive from it. */
172
- 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)
173
196
  /** Out-of-band actions that drive state: toolbar buttons (`click`) and stage pointer triggers (`enter`/`exit`/`hover`). */
174
197
  actions?: WindoAction<State>[]
175
198
  /** zod schema: validator + parser for the JSON-editable prop subset. `z.output ⊆ Props`. */
@@ -178,21 +201,24 @@ export interface WindoDefinition<Props = unknown, State = unknown, GroupSlug ext
178
201
  defaultProps: WindoDefaultProps<Props, State>
179
202
  /** Names of provider contexts this component opts into. */
180
203
  uses?: string[]
181
- variants?: WindoVariant<Props>[]
182
- /** Authored documentation table (not derived from the schema). */
183
- props?: WindoPropDoc[]
184
- /** Optional authored code snippet for the Code tab. */
185
- 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
186
210
  /** A local provider wrapping just this windo (in addition to `uses`). */
187
211
  providers?: ComponentType<{ children: ReactNode; ctx: WindoRenderContext<State> }>
188
212
  component: (props: Props, ctx: WindoRenderContext<State>) => ReactNode
189
213
  }
190
214
 
191
215
  /** Argument handed to the `windo(w => ...)` factory. */
192
- export interface WindoFactoryArg<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap> {
216
+ export interface WindoFactoryArg<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap, Tags extends readonly string[] = readonly string[]> {
193
217
  /** Configured groups keyed by slug. */
194
218
  groups: Record<Groups[number]['slug'], WindoGroup>
195
219
  contexts: Contexts
220
+ /** The config's declared tags, in declaration order. */
221
+ tags: Tags
196
222
  }
197
223
 
198
224
  /**
@@ -209,11 +235,15 @@ export interface WindoModule<Props = any, State = any> {
209
235
  * Config
210
236
  * ------------------------------------------------------------------ */
211
237
 
212
- export interface WindoConfig<Groups extends readonly WindoGroup[] = readonly WindoGroup[], Contexts extends WindoContextMap = WindoContextMap> {
238
+ export interface WindoConfig<Groups extends readonly WindoGroup[] = readonly WindoGroup[], Contexts extends WindoContextMap = WindoContextMap, Tags extends readonly string[] = readonly string[]> {
213
239
  /** Configured groups. A component's `group` must be one of these slugs. */
214
240
  groups: Groups
215
241
  /** Named contexts available to components. */
216
242
  contexts?: Contexts
243
+ /** The set of tags components may be assigned. A component's `tags` must be drawn from this list; the sidebar filters by them. */
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>
217
247
  /** Glob(s) for discovery, relative to project root. Default `**\/*.windo.tsx`. */
218
248
  include?: string | string[]
219
249
  /** Title shown in the workbench chrome. */
@@ -256,6 +286,7 @@ export interface WindoManifestEntry {
256
286
  id: string
257
287
  title: string
258
288
  group: string
289
+ tags: string[]
259
290
  status: WindoStatus
260
291
  description?: string
261
292
  deprecation?: string
@@ -1,59 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { z } from 'zod'
3
- import { describeSchema } from './descriptor'
4
- import type { WindoControlDescriptor } from './types'
5
-
6
- function field(fields: WindoControlDescriptor[], key: string): WindoControlDescriptor {
7
- const found = fields.find(f => f.key === key)
8
- if (!found) throw new Error(`no field "${key}"`)
9
- return found
10
- }
11
-
12
- describe('describeSchema', () => {
13
- it('returns no fields for a missing schema', () => {
14
- expect(describeSchema(undefined).fields).toEqual([])
15
- expect(describeSchema(null).fields).toEqual([])
16
- })
17
-
18
- it('describes a z.enum as kind enum with the right options', () => {
19
- const { fields } = describeSchema(z.object({ variant: z.enum(['primary', 'secondary', 'ghost']) }))
20
- const variant = field(fields, 'variant')
21
- expect(variant.kind).toBe('enum')
22
- expect(variant.options).toEqual(['primary', 'secondary', 'ghost'])
23
- })
24
-
25
- it('describes a z.boolean as kind boolean', () => {
26
- const { fields } = describeSchema(z.object({ disabled: z.boolean() }))
27
- expect(field(fields, 'disabled').kind).toBe('boolean')
28
- })
29
-
30
- it('describes a bounded z.number with min and max', () => {
31
- const { fields } = describeSchema(z.object({ count: z.number().min(0).max(10) }))
32
- const count = field(fields, 'count')
33
- expect(count.kind).toBe('number')
34
- expect(count.min).toBe(0)
35
- expect(count.max).toBe(10)
36
- })
37
-
38
- it('marks an optional field as optional', () => {
39
- const { fields } = describeSchema(z.object({ label: z.string().optional() }))
40
- const label = field(fields, 'label')
41
- expect(label.kind).toBe('string')
42
- expect(label.optional).toBe(true)
43
- })
44
-
45
- it('marks a required field as not optional', () => {
46
- const { fields } = describeSchema(z.object({ label: z.string() }))
47
- expect(field(fields, 'label').optional).toBe(false)
48
- })
49
-
50
- it('describes a z.coerce.date as kind date', () => {
51
- const { fields } = describeSchema(z.object({ when: z.coerce.date() }))
52
- expect(field(fields, 'when').kind).toBe('date')
53
- })
54
-
55
- it('describes a z.array as kind array', () => {
56
- const { fields } = describeSchema(z.object({ tags: z.array(z.string()) }))
57
- expect(field(fields, 'tags').kind).toBe('array')
58
- })
59
- })