@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,220 @@
1
+ import type { JSX } from 'preact'
2
+
3
+ /** Normalised field description, built from either a Control or a described input control. */
4
+ export interface FieldSpec {
5
+ kind: string
6
+ /** a friendlier name than the parameter key, and a sentence about it */
7
+ label?: string
8
+ description?: string
9
+ default?: unknown
10
+ options?: { label: string; value: unknown }[]
11
+ shape?: Record<string, FieldSpec>
12
+ item?: FieldSpec
13
+ min?: number
14
+ max?: number
15
+ }
16
+
17
+ export function specFromControl(ctl: any): FieldSpec {
18
+ const o = ctl.opts ?? {}
19
+ const meta = {
20
+ ...(typeof o.label === 'string' ? { label: o.label as string } : {}),
21
+ ...(typeof o.description === 'string' ? { description: o.description as string } : {}),
22
+ }
23
+ return { ...baseSpec(ctl, o), ...meta }
24
+ }
25
+
26
+ function baseSpec(ctl: any, o: any): FieldSpec {
27
+ switch (ctl.kind) {
28
+ case 'select':
29
+ case 'multiselect': {
30
+ const value = (x: unknown) => (typeof o.value === 'function' ? o.value(x) : x)
31
+ const label = (x: unknown) => (typeof o.label === 'function' ? o.label(x) : String(value(x)))
32
+ return {
33
+ kind: ctl.kind,
34
+ options: (o.options as unknown[]).map((x) => ({ label: label(x), value: value(x) })),
35
+ }
36
+ }
37
+ case 'object':
38
+ return {
39
+ kind: 'object',
40
+ shape: Object.fromEntries(
41
+ Object.entries(o.shape as Record<string, any>).map(([k, v]) => [k, specFromControl(v)]),
42
+ ),
43
+ }
44
+ case 'array':
45
+ return {
46
+ kind: 'array',
47
+ item: specFromControl(o.item),
48
+ default: o.default,
49
+ min: o.min,
50
+ max: o.max,
51
+ }
52
+ default:
53
+ return { kind: ctl.kind, default: o.default, min: o.min, max: o.max }
54
+ }
55
+ }
56
+
57
+ export function defaultFor(spec: FieldSpec): unknown {
58
+ switch (spec.kind) {
59
+ case 'select':
60
+ return spec.options?.[0]?.value
61
+ case 'multiselect':
62
+ return []
63
+ case 'object':
64
+ return Object.fromEntries(
65
+ Object.entries(spec.shape ?? {}).map(([k, s]) => [k, defaultFor(s)]),
66
+ )
67
+ case 'array':
68
+ return spec.default ?? []
69
+ case 'boolean':
70
+ return spec.default ?? false
71
+ case 'number':
72
+ return spec.default ?? 0
73
+ default:
74
+ return spec.default ?? ''
75
+ }
76
+ }
77
+
78
+ interface FieldProps {
79
+ name: string
80
+ spec: FieldSpec
81
+ value: unknown
82
+ onChange: (v: unknown) => void
83
+ }
84
+
85
+ const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
86
+
87
+ export function Field({ name, spec, value, onChange }: FieldProps): JSX.Element {
88
+ const wrap = (body: JSX.Element) => (
89
+ <div class="field">
90
+ <span class="label">{name}</span>
91
+ {body}
92
+ </div>
93
+ )
94
+ switch (spec.kind) {
95
+ case 'number':
96
+ return wrap(
97
+ <input
98
+ type="number"
99
+ value={Number(value ?? 0)}
100
+ min={spec.min}
101
+ max={spec.max}
102
+ onInput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
103
+ />,
104
+ )
105
+ case 'boolean':
106
+ return wrap(
107
+ <input
108
+ type="checkbox"
109
+ checked={Boolean(value)}
110
+ onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
111
+ />,
112
+ )
113
+ case 'date':
114
+ return wrap(
115
+ <input
116
+ type="date"
117
+ value={String(value ?? '')}
118
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
119
+ />,
120
+ )
121
+ case 'select': {
122
+ const idx = Math.max(
123
+ 0,
124
+ (spec.options ?? []).findIndex((o) => same(o.value, value)),
125
+ )
126
+ return wrap(
127
+ <select
128
+ value={idx}
129
+ onChange={(e) =>
130
+ onChange(spec.options?.[Number((e.target as HTMLSelectElement).value)]?.value)
131
+ }
132
+ >
133
+ {(spec.options ?? []).map((o, i) => (
134
+ <option value={i}>{o.label}</option>
135
+ ))}
136
+ </select>,
137
+ )
138
+ }
139
+ case 'multiselect': {
140
+ const current = (value as unknown[]) ?? []
141
+ return wrap(
142
+ <div>
143
+ {(spec.options ?? []).map((o) => (
144
+ <label class="row">
145
+ <input
146
+ type="checkbox"
147
+ checked={current.some((c) => same(c, o.value))}
148
+ onChange={(e) =>
149
+ onChange(
150
+ (e.target as HTMLInputElement).checked
151
+ ? [...current, o.value]
152
+ : current.filter((c) => !same(c, o.value)),
153
+ )
154
+ }
155
+ />
156
+ {o.label}
157
+ </label>
158
+ ))}
159
+ </div>,
160
+ )
161
+ }
162
+ case 'object': {
163
+ const obj = (value ?? {}) as Record<string, unknown>
164
+ return wrap(
165
+ <div class="nested">
166
+ {Object.entries(spec.shape ?? {}).map(([k, s]) => (
167
+ <Field
168
+ name={k}
169
+ spec={s}
170
+ value={obj[k]}
171
+ onChange={(v) => onChange({ ...obj, [k]: v })}
172
+ />
173
+ ))}
174
+ </div>,
175
+ )
176
+ }
177
+ case 'array': {
178
+ const list = (value as unknown[]) ?? []
179
+ const item = spec.item as FieldSpec
180
+ return wrap(
181
+ <div class="nested">
182
+ {list.map((v, i) => (
183
+ <div class="row">
184
+ <Field
185
+ name={`#${i + 1}`}
186
+ spec={item}
187
+ value={v}
188
+ onChange={(nv) => onChange(list.map((x, j) => (j === i ? nv : x)))}
189
+ />
190
+ <button
191
+ type="button"
192
+ class="btn"
193
+ disabled={spec.min !== undefined && list.length <= spec.min}
194
+ onClick={() => onChange(list.filter((_, j) => j !== i))}
195
+ >
196
+ Remove
197
+ </button>
198
+ </div>
199
+ ))}
200
+ <button
201
+ type="button"
202
+ class="btn"
203
+ disabled={spec.max !== undefined && list.length >= spec.max}
204
+ onClick={() => onChange([...list, defaultFor(item)])}
205
+ >
206
+ Add
207
+ </button>
208
+ </div>,
209
+ )
210
+ }
211
+ default:
212
+ return wrap(
213
+ <input
214
+ type="text"
215
+ value={String(value ?? '')}
216
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
217
+ />,
218
+ )
219
+ }
220
+ }
@@ -0,0 +1,22 @@
1
+ import { GitHubMark } from '../panel/icons'
2
+ import { REPO_URL } from '../viewer/site'
3
+
4
+ /**
5
+ * A way to the repository. Only in the viewer (a project's own published site has its own header and is
6
+ * not ours to put links in).
7
+ */
8
+ export function GitHubStar() {
9
+ if (!globalThis.__SPECBOOK_VIEWER__?.source) return null
10
+ return (
11
+ <a
12
+ class="btn gh-star"
13
+ href={REPO_URL}
14
+ target="_blank"
15
+ rel="noopener"
16
+ title="Specbook's source is on GitHub"
17
+ >
18
+ <GitHubMark />
19
+ <span class="gh-label">GitHub</span>
20
+ </a>
21
+ )
22
+ }
@@ -0,0 +1,70 @@
1
+ import { signal } from '@preact/signals'
2
+
3
+ const KEY = 'specbook:list-width'
4
+ export const MIN = 280
5
+ const maxWidth = () => Math.round(innerWidth * 0.55)
6
+
7
+ function load(): number | undefined {
8
+ try {
9
+ const n = Number(localStorage.getItem(KEY))
10
+ return n >= MIN ? n : undefined
11
+ } catch {
12
+ return undefined
13
+ }
14
+ }
15
+
16
+ /** The list column's width in px; undefined = the stylesheet's responsive default. */
17
+ export const listWidth = signal<number | undefined>(load())
18
+
19
+ function set(w: number | undefined) {
20
+ listWidth.value = w === undefined ? undefined : Math.round(Math.max(MIN, Math.min(maxWidth(), w)))
21
+ try {
22
+ if (listWidth.value === undefined) localStorage.removeItem(KEY)
23
+ else localStorage.setItem(KEY, String(listWidth.value))
24
+ } catch {
25
+ /* optional */
26
+ }
27
+ }
28
+
29
+ /** A draggable (and keyboard-operable) edge on the list column; double-click restores the default. */
30
+ export function ListResizer() {
31
+ const drag = (e: PointerEvent) => {
32
+ e.preventDefault()
33
+ document.body.classList.add('resizing')
34
+ const move = (m: PointerEvent) => set(m.clientX)
35
+ const up = () => {
36
+ document.body.classList.remove('resizing')
37
+ removeEventListener('pointermove', move)
38
+ removeEventListener('pointerup', up)
39
+ }
40
+ addEventListener('pointermove', move)
41
+ addEventListener('pointerup', up)
42
+ }
43
+ const current = () =>
44
+ listWidth.value ?? document.querySelector<HTMLElement>('.list-col')?.offsetWidth ?? 360
45
+
46
+ return (
47
+ // biome-ignore lint/a11y/useSemanticElements: a draggable splitter has no native element
48
+ <div
49
+ class="list-resizer"
50
+ role="separator"
51
+ aria-orientation="vertical"
52
+ aria-label="Resize the list"
53
+ aria-valuenow={listWidth.value}
54
+ aria-valuemin={MIN}
55
+ tabIndex={0}
56
+ title="Drag to resize · double-click to reset"
57
+ onPointerDown={drag}
58
+ onDblClick={() => set(undefined)}
59
+ onKeyDown={(e) => {
60
+ if (e.key === 'ArrowLeft') {
61
+ e.preventDefault()
62
+ set(current() - 24)
63
+ } else if (e.key === 'ArrowRight') {
64
+ e.preventDefault()
65
+ set(current() + 24)
66
+ }
67
+ }}
68
+ />
69
+ )
70
+ }
@@ -0,0 +1,17 @@
1
+ import { environments } from '../store'
2
+
3
+ /**
4
+ * In the viewer a document opens on mock data. Say so, and where the real API is, once, on the stage:
5
+ * someone who has pasted a link and sees a response should not wonder whether it came from the server.
6
+ */
7
+ export function ModeHint() {
8
+ if (!globalThis.__SPECBOOK_VIEWER__) return null
9
+ const live = environments.value.some((e) => e !== 'mock')
10
+ return (
11
+ <p class="muted overview-mode">
12
+ {live
13
+ ? 'Requests start on mock data. Switch Environment to live to call the real API.'
14
+ : 'Requests run on generated mock data: this document does not say where the API lives.'}
15
+ </p>
16
+ )
17
+ }
@@ -0,0 +1,85 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { type Entry, go, paletteOpen, search } from '../nav'
3
+
4
+ export function Palette({ entries }: { entries: Entry[] }) {
5
+ const [q, setQ] = useState('')
6
+ const [sel, setSel] = useState(0)
7
+ const input = useRef<HTMLInputElement>(null)
8
+ const open = paletteOpen.value
9
+
10
+ useEffect(() => {
11
+ const onKey = (e: KeyboardEvent) => {
12
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
13
+ e.preventDefault()
14
+ paletteOpen.value = !paletteOpen.value
15
+ } else if (e.key === 'Escape') paletteOpen.value = false
16
+ }
17
+ addEventListener('keydown', onKey)
18
+ return () => removeEventListener('keydown', onKey)
19
+ }, [])
20
+
21
+ useEffect(() => {
22
+ if (open) {
23
+ setQ('')
24
+ setSel(0)
25
+ setTimeout(() => input.current?.focus(), 0)
26
+ }
27
+ }, [open])
28
+
29
+ const results = useMemo(() => search(entries, q, 30), [entries, q])
30
+ if (!open) return null
31
+
32
+ const choose = (e: Entry | undefined) => {
33
+ if (!e) return
34
+ paletteOpen.value = false
35
+ go(e.route)
36
+ }
37
+
38
+ return (
39
+ <div class="palette-backdrop">
40
+ <button
41
+ type="button"
42
+ class="palette-dismiss"
43
+ aria-label="Close"
44
+ onClick={() => (paletteOpen.value = false)}
45
+ />
46
+ <div class="palette" role="dialog" aria-label="Jump to">
47
+ <input
48
+ ref={input}
49
+ type="text"
50
+ placeholder="Jump to a sequence or operation…"
51
+ value={q}
52
+ onInput={(e) => {
53
+ setQ((e.target as HTMLInputElement).value)
54
+ setSel(0)
55
+ }}
56
+ onKeyDown={(e) => {
57
+ if (e.key === 'ArrowDown') {
58
+ e.preventDefault()
59
+ setSel(Math.min(results.length - 1, sel + 1))
60
+ } else if (e.key === 'ArrowUp') {
61
+ e.preventDefault()
62
+ setSel(Math.max(0, sel - 1))
63
+ } else if (e.key === 'Enter') choose(results[sel])
64
+ }}
65
+ />
66
+ <ul>
67
+ {results.map((e, i) => (
68
+ <li>
69
+ <button
70
+ type="button"
71
+ class={i === sel ? 'sel' : ''}
72
+ onMouseEnter={() => setSel(i)}
73
+ onClick={() => choose(e)}
74
+ >
75
+ <span class="p-title">{e.title}</span>
76
+ <span class="muted">{e.kind === 'seq' ? 'Sequence' : e.group}</span>
77
+ </button>
78
+ </li>
79
+ ))}
80
+ {results.length === 0 && <li class="muted pad">Nothing matches “{q}”</li>}
81
+ </ul>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,15 @@
1
+ import { badgeHref, showBadge } from '../badge'
2
+ import { brand } from '../branding'
3
+ import { isStatic } from '../runtime'
4
+
5
+ export function PoweredBy() {
6
+ const host = globalThis.location?.hostname ?? ''
7
+ if (!showBadge({ host, published: isStatic, badge: brand.value.badge })) return null
8
+ return (
9
+ <p class="powered-by">
10
+ <a href={badgeHref(host)} target="_blank" rel="noopener">
11
+ Powered by Specbook
12
+ </a>
13
+ </p>
14
+ )
15
+ }
@@ -0,0 +1,94 @@
1
+ import { useState } from 'preact/hooks'
2
+ import { route } from '../nav'
3
+ import { ShareIcon } from '../panel/icons'
4
+ import { shareable, shareLinks } from '../viewer/share'
5
+
6
+ /**
7
+ * Share, for the hosted viewer only (a project's own site has no viewer global). The menu says why when a
8
+ * link would not work for others, rather than offering one that fails for whoever opens it.
9
+ */
10
+ export function ShareMenu() {
11
+ const v = globalThis.__SPECBOOK_VIEWER__
12
+ const [open, setOpen] = useState(false)
13
+ const [copied, setCopied] = useState(false)
14
+ if (!v?.source) return null
15
+ void route.value // re-render when the operation changes, so the link follows it
16
+
17
+ const can = shareable(v.source)
18
+ const links = can.ok
19
+ ? shareLinks(
20
+ { origin: location.origin, pathname: location.pathname, hash: location.hash },
21
+ can.spec,
22
+ v.open.title,
23
+ )
24
+ : undefined
25
+
26
+ const copy = async () => {
27
+ if (!links) return
28
+ try {
29
+ await navigator.clipboard.writeText(links.link)
30
+ setCopied(true)
31
+ setTimeout(() => setCopied(false), 1500)
32
+ } catch {
33
+ /* the link is shown in the menu to copy by hand */
34
+ }
35
+ }
36
+
37
+ return (
38
+ <div class="share">
39
+ <button
40
+ type="button"
41
+ class="btn"
42
+ aria-expanded={open}
43
+ aria-haspopup="true"
44
+ aria-label="Share"
45
+ onClick={() => setOpen(!open)}
46
+ >
47
+ <ShareIcon />
48
+ <span class="share-label">Share</span>
49
+ </button>
50
+ {open && (
51
+ <div class="share-pop" role="menu">
52
+ {links ? (
53
+ <>
54
+ <input
55
+ class="share-link"
56
+ readOnly
57
+ value={links.link}
58
+ aria-label="Link to this view"
59
+ onFocus={(e) => (e.target as HTMLInputElement).select()}
60
+ />
61
+ <div class="share-actions">
62
+ <button type="button" class="btn primary" onClick={copy}>
63
+ {copied ? 'Copied' : 'Copy link'}
64
+ </button>
65
+ {typeof navigator.share === 'function' && (
66
+ <button
67
+ type="button"
68
+ class="btn"
69
+ onClick={() =>
70
+ void navigator.share({ title: links.title, url: links.link }).catch(() => {})
71
+ }
72
+ >
73
+ Share…
74
+ </button>
75
+ )}
76
+ </div>
77
+ <ul class="share-targets">
78
+ {links.targets.map((t) => (
79
+ <li>
80
+ <a href={t.href} target="_blank" rel="noopener noreferrer">
81
+ {t.label}
82
+ </a>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </>
87
+ ) : (
88
+ <p class="muted">{can.ok ? '' : can.reason}</p>
89
+ )}
90
+ </div>
91
+ )}
92
+ </div>
93
+ )
94
+ }
@@ -0,0 +1,52 @@
1
+ import { cycleTheme, type Theme, theme } from '../theme'
2
+
3
+ const LABEL: Record<Theme, string> = {
4
+ auto: 'Theme: follows your system. Click for light.',
5
+ light: 'Theme: light. Click for dark.',
6
+ dark: 'Theme: dark. Click to follow your system.',
7
+ }
8
+
9
+ const Icon = ({ mode }: { mode: Theme }) => (
10
+ <svg
11
+ viewBox="0 0 24 24"
12
+ width="16"
13
+ height="16"
14
+ fill="none"
15
+ stroke="currentColor"
16
+ stroke-width="1.8"
17
+ stroke-linecap="round"
18
+ stroke-linejoin="round"
19
+ aria-hidden="true"
20
+ >
21
+ {mode === 'light' && (
22
+ <>
23
+ <circle cx="12" cy="12" r="4" />
24
+ <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
25
+ </>
26
+ )}
27
+ {mode === 'dark' && <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z" />}
28
+ {mode === 'auto' && (
29
+ <>
30
+ <circle cx="12" cy="12" r="8.5" />
31
+ <path d="M12 3.5v17a8.5 8.5 0 0 0 0-17z" fill="currentColor" stroke="none" />
32
+ </>
33
+ )}
34
+ </svg>
35
+ )
36
+
37
+ /** Light / dark / follow the system, remembered in this browser. */
38
+ export function ThemeToggle() {
39
+ const t = theme.value
40
+ return (
41
+ <button
42
+ type="button"
43
+ class="icon theme-toggle"
44
+ title={LABEL[t]}
45
+ aria-label={LABEL[t]}
46
+ data-theme-mode={t}
47
+ onClick={cycleTheme}
48
+ >
49
+ <Icon mode={t} />
50
+ </button>
51
+ )
52
+ }
@@ -0,0 +1,114 @@
1
+ import { render } from 'preact'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import type { SequenceInfo, Variant } from '../engine'
4
+ import { Host } from '../panel/Host'
5
+ import { engine, registry } from '../runtime'
6
+ import { env, environmentRisk, environments, version } from '../store'
7
+ import { panelSheets } from './styles'
8
+
9
+ export interface Target {
10
+ service?: string
11
+ operation?: string
12
+ sequence?: string
13
+ example?: string
14
+ /** open on the Docs tab instead of Try */
15
+ tab?: 'docs' | 'try'
16
+ }
17
+
18
+ interface Loaded {
19
+ variants: Record<string, Variant[]>
20
+ }
21
+
22
+ let loaded: Promise<Loaded> | undefined
23
+ const load = () =>
24
+ (loaded ??= (async () => {
25
+ environments.value = await engine.environments(version.value)
26
+ environmentRisk.value = await engine.environmentRisks(version.value)
27
+ env.value = environments.value.includes('mock') ? 'mock' : (environments.value[0] ?? 'mock')
28
+ return { variants: await engine.loadVariants(registry.requests, version.value) }
29
+ })())
30
+
31
+ /** Headers are read from the file text, so only the requested module (or the odd unresolved ones) is imported. */
32
+ async function loadSequence(id: string) {
33
+ const file = registry.sequenceMeta[id]?.file
34
+ const wanted = (f: string) => (file ? f === file : registry.unresolvedSequences.includes(f))
35
+ const entries = registry.sequences.filter((e) => wanted(e.file))
36
+ const list = await engine.loadSequences(entries, version.value)
37
+ return list.find((s) => s.seq.meta.id === id)
38
+ }
39
+
40
+ function findOperation(t: Target) {
41
+ const services = Object.values(registry.manifests)
42
+ const scope = t.service ? services.filter((m) => m.service === t.service) : services
43
+ for (const m of scope) {
44
+ const op = m.operations.find((o) => o.id === t.operation)
45
+ if (op) return { service: m.service, op }
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ function Embedded({ target, onClose }: { target: Target; onClose: () => void }) {
51
+ const [data, setData] = useState<Loaded>()
52
+ const [seq, setSeq] = useState<SequenceInfo>()
53
+ const [view, setView] = useState<{ tab: 'docs' | 'try'; example: string }>({
54
+ tab: target.tab ?? 'try',
55
+ example: target.example ?? '',
56
+ })
57
+ const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
58
+ setView({ tab: to.tab, example: to.example ?? view.example })
59
+ useEffect(() => {
60
+ load().then(setData)
61
+ if (target.sequence) loadSequence(target.sequence).then(setSeq)
62
+ }, [])
63
+ if (target.sequence)
64
+ return seq ? (
65
+ <Host kind="seq" info={seq} tab={view.tab} onNavigate={navigate} onClose={onClose} />
66
+ ) : null
67
+ if (!data) return null
68
+ const found = findOperation(target)
69
+ if (!found) return null
70
+ return (
71
+ <Host
72
+ kind="op"
73
+ service={found.service}
74
+ op={found.op}
75
+ variants={data.variants[`${found.service}/${found.op.id}`] ?? []}
76
+ example={view.example}
77
+ tab={view.tab}
78
+ onNavigate={navigate}
79
+ onClose={onClose}
80
+ />
81
+ )
82
+ }
83
+
84
+ class SpecbookPanel extends HTMLElement {
85
+ private mount!: HTMLElement
86
+
87
+ connectedCallback() {
88
+ if (this.shadowRoot) return
89
+ const root = this.attachShadow({ mode: 'open' })
90
+ root.adoptedStyleSheets = panelSheets()
91
+ this.mount = document.createElement('div')
92
+ this.mount.className = 'sb-root'
93
+ root.append(this.mount)
94
+ }
95
+
96
+ show(target: Target | undefined) {
97
+ render(
98
+ target ? <Embedded target={target} onClose={() => this.show(undefined)} /> : null,
99
+ this.mount,
100
+ )
101
+ }
102
+ }
103
+
104
+ customElements.define('specbook-panel', SpecbookPanel)
105
+
106
+ /** One panel serves every Play button on the page. */
107
+ export function show(target: Target) {
108
+ let panel = document.querySelector<SpecbookPanel>('specbook-panel')
109
+ if (!panel) {
110
+ panel = document.createElement('specbook-panel') as SpecbookPanel
111
+ document.body.append(panel)
112
+ }
113
+ panel.show(target)
114
+ }