@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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/chunk-5RM2VYAM.js +150 -0
- package/dist/chunk-5RM2VYAM.js.map +1 -0
- package/dist/cli.cjs +303 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +219 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.cjs +185 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.d.cts +11 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +11 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/index.ts +160 -0
- package/src/client/App.tsx +310 -0
- package/src/client/Canvas.tsx +358 -0
- package/src/client/Inspector.tsx +586 -0
- package/src/client/Sidebar.tsx +108 -0
- package/src/client/bridge.ts +124 -0
- package/src/client/chrome.css +1966 -0
- package/src/client/icons.tsx +110 -0
- package/src/client/index.ts +15 -0
- package/src/client/internal-types.ts +147 -0
- package/src/client/persist.ts +38 -0
- package/src/define-config.ts +54 -0
- package/src/descriptor.test.ts +59 -0
- package/src/descriptor.ts +185 -0
- package/src/globals.d.ts +9 -0
- package/src/index.ts +54 -0
- package/src/plugin/index.ts +181 -0
- package/src/preview/ctx.ts +43 -0
- package/src/preview/index.ts +283 -0
- package/src/preview/preview.css +81 -0
- package/src/preview/registry.ts +159 -0
- package/src/preview/render.tsx +90 -0
- package/src/preview/virtual.d.ts +8 -0
- package/src/protocol.ts +59 -0
- 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
|
+
}
|