@backmatter/studio 0.0.4 → 0.0.6

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,769 @@
1
+ // packages/studio/src/site-bar.tsx
2
+ // On-site bar: Live/Preview, the current page, the branch, then Agent and Publish.
3
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
4
+ import { isRoutable, routeFor, type RouteModel } from "@backmatter/core";
5
+ import {
6
+ checkoutBranch,
7
+ createPreviewTicket,
8
+ fetchDocs,
9
+ fetchGit,
10
+ fetchSchema,
11
+ type DocListItem,
12
+ } from "./api";
13
+ import { GitBranchIcon } from "./chrome/icons";
14
+ import { matchAppHotkey, modifierSymbol } from "./chrome/hotkeys";
15
+ import { PublishIcon, SparkIcon } from "./chat/icons";
16
+ import { PREVIEW_CHROME_BORDER } from "./preview-frame";
17
+
18
+ export const DEVBAR_CLASS = "backmatter-devbar";
19
+
20
+ const DEFAULT_PREVIEW_ENTRY = "/api/backmatter/preview";
21
+
22
+ export type PageMenuDoc = {
23
+ locale: string;
24
+ type: string;
25
+ slug: string;
26
+ };
27
+
28
+ export type PageMenuItem = {
29
+ key: string;
30
+ label: string;
31
+ href: string;
32
+ current: boolean;
33
+ };
34
+
35
+ /** Routable documents, in label order. Unroutable models stay out of the menu. */
36
+ export function pageMenuItems(input: {
37
+ docs: readonly PageMenuDoc[];
38
+ models: readonly RouteModel[];
39
+ current: PageMenuDoc | null;
40
+ }): PageMenuItem[] {
41
+ const byType = new Map(input.models.map((model) => [model.type, model]));
42
+ const locales = new Set(input.docs.map((doc) => doc.locale));
43
+ const showLocale = locales.size > 1;
44
+ const items: PageMenuItem[] = [];
45
+ for (const doc of input.docs) {
46
+ const model = byType.get(doc.type);
47
+ if (!model || !isRoutable(model)) continue;
48
+ const href = routeFor(model, doc.locale, doc.slug);
49
+ if (!href) continue;
50
+ const name = `${doc.type}/${doc.slug}`;
51
+ items.push({
52
+ key: `${doc.locale}/${doc.type}/${doc.slug}`,
53
+ label: showLocale ? `${name} · ${doc.locale}` : name,
54
+ href,
55
+ current: Boolean(
56
+ input.current &&
57
+ input.current.locale === doc.locale &&
58
+ input.current.type === doc.type &&
59
+ input.current.slug === doc.slug,
60
+ ),
61
+ });
62
+ }
63
+ items.sort((a, b) => a.label.localeCompare(b.label));
64
+ return items;
65
+ }
66
+
67
+ type MenuState = "page" | "branch" | null;
68
+
69
+ export function SiteBar({
70
+ label,
71
+ branch,
72
+ tree,
73
+ doc,
74
+ exiting,
75
+ chatOpen,
76
+ publishOpen,
77
+ changeCount,
78
+ previewEntryPath = DEFAULT_PREVIEW_ENTRY,
79
+ siteMode = "preview",
80
+ onExit,
81
+ onEnterPreview,
82
+ onToggleChat,
83
+ onTogglePublish,
84
+ }: {
85
+ label: string;
86
+ branch?: string;
87
+ tree?: string;
88
+ doc: (PageMenuDoc & { workspaceId: string }) | null;
89
+ siteMode?: "preview" | "live";
90
+ exiting?: boolean;
91
+ chatOpen: boolean;
92
+ publishOpen: boolean;
93
+ changeCount: number;
94
+ previewEntryPath?: string;
95
+ onExit: () => void;
96
+ onEnterPreview?: () => void;
97
+ onToggleChat: () => void;
98
+ onTogglePublish: () => void;
99
+ }) {
100
+ const rootRef = useRef<HTMLElement>(null);
101
+ const [menu, setMenu] = useState<MenuState>(null);
102
+
103
+ useEffect(() => {
104
+ if (!menu) return;
105
+ function onPointer(event: PointerEvent) {
106
+ const target = event.target;
107
+ if (!(target instanceof Node)) return;
108
+ if (rootRef.current?.contains(target)) return;
109
+ setMenu(null);
110
+ }
111
+ function onKey(event: KeyboardEvent) {
112
+ if (event.key !== "Escape") return;
113
+ setMenu(null);
114
+ }
115
+ document.addEventListener("pointerdown", onPointer);
116
+ document.addEventListener("keydown", onKey);
117
+ return () => {
118
+ document.removeEventListener("pointerdown", onPointer);
119
+ document.removeEventListener("keydown", onKey);
120
+ };
121
+ }, [menu]);
122
+
123
+ useEffect(() => {
124
+ function onKey(event: KeyboardEvent) {
125
+ if (matchAppHotkey(event) !== "chat") return;
126
+ setMenu(null);
127
+ }
128
+ document.addEventListener("keydown", onKey, true);
129
+ return () => document.removeEventListener("keydown", onKey, true);
130
+ }, []);
131
+
132
+ function dismissPanels() {
133
+ if (chatOpen) onToggleChat();
134
+ if (publishOpen) onTogglePublish();
135
+ }
136
+
137
+ function toggleMenu(next: Exclude<MenuState, null>) {
138
+ setMenu((open) => {
139
+ if (open === next) return null;
140
+ dismissPanels();
141
+ return next;
142
+ });
143
+ }
144
+
145
+ const publishEmphasis = publishOpen || changeCount > 0;
146
+
147
+ return (
148
+ <>
149
+ <style>{BAR_CSS}</style>
150
+ <footer ref={rootRef} aria-label="Backmatter" className={DEVBAR_CLASS}>
151
+ <div className={`${DEVBAR_CLASS}-mode`} role="group" aria-label="Site view">
152
+ <button
153
+ type="button"
154
+ aria-pressed={siteMode === "live"}
155
+ title="View the published site"
156
+ disabled={exiting || siteMode === "live"}
157
+ onClick={() => {
158
+ setMenu(null);
159
+ onExit();
160
+ }}
161
+ >
162
+ Live
163
+ </button>
164
+ <button
165
+ type="button"
166
+ aria-pressed={siteMode === "preview"}
167
+ title="View draft content"
168
+ disabled={exiting || siteMode === "preview" || !onEnterPreview}
169
+ onClick={() => {
170
+ setMenu(null);
171
+ onEnterPreview?.();
172
+ }}
173
+ >
174
+ <Dot />
175
+ Preview
176
+ </button>
177
+ </div>
178
+ <PageMenu
179
+ label={label}
180
+ doc={doc}
181
+ open={menu === "page"}
182
+ onToggle={() => toggleMenu("page")}
183
+ />
184
+ {branch ? (
185
+ <BranchMenu
186
+ branch={branch}
187
+ tree={tree}
188
+ workspaceId={doc?.workspaceId ?? null}
189
+ previewEntryPath={previewEntryPath}
190
+ open={menu === "branch"}
191
+ onToggle={() => toggleMenu("branch")}
192
+ />
193
+ ) : null}
194
+ <span className={`${DEVBAR_CLASS}-rule`} aria-hidden />
195
+ <div className={`${DEVBAR_CLASS}-actions`}>
196
+ <button
197
+ type="button"
198
+ className={`${DEVBAR_CLASS}-btn ${DEVBAR_CLASS}-agent${chatOpen ? ` ${DEVBAR_CLASS}-on` : ""}`}
199
+ aria-pressed={chatOpen}
200
+ aria-keyshortcuts="Meta+J Control+J"
201
+ disabled={!doc}
202
+ onClick={() => {
203
+ setMenu(null);
204
+ if (publishOpen) onTogglePublish();
205
+ onToggleChat();
206
+ }}
207
+ >
208
+ <SparkIcon />
209
+ <span>Agent</span>
210
+ <kbd>{modifierSymbol()}J</kbd>
211
+ </button>
212
+ <button
213
+ type="button"
214
+ className={`${DEVBAR_CLASS}-btn${publishEmphasis ? ` ${DEVBAR_CLASS}-on` : ""}`}
215
+ aria-pressed={publishOpen}
216
+ disabled={!doc}
217
+ onClick={() => {
218
+ setMenu(null);
219
+ if (chatOpen) onToggleChat();
220
+ onTogglePublish();
221
+ }}
222
+ >
223
+ <PublishIcon />
224
+ <span>
225
+ Publish
226
+ {changeCount ? <span className={`${DEVBAR_CLASS}-count`}> {changeCount}</span> : null}
227
+ </span>
228
+ </button>
229
+ </div>
230
+ </footer>
231
+ </>
232
+ );
233
+ }
234
+
235
+ function Dot() {
236
+ return <span className={`${DEVBAR_CLASS}-dot`} aria-hidden />;
237
+ }
238
+
239
+ function Chevron() {
240
+ return (
241
+ <svg className={`${DEVBAR_CLASS}-chevron`} width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
242
+ <path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
243
+ </svg>
244
+ );
245
+ }
246
+
247
+ function PageMenu({
248
+ label,
249
+ doc,
250
+ open,
251
+ onToggle,
252
+ }: {
253
+ label: string;
254
+ doc: (PageMenuDoc & { workspaceId: string }) | null;
255
+ open: boolean;
256
+ onToggle: () => void;
257
+ }) {
258
+ const [docs, setDocs] = useState<DocListItem[] | null>(null);
259
+ const [models, setModels] = useState<RouteModel[] | null>(null);
260
+ const [error, setError] = useState<string | null>(null);
261
+ const [query, setQuery] = useState("");
262
+ const listId = useId();
263
+
264
+ useEffect(() => {
265
+ if (!open) setQuery("");
266
+ }, [open]);
267
+
268
+ useEffect(() => {
269
+ if (!open || !doc || docs) return;
270
+ let cancelled = false;
271
+ void Promise.all([fetchDocs(doc.workspaceId), fetchSchema(doc.workspaceId)])
272
+ .then(([listed, schema]) => {
273
+ if (cancelled) return;
274
+ setDocs(listed.docs);
275
+ setModels(
276
+ (schema.schema?.payload.models ?? []).map((model) => ({
277
+ type: model.type,
278
+ ...(model.route !== undefined ? { route: model.route } : {}),
279
+ ...(model.indexSlug ? { indexSlug: model.indexSlug } : {}),
280
+ })),
281
+ );
282
+ })
283
+ .catch(() => {
284
+ if (!cancelled) setError("Couldn't load pages");
285
+ });
286
+ return () => {
287
+ cancelled = true;
288
+ };
289
+ }, [open, doc, docs]);
290
+
291
+ const items =
292
+ docs && models ? pageMenuItems({ docs, models, current: doc }) : null;
293
+ const shown = items ? filterItems(items, query) : null;
294
+
295
+ if (!doc) {
296
+ return <span className={`${DEVBAR_CLASS}-crumb`}>{label}</span>;
297
+ }
298
+
299
+ return (
300
+ <div className={`${DEVBAR_CLASS}-pop`}>
301
+ <button
302
+ type="button"
303
+ className={`${DEVBAR_CLASS}-crumb ${DEVBAR_CLASS}-page`}
304
+ aria-haspopup="menu"
305
+ aria-expanded={open}
306
+ aria-controls={listId}
307
+ onClick={onToggle}
308
+ >
309
+ <span>{label}</span>
310
+ <Chevron />
311
+ </button>
312
+ {open ? (
313
+ <div id={listId} role="menu" aria-label="Pages" className={`${DEVBAR_CLASS}-menu`}>
314
+ <MenuBody
315
+ query={query}
316
+ onQuery={setQuery}
317
+ showFilter={(items?.length ?? 0) > 7}
318
+ empty={shown?.length === 0}
319
+ emptyLabel={query.trim() ? "Nothing matches" : "No pages"}
320
+ error={error}
321
+ loading={shown === null && !error}
322
+ >
323
+ {shown?.map((item) => (
324
+ <button
325
+ key={item.key}
326
+ type="button"
327
+ role="menuitem"
328
+ className={`${DEVBAR_CLASS}-option${item.current ? ` ${DEVBAR_CLASS}-current` : ""}`}
329
+ aria-current={item.current ? "page" : undefined}
330
+ onClick={() => {
331
+ if (item.current || item.href === location.pathname) {
332
+ onToggle();
333
+ return;
334
+ }
335
+ location.assign(item.href);
336
+ }}
337
+ >
338
+ <span>{item.label}</span>
339
+ </button>
340
+ ))}
341
+ </MenuBody>
342
+ </div>
343
+ ) : null}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ function BranchMenu({
349
+ branch,
350
+ tree,
351
+ workspaceId,
352
+ previewEntryPath,
353
+ open,
354
+ onToggle,
355
+ }: {
356
+ branch: string;
357
+ tree?: string;
358
+ workspaceId: string | null;
359
+ previewEntryPath: string;
360
+ open: boolean;
361
+ onToggle: () => void;
362
+ }) {
363
+ const [names, setNames] = useState<string[] | null>(null);
364
+ const [defaultBranch, setDefaultBranch] = useState<string | null>(null);
365
+ const [error, setError] = useState<string | null>(null);
366
+ const [busy, setBusy] = useState(false);
367
+ const [query, setQuery] = useState("");
368
+ const listId = useId();
369
+
370
+ useEffect(() => {
371
+ if (!open) setQuery("");
372
+ }, [open]);
373
+
374
+ useEffect(() => {
375
+ if (!open || !workspaceId || names) return;
376
+ let cancelled = false;
377
+ void fetchGit(workspaceId)
378
+ .then((git) => {
379
+ if (cancelled) return;
380
+ const listed = git.branches.map((item) => item.name);
381
+ if (!listed.includes(branch)) listed.unshift(branch);
382
+ setNames(listed);
383
+ setDefaultBranch(git.defaultBranch);
384
+ })
385
+ .catch(() => {
386
+ if (!cancelled) setError("Couldn't load branches");
387
+ });
388
+ return () => {
389
+ cancelled = true;
390
+ };
391
+ }, [open, workspaceId, names, branch]);
392
+
393
+ const shown = names ? filterItems(names.map((name) => ({ key: name, label: name })), query) : null;
394
+
395
+ if (!workspaceId) {
396
+ return (
397
+ <span className={`${DEVBAR_CLASS}-crumb ${DEVBAR_CLASS}-branch`}>
398
+ <GitBranchIcon />
399
+ <span>{branch}</span>
400
+ </span>
401
+ );
402
+ }
403
+
404
+ return (
405
+ <div className={`${DEVBAR_CLASS}-pop`}>
406
+ <button
407
+ type="button"
408
+ className={`${DEVBAR_CLASS}-crumb ${DEVBAR_CLASS}-branch`}
409
+ aria-haspopup="menu"
410
+ aria-expanded={open}
411
+ aria-controls={listId}
412
+ aria-label={`Branch ${branch}`}
413
+ aria-busy={busy}
414
+ disabled={busy}
415
+ onClick={onToggle}
416
+ >
417
+ <GitBranchIcon />
418
+ <span>{branch}</span>
419
+ <Chevron />
420
+ </button>
421
+ {open ? (
422
+ <div id={listId} role="menu" aria-label="Branches" className={`${DEVBAR_CLASS}-menu ${DEVBAR_CLASS}-menu-end`}>
423
+ <MenuBody
424
+ query={query}
425
+ onQuery={setQuery}
426
+ showFilter={(names?.length ?? 0) > 7}
427
+ empty={shown?.length === 0}
428
+ emptyLabel={query.trim() ? "Nothing matches" : "No branches"}
429
+ error={error}
430
+ loading={shown === null && !error}
431
+ >
432
+ {shown?.map((item) => (
433
+ <button
434
+ key={item.key}
435
+ type="button"
436
+ role="menuitem"
437
+ className={`${DEVBAR_CLASS}-option${item.label === branch ? ` ${DEVBAR_CLASS}-current` : ""}`}
438
+ aria-current={item.label === branch ? "true" : undefined}
439
+ disabled={busy}
440
+ onClick={() => {
441
+ if (item.label === branch) {
442
+ onToggle();
443
+ return;
444
+ }
445
+ setBusy(true);
446
+ setError(null);
447
+ void switchPreviewBranch({
448
+ workspaceId,
449
+ branch: item.label,
450
+ tree,
451
+ redirect: location.pathname + location.search + location.hash,
452
+ previewEntryPath,
453
+ })
454
+ .catch((err: unknown) => {
455
+ setBusy(false);
456
+ setError(err instanceof Error ? err.message : "Couldn't switch branch");
457
+ });
458
+ }}
459
+ >
460
+ <span>{item.label}</span>
461
+ {item.label === defaultBranch ? <span className={`${DEVBAR_CLASS}-meta`}>default</span> : null}
462
+ </button>
463
+ ))}
464
+ </MenuBody>
465
+ </div>
466
+ ) : null}
467
+ </div>
468
+ );
469
+ }
470
+
471
+ function MenuBody({
472
+ query,
473
+ onQuery,
474
+ showFilter,
475
+ empty,
476
+ emptyLabel,
477
+ error,
478
+ loading,
479
+ children,
480
+ }: {
481
+ query: string;
482
+ onQuery: (value: string) => void;
483
+ showFilter: boolean;
484
+ empty: boolean;
485
+ emptyLabel: string;
486
+ error: string | null;
487
+ loading: boolean;
488
+ children: ReactNode;
489
+ }) {
490
+ return (
491
+ <>
492
+ {showFilter ? (
493
+ <input
494
+ value={query}
495
+ onChange={(event) => onQuery(event.target.value)}
496
+ placeholder="Filter"
497
+ aria-label="Filter"
498
+ autoComplete="off"
499
+ spellCheck={false}
500
+ />
501
+ ) : null}
502
+ {error ? <p className={`${DEVBAR_CLASS}-note ${DEVBAR_CLASS}-note-error`}>{error}</p> : null}
503
+ {loading ? <p className={`${DEVBAR_CLASS}-note`}>Loading…</p> : null}
504
+ {empty && !error ? <p className={`${DEVBAR_CLASS}-note`}>{emptyLabel}</p> : null}
505
+ {children}
506
+ </>
507
+ );
508
+ }
509
+
510
+ function filterItems<T extends { label: string }>(items: readonly T[], query: string): T[] {
511
+ const needle = query.trim().toLowerCase();
512
+ if (!needle) return [...items];
513
+ return items.filter((item) => item.label.toLowerCase().includes(needle));
514
+ }
515
+
516
+ export async function switchPreviewBranch(input: {
517
+ workspaceId: string;
518
+ branch: string;
519
+ tree?: string;
520
+ redirect: string;
521
+ previewEntryPath: string;
522
+ checkout?: typeof checkoutBranch;
523
+ ticket?: typeof createPreviewTicket;
524
+ assign?: (url: string) => void;
525
+ }): Promise<void> {
526
+ const checkout = input.checkout ?? checkoutBranch;
527
+ const ticket = input.ticket ?? createPreviewTicket;
528
+ const assign = input.assign ?? ((url: string) => location.assign(url));
529
+ await checkout(input.workspaceId, input.branch);
530
+ let minted: { ticket: string };
531
+ try {
532
+ minted = await ticket(input.workspaceId, {
533
+ redirect: input.redirect,
534
+ branch: input.branch,
535
+ tree: input.tree,
536
+ });
537
+ } catch (err) {
538
+ const message = err instanceof Error ? err.message : "Couldn't open the branch";
539
+ throw new Error(`Checked out ${input.branch}, but the preview didn't update. ${message}`);
540
+ }
541
+ const joiner = input.previewEntryPath.includes("?") ? "&" : "?";
542
+ assign(`${input.previewEntryPath}${joiner}ticket=${encodeURIComponent(minted.ticket)}`);
543
+ }
544
+
545
+ const BAR_CSS = `
546
+ @keyframes backmatter-devbar-in {
547
+ from { opacity: 0; transform: translateY(8px); }
548
+ to { opacity: 1; transform: none; }
549
+ }
550
+ .${DEVBAR_CLASS} {
551
+ position: fixed;
552
+ right: max(16px, env(safe-area-inset-right, 0px));
553
+ bottom: 0;
554
+ z-index: 2147483000;
555
+ isolation: isolate;
556
+ display: flex;
557
+ align-items: center;
558
+ gap: 8px;
559
+ width: max-content;
560
+ max-width: calc(100vw - 32px);
561
+ min-height: 40px;
562
+ padding: 4px 6px calc(4px + env(safe-area-inset-bottom, 0px)) 4px;
563
+ box-sizing: border-box;
564
+ border: 2px solid ${PREVIEW_CHROME_BORDER};
565
+ border-bottom: 0;
566
+ border-radius: 14px 14px 0 0;
567
+ background: #ffffff;
568
+ color: #71717a;
569
+ box-shadow: 0 10px 28px rgba(24, 24, 27, 0.12), 0 1px 2px rgba(24, 24, 27, 0.06);
570
+ font: 500 13px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
571
+ -webkit-font-smoothing: antialiased;
572
+ font-variant-numeric: tabular-nums;
573
+ animation: backmatter-devbar-in 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
574
+ }
575
+ @media (prefers-reduced-motion: reduce) {
576
+ .${DEVBAR_CLASS} { animation: none; }
577
+ }
578
+ .${DEVBAR_CLASS}-mode {
579
+ display: inline-flex;
580
+ flex-shrink: 0;
581
+ align-items: center;
582
+ gap: 2px;
583
+ padding: 2px;
584
+ border-radius: 9px;
585
+ background: #f4f4f5;
586
+ }
587
+ .${DEVBAR_CLASS}-mode button {
588
+ display: inline-flex;
589
+ align-items: center;
590
+ gap: 6px;
591
+ height: 26px;
592
+ padding: 0 8px;
593
+ border: 0;
594
+ border-radius: 7px;
595
+ background: transparent;
596
+ color: #71717a;
597
+ font: inherit;
598
+ cursor: pointer;
599
+ }
600
+ .${DEVBAR_CLASS}-mode button[aria-pressed="true"] {
601
+ background: #f4efe4;
602
+ color: #18181b;
603
+ }
604
+ .${DEVBAR_CLASS}-dot {
605
+ width: 6px;
606
+ height: 6px;
607
+ border-radius: 999px;
608
+ background: #c4841d;
609
+ flex-shrink: 0;
610
+ }
611
+ .${DEVBAR_CLASS}-pop { position: relative; min-width: 0; }
612
+ .${DEVBAR_CLASS}-crumb {
613
+ display: inline-flex;
614
+ align-items: center;
615
+ gap: 6px;
616
+ min-width: 0;
617
+ max-width: min(180px, 100%);
618
+ height: 28px;
619
+ padding: 0 6px;
620
+ border: 0;
621
+ border-radius: 7px;
622
+ background: transparent;
623
+ color: #18181b;
624
+ font: inherit;
625
+ }
626
+ button.${DEVBAR_CLASS}-crumb { cursor: pointer; }
627
+ .${DEVBAR_CLASS}-crumb > span {
628
+ min-width: 0;
629
+ overflow: hidden;
630
+ text-overflow: ellipsis;
631
+ white-space: nowrap;
632
+ }
633
+ .${DEVBAR_CLASS}-branch { color: #52525b; }
634
+ .${DEVBAR_CLASS}-chevron { flex-shrink: 0; color: #a1a1aa; }
635
+ .${DEVBAR_CLASS}-rule {
636
+ width: 1px;
637
+ height: 16px;
638
+ flex-shrink: 0;
639
+ background: #e4e4e7;
640
+ }
641
+ .${DEVBAR_CLASS}-actions {
642
+ display: inline-flex;
643
+ flex-shrink: 0;
644
+ align-items: center;
645
+ gap: 6px;
646
+ }
647
+ .${DEVBAR_CLASS}-btn {
648
+ display: inline-flex;
649
+ align-items: center;
650
+ gap: 6px;
651
+ height: 28px;
652
+ padding: 0 10px;
653
+ border: 1px solid #e4e4e7;
654
+ border-radius: 8px;
655
+ background: #ffffff;
656
+ color: #18181b;
657
+ font: inherit;
658
+ font-size: 13px;
659
+ cursor: pointer;
660
+ }
661
+ .${DEVBAR_CLASS}-agent {
662
+ border-color: transparent;
663
+ background: #f4f4f5;
664
+ }
665
+ .${DEVBAR_CLASS}-agent kbd {
666
+ font-family: Geist Mono, ui-monospace, SFMono-Regular, Menlo, monospace;
667
+ font-size: 11px;
668
+ font-weight: 400;
669
+ color: #71717a;
670
+ }
671
+ .${DEVBAR_CLASS}-count { font-variant-numeric: tabular-nums; }
672
+ .${DEVBAR_CLASS} button.${DEVBAR_CLASS}-on,
673
+ .${DEVBAR_CLASS} button.${DEVBAR_CLASS}-on:hover:not(:disabled) {
674
+ background: #18181b;
675
+ border-color: #18181b;
676
+ color: #fafafa;
677
+ }
678
+ .${DEVBAR_CLASS}-on kbd { color: #d4d4d8; }
679
+ .${DEVBAR_CLASS}-menu {
680
+ position: absolute;
681
+ bottom: calc(100% + 8px);
682
+ left: 0;
683
+ z-index: 2;
684
+ display: flex;
685
+ flex-direction: column;
686
+ width: 240px;
687
+ max-height: 280px;
688
+ overflow: auto;
689
+ padding: 4px;
690
+ border: 1px solid #e4e4e7;
691
+ border-radius: 10px;
692
+ background: #ffffff;
693
+ box-shadow: 0 16px 40px rgba(24, 24, 27, 0.14), 0 2px 8px rgba(24, 24, 27, 0.06);
694
+ }
695
+ .${DEVBAR_CLASS}-menu-end { left: auto; right: 0; }
696
+ .${DEVBAR_CLASS}-menu input {
697
+ width: 100%;
698
+ box-sizing: border-box;
699
+ height: 32px;
700
+ margin: 2px 0 4px;
701
+ padding: 0 8px;
702
+ border: 1px solid #e4e4e7;
703
+ border-radius: 7px;
704
+ background: #ffffff;
705
+ color: #18181b;
706
+ font: inherit;
707
+ font-size: 16px;
708
+ }
709
+ .${DEVBAR_CLASS}-option {
710
+ display: flex;
711
+ align-items: center;
712
+ gap: 8px;
713
+ width: 100%;
714
+ min-height: 32px;
715
+ padding: 0 8px;
716
+ border: 0;
717
+ border-radius: 7px;
718
+ background: transparent;
719
+ color: #18181b;
720
+ font: inherit;
721
+ font-size: 13px;
722
+ text-align: left;
723
+ cursor: pointer;
724
+ }
725
+ .${DEVBAR_CLASS}-option > span:first-child {
726
+ min-width: 0;
727
+ overflow: hidden;
728
+ text-overflow: ellipsis;
729
+ white-space: nowrap;
730
+ }
731
+ .${DEVBAR_CLASS}-current { background: #f4f4f5; }
732
+ .${DEVBAR_CLASS}-meta {
733
+ margin-left: auto;
734
+ flex-shrink: 0;
735
+ color: #71717a;
736
+ font-size: 11px;
737
+ font-weight: 400;
738
+ }
739
+ .${DEVBAR_CLASS}-note {
740
+ margin: 0;
741
+ padding: 8px;
742
+ color: #71717a;
743
+ font-size: 12px;
744
+ font-weight: 400;
745
+ }
746
+ .${DEVBAR_CLASS}-note-error { color: #b91c1c; }
747
+ @media (hover: hover) {
748
+ .${DEVBAR_CLASS}-mode button:hover:not(:disabled):not([aria-pressed="true"]) { color: #18181b; }
749
+ .${DEVBAR_CLASS}-crumb:hover:not(:disabled) { background: #f4f4f5; }
750
+ .${DEVBAR_CLASS}-btn:hover:not(:disabled):not(.${DEVBAR_CLASS}-on) { background: #fafafa; }
751
+ .${DEVBAR_CLASS}-agent:hover:not(:disabled):not(.${DEVBAR_CLASS}-on) { background: #ececee; }
752
+ .${DEVBAR_CLASS}-option:hover:not(:disabled) { background: #f4f4f5; }
753
+ }
754
+ .${DEVBAR_CLASS} button:focus-visible {
755
+ outline: 2px solid #18181b;
756
+ outline-offset: 2px;
757
+ }
758
+ .${DEVBAR_CLASS} button:disabled { cursor: default; opacity: 0.5; }
759
+ .${DEVBAR_CLASS}-menu input:focus-visible {
760
+ outline: 2px solid #18181b;
761
+ outline-offset: 1px;
762
+ }
763
+ html.backmatter-pointing, html.backmatter-pointing * { cursor: crosshair !important; }
764
+ html.backmatter-pointing .${DEVBAR_CLASS},
765
+ html.backmatter-pointing .${DEVBAR_CLASS} *,
766
+ html.backmatter-pointing [data-backmatter-panel],
767
+ html.backmatter-pointing [data-backmatter-panel] * { cursor: pointer !important; }
768
+ html.backmatter-pointing .${DEVBAR_CLASS} button:disabled { cursor: default !important; }
769
+ `;