@bielu/scalar-grpc 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.
@@ -0,0 +1,759 @@
1
+ <script setup lang="ts">
2
+ import type { JsonValue } from '@bufbuild/protobuf'
3
+ import { computed, onMounted, reactive, ref, watch } from 'vue'
4
+ import { getAuthState, resolveGrpcAuth } from '../auth'
5
+ import { resolveDocuments } from '../discovery'
6
+ import { loadGrpcServices } from '../grpc-bindings'
7
+ import { descriptorsUrlFor, invokeServerStreaming, invokeUnary, loadDescriptorRegistry } from '../grpc-client'
8
+ import type { GrpcDocumentRef, GrpcMethodModel, GrpcServiceModel } from '../types'
9
+
10
+ // `options` is the Scalar configuration Scalar binds to the element; `documents` is an optional
11
+ // explicit override. Documents are otherwise auto-discovered from `options.sources`.
12
+ const props = defineProps<{ options?: Record<string, any>; documents?: GrpcDocumentRef[] }>()
13
+
14
+ type LogEntry = { time: string; dir: 'in' | 'out' | 'sys'; text: string }
15
+
16
+ const loading = ref(true)
17
+ const services = ref<GrpcServiceModel[]>([])
18
+ // The console's own root element, used to discover which Scalar document it is rendered under.
19
+ const rootEl = ref<HTMLElement | null>(null)
20
+ // Scalar's active document slug (null on stock Scalar / standalone use, where the picker is shown).
21
+ const activeDocumentSlug = ref<string | null>(null)
22
+ const selectedDocumentName = ref<string | null>(null)
23
+ const selectedKey = ref<string | null>(null)
24
+ const baseUrlOverride = ref('')
25
+ const invoking = ref(false)
26
+ const log = reactive<LogEntry[]>([])
27
+ // Per-method state survives service switches, so it is keyed by service + method id (methods from
28
+ // different services/documents can share an `id`, which would otherwise overwrite each other).
29
+ const requestJson = reactive<Record<string, string>>({})
30
+ const metadataJson = reactive<Record<string, string>>({})
31
+ const results = reactive<Record<string, { ok: boolean; text: string }>>({})
32
+ // Which method is selected per service (keyed by serviceKey), and which message is the active example.
33
+ const selectedMethodIds = reactive<Record<string, string>>({})
34
+ const selectedMessageIndex = reactive<Record<string, number>>({})
35
+
36
+ const serviceKey = (service: GrpcServiceModel) => `${service.documentName}:${service.channelName}`
37
+ // A service-scoped key for a method. All displayed methods belong to the selected service, so the
38
+ // selected service key uniquely scopes them.
39
+ const methodKey = (method: GrpcMethodModel) => `${selectedKey.value}:${method.id}`
40
+
41
+ const uniqueDocuments = computed(() => {
42
+ const seen = new Set<string>()
43
+ const docs: string[] = []
44
+ for (const service of services.value) {
45
+ if (!seen.has(service.documentName)) {
46
+ seen.add(service.documentName)
47
+ docs.push(service.documentName)
48
+ }
49
+ }
50
+ return docs
51
+ })
52
+
53
+ // Scope the visible services to Scalar's active document when known (so the console reflects the
54
+ // document the user is viewing), otherwise to the manually picked document. Falls back to all
55
+ // services only when neither is set.
56
+ const scopedDocumentName = computed(() => activeDocumentSlug.value ?? selectedDocumentName.value)
57
+ const filteredServices = computed(() =>
58
+ scopedDocumentName.value
59
+ ? services.value.filter((service) => service.documentName === scopedDocumentName.value)
60
+ : services.value,
61
+ )
62
+
63
+ // The document picker only makes sense when the console spans several documents itself. Once it is
64
+ // scoped to Scalar's active document, the active document is authoritative, so the picker is hidden.
65
+ const showDocumentPicker = computed(() => !activeDocumentSlug.value && uniqueDocuments.value.length > 1)
66
+
67
+ const selectedService = computed(
68
+ () => filteredServices.value.find((service) => serviceKey(service) === selectedKey.value) ?? null,
69
+ )
70
+ // The selected method id for the current service, persisted per service so switching keeps each choice.
71
+ const selectedMethodId = computed<string | null>({
72
+ get: () => (selectedKey.value ? selectedMethodIds[selectedKey.value] ?? null : null),
73
+ set: (value) => {
74
+ if (selectedKey.value && value) {
75
+ selectedMethodIds[selectedKey.value] = value
76
+ }
77
+ },
78
+ })
79
+ const selectedMethod = computed(
80
+ () => selectedService.value?.methods.find((method) => method.id === selectedMethodId.value) ?? null,
81
+ )
82
+
83
+ // gRPC-Web only supports unary and server-streaming calls (missing method types are treated as
84
+ // unary). Client-/bidi-streaming methods render as documentation with a "not invokable" badge.
85
+ const isInvokable = (method: GrpcMethodModel) =>
86
+ method.methodType === undefined || method.methodType === 'unary' || method.methodType === 'serverStreaming'
87
+
88
+ const effectiveBaseUrl = computed(() =>
89
+ (baseUrlOverride.value || selectedService.value?.baseUrl || '').replace(/\/+$/, ''),
90
+ )
91
+
92
+ const methodUrl = computed(() => {
93
+ const service = selectedService.value
94
+ const method = selectedMethod.value
95
+ if (!service || !method) {
96
+ return ''
97
+ }
98
+ return `${effectiveBaseUrl.value}/${service.service}/${method.method}`
99
+ })
100
+
101
+ function addLog(dir: LogEntry['dir'], text: string) {
102
+ log.unshift({ time: new Date().toLocaleTimeString(), dir, text })
103
+ if (log.length > 200) {
104
+ log.pop()
105
+ }
106
+ }
107
+
108
+ function methodTypeLabel(method: GrpcMethodModel): string {
109
+ switch (method.methodType) {
110
+ case 'serverStreaming':
111
+ return 'SERVER STREAM'
112
+ case 'clientStreaming':
113
+ return 'CLIENT STREAM'
114
+ case 'bidirectionalStreaming':
115
+ return 'BIDI STREAM'
116
+ default:
117
+ return 'UNARY'
118
+ }
119
+ }
120
+
121
+ /** Parse the request editor contents into a proto3-JSON object (empty editor → empty message). */
122
+ function parseRequest(raw: string): JsonValue {
123
+ const text = (raw ?? '').trim()
124
+ if (!text) {
125
+ return {}
126
+ }
127
+ return JSON.parse(text) as JsonValue
128
+ }
129
+
130
+ /** Parse the metadata editor contents into flat string headers. */
131
+ function parseMetadata(raw: string): Record<string, string> {
132
+ const text = (raw ?? '').trim()
133
+ if (!text) {
134
+ return {}
135
+ }
136
+ const parsed = JSON.parse(text)
137
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
138
+ throw new Error('metadata must be a JSON object of header names to string values')
139
+ }
140
+ const headers: Record<string, string> = {}
141
+ for (const [key, value] of Object.entries(parsed)) {
142
+ headers[key] = String(value)
143
+ }
144
+ return headers
145
+ }
146
+
147
+ async function testRequest(method: GrpcMethodModel) {
148
+ const service = selectedService.value
149
+ if (!service || invoking.value) {
150
+ return
151
+ }
152
+ const key = methodKey(method)
153
+
154
+ let request: JsonValue
155
+ try {
156
+ request = parseRequest(requestJson[key])
157
+ } catch (error: any) {
158
+ results[key] = { ok: false, text: `Invalid JSON request: ${error?.message ?? error}` }
159
+ return
160
+ }
161
+ let headers: Record<string, string>
162
+ try {
163
+ headers = parseMetadata(metadataJson[key])
164
+ } catch (error: any) {
165
+ results[key] = { ok: false, text: `Invalid metadata: ${error?.message ?? error}` }
166
+ return
167
+ }
168
+
169
+ // Resolve Scalar's current auth state → no-op on stock Scalar or when no scheme is selected.
170
+ // Explicit metadata entered in the editor wins over auth-derived headers of the same name.
171
+ const authResult = resolveGrpcAuth(service.documentName, service.securitySchemes, getAuthState())
172
+ for (const warn of authResult.warnings) {
173
+ addLog('sys', `Auth warning: ${warn}`)
174
+ }
175
+ headers = { ...authResult.headers, ...headers }
176
+
177
+ const hasSchemes = service.securitySchemes && Object.keys(service.securitySchemes).length > 0
178
+ if (hasSchemes && Object.keys(authResult.headers).length === 0) {
179
+ addLog(
180
+ 'sys',
181
+ `Auth: no credentials resolved for document "${service.documentName}" — select a security ` +
182
+ `scheme in the Authentication panel and enter your credentials.`,
183
+ )
184
+ }
185
+
186
+ invoking.value = true
187
+ addLog('out', `${service.service}/${method.method} ${JSON.stringify(request)}`)
188
+ try {
189
+ const registry = await loadDescriptorRegistry(descriptorsUrlFor(effectiveBaseUrl.value))
190
+ const callOptions = {
191
+ baseUrl: effectiveBaseUrl.value,
192
+ headers,
193
+ timeoutMs: method.deadlineSeconds ? method.deadlineSeconds * 1000 : undefined,
194
+ }
195
+ if (method.methodType === 'serverStreaming') {
196
+ const items: JsonValue[] = []
197
+ await invokeServerStreaming(registry, service.service, method.method, request, callOptions, (message) => {
198
+ items.push(message)
199
+ addLog('in', `← ${JSON.stringify(message)}`)
200
+ })
201
+ results[key] = {
202
+ ok: true,
203
+ text: `Stream completed: ${items.length} message(s).\n${JSON.stringify(items, null, 2)}`,
204
+ }
205
+ } else {
206
+ const response = await invokeUnary(registry, service.service, method.method, request, callOptions)
207
+ results[key] = { ok: true, text: JSON.stringify(response, null, 2) }
208
+ addLog('in', `← ${JSON.stringify(response)}`)
209
+ }
210
+ } catch (error: any) {
211
+ results[key] = { ok: false, text: `Error: ${error?.message ?? error}` }
212
+ addLog('sys', `Invoke failed: ${error?.message ?? error}`)
213
+ } finally {
214
+ invoking.value = false
215
+ }
216
+ }
217
+
218
+ /** The example string for a given message of a method (falls back to an empty object). */
219
+ function exampleFor(method: GrpcMethodModel | null, index: number): string {
220
+ return method?.messages?.[index]?.example ?? '{}'
221
+ }
222
+
223
+ /** Select a message and (re)fill the request editor with its generated example. */
224
+ function applyExample(method: GrpcMethodModel, index: number) {
225
+ const key = methodKey(method)
226
+ selectedMessageIndex[key] = index
227
+ requestJson[key] = exampleFor(method, index)
228
+ }
229
+
230
+ /** Prefill a method's editor with its first example, but only if it is still untouched. */
231
+ function ensurePrefilled(method: GrpcMethodModel) {
232
+ const key = methodKey(method)
233
+ if (requestJson[key] === undefined) {
234
+ applyExample(method, selectedMessageIndex[key] ?? 0)
235
+ }
236
+ }
237
+
238
+ // Keep the selected service within the visible (document-scoped) set. Fires when the document
239
+ // changes (picker or active-document detection) and when services finish loading.
240
+ watch(
241
+ filteredServices,
242
+ (list) => {
243
+ if (!list.some((service) => serviceKey(service) === selectedKey.value)) {
244
+ selectedKey.value = list[0] ? serviceKey(list[0]) : null
245
+ }
246
+ },
247
+ { immediate: true },
248
+ )
249
+
250
+ watch(selectedService, (service) => {
251
+ baseUrlOverride.value = service?.baseUrl ?? ''
252
+ })
253
+
254
+ // When the available methods change (e.g. a different service), select the first one and prefill it.
255
+ watch(
256
+ () => selectedService.value?.methods ?? [],
257
+ (methods) => {
258
+ if (!methods.some((method) => method.id === selectedMethodId.value)) {
259
+ selectedMethodId.value = methods[0]?.id ?? null
260
+ }
261
+ methods.forEach(ensurePrefilled)
262
+ },
263
+ { immediate: true },
264
+ )
265
+
266
+ // Selecting a different method prefills its editor on first visit.
267
+ watch(selectedMethod, (method) => {
268
+ if (method) {
269
+ ensurePrefilled(method)
270
+ }
271
+ })
272
+
273
+ /**
274
+ * Discover Scalar's active document from the plugin-view wrapper this console is rendered inside.
275
+ * Scalar renders each `content.end` plugin view under a `<div id="{documentSlug}/plugin-view/…">`
276
+ * scoped to the active document, so the slug is the id prefix. Returns null when the console is not
277
+ * inside such a wrapper (stock Scalar / standalone), leaving the multi-document picker in place.
278
+ */
279
+ function detectActiveDocumentSlug(): string | null {
280
+ const wrapper = rootEl.value?.closest('[id*="/plugin-view/"]')
281
+ const slug = wrapper?.id.split('/plugin-view/')[0]
282
+ return slug || null
283
+ }
284
+
285
+ async function init() {
286
+ loading.value = true
287
+ services.value = await loadGrpcServices(resolveDocuments(props.options, props.documents))
288
+ // Seed a default document for the picker; the active-document scope (when detected) overrides it.
289
+ if (services.value.length > 0 && !selectedDocumentName.value) {
290
+ selectedDocumentName.value = services.value[0].documentName
291
+ }
292
+ loading.value = false
293
+ }
294
+
295
+ void init()
296
+ onMounted(() => {
297
+ activeDocumentSlug.value = detectActiveDocumentSlug()
298
+ })
299
+ </script>
300
+
301
+ <template>
302
+ <section ref="rootEl" class="bgr">
303
+ <header class="bgr__head">
304
+ <h2 class="bgr__title">gRPC</h2>
305
+ <span class="bgr__pill">gRPC-Web</span>
306
+ </header>
307
+
308
+ <p v-if="loading" class="bgr__muted">Loading gRPC services…</p>
309
+ <p v-else-if="filteredServices.length === 0" class="bgr__muted">
310
+ No gRPC services found in {{ activeDocumentSlug ? 'this document' : 'the AsyncAPI document(s)' }}.
311
+ </p>
312
+
313
+ <template v-else>
314
+ <!-- Target bar -->
315
+ <div class="bgr__card bgr__conn">
316
+ <div class="bgr__conn-row">
317
+ <label v-if="showDocumentPicker" class="bgr__field">
318
+ <span>Document</span>
319
+ <select v-model="selectedDocumentName">
320
+ <option v-for="doc in uniqueDocuments" :key="doc" :value="doc">{{ doc }}</option>
321
+ </select>
322
+ </label>
323
+ <label class="bgr__field">
324
+ <span>Service</span>
325
+ <select v-model="selectedKey">
326
+ <option v-for="service in filteredServices" :key="serviceKey(service)" :value="serviceKey(service)">
327
+ {{ service.service }}
328
+ </option>
329
+ </select>
330
+ </label>
331
+ <label class="bgr__field bgr__field--grow">
332
+ <span>Base URL</span>
333
+ <input v-model="baseUrlOverride" type="text" spellcheck="false" />
334
+ </label>
335
+ </div>
336
+ <div v-if="selectedService?.protoFile" class="bgr__conn-row bgr__conn-row--foot">
337
+ <span class="bgr__hint">{{ selectedService.protoFile }}</span>
338
+ </div>
339
+ </div>
340
+
341
+ <!-- Two columns on wide screens (method invocation | message log); stacked on narrow. -->
342
+ <div class="bgr__grid">
343
+ <!-- Left column: pick a method, edit the prefilled request, invoke over gRPC-Web. -->
344
+ <div class="bgr__col">
345
+ <h3 class="bgr__section">Methods</h3>
346
+ <p v-if="!selectedService || selectedService.methods.length === 0" class="bgr__muted">
347
+ No RPC methods declared for this service.
348
+ </p>
349
+ <article v-else class="bgr__card bgr__op">
350
+ <div class="bgr__conn-row">
351
+ <label class="bgr__field bgr__field--grow">
352
+ <span>Method</span>
353
+ <select v-model="selectedMethodId">
354
+ <option v-for="method in selectedService.methods" :key="method.id" :value="method.id">
355
+ {{ method.method }} · {{ methodTypeLabel(method) }}
356
+ </option>
357
+ </select>
358
+ </label>
359
+ <label v-if="selectedMethod && selectedMethod.messages.length > 1" class="bgr__field bgr__field--grow">
360
+ <span>Message</span>
361
+ <select
362
+ :value="selectedMessageIndex[methodKey(selectedMethod)] ?? 0"
363
+ @change="applyExample(selectedMethod, Number(($event.target as HTMLSelectElement).value))"
364
+ >
365
+ <option v-for="(message, index) in selectedMethod.messages" :key="message.name" :value="index">
366
+ {{ message.title || message.name }}
367
+ </option>
368
+ </select>
369
+ </label>
370
+ </div>
371
+
372
+ <template v-if="selectedMethod">
373
+ <div class="bgr__op-head bgr__op-head--spaced">
374
+ <span class="bgr__method" :data-kind="selectedMethod.methodType ?? 'unary'">
375
+ {{ methodTypeLabel(selectedMethod) }}
376
+ </span>
377
+ <span class="bgr__op-target">{{ selectedMethod.method }}</span>
378
+ <span v-if="!isInvokable(selectedMethod)" class="bgr__badge-warn">not invokable from the browser</span>
379
+ </div>
380
+ <p v-if="selectedMethod.summary" class="bgr__muted bgr__op-summary">{{ selectedMethod.summary }}</p>
381
+ <p class="bgr__hint bgr__op-summary">
382
+ {{ selectedMethod.requestType ?? '?' }} → {{ selectedMethod.responseType ?? '?'
383
+ }}<template v-if="selectedMethod.deadlineSeconds"> · deadline {{ selectedMethod.deadlineSeconds }}s</template>
384
+ <template v-if="selectedMethod.idempotencyLevel"> · {{ selectedMethod.idempotencyLevel }}</template>
385
+ </p>
386
+
387
+ <template v-if="isInvokable(selectedMethod)">
388
+ <div class="bgr__code-head">
389
+ <span class="bgr__hint">Request message (JSON)</span>
390
+ <button
391
+ type="button"
392
+ class="bgr__link"
393
+ :disabled="selectedMethod.messages.length === 0"
394
+ @click="applyExample(selectedMethod, selectedMessageIndex[methodKey(selectedMethod)] ?? 0)"
395
+ >
396
+ ↺ Reset to example
397
+ </button>
398
+ </div>
399
+ <textarea
400
+ v-model="requestJson[methodKey(selectedMethod)]"
401
+ class="bgr__code"
402
+ rows="6"
403
+ spellcheck="false"
404
+ placeholder='Request message as JSON, e.g. { "name": "world" }'
405
+ />
406
+ <div class="bgr__code-head">
407
+ <span class="bgr__hint">Metadata (JSON object of headers, optional)</span>
408
+ </div>
409
+ <textarea
410
+ v-model="metadataJson[methodKey(selectedMethod)]"
411
+ class="bgr__code"
412
+ rows="2"
413
+ spellcheck="false"
414
+ placeholder='{ "x-correlation-id": "1234" }'
415
+ />
416
+ <div class="bgr__op-foot">
417
+ <span class="bgr__hint">{{ methodUrl }}</span>
418
+ <button
419
+ type="button"
420
+ class="bgr__btn bgr__btn--primary"
421
+ :disabled="invoking"
422
+ @click="testRequest(selectedMethod)"
423
+ >
424
+ ▶ Test Request
425
+ </button>
426
+ </div>
427
+ <pre
428
+ v-if="results[methodKey(selectedMethod)]"
429
+ class="bgr__result"
430
+ :data-ok="results[methodKey(selectedMethod)].ok"
431
+ >{{ results[methodKey(selectedMethod)].text }}</pre>
432
+ </template>
433
+ <p v-else class="bgr__muted bgr__op-summary">
434
+ gRPC-Web supports unary and server-streaming calls only; client- and bidirectional-streaming
435
+ methods are shown for documentation. The example request below is generated from the AsyncAPI
436
+ payload schema.
437
+ </p>
438
+ <pre
439
+ v-if="!isInvokable(selectedMethod) && selectedMethod.messages.length > 0"
440
+ class="bgr__result bgr__result--sample"
441
+ >{{ selectedMethod.messages[0].example }}</pre>
442
+ </template>
443
+ </article>
444
+ </div>
445
+
446
+ <!-- Right column: the live message log. -->
447
+ <div class="bgr__col">
448
+ <h3 class="bgr__section">Messages</h3>
449
+ <ul class="bgr__log">
450
+ <li v-for="(entry, index) in log" :key="index" :class="`bgr__log-${entry.dir}`">
451
+ <span class="bgr__time">{{ entry.time }}</span>
452
+ <span class="bgr__badge">{{ entry.dir }}</span>
453
+ <span>{{ entry.text }}</span>
454
+ </li>
455
+ <li v-if="log.length === 0" class="bgr__muted">No messages yet.</li>
456
+ </ul>
457
+ </div>
458
+ </div>
459
+ </template>
460
+ </section>
461
+ </template>
462
+
463
+ <style scoped>
464
+ .bgr {
465
+ font-family: var(--scalar-font, system-ui, sans-serif);
466
+ color: var(--scalar-color-1, inherit);
467
+ font-size: var(--scalar-font-size-3, 0.9rem);
468
+ /* Render as its own Scalar-style section: a clear top divider, generous breathing room above,
469
+ and horizontal insets so it lines up with the operations/models instead of sitting flush
470
+ against the viewport edge. `--bgr-content-padding` lets hosts fine-tune the inset. */
471
+ padding: 2.5rem var(--bgr-content-padding, var(--scalar-content-padding, 24px)) 3rem;
472
+ margin-top: 2rem;
473
+ border-top: 1px solid var(--scalar-border-color, #e3e3e3);
474
+ max-width: var(--bgr-content-max-width, none);
475
+ }
476
+
477
+ .bgr__head {
478
+ display: flex;
479
+ align-items: center;
480
+ gap: 0.75rem;
481
+ margin-bottom: 1.25rem;
482
+ }
483
+
484
+ /* Two-column layout on wide screens (method | log); single column when narrow.
485
+ The target bar stays full width above this grid. */
486
+ .bgr__grid {
487
+ display: grid;
488
+ grid-template-columns: 1fr;
489
+ gap: 0 2rem;
490
+ align-items: start;
491
+ }
492
+
493
+ .bgr__col {
494
+ min-width: 0;
495
+ }
496
+
497
+ @media (min-width: 60rem) {
498
+ .bgr__grid {
499
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
500
+ }
501
+ }
502
+
503
+ .bgr__title {
504
+ margin: 0;
505
+ font-size: 1.25rem;
506
+ }
507
+
508
+ .bgr__pill {
509
+ font-size: 0.7rem;
510
+ text-transform: uppercase;
511
+ letter-spacing: 0.03em;
512
+ padding: 0.15rem 0.5rem;
513
+ border-radius: 999px;
514
+ background: var(--scalar-background-2, #eee);
515
+ color: var(--scalar-color-2, #555);
516
+ }
517
+
518
+ .bgr__card {
519
+ border: 1px solid var(--scalar-border-color, #e3e3e3);
520
+ border-radius: var(--scalar-radius, 8px);
521
+ background: var(--scalar-background-1, transparent);
522
+ padding: 0.85rem;
523
+ margin-bottom: 0.85rem;
524
+ }
525
+
526
+ .bgr__conn-row {
527
+ display: flex;
528
+ flex-wrap: wrap;
529
+ gap: 0.75rem;
530
+ align-items: flex-end;
531
+ }
532
+
533
+ .bgr__conn-row--foot {
534
+ margin-top: 0.75rem;
535
+ align-items: center;
536
+ }
537
+
538
+ .bgr__field {
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 0.25rem;
542
+ }
543
+
544
+ .bgr__field > span {
545
+ font-size: 0.72rem;
546
+ text-transform: uppercase;
547
+ letter-spacing: 0.03em;
548
+ color: var(--scalar-color-3, #777);
549
+ }
550
+
551
+ .bgr__field--grow {
552
+ flex: 1;
553
+ min-width: 12rem;
554
+ }
555
+
556
+ .bgr__field input,
557
+ .bgr__field select {
558
+ padding: 0.35rem 0.5rem;
559
+ border: 1px solid var(--scalar-border-color, #ddd);
560
+ border-radius: var(--scalar-radius, 6px);
561
+ background: var(--scalar-background-2, #fff);
562
+ color: inherit;
563
+ font: inherit;
564
+ }
565
+
566
+ .bgr__section {
567
+ margin: 1.25rem 0 0.5rem;
568
+ font-size: 0.95rem;
569
+ }
570
+
571
+ .bgr__op-head {
572
+ display: flex;
573
+ align-items: center;
574
+ gap: 0.6rem;
575
+ }
576
+
577
+ .bgr__op-head--spaced {
578
+ margin-top: 0.85rem;
579
+ }
580
+
581
+ .bgr__code-head {
582
+ display: flex;
583
+ align-items: center;
584
+ justify-content: space-between;
585
+ margin-top: 0.6rem;
586
+ }
587
+
588
+ .bgr__link {
589
+ font: inherit;
590
+ font-size: 0.72rem;
591
+ background: none;
592
+ border: 0;
593
+ padding: 0;
594
+ cursor: pointer;
595
+ color: var(--scalar-color-blue, #2563eb);
596
+ }
597
+
598
+ .bgr__link:disabled {
599
+ opacity: 0.5;
600
+ cursor: not-allowed;
601
+ }
602
+
603
+ .bgr__result--sample {
604
+ border-left-color: var(--scalar-color-3, #888);
605
+ margin-top: 0.6rem;
606
+ }
607
+
608
+ .bgr__method {
609
+ font-family: var(--scalar-font-code, monospace);
610
+ font-size: 0.7rem;
611
+ font-weight: 700;
612
+ letter-spacing: 0.03em;
613
+ padding: 0.15rem 0.45rem;
614
+ border-radius: var(--scalar-radius, 5px);
615
+ background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 16%, transparent);
616
+ color: var(--scalar-color-blue, #2563eb);
617
+ white-space: nowrap;
618
+ }
619
+
620
+ .bgr__method[data-kind='serverStreaming'] {
621
+ background: color-mix(in srgb, var(--scalar-color-green, #16a34a) 16%, transparent);
622
+ color: var(--scalar-color-green, #16a34a);
623
+ }
624
+
625
+ .bgr__method[data-kind='clientStreaming'],
626
+ .bgr__method[data-kind='bidirectionalStreaming'] {
627
+ background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 16%, transparent);
628
+ color: var(--scalar-color-orange, #d97706);
629
+ }
630
+
631
+ .bgr__badge-warn {
632
+ font-size: 0.68rem;
633
+ text-transform: uppercase;
634
+ letter-spacing: 0.03em;
635
+ padding: 0.12rem 0.4rem;
636
+ border-radius: 999px;
637
+ background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 16%, transparent);
638
+ color: var(--scalar-color-orange, #d97706);
639
+ white-space: nowrap;
640
+ }
641
+
642
+ .bgr__op-target {
643
+ font-family: var(--scalar-font-code, monospace);
644
+ font-weight: 600;
645
+ }
646
+
647
+ .bgr__op-summary {
648
+ margin: 0.4rem 0 0;
649
+ }
650
+
651
+ .bgr__code {
652
+ width: 100%;
653
+ margin-top: 0.6rem;
654
+ padding: 0.6rem;
655
+ border: 1px solid var(--scalar-border-color, #ddd);
656
+ border-radius: var(--scalar-radius, 6px);
657
+ background: var(--scalar-background-2, #1e1e1e);
658
+ color: inherit;
659
+ font-family: var(--scalar-font-code, monospace);
660
+ font-size: 0.82rem;
661
+ resize: vertical;
662
+ box-sizing: border-box;
663
+ }
664
+
665
+ .bgr__op-foot {
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: space-between;
669
+ margin-top: 0.6rem;
670
+ }
671
+
672
+ .bgr__hint {
673
+ font-size: 0.72rem;
674
+ color: var(--scalar-color-3, #888);
675
+ overflow-wrap: anywhere;
676
+ }
677
+
678
+ .bgr__btn {
679
+ font: inherit;
680
+ font-weight: 600;
681
+ padding: 0.4rem 0.8rem;
682
+ border-radius: var(--scalar-radius, 6px);
683
+ border: 1px solid var(--scalar-border-color, #ccc);
684
+ background: var(--scalar-background-2, #f4f4f4);
685
+ color: inherit;
686
+ cursor: pointer;
687
+ }
688
+
689
+ .bgr__btn--primary {
690
+ background: var(--scalar-button-1, #111);
691
+ color: var(--scalar-button-1-color, #fff);
692
+ border-color: transparent;
693
+ }
694
+
695
+ .bgr__btn:disabled {
696
+ opacity: 0.5;
697
+ cursor: not-allowed;
698
+ }
699
+
700
+ .bgr__result {
701
+ margin: 0.6rem 0 0;
702
+ padding: 0.6rem;
703
+ border-radius: var(--scalar-radius, 6px);
704
+ background: var(--scalar-background-2, #f6f6f6);
705
+ border-left: 3px solid var(--scalar-color-green, #16a34a);
706
+ font-family: var(--scalar-font-code, monospace);
707
+ font-size: 0.8rem;
708
+ white-space: pre-wrap;
709
+ overflow-wrap: anywhere;
710
+ }
711
+
712
+ .bgr__result[data-ok='false'] {
713
+ border-left-color: var(--scalar-color-red, #dc2626);
714
+ }
715
+
716
+ .bgr__log {
717
+ list-style: none;
718
+ margin: 0;
719
+ padding: 0.5rem;
720
+ max-height: 16rem;
721
+ overflow: auto;
722
+ border: 1px solid var(--scalar-border-color, #e3e3e3);
723
+ border-radius: var(--scalar-radius, 8px);
724
+ background: var(--scalar-background-1, transparent);
725
+ font-family: var(--scalar-font-code, monospace);
726
+ font-size: 0.78rem;
727
+ }
728
+
729
+ .bgr__log li {
730
+ display: flex;
731
+ gap: 0.5rem;
732
+ padding: 0.12rem 0;
733
+ }
734
+
735
+ .bgr__time {
736
+ opacity: 0.55;
737
+ }
738
+
739
+ .bgr__badge {
740
+ text-transform: uppercase;
741
+ font-size: 0.62rem;
742
+ align-self: center;
743
+ border-radius: 3px;
744
+ padding: 0 0.25rem;
745
+ background: var(--scalar-background-2, #eee);
746
+ }
747
+
748
+ .bgr__log-in .bgr__badge {
749
+ background: color-mix(in srgb, var(--scalar-color-blue, #2563eb) 20%, transparent);
750
+ }
751
+
752
+ .bgr__log-out .bgr__badge {
753
+ background: color-mix(in srgb, var(--scalar-color-orange, #d97706) 20%, transparent);
754
+ }
755
+
756
+ .bgr__muted {
757
+ color: var(--scalar-color-3, #888);
758
+ }
759
+ </style>