Blog/wwwroot/app.css 20.7 K · 923 lines · raw · history

1 /* ==========================================================================
2 bes.is — a small terminal-flavoured stylesheet.
3
4 1. Tokens design decisions, in one place
5 2. Reset & base the same starting point in every browser
6 3. Typography headings, text, lists, links
7 4. Layout page frame and layout utilities
8 5. Controls buttons, fields, everything the browser styles for us
9 6. Components header, footer, log, panels, json, dialogs
10 7. Media logo, images, video
11 ========================================================================== */
12
13
14 /* 1. Tokens ================================================================ */
15
16 :root {
17 /* Tells the browser to theme native UI (scrollbars, select popups, focus
18 rings, spinners) to match the page. Without it those stay light in a
19 dark page, which is the main reason controls look different per
20 browser. It also enables light-dark() below. */
21 color-scheme: light dark;
22
23 --black: #050505;
24 --white: #fafafa;
25
26 --background: light-dark(var(--white), var(--black));
27 --color: light-dark(var(--black), var(--white));
28 --border: var(--color);
29
30 --error: light-dark(#b00000, #ff6b6b);
31 --info: light-dark(#3d6f8e, #86b6d3);
32 --green: light-dark(#2f7a2d, #6fce6d);
33
34 /* Modular scale. Every gap, margin and padding comes from here so
35 spacing stays proportional to the fluid type size. */
36 --ratio: 1.5;
37 --s-2: calc(var(--s-1) / var(--ratio));
38 --s-1: calc(var(--s0) / var(--ratio));
39 --s0: 1rem;
40 --s1: calc(var(--s0) * var(--ratio));
41 --s2: calc(var(--s1) * var(--ratio));
42
43 /* A concrete stack instead of bare `monospace`: browsers disagree both on
44 which font that is and on its default size. */
45 --font: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
46
47 --measure: 80ch;
48 --radius: 0.5em;
49 --border-width: 1px;
50 --rule: 2px dotted var(--border);
51 }
52
53
54 /* 2. Reset & base ========================================================== */
55
56 *,
57 *::before,
58 *::after {
59 box-sizing: border-box;
60 }
61
62 html {
63 font-family: var(--font);
64 font-size: calc(1rem + 0.4vw);
65 line-height: 1.5;
66 -webkit-text-size-adjust: 100%;
67 tab-size: 4;
68 }
69
70 body {
71 margin: 0;
72 min-block-size: 100dvh;
73 display: flex;
74 flex-direction: column;
75 background-color: var(--background);
76 color: var(--color);
77 }
78
79 /* One focus style for every focusable thing, instead of six browser defaults.
80 `:focus:not(:focus-visible)` keeps programmatic focus (FocusOnNavigate
81 lands on the <h1> after every navigation) from flashing a ring. */
82 :focus-visible {
83 outline: 2px solid var(--color);
84 outline-offset: 2px;
85 }
86
87 :focus:not(:focus-visible) {
88 outline: none;
89 }
90
91 ::selection {
92 background-color: var(--color);
93 color: var(--background);
94 }
95
96
97 /* 3. Typography ============================================================ */
98
99 h1,
100 h2,
101 h3,
102 h4,
103 h5,
104 h6 {
105 margin-block: 0 var(--s-1);
106 font-weight: 700;
107 line-height: 1.2;
108 text-wrap: balance;
109 }
110
111 h1 {
112 font-size: calc(1em * var(--ratio));
113 }
114
115 h2 {
116 font-size: calc(1em * 1.25);
117 }
118
119 h3,
120 h4,
121 h5,
122 h6 {
123 font-size: 1em;
124 }
125
126 p {
127 margin-block: var(--s-1);
128 text-wrap: pretty;
129 }
130
131 ul,
132 ol {
133 margin-block: var(--s-1);
134 padding-inline-start: var(--s1);
135 }
136
137 pre {
138 margin-block: var(--s-1);
139 /* pre-line, so examples can stay indented in the .razor source */
140 white-space: pre-line;
141 overflow-wrap: anywhere;
142 }
143
144 small {
145 font-size: 0.75em;
146 }
147
148 a {
149 color: inherit;
150 text-decoration-line: underline;
151 text-decoration-thickness: var(--border-width);
152 text-underline-offset: 0.2em;
153 }
154
155 a:hover,
156 a:focus-visible {
157 background-color: var(--color);
158 color: var(--background);
159 text-decoration: none;
160 }
161
162
163 /* 4. Layout ================================================================ */
164
165 /* The frame a layout wraps its whole page in: header, content and footer in one column, with
166 `main` stretching so the footer sits at the bottom of a short page. It carries the rhythm that
167 used to be on <body>, so that /git can lay itself out its own way. */
168 .page {
169 flex: 1;
170 display: flex;
171 flex-direction: column;
172 gap: var(--s0);
173 padding-block: var(--s0);
174 }
175
176 .center {
177 box-sizing: content-box;
178 max-inline-size: var(--measure);
179 margin-inline: auto;
180 padding-inline: var(--s1);
181 }
182
183 main {
184 flex: 1;
185 border-block-start: var(--rule);
186 border-block-end: var(--rule);
187 padding-block: var(--s0);
188 }
189
190 .flex-row {
191 display: flex;
192 flex-wrap: wrap;
193 align-items: center;
194 gap: var(--s0);
195 }
196
197 /* A field shares the row instead of claiming a whole one to itself. */
198 .flex-row > :is(input[type="text"], textarea) {
199 flex: 1;
200 }
201
202 .flex-spread {
203 display: flex;
204 flex-wrap: wrap;
205 gap: var(--s0);
206 justify-content: space-between;
207 }
208
209 /* A stack on the left, the program left to run on the right (the log rows and
210 the worked examples in the docs). A grid rather than a spread flex row, so a
211 long program wraps inside its own column instead of taking a full-width line
212 of its own and leaving its stack stranded on the line above. */
213 .stack-step {
214 display: grid;
215 grid-template-columns: minmax(0, max-content) 1fr;
216 column-gap: var(--s0);
217 }
218
219 /* break-word, not anywhere: it keeps the column's minimum at one whole word, so
220 a long stack can never squeeze the program down to a letter per line. */
221 .stack-step > :last-child {
222 text-align: right;
223 overflow-wrap: break-word;
224 }
225
226 .row + .row {
227 margin-block-start: var(--s0);
228 }
229
230 /* Two columns, but only while they stay wide enough to read. */
231 .two-column {
232 columns: 2 24ch;
233 gap: var(--s1);
234 }
235
236 .noselect {
237 user-select: none;
238 -webkit-user-select: none;
239 }
240
241
242 /* 5. Controls ============================================================== */
243
244 /* Controls do not inherit the page font on their own — this is what keeps
245 fields and buttons from turning into system UI in each browser. */
246 button,
247 input,
248 select,
249 textarea {
250 font: inherit;
251 color: inherit;
252 background-color: var(--background);
253 border: var(--border-width) solid var(--border);
254 border-radius: var(--radius);
255 padding: 0.1em 0.5em;
256 max-inline-size: 100%;
257 }
258
259 label {
260 display: block;
261 }
262
263 input[type="text"] {
264 /* min-inline-size lets it shrink inside a .flex-row instead of overflowing */
265 inline-size: 100%;
266 min-inline-size: 0;
267 }
268
269 textarea {
270 display: block;
271 inline-size: 100%;
272 min-block-size: 4lh;
273 resize: vertical;
274 white-space: pre-wrap;
275 }
276
277 /* A block textarea leaves its button hard against it otherwise. */
278 textarea + button,
279 textarea + input[type="submit"] {
280 margin-block-start: var(--s-1);
281 }
282
283 /* Editors sized against the viewport rather than a row count. */
284 .editor {
285 block-size: 20vh;
286 }
287
288 .editor-tall {
289 block-size: 60vh;
290 }
291
292 select {
293 cursor: pointer;
294 }
295
296 button,
297 input[type="button"],
298 input[type="submit"],
299 input[type="reset"] {
300 min-inline-size: 8ch;
301 padding-inline: var(--s0);
302 text-align: center;
303 cursor: pointer;
304 }
305
306 button:hover,
307 input[type="button"]:hover,
308 input[type="submit"]:hover,
309 input[type="reset"]:hover {
310 background-color: var(--color);
311 color: var(--background);
312 }
313
314 button:active,
315 input[type="button"]:active,
316 input[type="submit"]:active,
317 input[type="reset"]:active {
318 translate: 0 1px;
319 }
320
321 /* The file input itself is a bare label for its button. */
322 input[type="file"] {
323 border: none;
324 background: none;
325 padding: 0;
326 cursor: pointer;
327 }
328
329 /* Kept in its own rule: an unsupported pseudo-element would invalidate the
330 whole selector list if it shared one with the buttons above. */
331 ::file-selector-button {
332 font: inherit;
333 color: inherit;
334 background-color: var(--background);
335 border: var(--border-width) solid var(--border);
336 border-radius: var(--radius);
337 min-inline-size: 8ch;
338 padding: 0.1em var(--s0);
339 margin-inline-end: var(--s-1);
340 cursor: pointer;
341 }
342
343 ::file-selector-button:hover {
344 background-color: var(--color);
345 color: var(--background);
346 }
347
348 input[type="checkbox"] {
349 appearance: none;
350 inline-size: 3ch;
351 padding: 0;
352 border: none;
353 background: none;
354 text-align: center;
355 cursor: pointer;
356 }
357
358 input[type="checkbox"]::after {
359 content: "[ ]";
360 }
361
362 input[type="checkbox"]:checked::after {
363 content: "[x]";
364 }
365
366 fieldset {
367 /* min-inline-size overrides the min-content floor browsers give fieldsets,
368 which otherwise makes them overflow flex and grid containers. */
369 min-inline-size: 0;
370 margin: 0;
371 border: var(--border-width) solid var(--border);
372 border-radius: var(--radius);
373 padding: var(--s-1) var(--s0);
374 }
375
376 legend {
377 padding-inline: var(--s-2);
378 }
379
380 legend::before {
381 content: "( ";
382 }
383
384 legend::after {
385 content: " )";
386 }
387
388 summary {
389 cursor: help;
390 }
391
392 summary::marker {
393 content: "> ";
394 }
395
396 details[open] > summary::marker {
397 content: "x ";
398 }
399
400 /* A heading is allowed to be a summary without breaking the line. */
401 summary > :is(h1, h2, h3, h4, h5, h6) {
402 display: inline;
403 }
404
405
406 /* 6. Components ============================================================ */
407
408 /* -- Site header --------------------------------------------------------- */
409
410 header {
411 display: flex;
412 flex-wrap: wrap;
413 align-items: center;
414 gap: var(--s0);
415 }
416
417 header > h1 {
418 margin-block: 0;
419 }
420
421 /* -- Site footer --------------------------------------------------------- */
422
423 /* Markers stay outside the text column, in a gutter wide enough for the
424 longest of them ("==> "). That way the arrow slides right on hover and
425 focus while the link itself never moves. The column gap is measured in the
426 same unit, so the second column's markers cannot reach the first. */
427 footer ul {
428 columns: 2 20ch;
429 padding-inline-start: 4ch;
430 column-gap: 5ch;
431 }
432
433 footer li::marker {
434 content: "> ";
435 }
436
437 footer li:has(a:hover)::marker {
438 content: " >";
439 }
440
441 footer li:has(a:focus)::marker {
442 content: "==> ";
443 }
444
445 footer li:has(a:hover:focus)::marker {
446 content: "==>";
447 }
448
449 /* NavLink marks the current page for us. */
450 footer li:has(a.active)::marker {
451 content: "* ";
452 }
453
454 /* -- Panels (bordered boxes with a title) -------------------------------- */
455
456 .panel {
457 margin-block: var(--s1);
458 border: 2px dashed var(--border);
459 padding: var(--s-1) var(--s0);
460 }
461
462 /* -- Log ------------------------------------------------------------------ */
463
464 #log {
465 margin-block-start: var(--s0);
466 }
467
468 .error::before,
469 .info::before,
470 .debug::before {
471 /* not selectable, so copying a log line copies the message only */
472 user-select: none;
473 -webkit-user-select: none;
474 }
475
476 .error::before {
477 content: "ERR: ";
478 color: var(--error);
479 }
480
481 .info::before {
482 content: "INF: ";
483 color: var(--info);
484 }
485
486 .debug::before {
487 content: "DBG: ";
488 color: var(--info);
489 }
490
491 /* Debug lines stay hidden until the checkbox next to the log is ticked. */
492 label:has(> #debug:not(:checked)) + #log .debug {
493 display: none;
494 }
495
496 /* Earlier runs, folded away and dimmed. */
497 .history {
498 opacity: 30%;
499 }
500
501 * + .history {
502 margin-block-start: var(--s1);
503 }
504
505 /* -- Rendered JSON -------------------------------------------------------- */
506
507 li.dash {
508 list-style-type: "- ";
509 }
510
511 span.name {
512 opacity: 50%;
513 }
514
515 .json-true {
516 color: var(--green);
517 }
518
519 .json-false {
520 color: var(--error);
521 }
522
523 .json-number {
524 color: var(--info);
525 }
526
527 .json-null {
528 opacity: 50%;
529 }
530
531 /* -- Dialogs -------------------------------------------------------------- */
532
533 dialog {
534 background-color: transparent;
535 color: var(--color);
536 border: none;
537 padding: 0;
538 max-inline-size: min(100% - var(--s2), 60ch);
539 }
540
541 dialog::backdrop {
542 backdrop-filter: blur(calc(var(--s1) / 20));
543 }
544
545 /* The panel inside supplies the surface, so the dialog itself can stay bare. */
546 dialog > .panel {
547 margin-block: 0;
548 background-color: var(--background);
549 }
550
551 dialog > .panel > legend {
552 background-color: var(--background);
553 border-radius: var(--radius);
554 }
555
556 /* -- Tables --------------------------------------------------------------- */
557
558 table {
559 inline-size: 100%;
560 border-collapse: collapse;
561 }
562
563 :is(th, td) {
564 text-align: start;
565 padding: var(--s-2) var(--s-1);
566 padding-inline-start: 0;
567 border-block-end: var(--border-width) solid var(--border);
568 vertical-align: baseline;
569 }
570
571 tbody tr:last-child :is(th, td) {
572 border-block-end: none;
573 }
574
575 /* A table with more columns than the page has room for scrolls inside its own
576 box rather than pushing the whole page sideways. */
577 .table-scroll {
578 overflow-x: auto;
579 }
580
581
582 /* -- rvrb stats ----------------------------------------------------------- */
583
584 .status-up {
585 color: var(--green);
586 }
587
588 .status-idle {
589 color: var(--info);
590 }
591
592 .status-down {
593 color: var(--error);
594 }
595
596 .muted {
597 opacity: 50%;
598 }
599
600 .now-playing {
601 display: flex;
602 flex-wrap: wrap;
603 gap: var(--s0);
604 align-items: start;
605 }
606
607 /* Overrides the block margin and auto-centring the base `img` rule gives every
608 image, which would otherwise push the art away from the track it belongs to. */
609 .now-playing .album-art {
610 margin: 0;
611 flex: none;
612 inline-size: 8em;
613 block-size: 8em;
614 border-radius: var(--radius);
615 }
616
617 .now-playing > div {
618 flex: 1 1 20ch;
619 }
620
621 progress {
622 inline-size: 100%;
623 block-size: var(--s-1);
624 }
625
626 /* Counts big enough to read at a glance, laid out as many per row as fit. */
627 .stats {
628 display: grid;
629 grid-template-columns: repeat(auto-fit, minmax(10ch, 1fr));
630 gap: var(--s-1);
631 margin-block: 0;
632 }
633
634 .stats dt {
635 opacity: 60%;
636 }
637
638 .stats dd {
639 margin-inline-start: 0;
640 font-size: calc(1em * var(--ratio));
641 }
642
643 /* Plays per day. One <li> per day, so a quiet day is an empty column rather
644 than a gap - see Rvrb.Stats.plays_per_day/1.
645
646 A grid rather than a flex row, because a bar's height is a percentage and
647 can only resolve against a track with a height of its own. Sharing one 6em
648 box with the labels made the two compete for it: flex shrank whichever bar
649 was tall enough to overflow *and* the labels under it, so the tallest bars
650 ended up hanging below the baseline the rest stood on. */
651 .bars {
652 /* A container, so the labels below can react to how much room each column
653 actually got rather than to the width of the viewport. */
654 container-type: inline-size;
655 display: grid;
656 grid-auto-flow: column;
657 grid-auto-columns: 1fr;
658 /* the bar track, then a row each for the count and the weekday */
659 grid-template-rows: 6em auto auto;
660 /* Column gap only: the labels belong against the bar they describe. */
661 gap: 0 var(--border-width);
662 margin-block: 0;
663 padding-inline: 0;
664 list-style: none;
665 }
666
667 /* subgrid, so every column's count and weekday line up on the same two rows
668 however tall the bar above them is. */
669 .bars li {
670 display: grid;
671 grid-row: span 3;
672 grid-template-rows: subgrid;
673 align-items: end;
674 justify-items: center;
675 }
676
677 .bars .bar {
678 inline-size: 100%;
679 background-color: var(--color);
680 }
681
682 .bars :is(.bar-label, .bar-day) {
683 font-size: 0.7em;
684 opacity: 60%;
685 }
686
687 /* Two weeks of columns in one text column: the day has to give way before the
688 count does, since the count is the number and the day is the axis. */
689 .bars .bar-day {
690 overflow: hidden;
691 max-inline-size: 100%;
692 /* Clipped rather than wrapped: three letters over two lines would push the
693 bars up and misalign every column. */
694 white-space: nowrap;
695 }
696
697 .bars .day-initial {
698 display: none;
699 }
700
701 /* "Thu" wants about 3.2ch, so a fortnight of them needs roughly this much
702 chart before the columns stop being wide enough to hold three letters. On a
703 phone they don't, and the day falls back to its initial rather than being
704 clipped mid-letter. */
705 @container (width < 21rem) {
706 .bars .day-abbrev {
707 display: none;
708 }
709
710 .bars .day-initial {
711 display: inline;
712 }
713 }
714
715
716 /* -- Storage ------------------------------------------------------------- */
717
718 .grid-stores {
719 display: grid;
720 grid-template-columns: 1fr auto auto;
721 align-items: center;
722 gap: var(--s-1) var(--s0);
723 }
724
725 /* -- Warframe drops ------------------------------------------------------- */
726
727 /* Chances are compared down the column, so they align right and shrink to fit. */
728 .chance {
729 text-align: end;
730 font-variant-numeric: tabular-nums;
731 inline-size: 1%;
732 white-space: nowrap;
733 }
734
735 /* Up to forty names, so columns rather than one long list. Grid rather than
736 `columns`, which fragments: a name too long for its column carried its last
737 word over into the next one. A grid item cannot break across columns, so a
738 long name wraps inside its own cell instead, and filling row by row puts the
739 closest match first in reading order. 26ch fits nine names in ten on one
740 line while still leaving room for a second column; the min() caps that floor
741 at the container, which a bare minmax() would overflow on a phone. */
742 .matches {
743 list-style: none;
744 margin: 0;
745 padding: 0;
746 display: grid;
747 grid-template-columns: repeat(auto-fill, minmax(min(26ch, 100%), 1fr));
748 /* The row gap is what keeps two names apart once they are narrow enough to
749 wrap onto a second line each. */
750 gap: var(--s-2) var(--s0);
751 }
752
753 .matches a.active {
754 font-weight: 700;
755 }
756
757 .matches a.active::before {
758 content: "> ";
759 }
760
761 /* Follows the search box or the match list, neither of which leaves room under
762 itself. */
763 .item-name {
764 margin-block-start: var(--s1);
765 }
766
767 /* Labels the table under it. Outside .table-scroll on purpose: inside, it
768 scrolled out of sight on a narrow screen. */
769 .relic-lead {
770 margin-block: var(--s-2) 0;
771 }
772
773 /* An offsite link sitting beside a name that already links within the site.
774 Dimmed and smaller so the two read as different kinds of thing, and it does
775 not compete with the name it follows. */
776 .wiki {
777 font-size: 0.8em;
778 font-weight: 400;
779 opacity: 50%;
780 white-space: nowrap;
781 }
782
783 .wiki:hover,
784 .wiki:focus {
785 opacity: 100%;
786 }
787
788 /* Each relic is a heading and its own small table. */
789 .relic + .relic {
790 margin-block-start: var(--s0);
791 padding-block-start: var(--s0);
792 border-block-start: var(--rule);
793 }
794
795 /* Name, rarity and both chances on one line, stacking when there is no room. */
796 .relic > h3 {
797 display: flex;
798 flex-wrap: wrap;
799 gap: var(--s-1);
800 align-items: baseline;
801 font-size: 1em;
802 }
803
804
805 /* -- Send (peer-to-peer file transfer) ------------------------------------ */
806
807 /* A target big enough to aim a dragged file at, dashed so it reads as a place to drop rather
808 than a box with something in it. */
809 .dropzone {
810 border: var(--border-width) dashed var(--border);
811 border-radius: var(--radius);
812 padding: var(--s0);
813 text-align: center;
814 }
815
816 .dropzone > p {
817 margin-block-end: 0;
818 }
819
820 /* The only feedback that the drop will land here. Translucent so it works over either scheme
821 without a token of its own. */
822 .dropzone.over {
823 background-color: color-mix(in srgb, var(--color) 12%, transparent);
824 }
825
826 .transfers {
827 list-style: none;
828 margin: 0;
829 padding: 0;
830 }
831
832 .transfers:not(:empty) {
833 margin-block-start: var(--s0);
834 }
835
836 /* Name and size on one line, bar and status under them: two rows of two, with the name free to
837 wrap and the two right-hand cells sized to their text. */
838 .transfer {
839 display: grid;
840 grid-template-columns: minmax(0, 1fr) auto;
841 column-gap: var(--s0);
842 align-items: baseline;
843 }
844
845 .transfer + .transfer {
846 margin-block-start: var(--s-1);
847 }
848
849 /* A file name is not prose and has no spaces to break at. */
850 .transfer-name {
851 overflow-wrap: anywhere;
852 }
853
854 .transfer-size,
855 .transfer-status {
856 white-space: nowrap;
857 font-variant-numeric: tabular-nums;
858 }
859
860 /* The one control that arrives with a colour of its own: the browser's accent is nowhere else
861 on this site. */
862 .transfer progress {
863 inline-size: 100%;
864 accent-color: var(--color);
865 }
866
867 .transfer.failed .transfer-status {
868 color: var(--error);
869 }
870
871
872 /* -- Scroll shortcuts ------------------------------------------------------ */
873
874 /* Docked to the viewport edge rather than the page flow, so it stays
875 reachable no matter how far down a long page (e.g. Query's history and
876 favorites) you've scrolled. */
877 .scroll-nav {
878 position: fixed;
879 inset-inline-end: var(--s0);
880 inset-block-start: 50%;
881 transform: translateY(-50%);
882 display: flex;
883 flex-direction: column;
884 gap: var(--s-1);
885 z-index: 100;
886 }
887
888 .scroll-nav button {
889 line-height: 1;
890 padding: var(--s-1);
891 }
892
893
894 /* 7. Media ================================================================= */
895
896 #logo {
897 block-size: 5em;
898 flex: none;
899 }
900
901 img {
902 display: block;
903 max-inline-size: 100%;
904 block-size: auto;
905 margin-inline: auto;
906 margin-block: var(--s0);
907 }
908
909 video {
910 display: block;
911 inline-size: 100%;
912 block-size: auto;
913 border-radius: var(--radius);
914 }
915
916 #qrcode {
917 margin-block: var(--s0);
918 text-align: center;
919 }
920
921 #qrcode :is(canvas, table) {
922 margin-inline: auto;
923 }