@specbook/ui 0.2.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 (68) hide show
  1. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  2. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  3. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  4. package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
  5. package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
  6. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  7. package/dist-viewer/assets/registry-DIHX11uY.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 +65 -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/Panel.tsx +43 -10
  40. package/src/panel/ParamGrid.tsx +15 -8
  41. package/src/panel/RequestPanel.tsx +91 -36
  42. package/src/panel/RequestView.tsx +2 -1
  43. package/src/panel/ResponseView.tsx +25 -14
  44. package/src/panel/SecretsBar.tsx +10 -9
  45. package/src/panel/SequenceDocs.tsx +7 -6
  46. package/src/panel/SequencePanel.tsx +124 -32
  47. package/src/panel/announce.ts +17 -0
  48. package/src/panel/icons.tsx +9 -0
  49. package/src/panel/short.ts +24 -0
  50. package/src/routing.ts +0 -9
  51. package/src/runtime.ts +19 -1
  52. package/src/secrets.ts +4 -2
  53. package/src/storage.ts +58 -0
  54. package/src/store.ts +8 -0
  55. package/src/styles/app.css +69 -6
  56. package/src/styles/embed.css +116 -0
  57. package/src/styles/panel.css +233 -0
  58. package/src/viewer/manifests.ts +2 -0
  59. package/src/viewer/protocol.ts +6 -0
  60. package/src/viewer/registry.ts +46 -10
  61. package/src/viewer/worker.ts +9 -1
  62. package/src/virtual.d.ts +8 -0
  63. package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
  64. package/dist-viewer/assets/index-ez9c20v3.js +0 -1
  65. package/dist-viewer/assets/main-38QGGpNT.js +0 -14
  66. package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
  67. package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
  68. package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
package/src/oauth.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  tokenRequestFields,
9
9
  } from '@specbook/core'
10
10
  import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
11
+ import { kv, type Store } from './storage'
11
12
 
12
13
  /**
13
14
  * Page side of "Sign in": opens the provider in a popup, waits for the return leg
@@ -137,14 +138,14 @@ export async function signIn(
137
138
  const CLIENTS = 'specbook.oauth.clients.v1'
138
139
  const EXPIRY = 'specbook.oauth.expiry.v1'
139
140
 
140
- const readJson = (store: Storage, key: string): Record<string, any> => {
141
+ const readJson = (store: Store, key: string): Record<string, any> => {
141
142
  try {
142
143
  return JSON.parse(store.getItem(key) ?? '{}')
143
144
  } catch {
144
145
  return {}
145
146
  }
146
147
  }
147
- const writeJson = (store: Storage, key: string, value: unknown) => {
148
+ const writeJson = (store: Store, key: string, value: unknown) => {
148
149
  try {
149
150
  store.setItem(key, JSON.stringify(value))
150
151
  } catch {
@@ -154,7 +155,7 @@ const writeJson = (store: Storage, key: string, value: unknown) => {
154
155
 
155
156
  export const recallClientId = (authorizationUrl: string): string | undefined => {
156
157
  try {
157
- return readJson(localStorage, CLIENTS)[authorizationUrl]
158
+ return readJson(kv.local, CLIENTS)[authorizationUrl]
158
159
  } catch {
159
160
  return undefined
160
161
  }
@@ -162,8 +163,8 @@ export const recallClientId = (authorizationUrl: string): string | undefined =>
162
163
 
163
164
  export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
164
165
  try {
165
- writeJson(localStorage, CLIENTS, {
166
- ...readJson(localStorage, CLIENTS),
166
+ writeJson(kv.local, CLIENTS, {
167
+ ...readJson(kv.local, CLIENTS),
167
168
  [authorizationUrl]: clientId,
168
169
  })
169
170
  } catch {
@@ -176,10 +177,10 @@ const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
176
177
  /** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
177
178
  export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
178
179
  try {
179
- const t = readJson(sessionStorage, EXPIRY)
180
+ const t = readJson(kv.session, EXPIRY)
180
181
  if (expiresIn === undefined) delete t[expiryKey(origin, name)]
181
182
  else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
182
- writeJson(sessionStorage, EXPIRY, t)
183
+ writeJson(kv.session, EXPIRY, t)
183
184
  } catch {
184
185
  /* no storage */
185
186
  }
@@ -187,7 +188,7 @@ export const noteExpiry = (origin: string, name: string, expiresIn: number | und
187
188
 
188
189
  export const expiryOf = (origin: string, name: string): number | undefined => {
189
190
  try {
190
- return readJson(sessionStorage, EXPIRY)[expiryKey(origin, name)]
191
+ return readJson(kv.session, EXPIRY)[expiryKey(origin, name)]
191
192
  } catch {
192
193
  return undefined
193
194
  }
@@ -20,10 +20,20 @@ interface Props {
20
20
  files?: string[]
21
21
  /** the project's own notes for body fields, by `data.<path>` */
22
22
  notes?: Map<string, string>
23
+ /** shown, not edited */
24
+ readOnly?: boolean
23
25
  }
24
26
 
25
27
  /** Keeps the typed texts locally, so invalid variables JSON isn't rewritten while the user is typing. */
26
- function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
28
+ function GraphqlBody({
29
+ draft,
30
+ onChange,
31
+ readOnly,
32
+ }: {
33
+ draft: Draft
34
+ onChange: (d: Draft) => void
35
+ readOnly?: boolean
36
+ }) {
27
37
  const [g, setG] = useState(() => splitGraphql(draft.body) as Graphql)
28
38
  const emitted = useRef(draft.body)
29
39
  useEffect(() => {
@@ -46,6 +56,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
46
56
  <span class="chip">graphql</span>
47
57
  </div>
48
58
  <BodyEditor
59
+ readOnly={readOnly}
49
60
  label="GraphQL query"
50
61
  language="graphql"
51
62
  value={g.query}
@@ -56,6 +67,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
56
67
  <span class="chip">json</span>
57
68
  </div>
58
69
  <BodyEditor
70
+ readOnly={readOnly}
59
71
  label="GraphQL variables"
60
72
  language="json"
61
73
  value={g.variables}
@@ -67,7 +79,16 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
67
79
 
68
80
  const language = (e: Encoding) => (e === 'graphql' ? 'graphql' : e === 'text' ? 'text' : 'json')
69
81
 
70
- export function Body({ encoding, draft, onChange, schema, defs, files = [], notes }: Props) {
82
+ export function Body({
83
+ encoding,
84
+ draft,
85
+ onChange,
86
+ schema,
87
+ defs,
88
+ files = [],
89
+ notes,
90
+ readOnly,
91
+ }: Props) {
71
92
  const [line, setLine] = useState(0)
72
93
  const paths = useMemo(() => linePaths(draft.body), [draft.body])
73
94
  const documented = encoding === 'json' || encoding === 'form'
@@ -136,6 +157,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
136
157
  title={encoding === 'binary' ? 'File' : 'Body'}
137
158
  bare={!!viewToggle}
138
159
  rows={draft.fields}
160
+ readOnly={readOnly}
139
161
  onChange={set}
140
162
  documented={[...new Set(fileKeys)].map((name) => ({ name, file: true }))}
141
163
  docFor={(k) => withNote(fieldDoc(schema, defs, keyPath(k), ['data']), notes, 'data', k)}
@@ -153,7 +175,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
153
175
  }
154
176
 
155
177
  if (encoding === 'graphql' && splitGraphql(draft.body))
156
- return <GraphqlBody draft={draft} onChange={onChange} />
178
+ return <GraphqlBody draft={draft} onChange={onChange} readOnly={readOnly} />
157
179
 
158
180
  return (
159
181
  <section class="body" aria-label="Body">
@@ -161,7 +183,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
161
183
  <h3>Body</h3>
162
184
  <span class="chip">{encoding}</span>
163
185
  <span class="spacer" />
164
- {encoding !== 'text' && (
186
+ {encoding !== 'text' && !readOnly && (
165
187
  <button type="button" class="link" onClick={format}>
166
188
  Format
167
189
  </button>
@@ -169,6 +191,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
169
191
  {viewToggle}
170
192
  </div>
171
193
  <BodyEditor
194
+ readOnly={readOnly}
172
195
  label="Request body"
173
196
  language={language(encoding === 'form' ? 'json' : encoding)}
174
197
  value={draft.body}
@@ -0,0 +1,79 @@
1
+ import { type AssertionResult, type DiffLine, diffAssertion } from '@specbook/core'
2
+ import { registry } from '../runtime'
3
+
4
+ /** The project file an assertion failed in, as `file:line`, when its stack names one of the project's requests or flows. */
5
+ export function whereOf(a: AssertionResult): string | undefined {
6
+ const loc = a.location
7
+ if (!loc) return undefined
8
+ const files = [...registry.requests, ...registry.sequences].map((e) => e.file)
9
+ // the stack names a file by its address in the page; the longest project path it ends with is the file
10
+ const hit = files.filter((f) => loc.file.includes(f)).sort((a, b) => b.length - a.length)[0]
11
+ return hit ? `${hit}:${loc.line}` : undefined
12
+ }
13
+
14
+ const GUTTER = { expected: '−', received: '+', same: ' ', skip: ' ' } as const
15
+ const SPOKEN = { expected: 'expected', received: 'received', same: '', skip: '' } as const
16
+
17
+ function Diff({ lines }: { lines: DiffLine[] }) {
18
+ return (
19
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a block that scrolls sideways must be reachable by keyboard
20
+ <section class="diff" tabIndex={0} aria-label="Expected and received values">
21
+ {lines.map((l) => (
22
+ <div class={`diff-line diff-${l.kind}`}>
23
+ <span class="diff-gutter" aria-hidden="true">
24
+ {GUTTER[l.kind]}
25
+ </span>
26
+ {SPOKEN[l.kind] && <span class="visually-hidden">{SPOKEN[l.kind]}: </span>}
27
+ <span class="diff-text">{l.text}</span>
28
+ </div>
29
+ ))}
30
+ </section>
31
+ )
32
+ }
33
+
34
+ /** One failed assertion: what it checks, what was expected and received as a colored diff, and where it is. */
35
+ export function Failure({ a }: { a: AssertionResult }) {
36
+ const lines = diffAssertion(a)
37
+ const where = whereOf(a)
38
+ return (
39
+ <fieldset class="check-fail">
40
+ <legend class="visually-hidden">Failed check: {a.label}</legend>
41
+ <div class="check-head">
42
+ <span class="check-mark" aria-hidden="true">
43
+ ✗
44
+ </span>
45
+ <strong>{a.label}</strong>
46
+ <span class="chip">{a.operator}</span>
47
+ </div>
48
+ {lines.length > 0 && (
49
+ <>
50
+ <div class="diff-legend" aria-hidden="true">
51
+ <span class="diff-key diff-expected">− Expected</span>
52
+ <span class="diff-key diff-received">+ Received</span>
53
+ </div>
54
+ <Diff lines={lines} />
55
+ </>
56
+ )}
57
+ {where && <div class="check-where">at {where}</div>}
58
+ </fieldset>
59
+ )
60
+ }
61
+
62
+ /** The checks of a step: passing ones in a line, failing ones as cards with their diff. */
63
+ export function Checks({ assertions }: { assertions: AssertionResult[] }) {
64
+ return (
65
+ <ul class="checks">
66
+ {assertions.map((a) =>
67
+ a.pass ? (
68
+ <li class="ok">
69
+ <span class="tick">✓</span> {a.label}
70
+ </li>
71
+ ) : (
72
+ <li class="bad">
73
+ <Failure a={a} />
74
+ </li>
75
+ ),
76
+ )}
77
+ </ul>
78
+ )
79
+ }
@@ -11,14 +11,15 @@ import {
11
11
  import type { ComponentChildren } from 'preact'
12
12
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
13
13
  import { engine } from '../runtime'
14
- import { env, version } from '../store'
14
+ import { kv } from '../storage'
15
+ import { env, envVarsVersion, version } from '../store'
15
16
  import { type Draft, requestParts } from './draft'
16
17
 
17
18
  const LANG_KEY = 'specbook:code-lang'
18
19
 
19
20
  function savedLang(): SnippetLang {
20
21
  try {
21
- const v = localStorage.getItem(LANG_KEY)
22
+ const v = kv.local.getItem(LANG_KEY)
22
23
  if (v === 'curl' || v === 'fetch') return v
23
24
  } catch {
24
25
  /* optional */
@@ -50,19 +51,25 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
50
51
 
51
52
  useEffect(() => {
52
53
  let live = true
53
- engine.config(version.value).then((c) => {
54
- if (!live) return
54
+ engine.config(version.value).then(async (c) => {
55
55
  const svc = c.services?.[service]
56
- const urls = svc?.baseUrl ?? {}
57
- // the environment being used, so a sample for the sandbox points at the sandbox
58
- setBase(urls[env.value] ?? urls.live ?? Object.values(urls)[0])
56
+ const addressIn = (e: string) =>
57
+ engine.baseUrlOf(service, e, version.value).catch(() => undefined)
58
+ // the environment being used, so a sample for the sandbox points at the sandbox; mock, or an
59
+ // environment with no address, uses `live`, else the first address the service lists
60
+ let address = env.value === 'mock' ? undefined : await addressIn(env.value)
61
+ address ??= await addressIn('live')
62
+ if (!address && svc && typeof svc.baseUrl === 'object')
63
+ address = Object.values(svc.baseUrl)[0]
64
+ if (!live) return
65
+ setBase(address)
59
66
  const name = profile === 'service' ? svc?.auth : profile
60
67
  setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
61
68
  })
62
69
  return () => {
63
70
  live = false
64
71
  }
65
- }, [service, profile, env.value])
72
+ }, [service, profile, env.value, envVarsVersion.value])
66
73
 
67
74
  const built = useMemo<{ text?: string; error?: string }>(() => {
68
75
  try {
@@ -76,7 +83,7 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
76
83
  setView(l)
77
84
  if (l === 'sent') return
78
85
  try {
79
- localStorage.setItem(LANG_KEY, l)
86
+ kv.local.setItem(LANG_KEY, l)
80
87
  } catch {
81
88
  /* optional */
82
89
  }
@@ -9,40 +9,71 @@ interface Props {
9
9
  /** forwarded so the grid can move between rows with Enter and the arrow keys */
10
10
  onKeyDown?: (e: KeyboardEvent) => void
11
11
  cell?: string
12
+ /** shown, not edited: no list, no typing */
13
+ readOnly?: boolean
12
14
  }
13
15
 
14
16
  /** A text input with the spec's allowed values as a dropdown; any other value can still be typed. */
15
- export function Combo({ value, options, onChange, label, placeholder, onKeyDown, cell }: Props) {
17
+ export function Combo({
18
+ value,
19
+ options,
20
+ onChange,
21
+ label,
22
+ placeholder,
23
+ onKeyDown,
24
+ cell,
25
+ readOnly,
26
+ }: Props) {
16
27
  const [open, setOpen] = useState(false)
17
28
  const [at, setAt] = useState(-1)
18
29
  const [pos, setPos] = useState<Record<string, string>>()
19
30
  const box = useRef<HTMLDivElement>(null)
20
31
 
21
- /** The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. */
32
+ /**
33
+ * The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. A
34
+ * `fixed` box is placed against the viewport unless an ancestor contains it (a `container-type` does), so
35
+ * a probe filling the inset-0 box says where its coordinates really start.
36
+ */
22
37
  const place = () => {
23
- const r = box.current?.getBoundingClientRect()
24
- if (!r) return
38
+ const el = box.current
39
+ const r = el?.getBoundingClientRect()
40
+ if (!el || !r) return
41
+ const probe = document.createElement('div')
42
+ probe.style.cssText = 'position:fixed;inset:0;visibility:hidden;pointer-events:none'
43
+ el.append(probe)
44
+ const cb = probe.getBoundingClientRect()
45
+ probe.remove()
25
46
  const below = innerHeight - r.bottom - 8
26
47
  const flip = below < 160 && r.top > below
27
48
  // strings with units: Preact 11 sets style properties as given
28
49
  setPos({
29
- left: `${r.left}px`,
50
+ left: `${r.left - cb.left}px`,
30
51
  width: `${Math.max(r.width, 160)}px`,
31
52
  maxHeight: `${Math.min(240, (flip ? r.top : below) - 4)}px`,
32
- ...(flip ? { bottom: `${innerHeight - r.top + 2}px` } : { top: `${r.bottom + 2}px` }),
53
+ ...(flip ? { bottom: `${cb.bottom - r.top + 2}px` } : { top: `${r.bottom + 2 - cb.top}px` }),
33
54
  })
34
55
  }
35
56
 
36
57
  useEffect(() => {
37
- if (!open) return
58
+ if (!open) {
59
+ // the next opening starts unplaced, not where it last was
60
+ setPos(undefined)
61
+ return
62
+ }
38
63
  place()
39
- const close = (e: Event) => {
40
- if (!(e.target instanceof Node && box.current?.contains(e.target))) setOpen(false)
64
+ // the list follows its field when something scrolls (a phone moves the field clear of its keyboard),
65
+ // and goes once the field is out of sight
66
+ const follow = (e: Event) => {
67
+ if (e.target instanceof Node && box.current?.contains(e.target)) return
68
+ const r = box.current?.getBoundingClientRect()
69
+ if (!r || r.bottom < 0 || r.top > innerHeight) setOpen(false)
70
+ else place()
41
71
  }
42
- addEventListener('scroll', close, true)
72
+ const close = () => setOpen(false)
73
+ addEventListener('scroll', follow, true)
43
74
  addEventListener('resize', close)
44
75
  return () => {
45
- removeEventListener('scroll', close, true)
76
+ removeEventListener('scroll', follow, true)
46
77
  removeEventListener('resize', close)
47
78
  }
48
79
  }, [open])
@@ -73,7 +104,8 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
73
104
  aria-expanded={open}
74
105
  role="combobox"
75
106
  autocomplete="off"
76
- onFocus={() => setOpen(true)}
107
+ readOnly={readOnly}
108
+ onFocus={() => !readOnly && setOpen(true)}
77
109
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
78
110
  onKeyDown={(e) => {
79
111
  if (open && e.key === 'ArrowDown') {
@@ -99,12 +131,19 @@ export function Combo({ value, options, onChange, label, placeholder, onKeyDown,
99
131
  class="combo-toggle"
100
132
  tabIndex={-1}
101
133
  aria-label={`Show ${label} options`}
134
+ disabled={readOnly}
102
135
  onClick={() => setOpen(!open)}
103
136
  >
104
137
  ▾
105
138
  </button>
106
139
  {open && (
107
- <div class="combo-list" role="listbox" style={pos}>
140
+ // until it is placed it waits out of sight at the corner: in the flow of the table it would grow
141
+ // the scrolling pane for a moment, and the pane jumps to the bottom
142
+ <div
143
+ class="combo-list"
144
+ role="listbox"
145
+ style={pos ?? { top: '0px', left: '0px', visibility: 'hidden' }}
146
+ >
108
147
  {options.map((o, i) => (
109
148
  <button
110
149
  type="button"
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'preact/hooks'
2
- import { defaultFor, type FieldSpec } from '../components/Fields'
2
+ import { defaultFor, type FieldSpec, generateFor } from '../components/Fields'
3
3
  import { Combo } from './Combo'
4
4
 
5
5
  interface Props {
@@ -135,16 +135,48 @@ function ValueCell({
135
135
  }
136
136
  }
137
137
 
138
+ /** A die: the control that makes another generated value. */
139
+ function Die() {
140
+ return (
141
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
142
+ <rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" />
143
+ <circle cx="5.2" cy="5.2" r="1.1" fill="currentColor" />
144
+ <circle cx="10.8" cy="10.8" r="1.1" fill="currentColor" />
145
+ <circle cx="8" cy="8" r="1.1" fill="currentColor" />
146
+ </svg>
147
+ )
148
+ }
149
+
138
150
  /**
139
- * A sequence's controls (params, or the choices of an input step) as the same spreadsheet the request
151
+ * A flow's controls (inputs, or the choices of an input step) as the same spreadsheet the request
140
152
  * parameters use: name, value, description.
141
153
  */
142
154
  export function ControlsGrid({ title, specs, values, onChange }: Props) {
143
155
  const names = Object.keys(specs)
144
156
  if (names.length === 0) return null
157
+ const generated = names.filter((n) => specs[n]?.generate)
145
158
  return (
146
159
  <section class="grid controls-grid" aria-label={title}>
147
- <h3>{title}</h3>
160
+ <h3 class="controls-head">
161
+ <span>{title}</span>
162
+ {generated.length > 1 && (
163
+ <button
164
+ type="button"
165
+ class="btn small gen-all"
166
+ title="Make a new value for every generated input"
167
+ onClick={() =>
168
+ onChange({
169
+ ...values,
170
+ ...Object.fromEntries(
171
+ generated.map((n) => [n, generateFor(specs[n] as FieldSpec)]),
172
+ ),
173
+ })
174
+ }
175
+ >
176
+ <Die /> Generate all
177
+ </button>
178
+ )}
179
+ </h3>
148
180
  <table>
149
181
  <colgroup>
150
182
  <col class="c-key" />
@@ -169,12 +201,25 @@ export function ControlsGrid({ title, specs, values, onChange }: Props) {
169
201
  </span>
170
202
  </td>
171
203
  <td class="g-val">
172
- <ValueCell
173
- name={name}
174
- spec={spec}
175
- value={values[name]}
176
- onChange={(v) => onChange({ ...values, [name]: v })}
177
- />
204
+ <div class={spec.generate ? 'cell-gen' : undefined}>
205
+ <ValueCell
206
+ name={name}
207
+ spec={spec}
208
+ value={values[name]}
209
+ onChange={(v) => onChange({ ...values, [name]: v })}
210
+ />
211
+ {spec.generate && (
212
+ <button
213
+ type="button"
214
+ class="gen"
215
+ aria-label={`Generate ${spec.label ?? name}`}
216
+ title="Make another value"
217
+ onClick={() => onChange({ ...values, [name]: generateFor(spec) })}
218
+ >
219
+ <Die />
220
+ </button>
221
+ )}
222
+ </div>
178
223
  </td>
179
224
  <td class="g-doc" title={spec.description}>
180
225
  <span class="chip">{hint(spec)}</span> {spec.description}
@@ -0,0 +1,36 @@
1
+ import { Component, type ComponentChildren } from 'preact'
2
+
3
+ interface Props {
4
+ children: ComponentChildren
5
+ }
6
+
7
+ /**
8
+ * What is drawn in a panel comes from a run, and a run is whatever the API answered and whatever the project's code did with
9
+ * it. If drawing that fails anyway, the panel says so in words and offers the run again, instead of stopping where it was
10
+ * with nothing to click: a render that throws leaves the part of the page under it dead.
11
+ */
12
+ export class ErrorBoundary extends Component<Props, { error?: Error }> {
13
+ state: { error?: Error } = {}
14
+
15
+ componentDidCatch(error: unknown) {
16
+ console.error('specbook: a panel could not be drawn', error)
17
+ this.setState({ error: error instanceof Error ? error : new Error(String(error)) })
18
+ }
19
+
20
+ render() {
21
+ const { error } = this.state
22
+ if (!error) return this.props.children
23
+ return (
24
+ <div class="panel-error" role="alert">
25
+ <p>
26
+ <strong>This could not be shown.</strong> The run finished, but what it returned could not
27
+ be drawn.
28
+ </p>
29
+ <pre>{error.message}</pre>
30
+ <button type="button" class="btn" onClick={() => this.setState({ error: undefined })}>
31
+ Try drawing it again
32
+ </button>
33
+ </div>
34
+ )
35
+ }
36
+ }
@@ -11,6 +11,11 @@ type Props = {
11
11
  /** dims the page and traps focus; the standalone site docks the panel beside its list instead */
12
12
  modal?: boolean
13
13
  tab?: PanelTab
14
+ /** `popover`: a centered dialog (a sheet from the bottom on a phone); `side`: the app's sheet from the right */
15
+ variant?: 'side' | 'popover'
16
+ /** an embed's preset flow inputs, and whether to run as soon as it is ready */
17
+ preset?: Record<string, unknown>
18
+ autoRun?: boolean
14
19
  /** switch tab, optionally opening a named example */
15
20
  onNavigate: (to: { tab: PanelTab; example?: string }) => void
16
21
  onPrev?: () => void
@@ -51,6 +56,7 @@ export function Host(p: Props) {
51
56
  subtitle="Sequence"
52
57
  flush
53
58
  modal={p.modal}
59
+ variant={p.variant}
54
60
  onPrev={p.onPrev}
55
61
  onNext={p.onNext}
56
62
  onClose={p.onClose}
@@ -58,7 +64,11 @@ export function Host(p: Props) {
58
64
  <div class="panel-split">
59
65
  {tabs}
60
66
  <div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
61
- {tab === 'docs' ? <SequenceDocs info={p.info} /> : <SequencePanel info={p.info} />}
67
+ {tab === 'docs' ? (
68
+ <SequenceDocs info={p.info} />
69
+ ) : (
70
+ <SequencePanel info={p.info} preset={p.preset} autoRun={p.autoRun} />
71
+ )}
62
72
  </div>
63
73
  </div>
64
74
  </Panel>
@@ -69,6 +79,7 @@ export function Host(p: Props) {
69
79
  subtitle={`${p.op.method} ${p.op.path}`}
70
80
  flush
71
81
  modal={p.modal}
82
+ variant={p.variant}
72
83
  onPrev={p.onPrev}
73
84
  onNext={p.onNext}
74
85
  onClose={p.onClose}
@@ -86,11 +97,12 @@ export function Host(p: Props) {
86
97
  />
87
98
  ) : (
88
99
  <RequestPanel
89
- key={`${p.service}/${p.op.id}`}
100
+ key={`${p.service}/${p.op.id}${p.example.startsWith('saved:') ? `@${p.example}` : ''}`}
90
101
  service={p.service}
91
102
  op={p.op}
92
103
  variants={p.variants}
93
104
  initial={p.example}
105
+ autoRun={p.autoRun}
94
106
  />
95
107
  )}
96
108
  </div>
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { noteExpiry, recallClientId, redirectUri, rememberClientId, signIn } from '../oauth'
4
4
  import { engine } from '../runtime'
5
5
  import { secretStore } from '../secrets'
6
- import { env, version } from '../store'
6
+ import { env, envVarsVersion, version } from '../store'
7
7
 
8
8
  type Status = { kind: 'idle' } | { kind: 'busy' } | { kind: 'error'; message: string }
9
9
 
@@ -39,6 +39,10 @@ export function OAuthSignIn({
39
39
  const [clientId, setClientId] = useState(
40
40
  () => oauth.clientId ?? recallClientId(providerKey) ?? '',
41
41
  )
42
+ // another environment may sign in somewhere else, with its own client
43
+ useEffect(() => {
44
+ setClientId(oauth.clientId ?? recallClientId(providerKey) ?? '')
45
+ }, [providerKey, oauth.clientId])
42
46
  const [endpoints, setEndpoints] = useState<Endpoints | undefined>(undefined)
43
47
  const [picked, setPicked] = useState<string[]>([])
44
48
  const [status, setStatus] = useState<Status>({ kind: 'idle' })
@@ -67,7 +71,7 @@ export function OAuthSignIn({
67
71
  return () => {
68
72
  on = false
69
73
  }
70
- }, [profile, env.value, version.value, attempt])
74
+ }, [profile, env.value, version.value, attempt, envVarsVersion.value])
71
75
 
72
76
  const scopeNames = Object.keys(endpoints?.scopes ?? {})
73
77