@specbook/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/apple-touch-icon.png +0 -0
  4. package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
  5. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  6. package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
  9. package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
  10. package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
  11. package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
  12. package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
  13. package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
  14. package/dist-viewer/favicon.svg +1 -0
  15. package/dist-viewer/index.html +19 -0
  16. package/dist-viewer/specbook.js +1 -0
  17. package/index.html +12 -0
  18. package/package.json +60 -3
  19. package/src/app.tsx +363 -0
  20. package/src/assets/mark.svg +1 -0
  21. package/src/badge.ts +14 -0
  22. package/src/branding.ts +92 -0
  23. package/src/components/Catalog.tsx +324 -0
  24. package/src/components/EnvBadge.tsx +46 -0
  25. package/src/components/EnvironmentMenu.tsx +262 -0
  26. package/src/components/Fields.tsx +220 -0
  27. package/src/components/GitHubStar.tsx +22 -0
  28. package/src/components/ListResizer.tsx +70 -0
  29. package/src/components/ModeHint.tsx +17 -0
  30. package/src/components/Palette.tsx +85 -0
  31. package/src/components/PoweredBy.tsx +15 -0
  32. package/src/components/ShareMenu.tsx +94 -0
  33. package/src/components/ThemeToggle.tsx +52 -0
  34. package/src/embed/panel.tsx +114 -0
  35. package/src/embed/styles.ts +27 -0
  36. package/src/embed.ts +65 -0
  37. package/src/engine.ts +432 -0
  38. package/src/extension.ts +101 -0
  39. package/src/fielddoc.ts +83 -0
  40. package/src/main.tsx +16 -0
  41. package/src/markdown.ts +133 -0
  42. package/src/nav.ts +118 -0
  43. package/src/panel/Body.tsx +182 -0
  44. package/src/panel/BodyEditor.tsx +92 -0
  45. package/src/panel/CodeSample.tsx +237 -0
  46. package/src/panel/Combo.tsx +124 -0
  47. package/src/panel/ControlsGrid.tsx +189 -0
  48. package/src/panel/FieldDoc.tsx +42 -0
  49. package/src/panel/FieldTable.tsx +113 -0
  50. package/src/panel/Host.tsx +100 -0
  51. package/src/panel/OperationDocs.tsx +181 -0
  52. package/src/panel/Panel.tsx +193 -0
  53. package/src/panel/ParamGrid.tsx +396 -0
  54. package/src/panel/RequestPanel.tsx +625 -0
  55. package/src/panel/RequestView.tsx +108 -0
  56. package/src/panel/ResponseView.tsx +182 -0
  57. package/src/panel/SecretsBar.tsx +156 -0
  58. package/src/panel/SequenceDocs.tsx +132 -0
  59. package/src/panel/SequencePanel.tsx +247 -0
  60. package/src/panel/StepExchange.tsx +118 -0
  61. package/src/panel/completions.ts +114 -0
  62. package/src/panel/draft.ts +106 -0
  63. package/src/panel/fieldnotes-split.ts +55 -0
  64. package/src/panel/fieldnotes.ts +40 -0
  65. package/src/panel/graphql.ts +31 -0
  66. package/src/panel/icons.tsx +85 -0
  67. package/src/panel/jsonCursor.ts +115 -0
  68. package/src/panel/stepnotes-split.ts +38 -0
  69. package/src/panel/stepnotes.ts +58 -0
  70. package/src/routing.ts +120 -0
  71. package/src/runtime.ts +32 -0
  72. package/src/schema.ts +359 -0
  73. package/src/secrets.ts +61 -0
  74. package/src/status.ts +27 -0
  75. package/src/store.ts +10 -0
  76. package/src/styles/app.css +821 -0
  77. package/src/styles/panel.css +1897 -0
  78. package/src/styles/tokens.css +122 -0
  79. package/src/theme.ts +37 -0
  80. package/src/viewer/Opening.tsx +43 -0
  81. package/src/viewer/bundles.ts +74 -0
  82. package/src/viewer/client.ts +54 -0
  83. package/src/viewer/embed.ts +78 -0
  84. package/src/viewer/faker-en.ts +3 -0
  85. package/src/viewer/main.tsx +285 -0
  86. package/src/viewer/mount.ts +52 -0
  87. package/src/viewer/protocol.ts +51 -0
  88. package/src/viewer/recent.ts +22 -0
  89. package/src/viewer/registry-data.ts +4 -0
  90. package/src/viewer/registry.ts +154 -0
  91. package/src/viewer/share.ts +134 -0
  92. package/src/viewer/site.ts +3 -0
  93. package/src/viewer/uploads.ts +53 -0
  94. package/src/viewer/viewer.css +184 -0
  95. package/src/viewer/worker-factory.ts +14 -0
  96. package/src/viewer/worker.ts +171 -0
  97. package/src/virtual.d.ts +104 -0
@@ -0,0 +1 @@
1
+ <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' role='img' aria-label='Specbook'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#8b5cf6'/><stop offset='1' stop-color='#5b21b6'/></linearGradient></defs><rect width='64' height='64' rx='15' fill='url(#g)'/><path d='M32 19.5c-4.5-3-10-3.6-15.5-2.4v28.4c5.500-1.200 11-.6 15.500 2.400 4.500-3 10-3.600 15.500-2.400V17.100c-5.500-1.200-11-.6-15.500 2.400z' fill='#fff' fill-opacity='.95'/><path d='M32 19.500v28.400' stroke='#6d28d9' stroke-width='2' stroke-linecap='round'/><path d='M38 27.500v9.500l7.500-4.750z' fill='#6d28d9'/><path d='M26.500 28.500l-4 3.750 4 3.750' fill='none' stroke='#6d28d9' stroke-width='2.200' stroke-linecap='round' stroke-linejoin='round'/></svg>
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="referrer" content="no-referrer" />
7
+ <title>Specbook</title>
8
+ <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
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">
14
+ <link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
15
+ </head>
16
+ <body>
17
+ <div id="app"></div>
18
+ </body>
19
+ </html>
@@ -0,0 +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();
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Specbook</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@specbook/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The Specbook docs app, spec viewer and embeddable specbook.js.",
5
+ "keywords": [
6
+ "specbook",
7
+ "openapi",
8
+ "swagger",
9
+ "api-documentation",
10
+ "api-explorer",
11
+ "openapi-viewer"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://ui.specbook.dev",
15
+ "bugs": {
16
+ "url": "https://github.com/specbook-dev/specbook/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/specbook-dev/specbook.git",
21
+ "directory": "packages/ui"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "exports": {
28
+ "./package.json": "./package.json"
29
+ },
30
+ "dependencies": {
31
+ "@preact/signals": "2.11.3",
32
+ "@specbook/codegen": "0.1.0",
33
+ "@specbook/core": "0.1.0",
34
+ "preact": "11.0.1",
35
+ "prism-code-editor": "^5.4.0"
36
+ },
37
+ "devDependencies": {
38
+ "@faker-js/faker": "9.3.0",
39
+ "@specbook/vite": "0.1.0",
40
+ "@vitest/browser-playwright": "5.0.3",
41
+ "playwright": "1.64.0",
42
+ "typescript": "7.0.2",
43
+ "vite": "8.3.4",
44
+ "vitest": "5.0.3"
45
+ },
46
+ "files": [
47
+ "src",
48
+ "index.html",
49
+ "dist-viewer",
50
+ "!**/*.test.*"
51
+ ],
52
+ "scripts": {
53
+ "typecheck": "tsc --noEmit",
54
+ "build": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "test:browser": "pnpm --filter @specbook/vite build && vitest run --config vitest.browser.config.ts",
57
+ "build:viewer": "vite build --config vite.viewer.config.ts",
58
+ "dev:viewer": "vite --config vite.viewer.config.ts",
59
+ "test:viewer": "pnpm run build:viewer && vitest run --config vitest.viewer.config.ts",
60
+ "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
+ }
6
63
  }
package/src/app.tsx ADDED
@@ -0,0 +1,363 @@
1
+ import { signal } from '@preact/signals'
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
+ import markUrl from './assets/mark.svg'
4
+ import { applyBranding, brand } from './branding'
5
+ import { CatalogList, Overview, type ServiceInfo } from './components/Catalog'
6
+ import { EnvBadge } from './components/EnvBadge'
7
+ import { EnvironmentMenu } from './components/EnvironmentMenu'
8
+ import { GitHubStar } from './components/GitHubStar'
9
+ import { ListResizer, listWidth } from './components/ListResizer'
10
+ import { Palette } from './components/Palette'
11
+ import { ShareMenu } from './components/ShareMenu'
12
+ import { ThemeToggle } from './components/ThemeToggle'
13
+ import type { SequenceInfo, Variant } from './engine'
14
+ 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'
17
+
18
+ // the hosted viewer is Specbook itself, so its header carries our mark unless the API brings a logo
19
+ const ownMark = globalThis.__SPECBOOK_VIEWER__?.source ? markUrl : undefined
20
+
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)
25
+ }
26
+
27
+ const fakerLocales = signal<string[]>([])
28
+
29
+ type HostModule = typeof import('./panel/Host')
30
+
31
+ /** The side panel is a separate chunk: it holds the editor and is only fetched on first Play. */
32
+ function usePanelHost() {
33
+ const [host, setHost] = useState<HostModule>()
34
+ const wanted = route.value.kind === 'op' || route.value.kind === 'seq'
35
+ useEffect(() => {
36
+ if (wanted && !host) import('./panel/Host').then(setHost)
37
+ }, [wanted, host])
38
+ return wanted ? host : undefined
39
+ }
40
+
41
+ export function App() {
42
+ const [variants, setVariants] = useState<Record<string, Variant[]>>({})
43
+ const [sequences, setSequences] = useState<SequenceInfo[] | undefined>(undefined)
44
+ const [menuOpen, setMenuOpen] = useState(false)
45
+
46
+ useEffect(() => {
47
+ const onHash = () => {
48
+ route.value = parseHash(location.hash)
49
+ }
50
+ addEventListener('hashchange', onHash)
51
+ // The project's config comes first: the overview waits for it. Saved requests and sequences are
52
+ // only needed for search and the Try picker, so they load once the first screen can be drawn.
53
+ const loadRest = () => {
54
+ engine.loadVariants(registry.requests, version.value).then(setVariants)
55
+ // Headers are read from the files' text; only ones that aren't plain literals are imported.
56
+ const odd = registry.sequences.filter((e) => registry.unresolvedSequences.includes(e.file))
57
+ if (odd.length) engine.loadSequences(odd, version.value).then(setSequences)
58
+ }
59
+ engine
60
+ .config(version.value)
61
+ .then(async (c) => {
62
+ fakerLocales.value = Object.keys(c.faker?.locales ?? {})
63
+ if (c.faker?.default) locale.value = c.faker.default
64
+ const b = c.branding
65
+ const assets = registry.brandingAssets
66
+ const only = Object.values(registry.manifests)
67
+ // with one API, the spec's own `info.x-logo` is the fallback after the project's files
68
+ const spec = only.length === 1 ? only[0]?.logo : undefined
69
+ const file = b?.logo ?? (await assets.logo?.().catch(() => undefined))
70
+ const resolved = {
71
+ ready: true,
72
+ name: b?.name,
73
+ title: b?.title,
74
+ tagline: b?.tagline,
75
+ links: b?.links ?? [],
76
+ badge: b?.badge,
77
+ logo: file ?? spec?.url,
78
+ logoBackground: file ? undefined : spec?.backgroundColor,
79
+ favicon: b?.favicon ?? (await assets.favicon?.().catch(() => undefined)),
80
+ }
81
+ brand.value = resolved
82
+ applyBranding(b, resolved, only.length === 1 ? (only[0]?.title ?? 'Specbook') : 'Specbook')
83
+ })
84
+ .catch(() => {
85
+ // a project without a readable config still gets its overview, from the APIs' own titles
86
+ brand.value = { ...brand.value, ready: true }
87
+ })
88
+ .finally(loadRest)
89
+ engine.environments(version.value).then((e) => {
90
+ environments.value = e
91
+ })
92
+ engine.environmentRisks(version.value).then((r) => {
93
+ environmentRisk.value = r
94
+ })
95
+ return () => removeEventListener('hashchange', onHash)
96
+ }, [])
97
+
98
+ // the settings menu (a phone) closes when you go somewhere else, or press Escape
99
+ const lastRoute = useRef(route.value)
100
+ useEffect(() => {
101
+ if (lastRoute.current === route.value) return
102
+ lastRoute.current = route.value
103
+ setMenuOpen(false)
104
+ }, [route.value])
105
+ useEffect(() => {
106
+ if (!menuOpen) return
107
+ const key = (e: KeyboardEvent) => e.key === 'Escape' && setMenuOpen(false)
108
+ addEventListener('keydown', key)
109
+ return () => removeEventListener('keydown', key)
110
+ }, [menuOpen])
111
+
112
+ const multiService = Object.keys(registry.manifests).length > 1
113
+ const entries = useMemo<Entry[]>(() => {
114
+ const out: Entry[] = []
115
+ const metas = [
116
+ ...Object.values(registry.sequenceMeta),
117
+ ...(sequences ?? []).map((s) => ({ ...s.seq.meta })),
118
+ ]
119
+ for (const m of metas) {
120
+ out.push({
121
+ kind: 'seq',
122
+ route: { kind: 'seq', id: m.id },
123
+ title: m.title,
124
+ group: m.tags?.[0] ?? 'sequence',
125
+ tags: m.tags,
126
+ flowGroup: m.group,
127
+ description: m.description,
128
+ hay: `${m.description ?? ''} ${(m.tags ?? []).join(' ')}`,
129
+ })
130
+ }
131
+ for (const m of Object.values(registry.manifests)) {
132
+ for (const o of m.operations) {
133
+ const key = `${m.service}/${o.id}`
134
+ const saved = (variants[key] ?? []).map((v) => v.name).join(' ')
135
+ out.push({
136
+ kind: 'op',
137
+ route: { kind: 'op', key },
138
+ title: o.summary?.replace(/\.$/, '') || o.path,
139
+ group: `${multiService ? `${m.service} · ` : ''}${o.tags[0] ?? 'default'}`,
140
+ service: m.service,
141
+ tag: o.tags[0] ?? 'default',
142
+ method: o.method,
143
+ path: o.path,
144
+ secured: o.secured,
145
+ hay: `${o.id} ${o.method} ${o.path} ${o.tags.join(' ')} ${saved}`,
146
+ })
147
+ }
148
+ }
149
+ return out
150
+ }, [sequences, variants, multiService])
151
+
152
+ const r = route.value
153
+ const opInfo = r.kind === 'op' ? findOp(r.key) : undefined
154
+ const seqId = r.kind === 'seq' ? r.id : undefined
155
+ const [opened, setOpened] = useState<SequenceInfo>()
156
+ useEffect(() => {
157
+ if (!seqId) return
158
+ const file = registry.sequenceMeta[seqId]?.file
159
+ if (file) {
160
+ const entry = registry.sequences.filter((e) => e.file === file)
161
+ engine.loadSequences(entry, version.value).then((l) => setOpened(l[0]))
162
+ } else setOpened(sequences?.find((s) => s.seq.meta.id === seqId))
163
+ }, [seqId, sequences, version.value])
164
+ const seqInfo = opened?.seq.meta.id === seqId ? opened : undefined
165
+ const panel = usePanelHost()
166
+ const close = () => go(pageOf(route.value))
167
+
168
+ const services = useMemo<ServiceInfo[]>(
169
+ () =>
170
+ Object.values(registry.manifests)
171
+ .map((m) => ({
172
+ id: m.service,
173
+ title: m.title,
174
+ version: m.version,
175
+ ops: m.operations.length,
176
+ }))
177
+ .sort((a, b) => a.id.localeCompare(b.id)),
178
+ [],
179
+ )
180
+
181
+ // previous/next within the group the open item belongs to
182
+ const neighbors = useMemo(() => {
183
+ if (r.kind !== 'op' && r.kind !== 'seq') return {}
184
+ const active = entries.find(
185
+ (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),
188
+ )
189
+ if (!active) return {}
190
+ const sibs = entries.filter((e) =>
191
+ active.kind === 'seq'
192
+ ? e.kind === 'seq'
193
+ : e.service === active.service && e.tag === active.tag,
194
+ )
195
+ const at = sibs.indexOf(active)
196
+ const to = (e?: Entry) => (e ? () => go({ ...e.route, play: '' } as typeof r) : undefined)
197
+ return { prev: to(sibs[at - 1]), next: to(sibs[at + 1]) }
198
+ }, [entries, r.kind === 'op' ? r.key : r.kind === 'seq' ? r.id : ''])
199
+
200
+ const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
201
+ go(
202
+ r.kind === 'op'
203
+ ? { kind: 'op', key: r.key, play: to.example ?? r.play ?? '', tab: to.tab }
204
+ : r.kind === 'seq'
205
+ ? { kind: 'seq', id: r.id, play: '', tab: to.tab }
206
+ : r,
207
+ )
208
+
209
+ return (
210
+ <div
211
+ class="app"
212
+ style={listWidth.value ? { '--sb-list-w': `${listWidth.value}px` } : undefined}
213
+ >
214
+ <header class="header">
215
+ <button type="button" class="brand" onClick={() => go({ kind: 'home' })}>
216
+ {(brand.value.logo ?? ownMark) && (
217
+ <img
218
+ class="brand-logo"
219
+ src={brand.value.logo ?? ownMark}
220
+ alt=""
221
+ style={
222
+ brand.value.logoBackground
223
+ ? {
224
+ background: brand.value.logoBackground,
225
+ padding: '3px 6px',
226
+ borderRadius: '4px',
227
+ }
228
+ : undefined
229
+ }
230
+ />
231
+ )}
232
+ <span class="brand-name">{brand.value.name ?? 'Specbook'}</span>
233
+ </button>
234
+ <button type="button" class="palette-btn" onClick={() => (paletteOpen.value = true)}>
235
+ <span>Jump to an operation or flow…</span>
236
+ <kbd>⌘K</kbd>
237
+ </button>
238
+ <span class="spacer" />
239
+ <span class="header-env">
240
+ <EnvBadge />
241
+ </span>
242
+ <button
243
+ type="button"
244
+ class="menu-btn icon"
245
+ aria-label="Settings"
246
+ aria-expanded={menuOpen}
247
+ onClick={() => setMenuOpen(!menuOpen)}
248
+ >
249
+ ⋯
250
+ </button>
251
+ <div class={`header-controls ${menuOpen ? 'open' : ''}`}>
252
+ {registry.loadFaker && (
253
+ <span
254
+ class="row muted"
255
+ title="Generated values such as {{faker.person.fullName}}: new on every run, or fixed so the same number always gives the same data"
256
+ >
257
+ <label class="row">
258
+ Synthetic data
259
+ <select
260
+ aria-label="Synthetic data"
261
+ value={seed.value === undefined ? 'random' : 'fixed'}
262
+ onChange={(e) => {
263
+ const fixed = (e.target as HTMLSelectElement).value === 'fixed'
264
+ seed.value = fixed ? (seed.value ?? 42) : undefined
265
+ }}
266
+ >
267
+ <option value="random">New each run</option>
268
+ <option value="fixed">Fixed</option>
269
+ </select>
270
+ </label>
271
+ {seed.value !== undefined && (
272
+ <input
273
+ type="number"
274
+ class="seed-input"
275
+ aria-label="Seed number"
276
+ title="The same number gives the same data every run"
277
+ value={seed.value}
278
+ onInput={(e) => {
279
+ const n = (e.target as HTMLInputElement).valueAsNumber
280
+ seed.value = Number.isFinite(n) ? n : undefined
281
+ }}
282
+ />
283
+ )}
284
+ </span>
285
+ )}
286
+ {fakerLocales.value.length > 1 && (
287
+ <label class="row muted">
288
+ locale
289
+ <select
290
+ value={locale.value ?? ''}
291
+ onChange={(e) =>
292
+ (locale.value = (e.target as HTMLSelectElement).value || undefined)
293
+ }
294
+ >
295
+ {fakerLocales.value.map((l) => (
296
+ <option value={l}>{l}</option>
297
+ ))}
298
+ </select>
299
+ </label>
300
+ )}
301
+ <EnvironmentMenu />
302
+ <label class="row">
303
+ Environment
304
+ <select
305
+ value={env.value}
306
+ onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
307
+ >
308
+ {environments.value.map((e) => (
309
+ <option value={e}>{e}</option>
310
+ ))}
311
+ </select>
312
+ </label>
313
+ <GitHubStar />
314
+ <ShareMenu />
315
+ <ThemeToggle />
316
+ </div>
317
+ </header>
318
+ <div class="layout" data-view={r.kind === 'home' ? 'splash' : 'list'}>
319
+ <nav class="list-col" aria-label="Operations and flows">
320
+ <CatalogList entries={entries} services={services} />
321
+ </nav>
322
+ <ListResizer />
323
+ <main class="stage">
324
+ {(r.kind === 'home' || r.kind === 'browse') && <Overview services={services} />}
325
+ </main>
326
+ </div>
327
+ <Palette entries={entries} />
328
+ {panel && r.kind === 'op' && opInfo && (
329
+ <panel.Host
330
+ kind="op"
331
+ modal={false}
332
+ service={opInfo.service}
333
+ op={opInfo.op}
334
+ variants={variants[r.key] ?? []}
335
+ example={r.play ?? ''}
336
+ tab={r.tab}
337
+ onNavigate={navigate}
338
+ onPrev={neighbors.prev}
339
+ onNext={neighbors.next}
340
+ onClose={close}
341
+ />
342
+ )}
343
+ {panel && r.kind === 'seq' && seqInfo && (
344
+ <panel.Host
345
+ kind="seq"
346
+ modal={false}
347
+ info={seqInfo}
348
+ tab={r.tab}
349
+ onNavigate={navigate}
350
+ onPrev={neighbors.prev}
351
+ onNext={neighbors.next}
352
+ onClose={close}
353
+ />
354
+ )}
355
+ </div>
356
+ )
357
+ }
358
+
359
+ function findOp(key: string) {
360
+ const [service, id] = key.split('/') as [string, string]
361
+ const op = registry.manifests[service]?.operations.find((o) => o.id === id)
362
+ return op ? { service, op } : undefined
363
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' role='img' aria-label='Specbook'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#8b5cf6'/><stop offset='1' stop-color='#5b21b6'/></linearGradient></defs><rect width='64' height='64' rx='15' fill='url(#g)'/><path d='M32 19.5c-4.5-3-10-3.6-15.5-2.4v28.4c5.500-1.200 11-.6 15.500 2.400 4.500-3 10-3.600 15.500-2.400V17.100c-5.500-1.200-11-.6-15.500 2.400z' fill='#fff' fill-opacity='.95'/><path d='M32 19.500v28.400' stroke='#6d28d9' stroke-width='2' stroke-linecap='round'/><path d='M38 27.500v9.500l7.500-4.750z' fill='#6d28d9'/><path d='M26.500 28.500l-4 3.750 4 3.750' fill='none' stroke='#6d28d9' stroke-width='2.200' stroke-linecap='round' stroke-linejoin='round'/></svg>
package/src/badge.ts ADDED
@@ -0,0 +1,14 @@
1
+ const OWN = /(^|\.)specbook\.dev$/i
2
+
3
+ /**
4
+ * "Powered by Specbook" shows on a published site (a build, or the viewer on someone's own host) and
5
+ * not for the author running the dev server, nor on specbook.dev itself, where the name is already
6
+ * everywhere. A project turns it off with `branding.badge: false`.
7
+ */
8
+ export function showBadge(o: { host: string; published: boolean; badge?: boolean }): boolean {
9
+ return o.published && o.badge !== false && !OWN.test(o.host)
10
+ }
11
+
12
+ /** The link back carries the host, so a site's referrals can be told apart without any tracking script. */
13
+ export const badgeHref = (host: string) =>
14
+ `https://ui.specbook.dev/?ref=${encodeURIComponent(host)}`
@@ -0,0 +1,92 @@
1
+ import { signal } from '@preact/signals'
2
+ import type { BrandingConfig } from '@specbook/core'
3
+
4
+ export interface Brand {
5
+ /** the project's config has been read; until then the overview would show its fallback text */
6
+ ready?: boolean
7
+ name?: string
8
+ title?: string
9
+ tagline?: string
10
+ logo?: string
11
+ /** background behind a logo that needs one (from the spec's x-logo) */
12
+ logoBackground?: string
13
+ favicon?: string
14
+ links: { label: string; href: string }[]
15
+ /** `false` hides the "Powered by Specbook" line */
16
+ badge?: boolean
17
+ }
18
+
19
+ /** The resolved branding: config text and colours, plus logo/favicon from URLs or `.specbook/branding/`. */
20
+ export const brand = signal<Brand>({ links: [], ready: false })
21
+
22
+ function rgb(hex: string): [number, number, number] | undefined {
23
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
24
+ if (!m) return undefined
25
+ const h =
26
+ (m[1] as string).length === 3
27
+ ? [...(m[1] as string)].map((c) => c + c).join('')
28
+ : (m[1] as string)
29
+ return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
30
+ }
31
+
32
+ /** Whichever of white and near-black has the better contrast ratio on the accent. */
33
+ export function onAccent(hex: string): string | undefined {
34
+ const c = rgb(hex)
35
+ if (!c) return undefined
36
+ const lin = c.map((v) => {
37
+ const s = v / 255
38
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
39
+ })
40
+ const l = 0.2126 * (lin[0] as number) + 0.7152 * (lin[1] as number) + 0.0722 * (lin[2] as number)
41
+ const dark = 0.006 // #0b1020
42
+ const onWhite = 1.05 / (l + 0.05)
43
+ const onDark = (l + 0.05) / (dark + 0.05)
44
+ return onDark > onWhite ? '#0b1020' : '#ffffff'
45
+ }
46
+
47
+ const tokens = (accent: string) =>
48
+ `--sb-accent:${accent};--sb-accent-hover:color-mix(in srgb,${accent} 82%,black);` +
49
+ `--sb-focus-ring:color-mix(in srgb,${accent} 45%,white);` +
50
+ (onAccent(accent) ? `--sb-text-on-accent:${onAccent(accent)};` : '')
51
+
52
+ /**
53
+ * The accent as CSS: unlayered, so it wins over the default tokens. A separate dark value applies
54
+ * under `prefers-color-scheme: dark` (unless the light theme is forced) and `data-theme="dark"`.
55
+ */
56
+ export function brandCss(b: Pick<BrandingConfig, 'accent' | 'accentDark'>): string {
57
+ if (!b.accent && !b.accentDark) return ''
58
+ const light = b.accent ? `:root{${tokens(b.accent)}}` : ''
59
+ if (!b.accentDark) return light
60
+ return (
61
+ `${light}` +
62
+ `@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){${tokens(b.accentDark)}}}` +
63
+ `:root[data-theme="dark"]{${tokens(b.accentDark)}}`
64
+ )
65
+ }
66
+
67
+ /** Applies the branding to the page: accent tokens, tab title and favicon. */
68
+ export function applyBranding(
69
+ cfg: BrandingConfig | undefined,
70
+ resolved: Brand,
71
+ fallbackName: string,
72
+ ) {
73
+ if (typeof document === 'undefined') return
74
+ let style = document.getElementById('specbook-brand') as HTMLStyleElement | null
75
+ if (!style) {
76
+ style = document.createElement('style')
77
+ style.id = 'specbook-brand'
78
+ document.head.append(style)
79
+ }
80
+ style.textContent = cfg ? brandCss(cfg) : ''
81
+ const name = resolved.name ?? fallbackName
82
+ document.title = resolved.title ? `${name} · ${resolved.title}` : name
83
+ if (resolved.favicon || resolved.logo) {
84
+ let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
85
+ if (!link) {
86
+ link = document.createElement('link')
87
+ link.rel = 'icon'
88
+ document.head.append(link)
89
+ }
90
+ link.href = (resolved.favicon ?? resolved.logo) as string
91
+ }
92
+ }