@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,324 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { brand } from '../branding'
3
+ import { catalogFilter, catalogService, type Entry, go, route, search } from '../nav'
4
+ import { Lock } from '../panel/icons'
5
+ import { ModeHint } from './ModeHint'
6
+ import { PoweredBy } from './PoweredBy'
7
+
8
+ export interface ServiceInfo {
9
+ id: string
10
+ title?: string
11
+ version?: string
12
+ ops: number
13
+ }
14
+
15
+ /** Groups with more operations than this start collapsed unless something is being searched. */
16
+ const OPEN_LIMIT = 60
17
+
18
+ const DEFAULT_FLOW_GROUP = 'Default'
19
+
20
+ function isActive(e: Entry): boolean {
21
+ const r = route.value
22
+ return (
23
+ (e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) ||
24
+ (e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id)
25
+ )
26
+ }
27
+
28
+ function OpRow({ e }: { e: Entry }) {
29
+ return (
30
+ <button
31
+ type="button"
32
+ class={`op-row ${isActive(e) ? 'active' : ''}`}
33
+ onClick={() => go(e.route)}
34
+ title={e.path ? `${e.title}\n${e.path}` : e.title}
35
+ >
36
+ <span class={`method ${e.method}`}>{e.method}</span>
37
+ <span class="op-title">
38
+ {e.title}
39
+ {e.secured && (
40
+ <span class="op-lock" title="Needs credentials">
41
+ <Lock />
42
+ </span>
43
+ )}
44
+ </span>
45
+ <span class="op-path">{e.path}</span>
46
+ </button>
47
+ )
48
+ }
49
+
50
+ /** Shared by the list and the overview: which entries the current pill and filter select. */
51
+ function useCatalog(entries: Entry[], services: ServiceInfo[]) {
52
+ const q = catalogFilter.value.trim()
53
+ const single = services.length <= 1
54
+ const svc = single ? (services[0]?.id ?? '') : catalogService.value
55
+ const ops = useMemo(() => entries.filter((e) => e.kind === 'op'), [entries])
56
+ const flows = useMemo(() => entries.filter((e) => e.kind === 'seq'), [entries])
57
+ const shownOps = useMemo(() => {
58
+ const scoped = svc ? ops.filter((e) => e.service === svc) : ops
59
+ return q ? search(scoped, q, 500) : scoped
60
+ }, [ops, svc, q])
61
+ const shownFlows = useMemo(() => {
62
+ const scoped = svc && !single ? flows.filter((e) => e.tags?.includes(svc)) : flows
63
+ return q ? search(scoped, q, 100) : scoped
64
+ }, [flows, svc, q, single])
65
+ return { q, single, svc, shownOps, shownFlows }
66
+ }
67
+
68
+ /** The left column: pills, filter and grouped rows. It never changes width, so opening the panel moves nothing. */
69
+ export function CatalogList({ entries, services }: { entries: Entry[]; services: ServiceInfo[] }) {
70
+ const filter = useRef<HTMLInputElement>(null)
71
+ const list = useRef<HTMLDivElement>(null)
72
+ // what the user chose per group; groups they haven't touched follow the default
73
+ const [chosen, setChosen] = useState<Map<string, boolean>>(new Map())
74
+ const { q, single, svc, shownOps, shownFlows } = useCatalog(entries, services)
75
+
76
+ useEffect(() => {
77
+ const key = (e: KeyboardEvent) => {
78
+ const el = e.target as HTMLElement
79
+ if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) {
80
+ e.preventDefault()
81
+ filter.current?.focus()
82
+ }
83
+ }
84
+ addEventListener('keydown', key)
85
+ return () => removeEventListener('keydown', key)
86
+ }, [])
87
+
88
+ // arrow keys walk the rows; going up from the first one returns to the filter
89
+ useEffect(() => {
90
+ const el = list.current
91
+ if (!el) return
92
+ const move = (e: KeyboardEvent) => {
93
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
94
+ const rows = [...el.querySelectorAll<HTMLElement>('.op-row, .flow-row')]
95
+ const at = rows.indexOf(document.activeElement as HTMLElement)
96
+ if (at < 0) return
97
+ const next = e.key === 'ArrowDown' ? at + 1 : at - 1
98
+ e.preventDefault()
99
+ if (next >= 0 && next < rows.length) rows[next]?.focus()
100
+ else if (next < 0) filter.current?.focus()
101
+ }
102
+ el.addEventListener('keydown', move)
103
+ return () => el.removeEventListener('keydown', move)
104
+ }, [])
105
+
106
+ const groups = useMemo(() => {
107
+ const m = new Map<string, Entry[]>()
108
+ for (const e of shownOps) {
109
+ const tag = e.tag ?? 'default'
110
+ const name = svc || single ? tag : `${e.service} · ${tag}`
111
+ m.set(name, [...(m.get(name) ?? []), e])
112
+ }
113
+ return [...m.entries()].sort(([a], [b]) => a.localeCompare(b))
114
+ }, [shownOps, svc, single])
115
+
116
+ // "Default" first, then the rest alphabetically
117
+ const flowGroups = useMemo(() => {
118
+ const m = new Map<string, Entry[]>()
119
+ for (const e of shownFlows) {
120
+ const name = e.flowGroup?.trim() || DEFAULT_FLOW_GROUP
121
+ m.set(name, [...(m.get(name) ?? []), e])
122
+ }
123
+ const rank = (n: string) => (n === DEFAULT_FLOW_GROUP ? 0 : 1)
124
+ return [...m.entries()].sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
125
+ }, [shownFlows])
126
+
127
+ const crowded = shownOps.length > OPEN_LIMIT && !q
128
+ const isOpen = (name: string, rows: Entry[]) =>
129
+ chosen.get(name) ?? (!crowded || rows.some(isActive))
130
+ const toggle = (name: string, rows: Entry[]) => {
131
+ const n = new Map(chosen)
132
+ n.set(name, !isOpen(name, rows))
133
+ setChosen(n)
134
+ }
135
+
136
+ return (
137
+ <div class="catalog" ref={list}>
138
+ <div class="cat-top">
139
+ {!single && (
140
+ <div class="pills" role="tablist" aria-label="APIs">
141
+ <button
142
+ type="button"
143
+ role="tab"
144
+ aria-selected={svc === ''}
145
+ class={`pill ${svc === '' ? 'on' : ''}`}
146
+ onClick={() => (catalogService.value = '')}
147
+ >
148
+ All
149
+ </button>
150
+ {services.map((s) => (
151
+ <button
152
+ type="button"
153
+ role="tab"
154
+ aria-selected={svc === s.id}
155
+ class={`pill ${svc === s.id ? 'on' : ''}`}
156
+ onClick={() => (catalogService.value = s.id)}
157
+ >
158
+ {s.id} <span class="count">{s.ops}</span>
159
+ </button>
160
+ ))}
161
+ </div>
162
+ )}
163
+ <div class="cat-filter-wrap">
164
+ <input
165
+ ref={filter}
166
+ type="text"
167
+ class="cat-filter"
168
+ placeholder={svc ? `Filter ${svc}` : 'Filter operations and flows'}
169
+ aria-label="Filter"
170
+ value={catalogFilter.value}
171
+ onInput={(e) => (catalogFilter.value = (e.target as HTMLInputElement).value)}
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'ArrowDown') {
174
+ e.preventDefault()
175
+ list.current?.querySelector<HTMLElement>('.op-row, .flow-row')?.focus()
176
+ }
177
+ if (e.key === 'Escape') catalogFilter.value = ''
178
+ }}
179
+ />
180
+ <kbd class="cat-hint" title="Focus the filter">
181
+ /
182
+ </kbd>
183
+ </div>
184
+ </div>
185
+
186
+ {flowGroups.length > 0 && (
187
+ <section class="flows" aria-label="Flows">
188
+ <div class="flows-title">
189
+ <span class="method flow">FLOW</span>
190
+ <span class="muted small">Multi-step sequences you can run end to end</span>
191
+ <span class="count">{shownFlows.length}</span>
192
+ </div>
193
+ {flowGroups.map(([name, rows]) => {
194
+ const key = `flows:${name}`
195
+ const solo = flowGroups.length === 1
196
+ const expanded = solo || (chosen.get(key) ?? true)
197
+ return (
198
+ <div class="flow-group" key={key}>
199
+ {!solo && (
200
+ <button
201
+ type="button"
202
+ class="group-head flow-head"
203
+ aria-expanded={expanded}
204
+ onClick={() => {
205
+ const n = new Map(chosen)
206
+ n.set(key, !expanded)
207
+ setChosen(n)
208
+ }}
209
+ >
210
+ <span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
211
+ <span class="count">{rows.length}</span>
212
+ </button>
213
+ )}
214
+ {expanded &&
215
+ rows.map((e) => (
216
+ <button
217
+ type="button"
218
+ class={`op-row flow-row ${isActive(e) ? 'active' : ''}`}
219
+ onClick={() => go(e.route)}
220
+ >
221
+ <span class="flow-play" aria-hidden="true">
222
+ ▶
223
+ </span>
224
+ <span class="op-title">{e.title}</span>
225
+ </button>
226
+ ))}
227
+ </div>
228
+ )
229
+ })}
230
+ </section>
231
+ )}
232
+
233
+ {groups.map(([name, rows]) => {
234
+ const expanded = isOpen(name, rows)
235
+ return (
236
+ <div class="op-group" key={name}>
237
+ <button
238
+ type="button"
239
+ class="group-head"
240
+ aria-expanded={expanded}
241
+ onClick={() => toggle(name, rows)}
242
+ >
243
+ <span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
244
+ <span class="count">{rows.length}</span>
245
+ </button>
246
+ {expanded && rows.map((e) => <OpRow e={e} />)}
247
+ </div>
248
+ )
249
+ })}
250
+ {groups.length === 0 && flowGroups.length === 0 && (
251
+ <p class="muted cat-empty">
252
+ {q ? `Nothing matches “${q}”.` : 'No operations.'}{' '}
253
+ {q && (
254
+ <button type="button" class="link" onClick={() => (catalogFilter.value = '')}>
255
+ Clear the filter
256
+ </button>
257
+ )}
258
+ </p>
259
+ )}
260
+ </div>
261
+ )
262
+ }
263
+
264
+ /** The stage when nothing is open: the API's identity, since the list is the content. */
265
+ export function Overview({ services }: { services: ServiceInfo[] }) {
266
+ const b = brand.value
267
+ // wait for the project's branding: showing the fallback first and swapping it makes the page jump
268
+ if (!b.ready) return <div class="overview" aria-busy="true" />
269
+ const total = services.reduce((n, s) => n + s.ops, 0)
270
+ const single = services.length === 1 ? services[0] : undefined
271
+ const title =
272
+ b.title ??
273
+ b.name ??
274
+ (single
275
+ ? (single.title ?? single.id)
276
+ : `${services.length} APIs · ${total.toLocaleString()} operations`)
277
+ const meta = single
278
+ ? `${single.ops} ${single.ops === 1 ? 'operation' : 'operations'}${single.version ? ` · v${single.version}` : ''}`
279
+ : b.name || b.title
280
+ ? `${services.length} APIs · ${total.toLocaleString()} operations`
281
+ : undefined
282
+ return (
283
+ <div class="overview">
284
+ {b.logo && (
285
+ <img
286
+ class="overview-logo"
287
+ src={b.logo}
288
+ alt=""
289
+ style={
290
+ b.logoBackground
291
+ ? { background: b.logoBackground, padding: '10px 16px', borderRadius: '10px' }
292
+ : undefined
293
+ }
294
+ />
295
+ )}
296
+ {b.title && b.name && <p class="overview-name">{b.name}</p>}
297
+ <h1>{title}</h1>
298
+ {b.tagline && <p class="tagline">{b.tagline}</p>}
299
+ {meta && <p class="muted">{meta}</p>}
300
+ <p class="overview-hint">Choose an operation or flow on the left to read it and try it.</p>
301
+ <ModeHint />
302
+ <button
303
+ type="button"
304
+ class="btn primary overview-browse"
305
+ onClick={() => go({ kind: 'browse' })}
306
+ >
307
+ Browse operations and flows
308
+ </button>
309
+ {b.links.length > 0 && (
310
+ <p class="overview-links">
311
+ {b.links.map((l) => (
312
+ <a href={l.href} target="_blank" rel="noreferrer">
313
+ {l.label}
314
+ </a>
315
+ ))}
316
+ </p>
317
+ )}
318
+ <p class="muted overview-keys">
319
+ <kbd>⌘</kbd> <kbd>K</kbd> jumps anywhere · <kbd>/</kbd> filters the list
320
+ </p>
321
+ <PoweredBy />
322
+ </div>
323
+ )
324
+ }
@@ -0,0 +1,46 @@
1
+ import { env, environmentRisk, environments } from '../store'
2
+
3
+ /**
4
+ * The environment a request will run in, and a way to change it. On a phone the header with the
5
+ * selector is behind a menu or under a panel, so the chip appears in the header and in each panel: it
6
+ * is a real control (a transparent native select over the label), so a tap opens the platform picker.
7
+ * Anything but `mock` is flagged, and production more strongly: an environment the config marks
8
+ * `risk: 'production'`, or by default one whose name contains "prod".
9
+ */
10
+ export function EnvBadge() {
11
+ const name = env.value
12
+ const names = environments.value.includes(name)
13
+ ? environments.value
14
+ : [name, ...environments.value]
15
+ const risk = environmentRisk.value[name]
16
+ const kind =
17
+ name === 'mock'
18
+ ? 'mock'
19
+ : risk === 'production' || (risk !== 'sandbox' && /prod/i.test(name))
20
+ ? 'prod'
21
+ : 'live'
22
+ const switchable = names.length > 1
23
+ return (
24
+ <span
25
+ class={`env-badge ${kind} ${switchable ? 'switch' : ''}`}
26
+ title={
27
+ name === 'mock'
28
+ ? 'Environment: mock (no network)'
29
+ : `Environment: ${name}. Requests go to the real API.`
30
+ }
31
+ >
32
+ <span class="env-name">{name}</span>
33
+ {switchable && (
34
+ <select
35
+ aria-label="Environment"
36
+ value={name}
37
+ onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
38
+ >
39
+ {names.map((n) => (
40
+ <option value={n}>{n}</option>
41
+ ))}
42
+ </select>
43
+ )}
44
+ </span>
45
+ )
46
+ }
@@ -0,0 +1,262 @@
1
+ import { useEffect, useMemo, useState } from 'preact/hooks'
2
+ import { engine, isStatic } from '../runtime'
3
+ import { env, version } from '../store'
4
+
5
+ type Source = 'session' | 'process' | 'environment' | 'shared' | undefined
6
+ type Scope = 'environment' | 'session' | 'shared'
7
+
8
+ interface SecretRow {
9
+ name: string
10
+ key: string
11
+ source: Source
12
+ }
13
+ interface EnvState {
14
+ vars: Record<string, string>
15
+ secrets: SecretRow[]
16
+ warnings: string[]
17
+ }
18
+
19
+ const SOURCE_LABEL: Record<NonNullable<Source>, string> = {
20
+ session: 'this session',
21
+ process: 'environment variable',
22
+ environment: 'environment file',
23
+ shared: 'shared file',
24
+ }
25
+
26
+ async function post(path: string, body: object) {
27
+ const r = await fetch(path, {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify(body),
31
+ })
32
+ if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
33
+ }
34
+
35
+ /**
36
+ * Header control for what the selected environment needs: its variables (`<env>.env`, committed) and
37
+ * secrets (`<env>.secrets.env`, gitignored, or this session only). The badge counts secrets still missing.
38
+ */
39
+ export function EnvironmentMenu() {
40
+ const [state, setState] = useState<EnvState>()
41
+ const [labels, setLabels] = useState<Record<string, string>>({})
42
+ const [open, setOpen] = useState(false)
43
+ const [scope, setScope] = useState<Scope>('environment')
44
+ const [draft, setDraft] = useState<Record<string, string>>({})
45
+ const [newVar, setNewVar] = useState({ name: '', value: '' })
46
+ const [error, setError] = useState<string>()
47
+ const name = env.value
48
+
49
+ const refresh = () =>
50
+ fetch(`/__specbook/environment?env=${encodeURIComponent(name)}`)
51
+ .then((r) => r.json() as Promise<EnvState>)
52
+ .then(setState, () => setState(undefined))
53
+
54
+ useEffect(() => {
55
+ if (isStatic) return
56
+ refresh()
57
+ setScope(name === 'mock' ? 'session' : 'environment')
58
+ setDraft({})
59
+ }, [name])
60
+
61
+ useEffect(() => {
62
+ engine.config(version.value).then((cfg) => {
63
+ const out: Record<string, string> = {}
64
+ for (const [profile, p] of Object.entries(cfg.auth ?? {}))
65
+ for (const m of JSON.stringify(p.apply).matchAll(/secrets\.([\w$-]+)/g))
66
+ out[m[1] as string] = p.title ?? profile
67
+ setLabels(out)
68
+ })
69
+ }, [])
70
+
71
+ const missing = useMemo(
72
+ () => (name === 'mock' ? 0 : (state?.secrets.filter((s) => !s.source).length ?? 0)),
73
+ [state, name],
74
+ )
75
+ if (isStatic || !state) return null
76
+ const hasAnything =
77
+ state.secrets.length > 0 || Object.keys(state.vars).length > 0 || name !== 'mock'
78
+ if (!hasAnything) return null
79
+
80
+ const run = async (fn: () => Promise<void>) => {
81
+ setError(undefined)
82
+ try {
83
+ await fn()
84
+ await refresh()
85
+ } catch (e) {
86
+ setError(e instanceof Error ? e.message : String(e))
87
+ }
88
+ }
89
+ const saveSecret = (secret: string, value: string) =>
90
+ run(async () => {
91
+ await post('/__specbook/secret', {
92
+ name: secret,
93
+ value,
94
+ scope,
95
+ env: scope === 'shared' ? undefined : name,
96
+ })
97
+ setDraft((d) => ({ ...d, [secret]: '' }))
98
+ })
99
+ const saveVar = (varName: string, value: string | null) =>
100
+ run(() => post('/__specbook/var', { env: name, name: varName, value }))
101
+
102
+ return (
103
+ <div class="envmenu">
104
+ <button
105
+ type="button"
106
+ class="btn envmenu-btn"
107
+ aria-expanded={open}
108
+ title={`Variables and credentials for ${name}`}
109
+ onClick={() => setOpen(!open)}
110
+ >
111
+ <span aria-hidden="true">⚙︎</span> Environment settings
112
+ {missing > 0 && <span class="badge-missing">{missing}</span>}
113
+ </button>
114
+
115
+ {open && (
116
+ <div class="envmenu-pop" role="dialog" aria-label={`Settings for ${name}`}>
117
+ <header class="envmenu-head">
118
+ <strong>{name}</strong>
119
+ <span class="muted small">
120
+ {name === 'mock' ? 'mocks need no credentials' : 'values apply to this environment'}
121
+ </span>
122
+ <span class="spacer" />
123
+ <button type="button" class="icon" aria-label="Close" onClick={() => setOpen(false)}>
124
+ ×
125
+ </button>
126
+ </header>
127
+
128
+ {state.warnings.map((w) => (
129
+ <p class="envmenu-warn">{w}</p>
130
+ ))}
131
+ {error && <p class="envmenu-warn">{error}</p>}
132
+
133
+ <section>
134
+ <h3>Variables</h3>
135
+ <p class="muted small">
136
+ Shared with the team in <code>.specbook/environments/{name}.env</code>.
137
+ </p>
138
+ {Object.entries(state.vars).map(([k, v]) => (
139
+ <div class="envrow">
140
+ <code>{k}</code>
141
+ <input
142
+ aria-label={`Value of ${k}`}
143
+ value={draft[`var:${k}`] ?? v}
144
+ onInput={(e) =>
145
+ setDraft({ ...draft, [`var:${k}`]: (e.target as HTMLInputElement).value })
146
+ }
147
+ onBlur={() => {
148
+ const next = draft[`var:${k}`]
149
+ if (next !== undefined && next !== v) saveVar(k, next)
150
+ }}
151
+ onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
152
+ />
153
+ <button
154
+ type="button"
155
+ class="icon"
156
+ aria-label={`Remove ${k}`}
157
+ onClick={() => saveVar(k, null)}
158
+ >
159
+ ×
160
+ </button>
161
+ </div>
162
+ ))}
163
+ <form
164
+ class="envrow"
165
+ onSubmit={(e) => {
166
+ e.preventDefault()
167
+ if (!newVar.name) return
168
+ saveVar(newVar.name, newVar.value).then(() => setNewVar({ name: '', value: '' }))
169
+ }}
170
+ >
171
+ <input
172
+ placeholder="name"
173
+ aria-label="New variable name"
174
+ value={newVar.name}
175
+ onInput={(e) =>
176
+ setNewVar({ ...newVar, name: (e.target as HTMLInputElement).value })
177
+ }
178
+ />
179
+ <input
180
+ placeholder="value"
181
+ aria-label="New variable value"
182
+ value={newVar.value}
183
+ onInput={(e) =>
184
+ setNewVar({ ...newVar, value: (e.target as HTMLInputElement).value })
185
+ }
186
+ />
187
+ <button type="submit" class="btn" disabled={!newVar.name}>
188
+ Add
189
+ </button>
190
+ </form>
191
+ </section>
192
+
193
+ <section>
194
+ <h3>Secrets</h3>
195
+ {state.secrets.length === 0 ? (
196
+ <p class="muted small">
197
+ None declared. Add <code>secrets: {'{ NAME: {} }'}</code> to the config.
198
+ </p>
199
+ ) : (
200
+ <>
201
+ <label class="envrow scope">
202
+ <span class="muted small">Save to</span>
203
+ <select
204
+ aria-label="Where to save secrets"
205
+ value={scope}
206
+ onChange={(e) => setScope((e.target as HTMLSelectElement).value as Scope)}
207
+ >
208
+ <option value="environment">{name}.secrets.env (remembered here)</option>
209
+ <option value="session">this session only</option>
210
+ <option value="shared">all environments (secrets.local.env)</option>
211
+ </select>
212
+ </label>
213
+ {state.secrets.map((s) => (
214
+ <form
215
+ class="secretrow"
216
+ onSubmit={(e) => {
217
+ e.preventDefault()
218
+ saveSecret(s.name, draft[s.name] ?? '')
219
+ }}
220
+ >
221
+ <div class="secretrow-head">
222
+ <span>{labels[s.name] ?? s.name}</span>
223
+ <code class="muted small">{s.key}</code>
224
+ <span class={`src ${s.source ? 'set' : 'missing'}`}>
225
+ {s.source ? SOURCE_LABEL[s.source] : 'missing'}
226
+ </span>
227
+ </div>
228
+ <div class="envrow">
229
+ <input
230
+ type="password"
231
+ autocomplete="off"
232
+ aria-label={`Value of ${s.name}`}
233
+ placeholder={s.source ? '•••••• (type to replace)' : 'paste a value'}
234
+ value={draft[s.name] ?? ''}
235
+ onInput={(e) =>
236
+ setDraft({ ...draft, [s.name]: (e.target as HTMLInputElement).value })
237
+ }
238
+ />
239
+ <button type="submit" class="btn" disabled={!draft[s.name]}>
240
+ Save
241
+ </button>
242
+ {s.source && s.source !== 'process' && (
243
+ <button
244
+ type="button"
245
+ class="link"
246
+ onClick={() => saveSecret(s.name, '')}
247
+ title="Remove the value from the selected place"
248
+ >
249
+ Clear
250
+ </button>
251
+ )}
252
+ </div>
253
+ </form>
254
+ ))}
255
+ </>
256
+ )}
257
+ </section>
258
+ </div>
259
+ )}
260
+ </div>
261
+ )
262
+ }