-
Notifications
You must be signed in to change notification settings - Fork 79
Expand file tree
/
Copy pathstyles-web.ts
More file actions
3176 lines (2863 loc) · 95 KB
/
styles-web.ts
File metadata and controls
3176 lines (2863 loc) · 95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Web stylesheet generator (FR-2726 — Backend.AI design system).
*
* Phase 1 replaces the legacy Infima palette with Backend.AI design
* tokens (orange `#FF7A00` primary, teal `#00BD9B` success, IBM Plex
* Sans KR + JetBrains Mono typography), adds a `[data-theme="dark"]`
* surface, and threads through consumer-tunable branding (primary
* color override). The legacy `--ifm-*` variable names are kept as
* aliases that resolve to BAI tokens, so every existing class rule in
* this file (and any external consumer) keeps working untouched —
* subsequent phases will introduce new components that consume the
* `--bai-*` tokens directly.
*/
import {
DEFAULT_PRIMARY_COLOR,
DEFAULT_PRIMARY_COLOR_ACTIVE,
DEFAULT_PRIMARY_COLOR_HOVER,
DEFAULT_PRIMARY_COLOR_SOFT,
validateCssColor,
} from "./config.js";
const CJK_LANGS = new Set(["ko", "ja", "zh", "zh-CN", "zh-TW"]);
/**
* Subset of `ResolvedBrandingConfig` consumed by the stylesheet. The
* generator validates each value before interpolating into the emitted
* CSS (FR-2726).
*/
export interface StyleBrandingTokens {
primaryColor?: string;
primaryColorHover?: string;
primaryColorActive?: string;
primaryColorSoft?: string;
}
export function generateWebStyles(
lang?: string,
branding?: StyleBrandingTokens,
): string {
const isCjk = lang ? CJK_LANGS.has(lang) : false;
// Validate every interpolated color so a misconfigured value cannot
// break out of the surrounding declaration and inject CSS rules.
const safe = (
value: string | undefined,
field: string,
fallback: string,
): string => (value ? validateCssColor(value, field) : fallback);
const primary = safe(
branding?.primaryColor,
"branding.primaryColor",
DEFAULT_PRIMARY_COLOR,
);
const primaryHover = safe(
branding?.primaryColorHover,
"branding.primaryColorHover",
DEFAULT_PRIMARY_COLOR_HOVER,
);
const primaryActive = safe(
branding?.primaryColorActive,
"branding.primaryColorActive",
DEFAULT_PRIMARY_COLOR_ACTIVE,
);
const primarySoft = safe(
branding?.primaryColorSoft,
"branding.primaryColorSoft",
DEFAULT_PRIMARY_COLOR_SOFT,
);
return `
/* Backend.AI Web UI Manual — Phase 1 (FR-2726)
Typography from Google Fonts. The @import is the first declaration
so the loader fires before any text renders. CJK fallback chain is
kept for offline/airgapped builds where Google Fonts may be blocked. */
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@300;400;500;600;700&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* ==========================================================================
Backend.AI Design Tokens (Phase 1 — FR-2726)
--------------------------------------------------------------------------
The \`--bai-*\` tokens are the source of truth. The \`--ifm-*\` aliases
below map them onto the Infima variable names so all legacy rules in
this file (and any external consumer that still references
\`--ifm-*\`) keep working unchanged.
========================================================================== */
:root {
/* Brand */
--bai-primary: ${primary};
--bai-primary-hover: ${primaryHover};
--bai-primary-active: ${primaryActive};
--bai-primary-soft: ${primarySoft};
/* Semantic */
--bai-success: #00BD9B;
--bai-warning: #FFBF00;
--bai-danger: #BB1F1F;
--bai-info: #2A99B8;
/* Typography */
--bai-font-sans: 'IBM Plex Sans KR', 'IBM Plex Sans', system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans", "Noto Sans KR", "Noto Sans CJK KR", "Noto Sans JP", "Noto Sans CJK JP", "Noto Sans TC", "Noto Sans CJK TC", "Noto Sans Thai", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bai-font-heading: 'IBM Plex Sans KR', 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
--bai-font-mono: 'JetBrains Mono', 'IBM Plex Mono', SFMono-Regular, Menlo, Consolas, "Liberation Mono", "Courier New", ui-monospace, monospace;
--bai-type-scale: 1;
/* Neutral surface */
--bai-bg: #FFFFFF;
--bai-bg-muted: #FAFAFA;
--bai-bg-subtle: #F5F5F5;
--bai-bg-sider: #FCFCFC;
--bai-border: #E8E8E8;
--bai-border-soft: #F0F0F0;
--bai-text: #141414;
--bai-text-2: #595959;
--bai-text-3: #8C8C8C;
--bai-text-4: #BFBFBF;
/* Elevation + shape */
--bai-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--bai-shadow-md: 0 4px 14px rgba(0, 0, 0, 0.07);
--bai-shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.18);
--bai-radius-sm: 4px;
--bai-radius: 8px;
--bai-radius-lg: 12px;
/* ── Infima aliases (legacy) ──────────────────────────────── */
--ifm-color-primary: var(--bai-primary);
--ifm-color-primary-dark: var(--bai-primary-active);
--ifm-color-primary-light: var(--bai-primary-hover);
--ifm-color-primary-lighter: var(--bai-primary-hover);
--ifm-color-primary-lightest: var(--bai-primary-soft);
--ifm-color-primary-darkest: #B34A00;
--ifm-color-primary-darker: var(--bai-primary-active);
--ifm-color-success: var(--bai-success);
--ifm-color-success-light: #1FCEAD;
--ifm-color-success-dark: #009C80;
--ifm-color-info: var(--bai-info);
--ifm-color-info-light: #4FB1CC;
--ifm-color-info-dark: #1F7E97;
--ifm-color-warning: var(--bai-warning);
--ifm-color-warning-light: #FFD747;
--ifm-color-warning-dark: #B88A00;
--ifm-color-danger: var(--bai-danger);
--ifm-color-danger-light: #DC3535;
--ifm-color-danger-dark: #8E1717;
--ifm-color-secondary: var(--bai-bg-subtle);
--ifm-color-emphasis-0: var(--bai-bg);
--ifm-color-emphasis-100: var(--bai-bg-muted);
--ifm-color-emphasis-200: var(--bai-border-soft);
--ifm-color-emphasis-300: var(--bai-border);
--ifm-color-emphasis-400: #DCDCDC;
--ifm-color-emphasis-500: var(--bai-text-4);
--ifm-color-emphasis-600: var(--bai-text-3);
--ifm-color-emphasis-700: var(--bai-text-2);
--ifm-color-emphasis-800: #424242;
--ifm-color-emphasis-900: var(--bai-text);
--ifm-color-emphasis-1000: #000;
--ifm-font-family-base: var(--bai-font-sans);
--ifm-font-family-monospace: var(--bai-font-mono);
--ifm-font-size-base: 100%;
--ifm-font-weight-light: 300;
--ifm-font-weight-normal: 400;
--ifm-font-weight-semibold: 500;
--ifm-font-weight-bold: 700;
--ifm-line-height-base: 1.65;
--ifm-global-spacing: 1rem;
--ifm-spacing-vertical: var(--ifm-global-spacing);
--ifm-spacing-horizontal: var(--ifm-global-spacing);
--ifm-heading-font-weight: 700;
--ifm-heading-line-height: 1.25;
--ifm-heading-margin-top: 0;
--ifm-heading-margin-bottom: var(--ifm-spacing-vertical);
--ifm-h1-font-size: 2rem;
--ifm-h2-font-size: 1.5rem;
--ifm-h3-font-size: 1.25rem;
--ifm-h4-font-size: 1rem;
--ifm-h5-font-size: 0.875rem;
--ifm-h6-font-size: 0.85rem;
--ifm-code-font-size: 90%;
--ifm-code-background: #f6f7f8;
--ifm-code-border-radius: 0.25rem;
--ifm-code-padding-vertical: 0.1rem;
--ifm-code-padding-horizontal: 0.35rem;
--ifm-pre-background: #f6f7f8;
--ifm-pre-border-radius: 0.4rem;
--ifm-pre-padding: 1rem;
--ifm-pre-line-height: 1.45;
--ifm-table-cell-padding: 0.75rem;
--ifm-table-border-width: 1px;
--ifm-table-border-color: var(--ifm-color-emphasis-300);
--ifm-table-stripe-background: var(--ifm-color-emphasis-100);
--ifm-table-head-background: inherit;
--ifm-table-head-font-weight: 700;
--ifm-blockquote-border-left-width: 2px;
--ifm-blockquote-border-color: var(--ifm-color-emphasis-300);
--ifm-blockquote-padding-horizontal: 1rem;
--ifm-blockquote-color: var(--ifm-color-emphasis-800);
--ifm-hr-border-color: var(--ifm-color-emphasis-200);
--ifm-hr-border-width: 1px;
--ifm-link-color: var(--ifm-color-primary);
--ifm-link-hover-color: var(--ifm-color-primary-dark);
--ifm-alert-border-width: 0;
--ifm-alert-border-left-width: 5px;
--ifm-alert-border-radius: 0.4rem;
--ifm-alert-padding-vertical: 1rem;
--ifm-alert-padding-horizontal: 1.2rem;
/* Layout (FR-2726 Phase 2) */
--bai-topbar-h: 56px;
--bai-sider-w: 280px;
--bai-toc-w: 240px;
--bai-content-max: 820px;
--bai-gutter: 32px;
/* Legacy aliases used by F3 grid rules (resolve to BAI tokens). */
--doc-sidebar-width: var(--bai-sider-w);
--doc-toc-width: var(--bai-toc-w);
}
/* ==========================================================================
Dark mode (Phase 1 surface — toggle UI ships in Phase 4)
--------------------------------------------------------------------------
Two opt-in routes:
1. \`<html data-theme="dark">\` — explicit override (set by Phase 4
toggle persisted in localStorage).
2. \`prefers-color-scheme: dark\` — passive default. The toggle, when
it lands, will write a sentinel \`data-theme\` value to opt out.
The selector below covers both without giving the OS preference
priority over an explicit user choice.
========================================================================== */
[data-theme="dark"] {
--bai-bg: #0E0E10;
--bai-bg-muted: #141417;
--bai-bg-subtle: #1A1A1F;
--bai-bg-sider: #111114;
--bai-border: #2A2A30;
--bai-border-soft: #1F1F25;
--bai-text: #F0F0F0;
--bai-text-2: #B5B5BD;
--bai-text-3: #7E7E89;
--bai-text-4: #4F4F58;
--bai-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--bai-shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
--bai-shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.6);
/* Dark-mode soft fill is derived from the resolved primary via
color-mix() so the tint stays in sync when consumers override
branding.primaryColor. Browsers without color-mix() simply
keep the light-mode --bai-primary-soft value (still readable). */
--bai-primary-soft: color-mix(in srgb, var(--bai-primary) 12%, transparent);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bai-bg: #0E0E10;
--bai-bg-muted: #141417;
--bai-bg-subtle: #1A1A1F;
--bai-bg-sider: #111114;
--bai-border: #2A2A30;
--bai-border-soft: #1F1F25;
--bai-text: #F0F0F0;
--bai-text-2: #B5B5BD;
--bai-text-3: #7E7E89;
--bai-text-4: #4F4F58;
--bai-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--bai-shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
--bai-shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.6);
/* Same color-mix derivation as the explicit dark theme block. */
--bai-primary-soft: color-mix(in srgb, var(--bai-primary) 12%, transparent);
}
}
/* ==========================================================================
Reset & Base
========================================================================== */
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-size: var(--ifm-font-size-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--bai-font-sans);
font-size: 1rem;
line-height: var(--ifm-line-height-base);
color: var(--bai-text);
background: var(--bai-bg);
margin: 0;
padding: 0;
${isCjk ? "word-break: keep-all;" : ""}
overflow-wrap: break-word;
}
/* ==========================================================================
Topbar (Phase 2 — FR-2726)
--------------------------------------------------------------------------
The topbar sits above the page grid as a sticky 56px strip with the
brand on the left, a center search trigger (host for the existing
search input until Phase 4 introduces a Cmd-K palette), and an
actions cluster on the right (lang switcher, version selector, GitHub
icon). The mobile menu icon and the search icon are hidden on
desktop and revealed on narrower viewports — see the responsive
block near the end of this file.
========================================================================== */
.bai-topbar {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 14px;
height: var(--bai-topbar-h);
padding: 0 20px;
background: var(--bai-bg);
border-bottom: 1px solid var(--bai-border);
}
/* Bump specificity past the generic .bai-iconbtn { display: inline-flex }
rule below so the menu button stays hidden on desktop. Without the
parent qualifier both selectors share specificity (0,1,0) and the
later iconbtn rule wins, leaving the desktop topbar with a visible
hamburger that opens the drawer-scrim on click. */
.bai-topbar .bai-topbar__menu {
display: none;
}
.bai-topbar__brand {
display: inline-flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
color: var(--bai-text);
text-decoration: none;
}
.bai-topbar__brand:hover {
text-decoration: none;
color: var(--bai-text);
}
.bai-brand-logo {
height: 22px;
width: auto;
display: block;
}
/* Brand logo swap (FR-2726 Phase 4). Both light and dark variants are
in the DOM; CSS hides whichever doesn't match the active data-theme.
:root:not([data-theme="dark"]) covers both the unset case (light is
default) and the explicit data-theme="light" case. */
:root:not([data-theme="dark"]) .bai-brand-logo--dark { display: none; }
[data-theme="dark"] .bai-brand-logo--light { display: none; }
[data-theme="dark"] .bai-brand-logo--dark { display: block; }
.bai-brand-fallback {
font-family: var(--bai-font-heading);
font-weight: 600;
font-size: 15px;
color: var(--bai-text);
}
.bai-brand-divider {
width: 1px;
height: 18px;
background: var(--bai-border);
}
.bai-brand-sub {
font-size: 12.5px;
font-weight: 500;
color: var(--bai-text-3);
letter-spacing: -0.005em;
}
.bai-brand-version {
font-family: var(--bai-font-mono);
font-size: 10.5px;
color: var(--bai-text-3);
background: var(--bai-bg-subtle);
border: 1px solid var(--bai-border);
padding: 2px 7px;
border-radius: 999px;
margin-left: 4px;
}
/* Topbar search — hosts \`#search-input\` (which the existing search.js
binds to). When Phase 4 lands, this trigger becomes a Cmd-K-style
button that opens a palette; until then the input stays inline so
typing-to-search keeps working without any JS changes. */
.bai-topbar__search {
flex: 1;
max-width: 520px;
margin: 0 auto;
position: relative;
display: flex;
align-items: center;
gap: 10px;
height: 36px;
padding: 0 12px 0 14px;
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius);
background: var(--bai-bg-muted);
color: var(--bai-text-3);
font: inherit;
font-size: 13px;
cursor: text;
transition: border-color 0.15s, background 0.15s;
}
.bai-topbar__search:hover,
.bai-topbar__search:focus-within {
border-color: var(--bai-primary);
background: var(--bai-bg);
}
.bai-topbar__search > svg {
flex-shrink: 0;
color: var(--bai-text-3);
}
.bai-topbar__search input {
flex: 1;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
color: var(--bai-text);
outline: none;
padding: 0;
min-width: 0;
}
.bai-topbar__search input::placeholder {
color: var(--bai-text-3);
}
.bai-kbd-group {
display: inline-flex;
gap: 3px;
flex-shrink: 0;
}
.bai-topbar__search kbd,
.bai-kbd-group kbd {
font-family: var(--bai-font-mono);
font-size: 10.5px;
background: var(--bai-bg);
border: 1px solid var(--bai-border);
border-bottom-width: 2px;
padding: 1px 6px;
border-radius: 4px;
color: var(--bai-text-2);
min-width: 18px;
text-align: center;
line-height: 1.4;
}
.bai-topbar .bai-topbar__searchicon {
display: none;
}
.bai-topbar__actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.bai-iconbtn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 32px;
padding: 0 8px;
border: 0;
background: transparent;
cursor: pointer;
color: var(--bai-text-2);
border-radius: 6px;
font: inherit;
text-decoration: none;
}
.bai-iconbtn:hover {
background: var(--bai-bg-subtle);
color: var(--bai-text);
text-decoration: none;
}
/* Topbar search trigger label (Phase 4) — replaces the inline input.
Visually mimics the input field so the bar still reads as a search
surface, but the click opens the palette overlay. */
.bai-topbar__search-label {
flex: 1;
text-align: left;
color: var(--bai-text-3);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Theme toggle (Phase 4). Shows the destination icon — the moon when
light mode is active, the sun when dark mode is active. */
.bai-theme-icon {
display: none;
}
:root:not([data-theme="dark"]) .bai-theme-icon--dark {
display: inline;
}
[data-theme="dark"] .bai-theme-icon--light {
display: inline;
}
/* Search palette (Phase 4 — FR-2726). The palette is a body-class-
controlled modal: when body has bai-palette-open, the .bai-palette
element is shown; otherwise it stays hidden. The scrim covers the
full viewport; the panel is centered horizontally with a fixed top
offset (~14vh) so the field lands near the user's gaze. Inside the
panel, #search-input is restyled to match the palette look-and-feel;
#search-results renders the existing search.js dropdown, repositioned
to flow inside the panel instead of floating absolutely. */
.bai-palette {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 14vh;
pointer-events: none;
}
.bai-palette[hidden] {
display: none;
}
body.bai-palette-open .bai-palette {
pointer-events: auto;
}
.bai-palette__scrim {
position: absolute;
inset: 0;
background: rgba(20, 20, 20, 0.45);
backdrop-filter: blur(4px);
}
[data-theme="dark"] .bai-palette__scrim {
background: rgba(0, 0, 0, 0.6);
}
.bai-palette__panel {
position: relative;
width: 600px;
max-width: 92vw;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bai-bg);
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius-lg);
box-shadow: var(--bai-shadow-lg);
overflow: hidden;
animation: bai-palette-in 180ms ease-out;
}
@keyframes bai-palette-in {
from {
transform: translateY(-12px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.bai-palette__searchrow {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--bai-border);
}
.bai-palette__searchrow > svg {
color: var(--bai-text-3);
flex-shrink: 0;
}
.bai-palette__panel #search-input {
flex: 1;
border: 0;
background: transparent;
font: inherit;
font-family: var(--bai-font-sans);
font-size: 15.5px;
color: var(--bai-text);
outline: 0;
padding: 0;
}
.bai-palette__panel #search-input::placeholder {
color: var(--bai-text-3);
}
.bai-palette__close-hint {
font-family: var(--bai-font-mono);
font-size: 10.5px;
background: var(--bai-bg-muted);
border: 1px solid var(--bai-border);
border-bottom-width: 2px;
padding: 1px 6px;
border-radius: 4px;
color: var(--bai-text-3);
flex-shrink: 0;
}
/* When the search-results dropdown lives inside the palette panel,
override its absolute positioning so it scrolls inline with the
panel body instead of floating relative to the viewport. */
.bai-palette__panel #search-results {
position: static;
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
max-height: none;
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.bai-palette__panel #search-results .search-result-item {
padding: 10px 18px;
border-bottom: 1px solid var(--bai-border-soft);
}
.bai-palette__panel #search-results .search-result-item:last-child {
border-bottom: 0;
}
.bai-palette__panel #search-results .search-result-item:hover,
.bai-palette__panel #search-results .search-result-item:focus {
background: var(--bai-bg-muted);
outline: 0;
}
.bai-palette__panel #search-results .search-result-title {
color: var(--bai-text);
font-size: 14px;
}
.bai-palette__panel #search-results .search-result-snippet {
color: var(--bai-text-3);
font-size: 12px;
}
/* Mobile drawer scrim (Phase 4). Created lazily by interactions.js
and added to the body when the user opens the drawer. Re-uses the
palette scrim look so the visual language stays consistent.
FR-2758: the scrim used to start at top: 0 (inset: 0), which pulled
the topbar (z-index 50) under the scrim's z-index 100 and blurred
the topbar along with the page. Tester feedback: the topbar
appeared to "load with a blur over it" when the drawer was opened.
We now anchor the scrim to start under the topbar instead, so the
topbar stays sharp and remains interactable (close-drawer
hamburger, theme toggle) while the drawer is open. */
.bai-scrim {
position: fixed;
/* Anchor below both the topbar and the sticky banner so neither
gets blurred while the drawer is open. --bai-banner-h is set by
version-banner.js (default 0px) so non-banner pages still start
the scrim right under the topbar. */
top: calc(var(--bai-topbar-h) + var(--bai-banner-h, 0px));
right: 0;
bottom: 0;
left: 0;
z-index: 100;
background: rgba(20, 20, 20, 0.45);
backdrop-filter: blur(4px);
display: none;
}
[data-theme="dark"] .bai-scrim {
background: rgba(0, 0, 0, 0.6);
}
body.bai-drawer-open .bai-scrim {
display: block;
}
/* When the drawer is open at narrow viewports, slide the sider in
from the left as a fixed-position overlay. Desktop layout is
unaffected — the @media query below scopes the override to
≤ 880px so the sider's regular sticky behavior is preserved. */
@media (max-width: 880px) {
body.bai-drawer-open .doc-sidebar {
display: block;
position: fixed;
top: calc(var(--bai-topbar-h) + var(--bai-banner-h, 0px));
left: 0;
width: 280px;
max-width: 86vw;
height: calc(100vh - var(--bai-topbar-h) - var(--bai-banner-h, 0px));
z-index: 101;
background: var(--bai-bg-sider);
border-right: 1px solid var(--bai-border);
box-shadow: var(--bai-shadow-lg);
overflow-y: auto;
/* FR-2768: contain touch scroll inside the drawer so reaching the
top/bottom of the nav doesn't bubble up and scroll the page
behind. Without this, mobile Safari/Chrome route the gesture to
the body whenever the sider hits a boundary — the user sees the
menu freeze and the article scroll instead. Pairs with the
body-lock rule below for defense in depth. */
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
animation: bai-drawer-in 200ms ease-out;
}
/* In drawer mode, .doc-sidebar is forced back to display:block
above, so the inner .doc-sidebar__scroll wrapper no longer has the
flex-sizing that gives it a constrained height — its content
determines its size, and there is nothing for overflow-y:auto to
scroll. Make that explicit by switching it to overflow:visible so
there is exactly one scrollport (the sider itself) and the
touch-action / overscroll-behavior rules above unambiguously
target the element that actually scrolls. */
body.bai-drawer-open .doc-sidebar__scroll {
overflow: visible;
}
/* Lock the underlying page scroll while the drawer is open. The
scrim already blocks taps, but on touch devices the gesture
hand-off (when the sider has no more room to scroll) still reaches
the body unless its overflow is clipped. */
body.bai-drawer-open {
overflow: hidden;
touch-action: none;
}
}
@keyframes bai-drawer-in {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Search results dropdown anchored to the topbar search container. */
.bai-topbar__search .search-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--bai-bg);
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius);
box-shadow: var(--bai-shadow-md);
max-height: 420px;
overflow-y: auto;
z-index: 60;
}
/* ==========================================================================
Page Layout — F3 three-column grid (rebased on BAI tokens — Phase 2)
--------------------------------------------------------------------------
Desktop: [sidebar] [main, max ~820px] [right-rail TOC, fixed width].
The topbar sits above this grid (sticky, 56px), so the sider/TOC are
sticky relative to (100vh - topbar-height).
========================================================================== */
.doc-page {
display: grid;
grid-template-columns: var(--bai-sider-w) minmax(0, 1fr) var(--bai-toc-w);
align-items: stretch;
min-height: calc(100vh - var(--bai-topbar-h));
}
.doc-sidebar {
position: sticky;
/* FR-2758: anchor below the sticky topbar AND the sticky version
banner. --bai-banner-h is set by version-banner.js (default 0px
when no banner is present) so non-banner pages keep the original
56px offset. Without this, the banner overlapped the top of the
sidebar's scroll area when the user scrolled. Same applies to
.doc-toc below. */
top: calc(var(--bai-topbar-h) + var(--bai-banner-h, 0px));
height: calc(100vh - var(--bai-topbar-h) - var(--bai-banner-h, 0px));
/* FR-2768: switch from a single scrollable aside to a flex column
with an internal scrollport. The version block becomes the first
row (no flex), the .doc-sidebar__scroll wrapper takes flex: 1 and
owns the overflow. This pins the version selector to the top of
the sider while the nav list scrolls beneath, matching the design
handoff (and avoiding the position:sticky / z-index dance). */
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--bai-border);
background: var(--bai-bg-sider);
}
/* The internal scrollport. Holds the synthetic Introduction entry +
the nav groups; everything that should scroll lives inside this
wrapper. Padding moves here from .doc-sidebar so the version block
above can render edge-to-edge with its own padding. */
.doc-sidebar__scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* Prevent scroll chaining: if the user scrolls the nav past its
top/bottom, the gesture stays inside this container instead of
bubbling out and scrolling the article. Same defense applied to
the mobile drawer override below. */
overscroll-behavior: contain;
padding: 10px 8px 24px;
}
.doc-sidebar__scroll::-webkit-scrollbar {
width: 8px;
}
.doc-sidebar__scroll::-webkit-scrollbar-thumb {
background: var(--bai-border);
border-radius: 4px;
}
/* Sidebar header (legacy F3) — Phase 2 hides it; the topbar carries the
product brand and version pill now. The structural HTML stays so
downstream consumers that haven't migrated still get a working page. */
.doc-sidebar-header {
display: none;
}
/* Search input — Phase 2 moves this into the topbar. The original
.doc-search wrapper still exists in the DOM for backwards compat
when consumers haven't run the new builder yet, so keep the legacy
styling in place behind a guard. */
.doc-sidebar > .doc-search {
padding: 8px 12px 12px;
position: relative;
border-bottom: 1px solid var(--bai-border-soft);
margin-bottom: 6px;
}
.doc-sidebar > .doc-search input {
width: 100%;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius-sm);
background: var(--bai-bg);
outline: none;
font-family: var(--bai-font-sans);
}
.doc-sidebar > .doc-search input:focus {
border-color: var(--bai-primary);
box-shadow: 0 0 0 2px var(--bai-primary-soft);
}
/* Generic search-results dropdown styling. The topbar's search container
overrides positioning so the dropdown anchors to the input rather than
to the legacy .doc-search wrapper. */
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bai-bg);
border: 1px solid var(--bai-border);
border-radius: var(--bai-radius);
box-shadow: var(--bai-shadow-md);
max-height: 420px;
overflow-y: auto;
z-index: 100;
}
.search-result-item {
display: block;
padding: 0.6rem 0.8rem;
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-100);
transition: background 0.1s;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover,
.search-result-item:focus {
background: var(--ifm-color-emphasis-100);
text-decoration: none;
outline: 2px solid var(--ifm-color-primary);
outline-offset: -2px;
}
.search-result-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--ifm-color-primary);
margin-bottom: 0.15rem;
}
.search-result-snippet {
font-size: 0.75rem;
color: var(--ifm-color-emphasis-600);
line-height: 1.4;
}
.search-no-results {
padding: 0.8rem;
font-size: 0.85rem;
color: var(--ifm-color-emphasis-600);
text-align: center;
}
/* FR-2733: version switcher block at the top of the sidebar (above the
nav groups). Renders only in versioned-docs mode; non-versioned builds
skip the wrapper entirely. The select itself mirrors the
.lang-switcher__select chrome (30px, 1px BAI border, transparent
background, BAI primary on hover, native chevron via SVG background
image, BAI primary focus outline) so the two site-level controls feel
like a coordinated pair. */
.doc-sidebar-version {
/* FR-2768: horizontal layout matching the design handoff —
uppercase label on the left, switcher pill on the right, soft
bottom rule separating it from the scrolling nav below. The
wrapper is a fixed-height row outside the scrollport so it stays
visible while the nav scrolls.
Horizontal padding is 24px (vs. the design's 18px) to align with
the indentation of nav items below: the .doc-sidebar__scroll
wrapper adds 8px padding and .doc-sidebar-group adds 6px margin
plus the summary's 10px inner padding (FR-2758 introduced this
extra margin). 8 + 6 + 10 = 24px from the sider edge to the
category label — the version label needs to sit at the same
x-coordinate so the grid aligns. */
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
padding: 14px 24px 10px;
border-bottom: 1px solid var(--bai-border-soft, var(--bai-border));
}
.doc-sidebar-version__label {
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bai-text-2);
flex: 0 0 auto;
}
.doc-sidebar-version .version-switcher {
flex: 0 0 auto;
/* FR-2768: native <select> auto-sizes to its longest option, which
for short version labels (e.g., "next") renders much narrower
than the prototype's pill button. Anchor the width so the
control reads as a deliberate UI element rather than a tiny
dropdown. */
min-width: 132px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 28px 0 10px;
border: 1px solid var(--bai-border);
border-radius: 6px;
background: transparent;
/* Hard-coded #595959 stroke matches --bai-text-2 in light mode; dark
mode fades a little but stays legible. CSS vars don't resolve
inside url() so an inline SVG is the only option without shipping