@specbook/ui 0.2.0 → 0.4.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 (71) hide show
  1. package/dist-viewer/assets/Host-DsO9W5X8.js +41 -0
  2. package/dist-viewer/assets/index-D59fkVA9.js +1 -0
  3. package/dist-viewer/assets/main-i06Egg5h.js +14 -0
  4. package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-CLHBtIzR.js} +2 -2
  5. package/dist-viewer/assets/panel-l1xvY4kI.css +1 -0
  6. package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-nV09I7Wr.js} +1 -1
  7. package/dist-viewer/assets/registry-b_jzfqy0.js +2 -0
  8. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  9. package/dist-viewer/index.html +4 -4
  10. package/dist-viewer/specbook.js +1 -1
  11. package/package.json +6 -5
  12. package/src/app.tsx +67 -12
  13. package/src/branding.ts +12 -4
  14. package/src/components/Catalog.tsx +128 -51
  15. package/src/components/EnvBadge.tsx +21 -3
  16. package/src/components/EnvironmentMenu.tsx +237 -127
  17. package/src/components/Fields.tsx +18 -1
  18. package/src/components/NewEnvironmentDialog.tsx +118 -0
  19. package/src/components/Palette.tsx +7 -2
  20. package/src/custom-environments.ts +63 -0
  21. package/src/embed/panel.tsx +286 -44
  22. package/src/embed/styles.ts +2 -0
  23. package/src/embed/target.ts +82 -0
  24. package/src/embed/theme.ts +75 -0
  25. package/src/embed.ts +44 -15
  26. package/src/engine.ts +274 -32
  27. package/src/environment-list.ts +16 -0
  28. package/src/main.tsx +7 -2
  29. package/src/nav.ts +12 -1
  30. package/src/oauth.ts +9 -8
  31. package/src/panel/Body.tsx +27 -4
  32. package/src/panel/Checks.tsx +79 -0
  33. package/src/panel/CodeSample.tsx +16 -9
  34. package/src/panel/Combo.tsx +52 -13
  35. package/src/panel/ControlsGrid.tsx +54 -9
  36. package/src/panel/ErrorBoundary.tsx +36 -0
  37. package/src/panel/Host.tsx +14 -2
  38. package/src/panel/OAuthSignIn.tsx +6 -2
  39. package/src/panel/OperationDocs.tsx +1 -1
  40. package/src/panel/Panel.tsx +43 -10
  41. package/src/panel/ParamGrid.tsx +15 -8
  42. package/src/panel/RequestPanel.tsx +102 -39
  43. package/src/panel/RequestView.tsx +2 -1
  44. package/src/panel/ResponseView.tsx +25 -14
  45. package/src/panel/SecretsBar.tsx +10 -9
  46. package/src/panel/SequenceDocs.tsx +7 -6
  47. package/src/panel/SequencePanel.tsx +124 -32
  48. package/src/panel/announce.ts +17 -0
  49. package/src/panel/icons.tsx +9 -0
  50. package/src/panel/short.ts +24 -0
  51. package/src/routing.ts +0 -9
  52. package/src/runtime.ts +19 -1
  53. package/src/secrets.ts +4 -2
  54. package/src/storage.ts +58 -0
  55. package/src/store.ts +8 -0
  56. package/src/styles/app.css +82 -6
  57. package/src/styles/embed.css +116 -0
  58. package/src/styles/panel.css +235 -4
  59. package/src/viewer/embed.ts +9 -2
  60. package/src/viewer/manifests.ts +2 -0
  61. package/src/viewer/mount.ts +2 -0
  62. package/src/viewer/protocol.ts +6 -0
  63. package/src/viewer/registry.ts +48 -10
  64. package/src/viewer/worker.ts +9 -1
  65. package/src/virtual.d.ts +8 -0
  66. package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
  67. package/dist-viewer/assets/index-ez9c20v3.js +0 -1
  68. package/dist-viewer/assets/main-38QGGpNT.js +0 -14
  69. package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
  70. package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
  71. package/dist-viewer/assets/worker-ClGb0tE1.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-ez9c20v3.js"></script>
11
- <link rel="modulepreload" crossorigin href="./assets/panel-C8Ve3LeS.js">
12
- <link rel="modulepreload" crossorigin href="./assets/mount-CGL2tOpN.js">
13
- <link rel="stylesheet" crossorigin href="./assets/panel-C-vBZrep.css">
10
+ <script type="module" crossorigin src="./assets/index-D59fkVA9.js"></script>
11
+ <link rel="modulepreload" crossorigin href="./assets/panel-nV09I7Wr.js">
12
+ <link rel="modulepreload" crossorigin href="./assets/mount-CLHBtIzR.js">
13
+ <link rel="stylesheet" crossorigin href="./assets/panel-l1xvY4kI.css">
14
14
  <link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
15
15
  </head>
16
16
  <body>
@@ -1 +1 @@
1
- import{a as e,r as t,t as n}from"./assets/mount-CGL2tOpN.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();
1
+ import{a as e,r as t,t as n}from"./assets/mount-CLHBtIzR.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,defaultEnv:t?.dataset.defaultEnv}}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,defaultEnv:typeof o.defaultEnv==`string`?o.defaultEnv:void 0,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.2.0",
3
+ "version": "0.4.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.2.0",
33
- "@specbook/core": "0.2.0",
32
+ "@specbook/codegen": "0.4.0",
33
+ "@specbook/core": "0.4.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.2.0",
39
+ "@specbook/vite": "0.4.0",
40
40
  "@vitest/browser-playwright": "5.0.3",
41
41
  "playwright": "1.64.0",
42
42
  "typescript": "7.0.2",
@@ -59,6 +59,7 @@
59
59
  "test:viewer": "pnpm run build:viewer && vitest run --config vitest.viewer.config.ts",
60
60
  "test:oauth": "pnpm --filter @specbook/cli build && pnpm run build:viewer && vitest run --config vitest.oauth.config.ts",
61
61
  "build:site": "pnpm run build:viewer && node scripts/assemble-site.mjs",
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"
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"
63
64
  }
64
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, environmentNote, 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)
@@ -88,6 +108,8 @@ export function App() {
88
108
  .finally(loadRest)
89
109
  engine.environments(version.value).then((e) => {
90
110
  environments.value = e
111
+ const first = globalThis.__SPECBOOK_VIEWER__?.defaultEnv
112
+ if (first && e.includes(first)) env.value = first
91
113
  })
92
114
  engine.environmentRisks(version.value).then((r) => {
93
115
  environmentRisk.value = r
@@ -134,6 +156,20 @@ export function App() {
134
156
  for (const m of Object.values(registry.manifests)) {
135
157
  for (const o of m.operations) {
136
158
  const key = `${m.service}/${o.id}`
159
+ // each saved request is listed on its own too, and opens its operation on that request
160
+ for (const v of variants[key] ?? [])
161
+ out.push({
162
+ kind: 'req',
163
+ route: { kind: 'op', key, play: `saved:${v.file}` },
164
+ title: v.name,
165
+ group: 'request',
166
+ service: m.service,
167
+ tag: o.tags[0] ?? 'default',
168
+ method: o.method,
169
+ path: o.path,
170
+ tags: v.def.input.tags,
171
+ hay: `${o.id} ${o.method} ${o.path} ${(v.def.input.tags ?? []).join(' ')} ${v.file}`,
172
+ })
137
173
  const saved = (variants[key] ?? []).map((v) => v.name).join(' ')
138
174
  out.push({
139
175
  kind: 'op',
@@ -186,14 +222,14 @@ export function App() {
186
222
  if (r.kind !== 'op' && r.kind !== 'seq') return {}
187
223
  const active = entries.find(
188
224
  (e) =>
189
- (e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) ||
190
- (e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id),
225
+ (e.kind === 'op' && r.kind === 'op' && e.route.kind === 'op' && e.route.key === r.key) ||
226
+ (e.kind === 'seq' && r.kind === 'seq' && e.route.kind === 'seq' && e.route.id === r.id),
191
227
  )
192
228
  if (!active) return {}
193
229
  const sibs = entries.filter((e) =>
194
230
  active.kind === 'seq'
195
231
  ? e.kind === 'seq'
196
- : e.service === active.service && e.tag === active.tag,
232
+ : e.kind === 'op' && e.service === active.service && e.tag === active.tag,
197
233
  )
198
234
  const at = sibs.indexOf(active)
199
235
  const to = (e?: Entry) => (e ? () => go({ ...e.route, play: '' } as typeof r) : undefined)
@@ -235,7 +271,7 @@ export function App() {
235
271
  <span class="brand-name">{brand.value.name ?? 'Specbook'}</span>
236
272
  </button>
237
273
  <button type="button" class="palette-btn" onClick={() => (paletteOpen.value = true)}>
238
- <span>Jump to an operation or flow…</span>
274
+ <span>{isMinimal ? 'Jump to a request or flow…' : 'Jump to an operation or flow…'}</span>
239
275
  <kbd>⌘K</kbd>
240
276
  </button>
241
277
  <span class="spacer" />
@@ -306,13 +342,23 @@ export function App() {
306
342
  Environment
307
343
  <select
308
344
  value={env.value}
309
- onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
345
+ onChange={(e) => {
346
+ const picked = (e.target as HTMLSelectElement).value
347
+ if (picked === NEW_ENVIRONMENT) {
348
+ // not an environment: open the dialog and leave the selection alone
349
+ ;(e.target as HTMLSelectElement).value = env.value
350
+ newEnvironmentOpen.value = true
351
+ } else env.value = picked
352
+ }}
310
353
  >
311
354
  {environments.value.map((e) => (
312
355
  <option value={e} title={environmentNote.value[e]}>
313
356
  {e}
314
357
  </option>
315
358
  ))}
359
+ {engine.canCreateEnvironment() && (
360
+ <option value={NEW_ENVIRONMENT}>New environment…</option>
361
+ )}
316
362
  </select>
317
363
  </label>
318
364
  <GitHubStar />
@@ -326,10 +372,19 @@ export function App() {
326
372
  </nav>
327
373
  <ListResizer />
328
374
  <main class="stage">
329
- {(r.kind === 'home' || r.kind === 'browse') && <Overview services={services} />}
375
+ {(r.kind === 'home' || r.kind === 'browse') && (
376
+ <Overview
377
+ services={services}
378
+ counts={{
379
+ flows: entries.filter((e) => e.kind === 'seq').length,
380
+ requests: entries.filter((e) => e.kind === 'req').length,
381
+ }}
382
+ />
383
+ )}
330
384
  </main>
331
385
  </div>
332
386
  <Palette entries={entries} />
387
+ <NewEnvironmentDialog />
333
388
  {panel && r.kind === 'op' && opInfo && (
334
389
  <panel.Host
335
390
  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, environmentNote, 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,13 +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
57
  <option value={n} title={environmentNote.value[n]}>
41
58
  {n}
42
59
  </option>
43
60
  ))}
61
+ {canCreate && <option value={NEW_ENVIRONMENT}>New environment…</option>}
44
62
  </select>
45
63
  )}
46
64
  </span>