@westopp/windo 0.1.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/dist/chunk-5RM2VYAM.js +150 -0
  4. package/dist/chunk-5RM2VYAM.js.map +1 -0
  5. package/dist/cli.cjs +303 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +1 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +138 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.cjs +219 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +374 -0
  14. package/dist/index.d.ts +374 -0
  15. package/dist/index.js +182 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/plugin.cjs +185 -0
  18. package/dist/plugin.cjs.map +1 -0
  19. package/dist/plugin.d.cts +11 -0
  20. package/dist/plugin.d.ts +11 -0
  21. package/dist/plugin.js +11 -0
  22. package/dist/plugin.js.map +1 -0
  23. package/package.json +95 -0
  24. package/src/cli/index.ts +160 -0
  25. package/src/client/App.tsx +310 -0
  26. package/src/client/Canvas.tsx +358 -0
  27. package/src/client/Inspector.tsx +586 -0
  28. package/src/client/Sidebar.tsx +108 -0
  29. package/src/client/bridge.ts +124 -0
  30. package/src/client/chrome.css +1966 -0
  31. package/src/client/icons.tsx +110 -0
  32. package/src/client/index.ts +15 -0
  33. package/src/client/internal-types.ts +147 -0
  34. package/src/client/persist.ts +38 -0
  35. package/src/define-config.ts +54 -0
  36. package/src/descriptor.test.ts +59 -0
  37. package/src/descriptor.ts +185 -0
  38. package/src/globals.d.ts +9 -0
  39. package/src/index.ts +54 -0
  40. package/src/plugin/index.ts +181 -0
  41. package/src/preview/ctx.ts +43 -0
  42. package/src/preview/index.ts +283 -0
  43. package/src/preview/preview.css +81 -0
  44. package/src/preview/registry.ts +159 -0
  45. package/src/preview/render.tsx +90 -0
  46. package/src/preview/virtual.d.ts +8 -0
  47. package/src/protocol.ts +59 -0
  48. package/src/types.ts +319 -0
@@ -0,0 +1,586 @@
1
+ // Inspector — Controls (props-as-JSON editor) / Props (cards) / Code /
2
+ // Variants / Context. Collapsible to a slim rail (right) or bar (bottom).
3
+ //
4
+ // Adapted from the mock inspector.jsx for windo: components live in the iframe,
5
+ // so Controls edits JSON only (no generated widgets) and Variants are
6
+ // click-to-apply cards summarising their prop patch instead of live renders.
7
+
8
+ import type { KeyboardEvent, ReactNode } from 'react'
9
+ import { useEffect, useMemo, useRef, useState } from 'react'
10
+ import { isPreviewMessage, isWindoMessage, WINDO_MSG } from '../protocol'
11
+ import type { WindoContextControlMeta, WindoContextMeta, WindoControlDescriptor, WindoEnvState, WindoLogEntry, WindoPropDoc } from '../types'
12
+ import { Icons } from './icons'
13
+ import type { ChromeEnv, InspectorProps } from './internal-types'
14
+
15
+ const INSPECTOR_TABS = ['Controls', 'Props', 'Code', 'Variants', 'Context', 'Console'] as const
16
+ type InspectorTab = (typeof INSPECTOR_TABS)[number]
17
+
18
+ /* ---------- Controls tab: props as editable JSON + read-only schema ---------- */
19
+
20
+ function schemaTypeOf(field: WindoControlDescriptor): string {
21
+ let base: string
22
+ if (field.kind === 'enum' && field.options?.length) base = field.options.map(o => `"${o}"`).join(' | ')
23
+ else base = field.kind
24
+ return field.optional ? `${base}?` : base
25
+ }
26
+
27
+ interface ControlsTabProps {
28
+ valuesJson: string
29
+ setValuesJson: InspectorProps['setValuesJson']
30
+ dirty: boolean
31
+ onSave: InspectorProps['onSave']
32
+ onReset: InspectorProps['onReset']
33
+ parseErrors: InspectorProps['parseErrors']
34
+ renderError: InspectorProps['renderError']
35
+ fields: WindoControlDescriptor[]
36
+ }
37
+
38
+ function ControlsTab({ valuesJson, setValuesJson, dirty, onSave, onReset, parseErrors, renderError, fields }: ControlsTabProps) {
39
+ const [flash, setFlash] = useState(false)
40
+
41
+ useEffect(() => {
42
+ if (!flash) return
43
+ const t = setTimeout(() => setFlash(false), 1600)
44
+ return () => clearTimeout(t)
45
+ }, [flash])
46
+
47
+ function save() {
48
+ onSave()
49
+ setFlash(true)
50
+ }
51
+
52
+ function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
53
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
54
+ e.preventDefault()
55
+ save()
56
+ }
57
+ }
58
+
59
+ const hasError = parseErrors.length > 0 || renderError !== null
60
+
61
+ return (
62
+ <div className="wb-codeedit">
63
+ <div className="wb-edit-main">
64
+ <div className="wb-codeedit-bar">
65
+ <span className="lbl">
66
+ Props · JSON
67
+ {dirty && <span className="wb-dirty-dot" title="Unsaved changes"></span>}
68
+ {flash && !dirty && <span className="wb-saved-flash">saved ✓</span>}
69
+ </span>
70
+ <button type="button" className="wb-ghostbtn" onClick={onReset} title="Reset props to defaults">
71
+ Reset
72
+ </button>
73
+ <button type="button" className="wb-save" disabled={!dirty} onClick={save} title="Save (⌘↵)">
74
+ Save
75
+ </button>
76
+ </div>
77
+ <textarea
78
+ className={`wb-codearea${hasError ? ' invalid' : ''}`}
79
+ spellCheck="false"
80
+ rows={Math.max(8, valuesJson.split('\n').length + 1)}
81
+ value={valuesJson}
82
+ onChange={e => setValuesJson(e.target.value)}
83
+ onKeyDown={onKeyDown}
84
+ aria-label="Component props as JSON"
85
+ />
86
+ {parseErrors.length > 0 && (
87
+ <div className="wb-code-error">
88
+ {parseErrors.map(err => (
89
+ <div key={`${err.path}:${err.message}`}>
90
+ {err.path}: {err.message}
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )}
95
+ {renderError && <div className="wb-code-error">{renderError.message}</div>}
96
+ </div>
97
+ <div className="wb-schema">
98
+ <div className="wb-schema-title">Schema</div>
99
+ {fields.map(field => (
100
+ <div className="wb-schema-row" key={field.key}>
101
+ <span className="k">{field.key}</span>
102
+ <span className="t">{schemaTypeOf(field)}</span>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ /* ---------- Props tab: cards ---------- */
111
+
112
+ function PropsTab({ props }: { props: WindoPropDoc[] }) {
113
+ return (
114
+ <div className="wb-prop-cards">
115
+ {props.map(p => (
116
+ <div className="wb-prop-card" key={p.name}>
117
+ <div className="row1">
118
+ <span className="pname">{p.name}</span>
119
+ {p.default !== undefined && <span className="pdefault">default {p.default}</span>}
120
+ </div>
121
+ <div className="ptype">{p.type}</div>
122
+ {p.desc && <p className="pdesc">{p.desc}</p>}
123
+ </div>
124
+ ))}
125
+ </div>
126
+ )
127
+ }
128
+
129
+ /* ---------- Code tab ---------- */
130
+
131
+ function CodeTab({ code }: { code: string | null }) {
132
+ const [copied, setCopied] = useState(false)
133
+
134
+ if (!code) {
135
+ return <div style={{ padding: '20px 16px', color: 'var(--faint)', fontSize: 12.5 }}>No code snippet for this component.</div>
136
+ }
137
+
138
+ function copy() {
139
+ if (!code) return
140
+ navigator.clipboard.writeText(code).then(() => {
141
+ setCopied(true)
142
+ setTimeout(() => setCopied(false), 1400)
143
+ })
144
+ }
145
+
146
+ return (
147
+ <div className="wb-code-wrap">
148
+ <pre className="wb-code">{code}</pre>
149
+ <button type="button" className={`wb-copy${copied ? ' copied' : ''}`} onClick={copy}>
150
+ {copied ? (
151
+ <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
152
+ <path d="m5 13 4 4L19 7"></path>
153
+ </svg>
154
+ ) : (
155
+ <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
156
+ <rect x="9" y="9" width="11" height="11" rx="2"></rect>
157
+ <path d="M5 15V5a2 2 0 0 1 2-2h10"></path>
158
+ </svg>
159
+ )}
160
+ {copied ? 'Copied' : 'Copy'}
161
+ </button>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ /* ---------- Variants tab: live, auto-scaled component thumbnails ---------- */
167
+
168
+ // Components live only inside a preview iframe, so each variant gets its own
169
+ // iframe driven through the same postMessage protocol as the main canvas: select
170
+ // the component, push the merged props (defaults + variant patch), mirror the
171
+ // ambient env. The whole cell is click-to-apply — it commits the patch into the
172
+ // Controls editor and the main canvas.
173
+ const VARIANT_NAT_W = 760
174
+ const VARIANT_NAT_H = 220
175
+
176
+ interface VariantCellProps {
177
+ iframeSrc: string
178
+ id: string
179
+ label: string
180
+ patch: Record<string, unknown>
181
+ defaults: unknown
182
+ env: WindoEnvState
183
+ onApplyVariant: InspectorProps['onApplyVariant']
184
+ }
185
+
186
+ function VariantCell({ iframeSrc, id, label, patch, defaults, env, onApplyVariant }: VariantCellProps) {
187
+ const frameRef = useRef<HTMLIFrameElement>(null)
188
+ const thumbRef = useRef<HTMLDivElement>(null)
189
+ const [scale, setScale] = useState(0.25)
190
+
191
+ const merged = useMemo(() => JSON.stringify({ ...((defaults as object) ?? {}), ...patch }), [defaults, patch])
192
+
193
+ // Drive this cell's iframe: on its `ready` (and whenever props/env change)
194
+ // push the selection, merged props, and env so it mirrors the canvas.
195
+ useEffect(() => {
196
+ const frame = frameRef.current
197
+ if (!frame) return
198
+ const send = () => {
199
+ const win = frame.contentWindow
200
+ if (!win) return
201
+ win.postMessage({ source: WINDO_MSG, dir: 'host', type: 'select', id }, '*')
202
+ win.postMessage({ source: WINDO_MSG, dir: 'host', type: 'set-props', id, json: merged }, '*')
203
+ win.postMessage({ source: WINDO_MSG, dir: 'host', type: 'set-env', env }, '*')
204
+ }
205
+ const onMessage = (e: MessageEvent) => {
206
+ if (e.source !== frame.contentWindow) return
207
+ if (!isWindoMessage(e.data) || !isPreviewMessage(e.data)) return
208
+ if (e.data.type === 'ready') send()
209
+ }
210
+ window.addEventListener('message', onMessage)
211
+ send() // re-sync when already-ready (props/env changed after first load)
212
+ return () => window.removeEventListener('message', onMessage)
213
+ }, [id, merged, env])
214
+
215
+ // Scale the natural-width render down to the cell's content width.
216
+ useEffect(() => {
217
+ const thumb = thumbRef.current
218
+ if (!thumb) return
219
+ const measure = () => {
220
+ const w = thumb.clientWidth
221
+ if (w) setScale(w / VARIANT_NAT_W)
222
+ }
223
+ measure()
224
+ const ro = new ResizeObserver(measure)
225
+ ro.observe(thumb)
226
+ return () => ro.disconnect()
227
+ }, [])
228
+
229
+ // The cell is a div (it contains an iframe, which can't live inside a button);
230
+ // a transparent button overlay is the real click/focus target.
231
+ return (
232
+ <div className="wb-variant-cell clickable">
233
+ <div className="wb-variant-stage">
234
+ <div className="wb-variant-thumb" ref={thumbRef} style={{ height: Math.round(VARIANT_NAT_H * scale) }}>
235
+ <iframe
236
+ ref={frameRef}
237
+ src={iframeSrc}
238
+ className="wb-variant-frame"
239
+ title={`${label} preview`}
240
+ tabIndex={-1}
241
+ style={{ width: VARIANT_NAT_W, height: VARIANT_NAT_H, transform: `scale(${scale})` }}
242
+ />
243
+ </div>
244
+ </div>
245
+ <div className="wb-variant-label">
246
+ <span className="vname">{label}</span>
247
+ <span className="vapply">apply</span>
248
+ </div>
249
+ <button type="button" className="wb-variant-apply" aria-label={`Apply variant ${label}`} title="Apply this variant" onClick={() => onApplyVariant(patch)} />
250
+ </div>
251
+ )
252
+ }
253
+
254
+ interface VariantsTabProps {
255
+ iframeSrc: string
256
+ describe: InspectorProps['describe']
257
+ env: ChromeEnv
258
+ onApplyVariant: InspectorProps['onApplyVariant']
259
+ }
260
+
261
+ function VariantsTab({ iframeSrc, describe, env, onApplyVariant }: VariantsTabProps) {
262
+ const variants = describe?.variants ?? []
263
+ const previewEnv = useMemo<WindoEnvState>(
264
+ () => ({
265
+ colorScheme: env.colorScheme,
266
+ viewport: { width: VARIANT_NAT_W, height: VARIANT_NAT_H, name: 'desktop' },
267
+ reducedMotion: env.reducedMotion,
268
+ direction: env.direction,
269
+ locale: env.locale,
270
+ contexts: env.contexts,
271
+ }),
272
+ [env.colorScheme, env.reducedMotion, env.direction, env.locale, env.contexts]
273
+ )
274
+
275
+ if (!variants.length) {
276
+ return <div className="wb-context-empty">No variant gallery — use the Controls tab and resize the canvas to explore states.</div>
277
+ }
278
+ return (
279
+ <div className="wb-variants">
280
+ {variants.map(vr => (
281
+ <VariantCell key={vr.label} iframeSrc={iframeSrc} id={describe?.id ?? ''} label={vr.label} patch={vr.props} defaults={describe?.defaults} env={previewEnv} onApplyVariant={onApplyVariant} />
282
+ ))}
283
+ </div>
284
+ )
285
+ }
286
+
287
+ /* ---------- Context tab ---------- */
288
+
289
+ interface ContextControlProps {
290
+ control: WindoContextControlMeta
291
+ value: unknown
292
+ onChange: (value: unknown) => void
293
+ }
294
+
295
+ function ContextControl({ control, value, onChange }: ContextControlProps) {
296
+ const label = control.label ?? control.key
297
+ let field: ReactNode
298
+ if (control.type === 'enum') {
299
+ field = (
300
+ <select className="wb-ctrl-input" value={String(value)} onChange={e => onChange(e.target.value)}>
301
+ {(control.options ?? []).map(o => (
302
+ <option key={o} value={o}>
303
+ {o}
304
+ </option>
305
+ ))}
306
+ </select>
307
+ )
308
+ } else if (control.type === 'boolean') {
309
+ const checked = Boolean(value)
310
+ field = <button type="button" role="switch" aria-checked={checked} aria-label={label} className={`wb-switch${checked ? ' on' : ''}`} onClick={() => onChange(!checked)} />
311
+ } else if (control.type === 'number') {
312
+ field = (
313
+ <input
314
+ type="number"
315
+ className="wb-ctrl-input"
316
+ value={value === undefined || value === null ? '' : Number(value)}
317
+ min={control.min}
318
+ max={control.max}
319
+ step={control.step}
320
+ onChange={e => onChange(e.target.valueAsNumber)}
321
+ />
322
+ )
323
+ } else {
324
+ field = <input type="text" className="wb-ctrl-input" value={value === undefined || value === null ? '' : String(value)} onChange={e => onChange(e.target.value)} />
325
+ }
326
+ return (
327
+ <div className="wb-ctrl-row">
328
+ <span className="clbl">{label}</span>
329
+ <span className="cfield">{field}</span>
330
+ </div>
331
+ )
332
+ }
333
+
334
+ interface ContextSectionProps {
335
+ ctx: WindoContextMeta
336
+ env: ChromeEnv
337
+ setContextValue: InspectorProps['setContextValue']
338
+ }
339
+
340
+ function ContextSection({ ctx, env, setContextValue }: ContextSectionProps) {
341
+ const stored = env.contexts[ctx.name] ?? {}
342
+ return (
343
+ <div className="wb-context-group">
344
+ <div className="wb-context-group-head">
345
+ <span className="gname">{ctx.label ?? ctx.name}</span>
346
+ <span className="grow" />
347
+ {ctx.hasProvider && <span className="gtag">provider</span>}
348
+ </div>
349
+ {ctx.description && <p className="wb-context-group-desc">{ctx.description}</p>}
350
+ {ctx.controls.length === 0 ? (
351
+ <div className="wb-ctrl-row">
352
+ <span className="clbl">No controls</span>
353
+ </div>
354
+ ) : (
355
+ ctx.controls.map(control => {
356
+ const current = control.key in stored ? stored[control.key] : control.default
357
+ return <ContextControl key={control.key} control={control} value={current} onChange={value => setContextValue(ctx.name, control.key, value)} />
358
+ })
359
+ )}
360
+ </div>
361
+ )
362
+ }
363
+
364
+ interface ContextTabProps {
365
+ contexts: WindoContextMeta[]
366
+ env: ChromeEnv
367
+ setEnv: InspectorProps['setEnv']
368
+ setContextValue: InspectorProps['setContextValue']
369
+ }
370
+
371
+ function ContextTab({ contexts, env, setEnv, setContextValue }: ContextTabProps) {
372
+ return (
373
+ <div className="wb-context">
374
+ {contexts.map(ctx => (
375
+ <ContextSection key={ctx.name} ctx={ctx} env={env} setContextValue={setContextValue} />
376
+ ))}
377
+ <div className="wb-context-group">
378
+ <div className="wb-context-group-head">
379
+ <span className="gname">Environment</span>
380
+ </div>
381
+ <div className="wb-ctrl-row">
382
+ <span className="clbl">Direction</span>
383
+ <span className="cfield">
384
+ <select className="wb-ctrl-input" value={env.direction} onChange={e => setEnv({ direction: e.target.value as ChromeEnv['direction'] })}>
385
+ <option value="ltr">ltr</option>
386
+ <option value="rtl">rtl</option>
387
+ </select>
388
+ </span>
389
+ </div>
390
+ <div className="wb-ctrl-row">
391
+ <span className="clbl">Reduced motion</span>
392
+ <span className="cfield">
393
+ <button
394
+ type="button"
395
+ role="switch"
396
+ aria-checked={env.reducedMotion}
397
+ aria-label="Reduced motion"
398
+ className={`wb-switch${env.reducedMotion ? ' on' : ''}`}
399
+ onClick={() => setEnv({ reducedMotion: !env.reducedMotion })}
400
+ />
401
+ </span>
402
+ </div>
403
+ <div className="wb-ctrl-row">
404
+ <span className="clbl">Locale</span>
405
+ <span className="cfield">
406
+ <input type="text" className="wb-ctrl-input" value={env.locale} onChange={e => setEnv({ locale: e.target.value })} />
407
+ </span>
408
+ </div>
409
+ </div>
410
+ </div>
411
+ )
412
+ }
413
+
414
+ /* ---------- Logs strip ---------- */
415
+
416
+ function formatLogTime(ts: number): string {
417
+ try {
418
+ return new Date(ts).toLocaleTimeString(undefined, { hour12: false })
419
+ } catch {
420
+ return ''
421
+ }
422
+ }
423
+
424
+ function formatLogMessage(entry: WindoLogEntry): string {
425
+ const body = entry.args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(', ') || '—'
426
+ return body.length > 800 ? `${body.slice(0, 800)}…` : body
427
+ }
428
+
429
+ function formatStateValue(value: unknown): string {
430
+ if (value === undefined) return 'undefined'
431
+ const out = typeof value === 'string' ? value : (JSON.stringify(value) ?? 'undefined')
432
+ return out.length > 80 ? `${out.slice(0, 80)}…` : out
433
+ }
434
+
435
+ function LogsStrip({ logs, clearLogs }: { logs: InspectorProps['logs']; clearLogs: InspectorProps['clearLogs'] }) {
436
+ const recent = logs.slice(-50)
437
+ return (
438
+ <div className="wb-logs">
439
+ <div className="wb-logs-bar">
440
+ <span className="lbl">Logs</span>
441
+ <span className="cnt">{logs.length}</span>
442
+ <button type="button" className="wb-ghostbtn" onClick={clearLogs} disabled={!logs.length} title="Clear logs">
443
+ Clear
444
+ </button>
445
+ </div>
446
+ {logs.length === 0 ? (
447
+ <div className="wb-logs-empty">
448
+ Events the component emits via <code>ctx.logger.log(…)</code> land here — e.g. a button’s click handler or an action’s <code>run</code>. Interact with the preview to see them.
449
+ </div>
450
+ ) : (
451
+ <div className="wb-logs-list">
452
+ {recent.map(entry => (
453
+ <div className="wb-log log" key={entry.seq}>
454
+ <span className="ltime">{formatLogTime(entry.ts)}</span>
455
+ <span className="lmsg">{formatLogMessage(entry)}</span>
456
+ </div>
457
+ ))}
458
+ </div>
459
+ )}
460
+ </div>
461
+ )
462
+ }
463
+
464
+ /* ---------- Inspector shell ---------- */
465
+
466
+ export function Inspector(props: InspectorProps) {
467
+ const {
468
+ entry,
469
+ describe,
470
+ iframeSrc,
471
+ valuesJson,
472
+ setValuesJson,
473
+ dirty,
474
+ onSave,
475
+ onReset,
476
+ parseErrors,
477
+ renderError,
478
+ logs,
479
+ clearLogs,
480
+ state,
481
+ contexts,
482
+ env,
483
+ setEnv,
484
+ setContextValue,
485
+ onApplyVariant,
486
+ position,
487
+ collapsed,
488
+ onToggle,
489
+ } = props
490
+ const [tab, setTab] = useState<InspectorTab>('Controls')
491
+ const stateKeys = Object.keys(state)
492
+
493
+ const status = entry?.status ?? 'stable'
494
+
495
+ if (collapsed) {
496
+ if (position === 'right') {
497
+ return (
498
+ <div className="wb-inspector right collapsed">
499
+ <button type="button" className="wb-iconbtn" title="Expand inspector" onClick={onToggle}>
500
+ {Icons.chevsLeft}
501
+ </button>
502
+ <span className="wb-rail-label">Inspector{entry ? ` — ${entry.title}` : ''}</span>
503
+ </div>
504
+ )
505
+ }
506
+ return (
507
+ <div className="wb-inspector bottom collapsed">
508
+ <button type="button" className="wb-iconbtn" title="Expand inspector" onClick={onToggle}>
509
+ {Icons.chevsUp}
510
+ </button>
511
+ <span className="cname">{entry?.title ?? 'Inspector'}</span>
512
+ <span className={`wb-status ${status}`}>{status}</span>
513
+ </div>
514
+ )
515
+ }
516
+
517
+ return (
518
+ <div className={`wb-inspector ${position}`}>
519
+ {position === 'right' && (
520
+ <div className="wb-doc-head" style={{ paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
521
+ <h2>
522
+ {entry?.title ?? 'Inspector'}
523
+ <span className={`wb-status ${status}`}>{status}</span>
524
+ <button type="button" className="wb-iconbtn" style={{ marginLeft: 'auto', marginRight: -7 }} title="Collapse inspector" onClick={onToggle}>
525
+ {Icons.chevsRight}
526
+ </button>
527
+ </h2>
528
+ {entry?.description && <p>{entry.description}</p>}
529
+ {entry?.deprecation && (
530
+ <div className="wb-deprecated-note" style={{ margin: '10px 0 0 0' }}>
531
+ {entry.deprecation}
532
+ </div>
533
+ )}
534
+ </div>
535
+ )}
536
+ <div className="wb-tabs" role="tablist">
537
+ {INSPECTOR_TABS.map(t => (
538
+ <button type="button" key={t} role="tab" aria-selected={tab === t} className={`wb-tab${tab === t ? ' on' : ''}`} onClick={() => setTab(t)}>
539
+ {t}
540
+ </button>
541
+ ))}
542
+ <span className="wb-tabs-aux">
543
+ {position === 'bottom' && (
544
+ <>
545
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry?.deprecation || entry?.description}</span>
546
+ <span className={`wb-status ${status}`}>{status}</span>
547
+ <button type="button" className="wb-iconbtn" title="Collapse inspector" onClick={onToggle}>
548
+ {Icons.chevsDown}
549
+ </button>
550
+ </>
551
+ )}
552
+ </span>
553
+ </div>
554
+ {stateKeys.length > 0 && (
555
+ <div className="wb-state" title="Component-local state (read-only) — driven by actions and the component">
556
+ <span className="wb-state-label">state</span>
557
+ {stateKeys.map(k => (
558
+ <span className="wb-state-item" key={k}>
559
+ <span className="k">{k}</span>
560
+ <span className="v">{formatStateValue(state[k])}</span>
561
+ </span>
562
+ ))}
563
+ </div>
564
+ )}
565
+ <div className="wb-inspector-body">
566
+ {tab === 'Controls' && (
567
+ <ControlsTab
568
+ valuesJson={valuesJson}
569
+ setValuesJson={setValuesJson}
570
+ dirty={dirty}
571
+ onSave={onSave}
572
+ onReset={onReset}
573
+ parseErrors={parseErrors}
574
+ renderError={renderError}
575
+ fields={describe?.descriptor.fields ?? []}
576
+ />
577
+ )}
578
+ {tab === 'Props' && <PropsTab props={describe?.props ?? []} />}
579
+ {tab === 'Code' && <CodeTab code={describe?.code ?? null} />}
580
+ {tab === 'Variants' && <VariantsTab iframeSrc={iframeSrc} describe={describe} env={env} onApplyVariant={onApplyVariant} />}
581
+ {tab === 'Context' && <ContextTab contexts={contexts} env={env} setEnv={setEnv} setContextValue={setContextValue} />}
582
+ {tab === 'Console' && <LogsStrip logs={logs} clearLogs={clearLogs} />}
583
+ </div>
584
+ </div>
585
+ )
586
+ }
@@ -0,0 +1,108 @@
1
+ // Sidebar / library nav. Groups come from config (ordered); entries are placed
2
+ // under the group whose slug matches entry.group. Collapsed groups persist to
3
+ // localStorage under windo:navgroups; an active search query forces all open.
4
+
5
+ import { useState } from 'react'
6
+ import { Icons } from './icons'
7
+ import type { SidebarProps } from './internal-types'
8
+
9
+ const STORAGE_KEY = 'windo:navgroups'
10
+
11
+ function loadClosedGroups(): Record<string, boolean> {
12
+ try {
13
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
14
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, boolean>
15
+ } catch {
16
+ /* corrupted — fall through */
17
+ }
18
+ return {}
19
+ }
20
+
21
+ function GroupChevron() {
22
+ return (
23
+ <svg aria-hidden="true" className="chev" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
24
+ <path d="m6 9 6 6 6-6" />
25
+ </svg>
26
+ )
27
+ }
28
+
29
+ export function Sidebar(props: SidebarProps) {
30
+ const { groups, manifest, selected, onSelect, query, setQuery, collapsed, onToggle } = props
31
+ const [closedGroups, setClosedGroups] = useState(loadClosedGroups)
32
+
33
+ function toggleGroup(slug: string) {
34
+ setClosedGroups(prev => {
35
+ const next = { ...prev }
36
+ if (next[slug]) {
37
+ delete next[slug]
38
+ } else {
39
+ next[slug] = true
40
+ }
41
+ try {
42
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
43
+ } catch {
44
+ /* storage unavailable — keep in-memory state */
45
+ }
46
+ return next
47
+ })
48
+ }
49
+
50
+ if (collapsed) {
51
+ return (
52
+ <aside className="wb-sidebar collapsed">
53
+ <button type="button" className="wb-iconbtn" title="Expand library" onClick={onToggle}>
54
+ {Icons.chevsRight}
55
+ </button>
56
+ <span className="wb-rail-label">Library</span>
57
+ </aside>
58
+ )
59
+ }
60
+
61
+ const q = query.trim().toLowerCase()
62
+ const groupName = (slug: string) => groups.find(g => g.slug === slug)?.name ?? slug
63
+ const filtered = manifest.filter(e => !q || e.title.toLowerCase().includes(q) || groupName(e.group).toLowerCase().includes(q))
64
+
65
+ return (
66
+ <aside className="wb-sidebar">
67
+ <div className="wb-sidebar-head">
68
+ <div className="wb-search">
69
+ {Icons.search}
70
+ <input type="text" placeholder="Search components…" value={query} onChange={e => setQuery(e.target.value)} />
71
+ </div>
72
+ <button type="button" className="wb-iconbtn" title="Collapse library" onClick={onToggle}>
73
+ {Icons.chevsLeft}
74
+ </button>
75
+ </div>
76
+ <nav className="wb-nav">
77
+ {groups.map(group => {
78
+ const items = filtered.filter(e => e.group === group.slug)
79
+ if (!items.length) return null
80
+ const open = !!q || !closedGroups[group.slug]
81
+ const holdsSelected = items.some(e => e.id === selected)
82
+ return (
83
+ <div className="wb-nav-group" key={group.slug}>
84
+ <button type="button" className={`wb-nav-group-label${open ? '' : ' closed'}`} aria-expanded={open} onClick={() => toggleGroup(group.slug)}>
85
+ <GroupChevron />
86
+ <span className="cat">{group.name}</span>
87
+ <span className="grow" />
88
+ {!open && holdsSelected && <span className="seldot" title="Contains selected component" />}
89
+ {!open && <span className="cnt">{items.length}</span>}
90
+ </button>
91
+ {open && (
92
+ <div className="wb-nav-list">
93
+ {items.map(e => (
94
+ <button type="button" key={e.id} className={`wb-nav-item${e.id === selected ? ' selected' : ''}`} onClick={() => onSelect(e.id)}>
95
+ <span className="name">{e.title}</span>
96
+ {e.status !== 'stable' && <span className={`wb-status ${e.status}`}>{e.status}</span>}
97
+ </button>
98
+ ))}
99
+ </div>
100
+ )}
101
+ </div>
102
+ )
103
+ })}
104
+ {!filtered.length && <div className="wb-nav-empty">No components match “{query}”</div>}
105
+ </nav>
106
+ </aside>
107
+ )
108
+ }