@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
@@ -1,13 +1,16 @@
1
1
  import type { RunResult, StepResult } from '@specbook/core'
2
- import { useEffect, useMemo, useState } from 'preact/hooks'
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  import { EnvBadge } from '../components/EnvBadge'
4
4
  import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
5
5
  import type { SequenceInfo } from '../engine'
6
6
  import { engine } from '../runtime'
7
7
  import { env, locale, seed, version } from '../store'
8
+ import { announcement, describeRun } from './announce'
9
+ import { Failure } from './Checks'
8
10
  import { ControlsGrid } from './ControlsGrid'
9
11
  import { SecretsBar } from './SecretsBar'
10
12
  import { StepExchange } from './StepExchange'
13
+ import { short, show } from './short'
11
14
  import { useSequenceNotes } from './stepnotes'
12
15
 
13
16
  /** `Check items #2` belongs to the step `Check items`. */
@@ -24,18 +27,24 @@ const ICON = {
24
27
  paused: '⏸',
25
28
  pending: '',
26
29
  }
27
- const short = (v: unknown) => {
28
- const s = typeof v === 'string' ? v : JSON.stringify(v)
29
- return s.length > 28 ? `${s.slice(0, 27)}…` : s
30
- }
31
30
 
32
31
  /** |timeline|request|: the journey on the left, the selected step's exchange on the right. */
33
- export function SequencePanel({ info }: { info: SequenceInfo }) {
32
+ export function SequencePanel({
33
+ info,
34
+ preset,
35
+ autoRun,
36
+ }: {
37
+ info: SequenceInfo
38
+ /** input values to start from instead of the defaults (an embed's data-specbook-inputs) */
39
+ preset?: Record<string, unknown>
40
+ /** run on the defaults as soon as the flow is ready */
41
+ autoRun?: boolean
42
+ }) {
34
43
  const { seq } = info
35
44
  const specs = useMemo(
36
45
  () =>
37
46
  Object.fromEntries(
38
- Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([k, c]) => [
47
+ Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([k, c]) => [
39
48
  k,
40
49
  specFromControl(c),
41
50
  ]),
@@ -52,7 +61,7 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
52
61
  }
53
62
  return [...names]
54
63
  }, [seq])
55
- const [params, setParams] = useState<Record<string, unknown>>({})
64
+ const [inputs, setInputs] = useState<Record<string, unknown>>({})
56
65
  const [result, setResult] = useState<RunResult>()
57
66
  const [live, setLive] = useState<Live>({})
58
67
  const [running, setRunning] = useState(false)
@@ -67,11 +76,41 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
67
76
  const [sheet, setSheet] = useState(false)
68
77
  const [error, setError] = useState<string>()
69
78
 
79
+ const hasGenerated = Object.values(specs).some((s) => s.generate)
80
+ // a flow with inputs can run on their defaults alone, as `specbook test` does
81
+ const hasInputs = Object.keys(specs).length > 0 || seq.nodes.some((n) => n.kind === 'input')
82
+
83
+ /** Every input at its default; a generated one needs faker, which is made ready first. */
84
+ const freshInputs = async () => {
85
+ if (hasGenerated)
86
+ await engine.fakerReady(version.value, locale.value, seed.value).catch(() => {})
87
+ return Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))
88
+ }
89
+
70
90
  useEffect(() => {
71
- setParams(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])))
91
+ let current = true
92
+ const start = (v: Record<string, unknown>) => ({ ...v, ...(preset ?? {}) })
93
+ setInputs(start(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))))
94
+ // generated values need faker: fill them in as soon as it is ready
95
+ if (hasGenerated)
96
+ void freshInputs().then((v) => {
97
+ if (!current) return
98
+ setInputs(start(v))
99
+ if (autoRun) void run({ inputs: start(v), headless: true })
100
+ })
101
+ else if (autoRun)
102
+ void run({
103
+ inputs: start(
104
+ Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])),
105
+ ),
106
+ headless: true,
107
+ })
72
108
  setResult(undefined)
73
109
  setLive({})
74
110
  setPause(undefined)
111
+ return () => {
112
+ current = false
113
+ }
75
114
  }, [info.file])
76
115
 
77
116
  const planned = seq.nodes.map((n) => n.name)
@@ -80,7 +119,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
80
119
  : planned.map((n) => ({ name: n }))
81
120
  const current = rows[picked]?.step
82
121
 
83
- const run = async () => {
122
+ /** ends the run that is going: what is out is stopped, and nothing after it starts */
123
+ const stopper = useRef<AbortController | undefined>(undefined)
124
+ const [stopped, setStopped] = useState(false)
125
+ const stop = () => {
126
+ setStopped(true)
127
+ stopper.current?.abort()
128
+ // a run waiting for a choice is released, and then finds it is stopped
129
+ pause?.resolve(choice)
130
+ }
131
+
132
+ const run = async (options: { inputs?: Record<string, unknown>; headless?: boolean } = {}) => {
133
+ const ctl = new AbortController()
134
+ stopper.current = ctl
135
+ setStopped(false)
84
136
  setRunning(true)
85
137
  setResult(undefined)
86
138
  setError(undefined)
@@ -91,32 +143,42 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
91
143
  const r = await engine.run(seq, {
92
144
  env: env.value,
93
145
  version: version.value,
94
- params,
146
+ inputs: options.inputs ?? inputs,
95
147
  locale: locale.value,
96
148
  seed: seed.value,
149
+ signal: ctl.signal,
97
150
  onEvent: (e) => {
98
151
  if (e.type === 'step:start') setLive((l) => ({ ...l, [e.name]: 'running' }))
99
152
  if (e.type === 'step:end') setLive((l) => ({ ...l, [e.name]: e.status }))
100
153
  },
101
- onInput: ({ name, controls }) =>
102
- new Promise((resolve) => {
103
- const c = controls as Record<string, FieldSpec>
104
- setLive((l) => ({ ...l, [name]: 'paused' }))
105
- setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
106
- setPause({
107
- name,
108
- controls: c,
109
- resolve: (values) => {
110
- setPause(undefined)
111
- resolve(values)
112
- },
113
- })
114
- }),
154
+ onInput: options.headless
155
+ ? undefined
156
+ : ({ name, controls }) =>
157
+ new Promise((resolve) => {
158
+ const c = controls as Record<string, FieldSpec>
159
+ setLive((l) => ({ ...l, [name]: 'paused' }))
160
+ setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
161
+ setPause({
162
+ name,
163
+ controls: c,
164
+ resolve: (values) => {
165
+ setPause(undefined)
166
+ resolve(values)
167
+ },
168
+ })
169
+ }),
115
170
  })
116
171
  setResult(r)
172
+ announcement.value = describeRun(r)
173
+ // a run that did not pass shows its first failing step, not its first step
174
+ const bad = r.steps
175
+ .filter((s) => s.kind !== 'auth')
176
+ .findIndex((s) => s.status === 'fail' || s.status === 'error')
177
+ if (bad >= 0) setPicked(bad)
117
178
  } catch (e) {
118
179
  setError(e instanceof Error ? e.message : String(e))
119
180
  } finally {
181
+ stopper.current = undefined
120
182
  setRunning(false)
121
183
  setPause(undefined)
122
184
  }
@@ -128,17 +190,43 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
128
190
  <div class={`sequence-panel ${sheet && current ? 'sheet-open' : ''}`} data-seq={seq.meta.id}>
129
191
  <div class="seq-left">
130
192
  <SecretsBar services={serviceNames} />
131
- <ControlsGrid title="Controls" specs={specs} values={params} onChange={setParams} />
193
+ <ControlsGrid title="Inputs" specs={specs} values={inputs} onChange={setInputs} />
132
194
  <div class="seq-run">
133
- <button type="button" class="btn primary" disabled={running} onClick={run}>
195
+ <button type="button" class="btn primary" disabled={running} onClick={() => run()}>
134
196
  {running ? 'Running…' : '▶ Run'}
135
197
  </button>
198
+ {running && (
199
+ <button type="button" class="btn" onClick={stop}>
200
+ Stop
201
+ </button>
202
+ )}
203
+ {hasInputs && (
204
+ <button
205
+ type="button"
206
+ class="btn"
207
+ disabled={running}
208
+ title="Skip every question and use each input's default, as specbook test does"
209
+ onClick={async () => {
210
+ const fresh = await freshInputs()
211
+ setInputs(fresh)
212
+ await run({ inputs: fresh, headless: true })
213
+ }}
214
+ >
215
+ Run with defaults
216
+ </button>
217
+ )}
136
218
  <EnvBadge />
137
219
  {result && (
138
220
  <span class={`badge ${overall}`}>
139
221
  {ICON[overall as 'pass']}{' '}
140
- {overall === 'pass' ? 'Passed' : overall === 'fail' ? 'Failed' : 'Error'} ·{' '}
141
- {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
222
+ {overall === 'pass'
223
+ ? 'Passed'
224
+ : stopped
225
+ ? 'Stopped'
226
+ : overall === 'fail'
227
+ ? 'Failed'
228
+ : 'Error'}{' '}
229
+ · {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
142
230
  </span>
143
231
  )}
144
232
  </div>
@@ -187,16 +275,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
187
275
  ))}
188
276
  {step &&
189
277
  Object.entries(step.captured).map(([k, v]) => (
190
- <span class="cap make" title={JSON.stringify(v)}>
278
+ <span class="cap make" title={show(v)}>
191
279
  → {k} <b>{short(v)}</b>
192
280
  </span>
193
281
  ))}
194
282
  {step?.error && <div class="error-text">{step.error}</div>}
195
283
  {failed.map((a) => (
196
- <div class="error-text">✗ {a.label}</div>
284
+ <Failure a={a} />
197
285
  ))}
198
286
  {status === 'skipped' && (
199
- <div class="muted">Skipped: an earlier step did not pass.</div>
287
+ <div class="muted">
288
+ {stopped
289
+ ? 'Skipped: you stopped the run.'
290
+ : 'Skipped: an earlier step did not pass.'}
291
+ </div>
200
292
  )}
201
293
  {pause && live[row.name] === 'paused' && (
202
294
  <div class="pause">
@@ -0,0 +1,17 @@
1
+ import { signal } from '@preact/signals'
2
+ import type { RunResult } from '@specbook/core'
3
+
4
+ /** What a screen reader is told when a run finishes: the panel renders it in a polite live region. */
5
+ export const announcement = signal('')
6
+
7
+ /** One sentence for a finished run: a request by its response, a flow by how many steps passed. */
8
+ export function describeRun(r: RunResult): string {
9
+ if (r.kind === 'request') {
10
+ const s = r.steps.find((x) => x.response) ?? r.steps[0]
11
+ if (!s?.response) return r.status === 'error' ? 'The request could not be sent.' : 'Done.'
12
+ const checks = s.assertions.filter((a) => !a.pass).length
13
+ return `${s.response.status}, ${r.durationMs} milliseconds${checks ? `, ${checks} check${checks === 1 ? '' : 's'} failed` : ''}.`
14
+ }
15
+ const word = r.status === 'pass' ? 'passed' : r.status === 'fail' ? 'failed' : 'could not run'
16
+ return `Flow ${word}, ${r.totals.passed} of ${r.totals.steps} steps passed.`
17
+ }
@@ -83,3 +83,12 @@ export function StarIcon({ size = 14 }: { size?: number }) {
83
83
  </svg>
84
84
  )
85
85
  }
86
+
87
+ /** A filled square, the usual "stop" mark. */
88
+ export function StopSquare({ size = 16 }: { size?: number }) {
89
+ return (
90
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
91
+ <rect x="6" y="6" width="12" height="12" rx="2" />
92
+ </svg>
93
+ )
94
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A value as text, whatever it is. A run hands the page whatever the project's code and the API produced, and JSON cannot
3
+ * write all of it: `undefined` and functions come back as nothing, a circular object or a BigInt throws. A line that cannot
4
+ * be drawn must not stop the panel, so this never throws and never returns nothing.
5
+ */
6
+ export function show(v: unknown, space?: number): string {
7
+ try {
8
+ const s = JSON.stringify(v, null, space)
9
+ if (s !== undefined) return s
10
+ } catch {
11
+ /* circular, or something JSON has no form for: name it instead */
12
+ }
13
+ try {
14
+ return String(v)
15
+ } catch {
16
+ return Object.prototype.toString.call(v)
17
+ }
18
+ }
19
+
20
+ /** A value for a line of text: its text, cut at 28 characters. */
21
+ export function short(v: unknown): string {
22
+ const s = typeof v === 'string' ? v : show(v)
23
+ return s.length > 28 ? `${s.slice(0, 27)}…` : s
24
+ }
package/src/routing.ts CHANGED
@@ -29,15 +29,6 @@ export function routeFor(o: {
29
29
  return 'direct'
30
30
  }
31
31
 
32
- /** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
33
- export function absoluteBase(baseUrl: string, pageHref?: string): string {
34
- try {
35
- return new URL(baseUrl, pageHref).toString()
36
- } catch {
37
- return baseUrl
38
- }
39
- }
40
-
41
32
  const names = (req: PreparedRequest): string[] => {
42
33
  const out = new Set<string>()
43
34
  const scan = (v: unknown) => {
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as reg from 'virtual:specbook/registry'
2
+ import { createEnvironmentStore } from './custom-environments'
2
3
  import { createEngine, pickRunnable } from './engine'
3
4
  import { detectExtension, type ExtensionBridge } from './extension'
4
5
  import { version } from './store'
@@ -14,10 +15,27 @@ const extension = async () => {
14
15
  return bridge
15
16
  }
16
17
 
17
- export const engine = createEngine(reg as never, { extension })
18
+ /**
19
+ * A page with no project files keeps the environments a person makes in the browser: per spec in the
20
+ * hosted viewer (its registry says where), per page for a built site. An embed has none.
21
+ */
22
+ const storeAt = (reg as { environmentStore?: { scope: string; session: boolean } | null })
23
+ .environmentStore
24
+ const environmentStore =
25
+ reg.mode === 'static' && storeAt !== null
26
+ ? createEnvironmentStore(
27
+ storeAt?.scope ?? `${globalThis.location?.origin}${globalThis.location?.pathname}`,
28
+ storeAt?.session ? 'session' : 'local',
29
+ )
30
+ : undefined
31
+
32
+ export const engine = createEngine(reg as never, { extension, environmentStore })
18
33
 
19
34
  export const isStatic = reg.mode === 'static'
20
35
 
36
+ /** A build that publishes only the requests and flows the project uses (`specbook build --minimal`). */
37
+ export const isMinimal = !!(reg as { minimal?: boolean }).minimal
38
+
21
39
  /** Dev only: writes a project file through the plugin, then busts import caches. */
22
40
  export async function saveFile(path: string, content: string): Promise<void> {
23
41
  const r = await fetch('/__specbook/save', {
package/src/secrets.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * closes) with an in-memory fallback; another backend (an encrypted store, say) can sit behind the same
7
7
  * interface. Requests carry `{{secrets.NAME}}` placeholders until the transport sends them.
8
8
  */
9
+ import { kv } from './storage'
10
+
9
11
  const KEY = 'specbook.secrets.v1'
10
12
 
11
13
  type Table = Record<string, Record<string, string>>
@@ -20,7 +22,7 @@ let memory: Table = {}
20
22
 
21
23
  const read = (): Table => {
22
24
  try {
23
- const raw = sessionStorage.getItem(KEY)
25
+ const raw = kv.session.getItem(KEY)
24
26
  return raw ? (JSON.parse(raw) as Table) : memory
25
27
  } catch {
26
28
  return memory
@@ -30,7 +32,7 @@ const read = (): Table => {
30
32
  const write = (t: Table) => {
31
33
  memory = t
32
34
  try {
33
- sessionStorage.setItem(KEY, JSON.stringify(t))
35
+ kv.session.setItem(KEY, JSON.stringify(t))
34
36
  } catch {
35
37
  /* private mode or blocked storage: the in-memory copy still serves this page */
36
38
  }
package/src/storage.ts ADDED
@@ -0,0 +1,58 @@
1
+ // Where the UI keeps what a person typed. In the app and the viewer it is the browser's storage, kept as before. In an embed
2
+ // it is memory: a docs page shares its origin's `localStorage` with its own code, and a visitor's drafts and credentials
3
+ // must not land there, or be read from there.
4
+
5
+ export type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
6
+
7
+ function memory(): Store {
8
+ const m = new Map<string, string>()
9
+ return {
10
+ getItem: (k) => m.get(k) ?? null,
11
+ setItem: (k, v) => void m.set(k, String(v)),
12
+ removeItem: (k) => void m.delete(k),
13
+ }
14
+ }
15
+
16
+ /** Reading the global can itself throw (blocked site data), so it is done when asked and a failure falls back to memory. */
17
+ function browser(which: 'localStorage' | 'sessionStorage'): Store {
18
+ try {
19
+ const s = globalThis[which]
20
+ if (s) return s
21
+ } catch {
22
+ /* blocked */
23
+ }
24
+ return memory()
25
+ }
26
+
27
+ let local: Store | undefined
28
+ let session: Store | undefined
29
+ let isolated = false
30
+
31
+ export const kv = {
32
+ get local(): Store {
33
+ if (!isolated) return browser('localStorage')
34
+ if (!local) local = memory()
35
+ return local
36
+ },
37
+ get session(): Store {
38
+ if (!isolated) return browser('sessionStorage')
39
+ if (!session) session = memory()
40
+ return session
41
+ },
42
+ }
43
+
44
+ /** From here on nothing is read from or written to the page's own storage. */
45
+ export function useMemoryStorage(): void {
46
+ isolated = true
47
+ local = undefined
48
+ session = undefined
49
+ }
50
+
51
+ /** Back to the browser's storage (for tests). */
52
+ export function useBrowserStorage(): void {
53
+ isolated = false
54
+ local = undefined
55
+ session = undefined
56
+ }
57
+
58
+ export const storageIsolated = () => isolated
package/src/store.ts CHANGED
@@ -6,5 +6,15 @@ export const env = signal('mock')
6
6
  export const environments = signal<string[]>(['mock'])
7
7
  /** Environments the config marks `production` or `sandbox`; others are judged by their name. */
8
8
  export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>({})
9
+ /** What the config says each environment is, for the tooltip of its entry in a picker. */
10
+ export const environmentNote = signal<Record<string, string>>({})
9
11
  export const locale = signal<string | undefined>(undefined)
10
12
  export const seed = signal<number | undefined>(undefined)
13
+ /** Bumped when an environment's variables change, so what is derived from them (addresses, origins) is redone. */
14
+ export const envVarsVersion = signal(0)
15
+ /** Whether Environment settings is open; set from elsewhere to point a person at it. */
16
+ export const envSettingsOpen = signal(false)
17
+ /** The New environment dialog. */
18
+ export const newEnvironmentOpen = signal(false)
19
+ /** What the shell around the panels offers. An embed has no dialog for creating an environment or for settings. */
20
+ export const features = signal({ createEnvironment: true, environmentSettings: true })
@@ -146,6 +146,8 @@
146
146
  .list-col {
147
147
  min-height: 0;
148
148
  overflow: auto;
149
+ /* folding a group changes how tall the list is: keep the scrollbar's space, so the list does not widen and narrow */
150
+ scrollbar-gutter: stable;
149
151
  /* scrolling to the end of the list must not drag the whole page along */
150
152
  overscroll-behavior: contain;
151
153
  padding: var(--sb-space-3);
@@ -287,6 +289,37 @@
287
289
  font-size: 15px;
288
290
  outline: none;
289
291
  }
292
+ /* New environment: a small form in the palette's frame, above a docked panel and the settings popover */
293
+ .newenv-backdrop {
294
+ z-index: 60;
295
+ }
296
+ .newenv {
297
+ /* the palette stretches to fill its height; a short form should not */
298
+ align-self: flex-start;
299
+ }
300
+ .newenv form {
301
+ display: flex;
302
+ flex-direction: column;
303
+ gap: var(--sb-space-2);
304
+ padding: var(--sb-space-4);
305
+ }
306
+ .newenv h2 {
307
+ margin: 0;
308
+ font-size: 15px;
309
+ }
310
+ .newenv label {
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: 4px;
314
+ }
315
+ .newenv input,
316
+ .newenv select {
317
+ border: 1px solid var(--sb-border);
318
+ border-radius: var(--sb-radius-control);
319
+ padding: 8px 10px;
320
+ /* 16px, so iOS does not zoom the page when a field is focused */
321
+ font-size: 16px;
322
+ }
290
323
  .palette ul {
291
324
  list-style: none;
292
325
  margin: 0;
@@ -427,7 +460,11 @@
427
460
  padding: var(--sb-space-3) 0 0;
428
461
  border-top: 1px solid var(--sb-border);
429
462
  }
463
+ /* the tick says it passed; the sentence is muted, so a list of checks reads calmly and a failure stands out */
430
464
  .checks .ok {
465
+ color: var(--sb-text-muted);
466
+ }
467
+ .checks .ok .tick {
431
468
  color: var(--sb-success);
432
469
  }
433
470
  .checks .bad {
@@ -657,7 +694,7 @@
657
694
  white-space: nowrap;
658
695
  }
659
696
  .palette-btn kbd,
660
- .header .spacer {
697
+ .header > .spacer {
661
698
  display: none;
662
699
  }
663
700
  .header .menu-btn {
@@ -693,12 +730,37 @@
693
730
  justify-content: space-between;
694
731
  flex-wrap: nowrap;
695
732
  }
696
- .header-controls select {
697
- min-width: 140px;
733
+ /* every labelled control in the menu: the label to the left, the control the same width on the right */
734
+ .header-controls .row > select {
735
+ flex: 0 0 56%;
736
+ width: 56%;
737
+ min-width: 0;
738
+ }
739
+ /* Synthetic data is a row inside a row (its label and, once fixed, a seed field): the inner one fills it */
740
+ .header-controls .row > .row {
741
+ flex: 1 1 100%;
742
+ }
743
+ .header-controls .row.muted {
744
+ flex-wrap: wrap;
745
+ }
746
+ .header-controls .seed-input {
747
+ flex: 0 0 56%;
748
+ margin-left: auto;
749
+ }
750
+ .header-controls .envmenu,
751
+ .header-controls .envmenu-btn {
752
+ width: 100%;
753
+ }
754
+ .header-controls .envmenu-btn {
755
+ display: flex;
756
+ align-items: center;
757
+ justify-content: center;
758
+ gap: var(--sb-space-2);
698
759
  }
699
760
  /* in the menu there is room for the words, and each link is a full row */
700
761
  .header-controls .gh-label,
701
- .header-controls .share-label {
762
+ .header-controls .share-label,
763
+ .header-controls .envmenu-label {
702
764
  display: inline;
703
765
  }
704
766
  .header-controls .gh-star,
@@ -800,10 +862,11 @@
800
862
  min-width: 120px;
801
863
  max-width: 360px;
802
864
  }
803
- /* GitHub and Share keep their icons and lose their words where the header is tight */
865
+ /* GitHub, Share and Environment settings keep their icons and lose their words where the header is tight */
804
866
  @media (max-width: 1360px) {
805
867
  .gh-label,
806
- .share-label {
868
+ .share-label,
869
+ .envmenu-label {
807
870
  display: none;
808
871
  }
809
872
  }