@specbook/ui 0.1.0 → 0.3.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 (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
@@ -7,10 +7,10 @@
7
7
  <title>Specbook</title>
8
8
  <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
9
9
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
10
- <script type="module" crossorigin src="./assets/index-DnYdlDXQ.js"></script>
11
- <link rel="modulepreload" crossorigin href="./assets/panel-CoQ6iQpX.js">
12
- <link rel="modulepreload" crossorigin href="./assets/mount-B-qFOcWp.js">
13
- <link rel="stylesheet" crossorigin href="./assets/panel-Coi9H1nv.css">
10
+ <script type="module" crossorigin src="./assets/index-BBlFcQUE.js"></script>
11
+ <link rel="modulepreload" crossorigin href="./assets/panel-9eYU7Gz6.js">
12
+ <link rel="modulepreload" crossorigin href="./assets/mount-DfwluJy4.js">
13
+ <link rel="stylesheet" crossorigin href="./assets/panel-hNOojwiM.css">
14
14
  <link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
15
15
  </head>
16
16
  <body>
@@ -1 +1 @@
1
- import{r as e,t}from"./assets/mount-B-qFOcWp.js";function n(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl}}function r(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var i=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function a(){let a=n(),o=r(a.target);if(!a.spec)return i(o,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let s;try{s=new URL(a.spec,location.href).toString()}catch{return i(o,`Specbook: "${a.spec}" is not a valid address.`,!0)}i(o,`Loading the API…`);let c=new e;try{let e=await c.open({kind:`url`,url:s}).promise;o.textContent=``,o.removeAttribute(`style`),await t({result:e,worker:c,source:{kind:`url`,url:s},baseUrl:a.baseUrl,shareable:!1,setTitle:!1})}catch(e){i(o,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}a();
1
+ import{a as e,r as t,t as n}from"./assets/mount-DfwluJy4.js";function r(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl}}function i(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var a=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function o(){if(await e({stripWhenUnclaimed:!1}))return;let o=r(),s=i(o.target);if(!o.spec)return a(s,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let c;try{c=new URL(o.spec,location.href).toString()}catch{return a(s,`Specbook: "${o.spec}" is not a valid address.`,!0)}a(s,`Loading the API…`);let l=new t;try{let e=await l.open({kind:`url`,url:c}).promise;s.textContent=``,s.removeAttribute(`style`),await n({result:e,worker:l,source:{kind:`url`,url:c},baseUrl:o.baseUrl,shareable:!1,setTitle:!1})}catch(e){a(s,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}o();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/ui",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Specbook docs app, spec viewer and embeddable specbook.js.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -29,14 +29,14 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@preact/signals": "2.11.3",
32
- "@specbook/codegen": "0.1.0",
33
- "@specbook/core": "0.1.0",
32
+ "@specbook/codegen": "0.3.0",
33
+ "@specbook/core": "0.3.0",
34
34
  "preact": "11.0.1",
35
35
  "prism-code-editor": "^5.4.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@faker-js/faker": "9.3.0",
39
- "@specbook/vite": "0.1.0",
39
+ "@specbook/vite": "0.3.0",
40
40
  "@vitest/browser-playwright": "5.0.3",
41
41
  "playwright": "1.64.0",
42
42
  "typescript": "7.0.2",
@@ -57,7 +57,9 @@
57
57
  "build:viewer": "vite build --config vite.viewer.config.ts",
58
58
  "dev:viewer": "vite --config vite.viewer.config.ts",
59
59
  "test:viewer": "pnpm run build:viewer && vitest run --config vitest.viewer.config.ts",
60
+ "test:oauth": "pnpm --filter @specbook/cli build && pnpm run build:viewer && vitest run --config vitest.oauth.config.ts",
60
61
  "build:site": "pnpm run build:viewer && node scripts/assemble-site.mjs",
61
- "deploy:site": "pnpm run build:viewer && node scripts/assemble-site.mjs --previous https://ui.specbook.dev && npx --yes wrangler@4 pages deploy dist-site --project-name specbook-ui --branch main"
62
+ "deploy:site": "pnpm run build:viewer && node scripts/assemble-site.mjs --previous https://ui.specbook.dev && npx --yes wrangler@4 pages deploy dist-site --project-name specbook-ui --branch main",
63
+ "test:embed": "pnpm --filter @specbook/cli build && vitest run --config vitest.embed.config.ts"
62
64
  }
63
65
  }
package/src/app.tsx CHANGED
@@ -7,21 +7,40 @@ import { EnvBadge } from './components/EnvBadge'
7
7
  import { EnvironmentMenu } from './components/EnvironmentMenu'
8
8
  import { GitHubStar } from './components/GitHubStar'
9
9
  import { ListResizer, listWidth } from './components/ListResizer'
10
+ import { NewEnvironmentDialog } from './components/NewEnvironmentDialog'
10
11
  import { Palette } from './components/Palette'
11
12
  import { ShareMenu } from './components/ShareMenu'
12
13
  import { ThemeToggle } from './components/ThemeToggle'
13
14
  import type { SequenceInfo, Variant } from './engine'
14
15
  import { type Entry, go, pageOf, paletteOpen, parseHash, route, setDefaultService } from './nav'
15
- import { engine, registry } from './runtime'
16
- import { env, environmentRisk, environments, locale, seed, version } from './store'
16
+ import { engine, isMinimal, registry } from './runtime'
17
+ import {
18
+ env,
19
+ environmentNote,
20
+ environmentRisk,
21
+ environments,
22
+ locale,
23
+ newEnvironmentOpen,
24
+ seed,
25
+ version,
26
+ } from './store'
27
+
28
+ /** The picker's last entry: not an environment, a way to make one. */
29
+ const NEW_ENVIRONMENT = '\u0000new-environment'
17
30
 
18
31
  // the hosted viewer is Specbook itself, so its header carries our mark unless the API brings a logo
19
32
  const ownMark = globalThis.__SPECBOOK_VIEWER__?.source ? markUrl : undefined
20
33
 
21
- // one API: its links leave the service out
22
- {
23
- const names = Object.keys(registry.manifests)
24
- setDefaultService(names.length === 1 ? names[0] : undefined)
34
+ /**
35
+ * One API: its links leave the service out. The services' lists are fetched before the app first renders, not
36
+ * before its code loads, so this is read as the app first renders and not as the module loads. It also reads the
37
+ * route again from the address, so it runs once: on every render it would throw away the route's state.
38
+ */
39
+ function useDefaultService() {
40
+ useState(() => {
41
+ const names = Object.keys(registry.manifests)
42
+ setDefaultService(names.length === 1 ? names[0] : undefined)
43
+ })
25
44
  }
26
45
 
27
46
  const fakerLocales = signal<string[]>([])
@@ -39,6 +58,7 @@ function usePanelHost() {
39
58
  }
40
59
 
41
60
  export function App() {
61
+ useDefaultService()
42
62
  const [variants, setVariants] = useState<Record<string, Variant[]>>({})
43
63
  const [sequences, setSequences] = useState<SequenceInfo[] | undefined>(undefined)
44
64
  const [menuOpen, setMenuOpen] = useState(false)
@@ -92,6 +112,9 @@ export function App() {
92
112
  engine.environmentRisks(version.value).then((r) => {
93
113
  environmentRisk.value = r
94
114
  })
115
+ engine.environmentNotes(version.value).then((n) => {
116
+ environmentNote.value = n
117
+ })
95
118
  return () => removeEventListener('hashchange', onHash)
96
119
  }, [])
97
120
 
@@ -131,6 +154,20 @@ export function App() {
131
154
  for (const m of Object.values(registry.manifests)) {
132
155
  for (const o of m.operations) {
133
156
  const key = `${m.service}/${o.id}`
157
+ // each saved request is listed on its own too, and opens its operation on that request
158
+ for (const v of variants[key] ?? [])
159
+ out.push({
160
+ kind: 'req',
161
+ route: { kind: 'op', key, play: `saved:${v.file}` },
162
+ title: v.name,
163
+ group: 'request',
164
+ service: m.service,
165
+ tag: o.tags[0] ?? 'default',
166
+ method: o.method,
167
+ path: o.path,
168
+ tags: v.def.input.tags,
169
+ hay: `${o.id} ${o.method} ${o.path} ${(v.def.input.tags ?? []).join(' ')} ${v.file}`,
170
+ })
134
171
  const saved = (variants[key] ?? []).map((v) => v.name).join(' ')
135
172
  out.push({
136
173
  kind: 'op',
@@ -183,14 +220,14 @@ export function App() {
183
220
  if (r.kind !== 'op' && r.kind !== 'seq') return {}
184
221
  const active = entries.find(
185
222
  (e) =>
186
- (e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) ||
187
- (e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id),
223
+ (e.kind === 'op' && r.kind === 'op' && e.route.kind === 'op' && e.route.key === r.key) ||
224
+ (e.kind === 'seq' && r.kind === 'seq' && e.route.kind === 'seq' && e.route.id === r.id),
188
225
  )
189
226
  if (!active) return {}
190
227
  const sibs = entries.filter((e) =>
191
228
  active.kind === 'seq'
192
229
  ? e.kind === 'seq'
193
- : e.service === active.service && e.tag === active.tag,
230
+ : e.kind === 'op' && e.service === active.service && e.tag === active.tag,
194
231
  )
195
232
  const at = sibs.indexOf(active)
196
233
  const to = (e?: Entry) => (e ? () => go({ ...e.route, play: '' } as typeof r) : undefined)
@@ -232,7 +269,7 @@ export function App() {
232
269
  <span class="brand-name">{brand.value.name ?? 'Specbook'}</span>
233
270
  </button>
234
271
  <button type="button" class="palette-btn" onClick={() => (paletteOpen.value = true)}>
235
- <span>Jump to an operation or flow…</span>
272
+ <span>{isMinimal ? 'Jump to a request or flow…' : 'Jump to an operation or flow…'}</span>
236
273
  <kbd>⌘K</kbd>
237
274
  </button>
238
275
  <span class="spacer" />
@@ -303,11 +340,23 @@ export function App() {
303
340
  Environment
304
341
  <select
305
342
  value={env.value}
306
- onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
343
+ onChange={(e) => {
344
+ const picked = (e.target as HTMLSelectElement).value
345
+ if (picked === NEW_ENVIRONMENT) {
346
+ // not an environment: open the dialog and leave the selection alone
347
+ ;(e.target as HTMLSelectElement).value = env.value
348
+ newEnvironmentOpen.value = true
349
+ } else env.value = picked
350
+ }}
307
351
  >
308
352
  {environments.value.map((e) => (
309
- <option value={e}>{e}</option>
353
+ <option value={e} title={environmentNote.value[e]}>
354
+ {e}
355
+ </option>
310
356
  ))}
357
+ {engine.canCreateEnvironment() && (
358
+ <option value={NEW_ENVIRONMENT}>New environment…</option>
359
+ )}
311
360
  </select>
312
361
  </label>
313
362
  <GitHubStar />
@@ -321,10 +370,19 @@ export function App() {
321
370
  </nav>
322
371
  <ListResizer />
323
372
  <main class="stage">
324
- {(r.kind === 'home' || r.kind === 'browse') && <Overview services={services} />}
373
+ {(r.kind === 'home' || r.kind === 'browse') && (
374
+ <Overview
375
+ services={services}
376
+ counts={{
377
+ flows: entries.filter((e) => e.kind === 'seq').length,
378
+ requests: entries.filter((e) => e.kind === 'req').length,
379
+ }}
380
+ />
381
+ )}
325
382
  </main>
326
383
  </div>
327
384
  <Palette entries={entries} />
385
+ <NewEnvironmentDialog />
328
386
  {panel && r.kind === 'op' && opInfo && (
329
387
  <panel.Host
330
388
  kind="op"
package/src/branding.ts CHANGED
@@ -53,14 +53,22 @@ const tokens = (accent: string) =>
53
53
  * The accent as CSS: unlayered, so it wins over the default tokens. A separate dark value applies
54
54
  * under `prefers-color-scheme: dark` (unless the light theme is forced) and `data-theme="dark"`.
55
55
  */
56
- export function brandCss(b: Pick<BrandingConfig, 'accent' | 'accentDark'>): string {
56
+ export function brandCss(
57
+ b: Pick<BrandingConfig, 'accent' | 'accentDark'>,
58
+ scope: 'root' | 'host' = 'root',
59
+ ): string {
57
60
  if (!b.accent && !b.accentDark) return ''
58
- const light = b.accent ? `:root{${tokens(b.accent)}}` : ''
61
+ // in a shadow root the tokens sit on the host element, which :root does not match
62
+ const base = scope === 'host' ? ':host' : ':root'
63
+ const notLight =
64
+ scope === 'host' ? ':host(:not([data-theme="light"]))' : ':root:not([data-theme="light"])'
65
+ const dark = scope === 'host' ? ':host([data-theme="dark"])' : ':root[data-theme="dark"]'
66
+ const light = b.accent ? `${base}{${tokens(b.accent)}}` : ''
59
67
  if (!b.accentDark) return light
60
68
  return (
61
69
  `${light}` +
62
- `@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){${tokens(b.accentDark)}}}` +
63
- `:root[data-theme="dark"]{${tokens(b.accentDark)}}`
70
+ `@media (prefers-color-scheme: dark){${notLight}{${tokens(b.accentDark)}}}` +
71
+ `${dark}{${tokens(b.accentDark)}}`
64
72
  )
65
73
  }
66
74
 
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
2
  import { brand } from '../branding'
3
3
  import { catalogFilter, catalogService, type Entry, go, route, search } from '../nav'
4
4
  import { Lock } from '../panel/icons'
5
+ import { isMinimal } from '../runtime'
5
6
  import { ModeHint } from './ModeHint'
6
7
  import { PoweredBy } from './PoweredBy'
7
8
 
@@ -19,10 +20,12 @@ const DEFAULT_FLOW_GROUP = 'Default'
19
20
 
20
21
  function isActive(e: Entry): boolean {
21
22
  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
- )
23
+ if (e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) {
24
+ // a saved request is active on its own link; the operation row stays quiet while one of its requests is open
25
+ const onSaved = (r.play ?? '').startsWith('saved:')
26
+ return e.kind === 'req' ? r.play === e.route.play : !onSaved
27
+ }
28
+ return e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id
26
29
  }
27
30
 
28
31
  function OpRow({ e }: { e: Entry }) {
@@ -54,6 +57,11 @@ function useCatalog(entries: Entry[], services: ServiceInfo[]) {
54
57
  const svc = single ? (services[0]?.id ?? '') : catalogService.value
55
58
  const ops = useMemo(() => entries.filter((e) => e.kind === 'op'), [entries])
56
59
  const flows = useMemo(() => entries.filter((e) => e.kind === 'seq'), [entries])
60
+ const reqs = useMemo(() => entries.filter((e) => e.kind === 'req'), [entries])
61
+ const shownReqs = useMemo(() => {
62
+ const scoped = svc ? reqs.filter((e) => e.service === svc) : reqs
63
+ return q ? search(scoped, q, 200) : scoped
64
+ }, [reqs, svc, q])
57
65
  const shownOps = useMemo(() => {
58
66
  const scoped = svc ? ops.filter((e) => e.service === svc) : ops
59
67
  return q ? search(scoped, q, 500) : scoped
@@ -62,7 +70,7 @@ function useCatalog(entries: Entry[], services: ServiceInfo[]) {
62
70
  const scoped = svc && !single ? flows.filter((e) => e.tags?.includes(svc)) : flows
63
71
  return q ? search(scoped, q, 100) : scoped
64
72
  }, [flows, svc, q, single])
65
- return { q, single, svc, shownOps, shownFlows }
73
+ return { q, single, svc, shownOps, shownFlows, shownReqs }
66
74
  }
67
75
 
68
76
  /** The left column: pills, filter and grouped rows. It never changes width, so opening the panel moves nothing. */
@@ -71,7 +79,14 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
71
79
  const list = useRef<HTMLDivElement>(null)
72
80
  // what the user chose per group; groups they haven't touched follow the default
73
81
  const [chosen, setChosen] = useState<Map<string, boolean>>(new Map())
74
- const { q, single, svc, shownOps, shownFlows } = useCatalog(entries, services)
82
+ const { q, single, svc, shownOps, shownFlows, shownReqs } = useCatalog(entries, services)
83
+ // the flows and the requests are each a section that folds, open until the person closes it
84
+ const sectionOpen = (name: string) => chosen.get(`section:${name}`) ?? true
85
+ const toggleSection = (name: string) => {
86
+ const n = new Map(chosen)
87
+ n.set(`section:${name}`, !sectionOpen(name))
88
+ setChosen(n)
89
+ }
75
90
 
76
91
  useEffect(() => {
77
92
  const key = (e: KeyboardEvent) => {
@@ -165,7 +180,13 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
165
180
  ref={filter}
166
181
  type="text"
167
182
  class="cat-filter"
168
- placeholder={svc ? `Filter ${svc}` : 'Filter operations and flows'}
183
+ placeholder={
184
+ svc
185
+ ? `Filter ${svc}`
186
+ : isMinimal
187
+ ? 'Filter requests and flows'
188
+ : 'Filter operations and flows'
189
+ }
169
190
  aria-label="Filter"
170
191
  value={catalogFilter.value}
171
192
  onInput={(e) => (catalogFilter.value = (e.target as HTMLInputElement).value)}
@@ -185,48 +206,89 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
185
206
 
186
207
  {flowGroups.length > 0 && (
187
208
  <section class="flows" aria-label="Flows">
188
- <div class="flows-title">
209
+ <button
210
+ type="button"
211
+ class="flows-title"
212
+ aria-expanded={sectionOpen('flows')}
213
+ onClick={() => toggleSection('flows')}
214
+ >
215
+ <span class="caret">{sectionOpen('flows') ? '▾' : '▸'}</span>
189
216
  <span class="method flow">FLOW</span>
190
- <span class="muted small">Multi-step sequences you can run end to end</span>
217
+ <span class="muted small">Multi-step flows you can run end to end</span>
191
218
  <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) => (
219
+ </button>
220
+ {sectionOpen('flows') &&
221
+ flowGroups.map(([name, rows]) => {
222
+ const key = `flows:${name}`
223
+ const solo = flowGroups.length === 1
224
+ const expanded = solo || (chosen.get(key) ?? true)
225
+ return (
226
+ <div class="flow-group" key={key}>
227
+ {!solo && (
216
228
  <button
217
229
  type="button"
218
- class={`op-row flow-row ${isActive(e) ? 'active' : ''}`}
219
- onClick={() => go(e.route)}
230
+ class="group-head flow-head"
231
+ aria-expanded={expanded}
232
+ onClick={() => {
233
+ const n = new Map(chosen)
234
+ n.set(key, !expanded)
235
+ setChosen(n)
236
+ }}
220
237
  >
221
- <span class="flow-play" aria-hidden="true">
222
- ▶
223
- </span>
224
- <span class="op-title">{e.title}</span>
238
+ <span class="caret">{expanded ? '▾' : '▸'}</span> {name}{' '}
239
+ <span class="count">{rows.length}</span>
225
240
  </button>
241
+ )}
242
+ {expanded &&
243
+ rows.map((e) => (
244
+ <button
245
+ type="button"
246
+ class={`op-row flow-row ${isActive(e) ? 'active' : ''}`}
247
+ onClick={() => go(e.route)}
248
+ >
249
+ <span class="flow-play" aria-hidden="true">
250
+ ▶
251
+ </span>
252
+ <span class="op-title">{e.title}</span>
253
+ </button>
254
+ ))}
255
+ </div>
256
+ )
257
+ })}
258
+ </section>
259
+ )}
260
+
261
+ {shownReqs.length > 0 && (
262
+ <section class="flows requests" aria-label="Requests">
263
+ <button
264
+ type="button"
265
+ class="flows-title"
266
+ aria-expanded={sectionOpen('requests')}
267
+ onClick={() => toggleSection('requests')}
268
+ >
269
+ <span class="caret">{sectionOpen('requests') ? '▾' : '▸'}</span>
270
+ <span class="method req">REQ</span>
271
+ <span class="muted small">Requests you can run and check</span>
272
+ <span class="count">{shownReqs.length}</span>
273
+ </button>
274
+ {sectionOpen('requests') &&
275
+ shownReqs.map((e) => (
276
+ <button
277
+ type="button"
278
+ class={`op-row req-row ${isActive(e) ? 'active' : ''}`}
279
+ onClick={() => go(e.route)}
280
+ title={`${e.method} ${e.path}`}
281
+ >
282
+ <span class={`method ${e.method}`}>{e.method}</span>
283
+ <span class="op-title">
284
+ {e.title}
285
+ {(e.tags ?? []).map((t) => (
286
+ <span class="chip req-tag">{t}</span>
226
287
  ))}
227
- </div>
228
- )
229
- })}
288
+ </span>
289
+ <span class="op-path">{e.path}</span>
290
+ </button>
291
+ ))}
230
292
  </section>
231
293
  )}
232
294
 
@@ -247,7 +309,7 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
247
309
  </div>
248
310
  )
249
311
  })}
250
- {groups.length === 0 && flowGroups.length === 0 && (
312
+ {groups.length === 0 && flowGroups.length === 0 && shownReqs.length === 0 && (
251
313
  <p class="muted cat-empty">
252
314
  {q ? `Nothing matches “${q}”.` : 'No operations.'}{' '}
253
315
  {q && (
@@ -262,7 +324,13 @@ export function CatalogList({ entries, services }: { entries: Entry[]; services:
262
324
  }
263
325
 
264
326
  /** The stage when nothing is open: the API's identity, since the list is the content. */
265
- export function Overview({ services }: { services: ServiceInfo[] }) {
327
+ export function Overview({
328
+ services,
329
+ counts,
330
+ }: {
331
+ services: ServiceInfo[]
332
+ counts?: { flows: number; requests: number }
333
+ }) {
266
334
  const b = brand.value
267
335
  // wait for the project's branding: showing the fallback first and swapping it makes the page jump
268
336
  if (!b.ready) return <div class="overview" aria-busy="true" />
@@ -274,11 +342,16 @@ export function Overview({ services }: { services: ServiceInfo[] }) {
274
342
  (single
275
343
  ? (single.title ?? single.id)
276
344
  : `${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
345
+ const worth = (n: number, one: string, many: string) =>
346
+ `${n.toLocaleString()} ${n === 1 ? one : many}`
347
+ const meta =
348
+ isMinimal && counts
349
+ ? `${worth(counts.flows, 'flow', 'flows')} · ${worth(counts.requests, 'request', 'requests')}`
350
+ : single
351
+ ? `${single.ops} ${single.ops === 1 ? 'operation' : 'operations'}${single.version ? ` · v${single.version}` : ''}`
352
+ : b.name || b.title
353
+ ? `${services.length} APIs · ${total.toLocaleString()} operations`
354
+ : undefined
282
355
  return (
283
356
  <div class="overview">
284
357
  {b.logo && (
@@ -297,14 +370,18 @@ export function Overview({ services }: { services: ServiceInfo[] }) {
297
370
  <h1>{title}</h1>
298
371
  {b.tagline && <p class="tagline">{b.tagline}</p>}
299
372
  {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>
373
+ <p class="overview-hint">
374
+ {isMinimal
375
+ ? 'Choose a request or flow on the left to run it.'
376
+ : 'Choose an operation or flow on the left to read it and try it.'}
377
+ </p>
301
378
  <ModeHint />
302
379
  <button
303
380
  type="button"
304
381
  class="btn primary overview-browse"
305
382
  onClick={() => go({ kind: 'browse' })}
306
383
  >
307
- Browse operations and flows
384
+ {isMinimal ? 'Browse requests and flows' : 'Browse operations and flows'}
308
385
  </button>
309
386
  {b.links.length > 0 && (
310
387
  <p class="overview-links">
@@ -1,4 +1,14 @@
1
- import { env, environmentRisk, environments } from '../store'
1
+ import { engine } from '../runtime'
2
+ import {
3
+ env,
4
+ environmentNote,
5
+ environmentRisk,
6
+ environments,
7
+ features,
8
+ newEnvironmentOpen,
9
+ } from '../store'
10
+
11
+ const NEW_ENVIRONMENT = '\u0000new-environment'
2
12
 
3
13
  /**
4
14
  * The environment a request will run in, and a way to change it. On a phone the header with the
@@ -19,7 +29,8 @@ export function EnvBadge() {
19
29
  : risk === 'production' || (risk !== 'sandbox' && /prod/i.test(name))
20
30
  ? 'prod'
21
31
  : 'live'
22
- const switchable = names.length > 1
32
+ const canCreate = features.value.createEnvironment && engine.canCreateEnvironment()
33
+ const switchable = names.length > 1 || canCreate
23
34
  return (
24
35
  <span
25
36
  class={`env-badge ${kind} ${switchable ? 'switch' : ''}`}
@@ -34,11 +45,20 @@ export function EnvBadge() {
34
45
  <select
35
46
  aria-label="Environment"
36
47
  value={name}
37
- onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
48
+ onChange={(e) => {
49
+ const picked = (e.target as HTMLSelectElement).value
50
+ if (picked === NEW_ENVIRONMENT) {
51
+ ;(e.target as HTMLSelectElement).value = env.value
52
+ newEnvironmentOpen.value = true
53
+ } else env.value = picked
54
+ }}
38
55
  >
39
56
  {names.map((n) => (
40
- <option value={n}>{n}</option>
57
+ <option value={n} title={environmentNote.value[n]}>
58
+ {n}
59
+ </option>
41
60
  ))}
61
+ {canCreate && <option value={NEW_ENVIRONMENT}>New environment…</option>}
42
62
  </select>
43
63
  )}
44
64
  </span>