@specbook/ui 0.0.0-stage → 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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/apple-touch-icon.png +0 -0
  4. package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
  5. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  6. package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
  9. package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
  10. package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
  11. package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
  12. package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
  13. package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
  14. package/dist-viewer/favicon.svg +1 -0
  15. package/dist-viewer/index.html +19 -0
  16. package/dist-viewer/specbook.js +1 -0
  17. package/index.html +12 -0
  18. package/package.json +60 -3
  19. package/src/app.tsx +363 -0
  20. package/src/assets/mark.svg +1 -0
  21. package/src/badge.ts +14 -0
  22. package/src/branding.ts +92 -0
  23. package/src/components/Catalog.tsx +324 -0
  24. package/src/components/EnvBadge.tsx +46 -0
  25. package/src/components/EnvironmentMenu.tsx +262 -0
  26. package/src/components/Fields.tsx +220 -0
  27. package/src/components/GitHubStar.tsx +22 -0
  28. package/src/components/ListResizer.tsx +70 -0
  29. package/src/components/ModeHint.tsx +17 -0
  30. package/src/components/Palette.tsx +85 -0
  31. package/src/components/PoweredBy.tsx +15 -0
  32. package/src/components/ShareMenu.tsx +94 -0
  33. package/src/components/ThemeToggle.tsx +52 -0
  34. package/src/embed/panel.tsx +114 -0
  35. package/src/embed/styles.ts +27 -0
  36. package/src/embed.ts +65 -0
  37. package/src/engine.ts +432 -0
  38. package/src/extension.ts +101 -0
  39. package/src/fielddoc.ts +83 -0
  40. package/src/main.tsx +16 -0
  41. package/src/markdown.ts +133 -0
  42. package/src/nav.ts +118 -0
  43. package/src/panel/Body.tsx +182 -0
  44. package/src/panel/BodyEditor.tsx +92 -0
  45. package/src/panel/CodeSample.tsx +237 -0
  46. package/src/panel/Combo.tsx +124 -0
  47. package/src/panel/ControlsGrid.tsx +189 -0
  48. package/src/panel/FieldDoc.tsx +42 -0
  49. package/src/panel/FieldTable.tsx +113 -0
  50. package/src/panel/Host.tsx +100 -0
  51. package/src/panel/OperationDocs.tsx +181 -0
  52. package/src/panel/Panel.tsx +193 -0
  53. package/src/panel/ParamGrid.tsx +396 -0
  54. package/src/panel/RequestPanel.tsx +625 -0
  55. package/src/panel/RequestView.tsx +108 -0
  56. package/src/panel/ResponseView.tsx +182 -0
  57. package/src/panel/SecretsBar.tsx +156 -0
  58. package/src/panel/SequenceDocs.tsx +132 -0
  59. package/src/panel/SequencePanel.tsx +247 -0
  60. package/src/panel/StepExchange.tsx +118 -0
  61. package/src/panel/completions.ts +114 -0
  62. package/src/panel/draft.ts +106 -0
  63. package/src/panel/fieldnotes-split.ts +55 -0
  64. package/src/panel/fieldnotes.ts +40 -0
  65. package/src/panel/graphql.ts +31 -0
  66. package/src/panel/icons.tsx +85 -0
  67. package/src/panel/jsonCursor.ts +115 -0
  68. package/src/panel/stepnotes-split.ts +38 -0
  69. package/src/panel/stepnotes.ts +58 -0
  70. package/src/routing.ts +120 -0
  71. package/src/runtime.ts +32 -0
  72. package/src/schema.ts +359 -0
  73. package/src/secrets.ts +61 -0
  74. package/src/status.ts +27 -0
  75. package/src/store.ts +10 -0
  76. package/src/styles/app.css +821 -0
  77. package/src/styles/panel.css +1897 -0
  78. package/src/styles/tokens.css +122 -0
  79. package/src/theme.ts +37 -0
  80. package/src/viewer/Opening.tsx +43 -0
  81. package/src/viewer/bundles.ts +74 -0
  82. package/src/viewer/client.ts +54 -0
  83. package/src/viewer/embed.ts +78 -0
  84. package/src/viewer/faker-en.ts +3 -0
  85. package/src/viewer/main.tsx +285 -0
  86. package/src/viewer/mount.ts +52 -0
  87. package/src/viewer/protocol.ts +51 -0
  88. package/src/viewer/recent.ts +22 -0
  89. package/src/viewer/registry-data.ts +4 -0
  90. package/src/viewer/registry.ts +154 -0
  91. package/src/viewer/share.ts +134 -0
  92. package/src/viewer/site.ts +3 -0
  93. package/src/viewer/uploads.ts +53 -0
  94. package/src/viewer/viewer.css +184 -0
  95. package/src/viewer/worker-factory.ts +14 -0
  96. package/src/viewer/worker.ts +171 -0
  97. package/src/virtual.d.ts +104 -0
@@ -0,0 +1,396 @@
1
+ import { signal } from '@preact/signals'
2
+ import { useEffect, useRef } from 'preact/hooks'
3
+ import type { FieldDoc } from '../fielddoc'
4
+ import { inlineSummary, plainText } from '../markdown'
5
+ import { Combo } from './Combo'
6
+ import type { FieldRow } from './draft'
7
+
8
+ export interface Documented {
9
+ name: string
10
+ doc?: FieldDoc
11
+ /** array parameter: the key may repeat */
12
+ repeat?: boolean
13
+ file?: boolean
14
+ }
15
+
16
+ interface Props {
17
+ title: string
18
+ rows: FieldRow[]
19
+ onChange: (rows: FieldRow[]) => void
20
+ /** Documented fields missing from `rows` are listed unchecked, so the grid doubles as the docs. */
21
+ documented?: Documented[]
22
+ /** docs for keys that are not in `documented` (e.g. bracket keys of a form body) */
23
+ docFor?: (key: string) => FieldDoc | undefined
24
+ /** keys are fixed and rows cannot be added, removed or switched off (path parameters, variables) */
25
+ fixed?: boolean
26
+ /** row key to highlight as a problem */
27
+ bad?: string
28
+ placeholder?: string
29
+ /** the grid sits under a tab that already names it */
30
+ bare?: boolean
31
+ }
32
+
33
+ type Shown = FieldRow & { virtual?: true }
34
+
35
+ const COLS_KEY = 'specbook:grid-cols'
36
+ const DEFAULT_COLS = { key: 26, val: 34 }
37
+ const MIN_COL = 10
38
+
39
+ /** Key and Value column widths as percentages of the table, shared by every grid; Description takes the rest. */
40
+ const cols = signal(loadCols())
41
+
42
+ function loadCols(): { key: number; val: number } {
43
+ try {
44
+ const c = JSON.parse(localStorage.getItem(COLS_KEY) ?? 'null')
45
+ if (c && c.key >= MIN_COL && c.val >= MIN_COL && c.key + c.val <= 100 - MIN_COL) return c
46
+ } catch {
47
+ /* fall through */
48
+ }
49
+ return DEFAULT_COLS
50
+ }
51
+
52
+ function setCols(next: { key: number; val: number } | undefined) {
53
+ const c = next ?? DEFAULT_COLS
54
+ const key = Math.min(100 - 2 * MIN_COL, Math.max(MIN_COL, c.key))
55
+ const val = Math.min(100 - key - MIN_COL, Math.max(MIN_COL, c.val))
56
+ cols.value = { key: Math.round(key * 10) / 10, val: Math.round(val * 10) / 10 }
57
+ try {
58
+ if (next) localStorage.setItem(COLS_KEY, JSON.stringify(cols.value))
59
+ else localStorage.removeItem(COLS_KEY)
60
+ } catch {
61
+ /* optional */
62
+ }
63
+ }
64
+
65
+ /** The shared column layout, so other grids (sequence controls) line up with the parameter grids. */
66
+ export function GridColgroup() {
67
+ return (
68
+ <colgroup>
69
+ <col style={{ width: '36px' }} />
70
+ <col style={{ width: `${cols.value.key}%` }} />
71
+ <col style={{ width: `${cols.value.val}%` }} />
72
+ <col />
73
+ <col style={{ width: '60px' }} />
74
+ </colgroup>
75
+ )
76
+ }
77
+
78
+ /** The handle on a column's right edge: drag, ←/→ to nudge, double-click to reset. */
79
+ function ColResizer({ which, table }: { which: 'key' | 'val'; table: () => HTMLElement | null }) {
80
+ const move = (dPct: number) => {
81
+ const c = cols.value
82
+ // the Key edge trades width with Value; the Value edge trades with Description
83
+ setCols(
84
+ which === 'key'
85
+ ? { key: c.key + dPct, val: c.val - dPct }
86
+ : { key: c.key, val: c.val + dPct },
87
+ )
88
+ }
89
+ const drag = (e: PointerEvent) => {
90
+ e.preventDefault()
91
+ const width = table()?.getBoundingClientRect().width ?? 1
92
+ let last = e.clientX
93
+ document.body.classList.add('resizing')
94
+ const onMove = (m: PointerEvent) => {
95
+ move(((m.clientX - last) / width) * 100)
96
+ last = m.clientX
97
+ }
98
+ const up = () => {
99
+ document.body.classList.remove('resizing')
100
+ removeEventListener('pointermove', onMove)
101
+ removeEventListener('pointerup', up)
102
+ }
103
+ addEventListener('pointermove', onMove)
104
+ addEventListener('pointerup', up)
105
+ }
106
+ return (
107
+ // biome-ignore lint/a11y/useSemanticElements: a draggable splitter has no native element
108
+ <span
109
+ class="col-resizer"
110
+ role="separator"
111
+ aria-orientation="vertical"
112
+ aria-label={`Resize the ${which === 'key' ? 'Key' : 'Value'} column`}
113
+ aria-valuenow={Math.round(cols.value[which])}
114
+ aria-valuemin={MIN_COL}
115
+ aria-valuemax={100 - 2 * MIN_COL}
116
+ tabIndex={0}
117
+ title="Drag to resize · double-click to reset"
118
+ onPointerDown={drag}
119
+ onDblClick={() => setCols(undefined)}
120
+ onKeyDown={(e) => {
121
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
122
+ e.preventDefault()
123
+ move(e.key === 'ArrowLeft' ? -2 : 2)
124
+ }
125
+ }}
126
+ />
127
+ )
128
+ }
129
+
130
+ const KEY = 1
131
+ const VALUE = 2
132
+
133
+ /** `key=value`, `key: value` or `key<TAB>value` lines, as copied from docs, curl or a spreadsheet. */
134
+ export function parsePasted(text: string): { key: string; value: string }[] {
135
+ return text
136
+ .split(/\r?\n/)
137
+ .map((l) => l.trim())
138
+ .filter(Boolean)
139
+ .map((l) => {
140
+ const at = l.search(/[=\t]|:\s/)
141
+ return at < 0
142
+ ? { key: l, value: '' }
143
+ : { key: l.slice(0, at).trim(), value: l.slice(at + 1).trim() }
144
+ })
145
+ }
146
+
147
+ const Icon = ({ d }: { d: string }) => (
148
+ <svg
149
+ viewBox="0 0 16 16"
150
+ width="14"
151
+ height="14"
152
+ fill="none"
153
+ stroke="currentColor"
154
+ aria-hidden="true"
155
+ >
156
+ <path d={d} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
157
+ </svg>
158
+ )
159
+
160
+ export function ParamGrid({
161
+ title,
162
+ rows,
163
+ onChange,
164
+ documented = [],
165
+ docFor,
166
+ fixed,
167
+ bad,
168
+ placeholder,
169
+ bare,
170
+ }: Props) {
171
+ const root = useRef<HTMLElement>(null)
172
+ const table = useRef<HTMLTableElement>(null)
173
+ const focusNext = useRef<string | undefined>(undefined)
174
+ const have = new Set(rows.map((r) => r.key))
175
+ const virtual: Shown[] = documented
176
+ .filter((d) => !have.has(d.name))
177
+ .map((d) => ({ key: d.name, value: '', enabled: false, virtual: true }))
178
+ const all: Shown[] = [...rows, ...virtual]
179
+ const info = (key: string) => documented.find((d) => d.name === key)
180
+ const docOf = (key: string) => info(key)?.doc ?? docFor?.(key)
181
+
182
+ useEffect(() => {
183
+ if (!focusNext.current) return
184
+ const el = root.current?.querySelector<HTMLInputElement>(`[data-cell="${focusNext.current}"]`)
185
+ focusNext.current = undefined
186
+ if (!el) return
187
+ el.focus()
188
+ el.setSelectionRange?.(el.value.length, el.value.length)
189
+ })
190
+
191
+ const real = (r: Shown) => !r.virtual || r.enabled || r.value !== '' || r.file
192
+ const commit = (next: Shown[]) => onChange(next.filter(real).map(({ virtual: _v, ...r }) => r))
193
+ const patch = (i: number, p: Partial<FieldRow>) =>
194
+ commit(all.map((r, j) => (j === i ? { ...r, ...p } : r)))
195
+
196
+ const go = (i: number, col: number) =>
197
+ root.current?.querySelector<HTMLElement>(`[data-cell="${i}:${col}"]`)?.focus()
198
+ const keys = (i: number, col: number) => (e: KeyboardEvent) => {
199
+ if (e.key === 'Enter' || e.key === 'ArrowDown') {
200
+ e.preventDefault()
201
+ go(i + (e.shiftKey ? -1 : 1), col)
202
+ } else if (e.key === 'ArrowUp') {
203
+ e.preventDefault()
204
+ go(i - 1, col)
205
+ }
206
+ }
207
+
208
+ const repeat = (i: number) => {
209
+ const r = all[i] as Shown
210
+ const options = docOf(r.key)?.enum ?? []
211
+ const used = new Set(all.filter((x) => x.key === r.key).map((x) => x.value))
212
+ const value = options.find((o) => !used.has(o)) ?? ''
213
+ const added: Shown = { key: r.key, value, enabled: true }
214
+ const next = [...all]
215
+ next.splice(i + 1, 0, added)
216
+ focusNext.current = `${next.filter(real).indexOf(added)}:${VALUE}`
217
+ commit(next)
218
+ }
219
+
220
+ const paste = (e: ClipboardEvent, at: number) => {
221
+ const text = e.clipboardData?.getData('text') ?? ''
222
+ if (!/\r?\n/.test(text.trim())) return
223
+ e.preventDefault()
224
+ const added = parsePasted(text).map((p) => ({ ...p, enabled: true }))
225
+ const next = [...rows]
226
+ next.splice(at, at < rows.length && !rows[at]?.key ? 1 : 0, ...added)
227
+ onChange(next)
228
+ }
229
+
230
+ const ghost = all.length
231
+ return (
232
+ <section class="grid" aria-label={title} ref={root}>
233
+ {!bare && <h3>{title}</h3>}
234
+ <table ref={table}>
235
+ <GridColgroup />
236
+ <thead>
237
+ <tr>
238
+ <th class="g-check" />
239
+ <th class="g-key">
240
+ Key
241
+ <ColResizer which="key" table={() => table.current} />
242
+ </th>
243
+ <th class="g-val">
244
+ Value
245
+ <ColResizer which="val" table={() => table.current} />
246
+ </th>
247
+ <th class="g-doc">Description</th>
248
+ <th class="g-act" />
249
+ </tr>
250
+ </thead>
251
+ <tbody>
252
+ {all.map((r, i) => {
253
+ const d = docOf(r.key)
254
+ const isFile = info(r.key)?.file
255
+ const locked = fixed || r.virtual || info(r.key)
256
+ return (
257
+ <tr class={`${r.enabled ? '' : 'off'} ${bad === r.key ? 'bad' : ''}`} key={i}>
258
+ <td class="g-check">
259
+ <input
260
+ type="checkbox"
261
+ aria-label={`Send ${r.key}`}
262
+ checked={r.enabled}
263
+ disabled={fixed}
264
+ onChange={(e) => patch(i, { enabled: (e.target as HTMLInputElement).checked })}
265
+ />
266
+ </td>
267
+ <td class="g-key">
268
+ {locked ? (
269
+ <span class="key">
270
+ <code>{r.key}</code>
271
+ {d?.required && (
272
+ <span class="req" title="required">
273
+ *
274
+ </span>
275
+ )}
276
+ </span>
277
+ ) : (
278
+ <input
279
+ class="cell"
280
+ data-cell={`${i}:${KEY}`}
281
+ value={r.key}
282
+ placeholder="key"
283
+ aria-label="Name"
284
+ onInput={(e) => patch(i, { key: (e.target as HTMLInputElement).value })}
285
+ onKeyDown={keys(i, KEY)}
286
+ onPaste={(e) => paste(e, i)}
287
+ />
288
+ )}
289
+ </td>
290
+ <td class="g-val">
291
+ {isFile ? (
292
+ <label class="file-cell">
293
+ <input
294
+ type="file"
295
+ aria-label={`File for ${r.key}`}
296
+ onChange={(e) => {
297
+ const file = (e.target as HTMLInputElement).files?.[0]
298
+ if (file) patch(i, { file, value: file.name, enabled: true })
299
+ }}
300
+ />
301
+ <span class={r.value ? '' : 'muted'}>{r.value || 'Choose a file…'}</span>
302
+ </label>
303
+ ) : d?.enum ? (
304
+ <Combo
305
+ value={r.value}
306
+ options={d.enum.map(String)}
307
+ label={r.key}
308
+ cell={`${i}:${VALUE}`}
309
+ placeholder="value"
310
+ onKeyDown={keys(i, VALUE)}
311
+ onChange={(value) => patch(i, { value, enabled: true })}
312
+ />
313
+ ) : (
314
+ <input
315
+ class="cell"
316
+ data-cell={`${i}:${VALUE}`}
317
+ value={r.value}
318
+ placeholder={placeholder ?? 'value'}
319
+ aria-label={`Value of ${r.key}`}
320
+ onInput={(e) =>
321
+ patch(i, { value: (e.target as HTMLInputElement).value, enabled: true })
322
+ }
323
+ onKeyDown={keys(i, VALUE)}
324
+ />
325
+ )}
326
+ </td>
327
+ <td class="g-doc" title={plainText(d?.description)}>
328
+ {d && (
329
+ <>
330
+ <span class="chip">{d.type}</span>{' '}
331
+ <span
332
+ class="doc-inline"
333
+ dangerouslySetInnerHTML={{ __html: inlineSummary(d.description) }}
334
+ />
335
+ </>
336
+ )}
337
+ </td>
338
+ <td class="g-act">
339
+ {!fixed && r.key && (
340
+ <button
341
+ type="button"
342
+ class="icon"
343
+ title="Repeat this key with another value"
344
+ aria-label={`Repeat ${r.key}`}
345
+ onClick={() => repeat(i)}
346
+ >
347
+ <Icon d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" />
348
+ </button>
349
+ )}
350
+ {!fixed && !r.virtual && (
351
+ <button
352
+ type="button"
353
+ class="icon"
354
+ title="Remove"
355
+ aria-label={`Remove ${r.key}`}
356
+ onClick={() => commit(all.filter((_, j) => j !== i))}
357
+ >
358
+ <Icon d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
359
+ </button>
360
+ )}
361
+ </td>
362
+ </tr>
363
+ )
364
+ })}
365
+ {!fixed && (
366
+ <tr class="ghost" key={ghost}>
367
+ <td class="g-check" />
368
+ <td class="g-key">
369
+ <input
370
+ class="cell"
371
+ data-cell={`${ghost}:${KEY}`}
372
+ value=""
373
+ placeholder="Add a key"
374
+ aria-label="New key"
375
+ onInput={(e) => {
376
+ // the new row lands before the unchecked documented ones, so focus follows it there
377
+ focusNext.current = `${rows.length}:${KEY}`
378
+ onChange([
379
+ ...rows,
380
+ { key: (e.target as HTMLInputElement).value, value: '', enabled: true },
381
+ ])
382
+ }}
383
+ onKeyDown={keys(ghost, KEY)}
384
+ onPaste={(e) => paste(e, rows.length)}
385
+ />
386
+ </td>
387
+ <td class="g-val" />
388
+ <td class="g-doc" />
389
+ <td class="g-act" />
390
+ </tr>
391
+ )}
392
+ </tbody>
393
+ </table>
394
+ </section>
395
+ )
396
+ }