Repository navigation
Expand file tree
/
Copy paththeme.css
More file actions
2782 lines (2388 loc) · 85.6 KB
/
Copy paththeme.css
File metadata and controls
2782 lines (2388 loc) · 85.6 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
/* ── Clodd CMS — Public Theme ─────────────────────────────────────────────── */
/* ── Inter — self-hosted variable font (rsms/inter, OFL license) ─────────────
Sets everything on the site: UI, prose, and headings, and the OG card the
server draws is set in the static cut of the same family — a shared link and
the page it opens are the one typeface.
Self-hosted rather than linked from a CDN: every CSP in the repo is
`font-src 'self'`, so the CDN would mean loosening the policy in three nginx
configs and putting a third-party request in front of first paint. The files
are built from the upstream Inter 4.1 variable fonts with the weight axis
clamped to 200–800, the optical-size axis pinned at 14 (the text cut), and
hinting stripped. The subset is Latin-1 + Latin Extended-A plus punctuation
and arrows, not the full latin-ext range DM Sans shipped: Inter's coverage
there is three times the size, and the wider cut came to 105 KB on the
preloaded critical path against 49 KB for this one. Every character outside
it in the published archive is an emoji, which no text face draws anyway.
Two things follow from the swap back off the system stack, and both are
load-bearing:
• **The face is downloaded, so it must not block paint.** `font-display:
swap` on both cuts, and `templates/base.php` preloads the roman — and only
the roman, which sets essentially every glyph on a page. That preload's
URL has to stay character-for-character identical to the `src` below, or
the browser fetches the file twice.
• **The weight axis is live again, and the rules below still ask for 400 or
700.** That is now a design decision rather than a limit of the family:
1.26.0 flattened the ten rules that asked for 500 or 600 because GT
Walsheim shipped as two static cuts, and the flatter UI outlived it. Ask
for 500 here and you will get 500 — so only ask deliberately. */
@font-face {
font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/InterVariable-Italic.woff2') format('woff2');
font-weight: 200 800;
font-style: italic;
font-display: swap;
}
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Tells the browser to paint its own UI — scrollbars on overflowing code
blocks, form controls — to match the theme. Without it Chrome draws a
light scrollbar under every dark-mode <pre>. */
color-scheme: light;
--color-text: #1C1C1C;
--color-muted: #6B6B6B;
--color-border: #D6D6D6;
--color-border-hover: #BDBDBD;
/* The page is a pale grey and an article card is a white sheet resting on
it, so the two need separate tokens. */
--color-bg: #F8F8F8;
--color-surface: #FFFFFF;
--color-chrome-bg: var(--color-bg);
--color-code-bg: #EEEEEE;
--color-link: #0B5FA8;
--color-header-bg: var(--color-bg);
--color-chrome-border: var(--color-border);
/* Page tone, behind a gallery image while it loads. Note cards used it too
until 1.54.1, when they moved to the surface fill. */
--color-card-tint: var(--color-bg);
/* Hairline above an article's footer meta; lighter than a card border so it
reads as a division inside the card rather than another edge. */
--color-rule: color-mix(in srgb, var(--color-border) 60%, transparent);
/* Pure black at these alphas lands heavier than the old warm shadow did;
a dark grey keeps the same weight. */
--shadow-rgb: 40, 40, 40;
--max-content: 740px;
--max-wide: 780px;
/* Corner radius for every rounded box on the page — cards, images, embeds,
pills, form controls, and, since 1.47.1, the small chrome that used to
hold out at 3px: the two header icon buttons and the nav dropdown. One
knob, one corner. The avatar is the only exception left, and the reason
is written above .site-header__avatar: a face is not a panel. */
--radius: 6px;
/* The type stacks, named once. Every rule below reaches for these rather
than repeating the list — eight copies of the sans stack is eight places
for it to drift. */
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
/* ── Dark mode tokens ──────────────────────────────────────────────────────── */
/* Applied when the system prefers dark AND the user hasn't manually forced light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-text: #E8E8E8;
--color-muted: #9E9E9E;
--color-border: #3D3D3D;
--color-border-hover: #505050;
--color-bg: #1E1E1E;
--color-surface: #272727;
--color-chrome-bg: var(--color-bg);
--color-code-bg: #2F2F2F;
--color-link: #6FC0EA;
--color-header-bg: var(--color-chrome-bg);
--color-chrome-border: rgba(255,255,255,0.07);
--color-card-tint: var(--color-bg);
--color-rule: color-mix(in srgb, var(--color-border) 70%, transparent);
--shadow-rgb: 0, 0, 0;
}
}
/* Applied when the user has manually toggled dark mode */
[data-theme="dark"] {
color-scheme: dark;
--color-text: #E8E8E8;
--color-muted: #9E9E9E;
--color-border: #3D3D3D;
--color-border-hover: #505050;
--color-bg: #1E1E1E;
--color-surface: #272727;
--color-chrome-bg: var(--color-bg);
--color-code-bg: #2F2F2F;
--color-link: #6FC0EA;
--color-header-bg: var(--color-chrome-bg);
--color-chrome-border: rgba(255,255,255,0.07);
--color-card-tint: var(--color-bg);
--color-rule: color-mix(in srgb, var(--color-border) 70%, transparent);
--shadow-rgb: 0, 0, 0;
}
html { font-size: 18px; scroll-behavior: smooth; overflow-y: scroll; }
/* ── Quality floor ────────────────────────────────────────────────────────── */
/* Keyboard users need to see where they are. Nothing in the theme drew a focus
ring before this, so tabbing through the nav and cards was invisible. */
:focus-visible {
outline: 2px solid var(--color-link);
outline-offset: 2px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* 30px on the 18px root. Leading is set against the x-height rather than the
em, and Inter sets a tall one — .546em, taller than DM Sans's .504 and far
from Walsheim's .461, which is the face the short-lived 1.5556 was tuned
for. A taller x-height wants more leading, never less, so this holds. Keep
it a whole pixel at the root size: 1.6667 is 30/18. */
body {
font-family: var(--font-sans);
line-height: 1.6667;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
padding: .5rem;
}
/* .site-main { flex: 1; width: 100%; } */
/* Headings run tighter than body copy — Inter is spaced for text sizes and
sets loose above them. Uppercase micro-labels (.webmentions__title and friends) set their own
positive tracking and keep it, since class beats element. */
h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; }
a {
color: var(--color-link);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover { text-decoration-thickness: 2px; }
img, video {
max-width: 100%;
height: auto;
display: block;
}
audio { width: 100%; }
/* ── Site header ──────────────────────────────────────────────────────────── */
.site-header {
/* border-bottom: 1px solid var(--color-chrome-border); */
padding: 1.5rem;
background: var(--color-header-bg);
position: relative;
}
/*
@media (min-width: 640px) {
.site-header {
position: sticky;
top: 0;
z-index: 10000;
}
}
*/
.site-header__inner {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.site-header__title {
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: .6rem;
}
.site-header__title:hover { color: var(--color-link); }
/* The avatar lives inside the home link so the whole lockup is one target.
Same treatment as the webmention avatars (.wm-avatar): a circle, cropped to
fill, with the code background standing in until the image arrives.
Deliberately 50% and not --radius, which it was between 1.35.0 and here. A
face is the only thing on the page that is not a panel, and --radius is what
every panel is cornered with — so the circle is what keeps a person from
reading as more chrome. That also means the two must not move together:
changing --radius should leave every avatar alone. OgImage::AVATAR_RADIUS_RATIO
is the OG card's copy of this same decision. */
.site-header__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--color-code-bg);
display: block;
flex-shrink: 0;
}
.site-nav {
display: flex;
gap: 1.1rem;
flex-wrap: wrap;
margin-right: 0.25rem;
}
.site-nav a {
color: var(--color-muted);
text-decoration: none;
font-size: .95rem;
transition: color .15s;
}
.site-nav a:hover { color: var(--color-text); text-decoration: none; }
.site-nav__item {
position: relative;
display: flex;
align-items: center;
}
.site-nav__sub {
position: absolute;
top: 100%;
left: 0;
margin: 0;
padding: .5rem 0;
list-style: none;
background: var(--color-header-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
min-width: 12rem;
z-index: 100;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px);
transition: opacity .15s ease, transform .15s ease, visibility 0s linear .15s;
}
.site-nav__item.has-children:hover > .site-nav__sub,
.site-nav__item.has-children:focus-within > .site-nav__sub {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
transition: opacity .15s ease, transform .15s ease;
}
.site-nav__sub a {
display: block;
padding: .4rem 1rem;
color: var(--color-muted);
font-size: .9rem;
white-space: nowrap;
}
.site-nav__sub a:hover { color: var(--color-text); background: var(--color-code-bg); }
/* Right-hand side of header: nav + theme toggle grouped together */
.site-header__right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.theme-toggle,
.nav-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-muted);
display: flex;
align-items: center;
padding: .25rem;
border-radius: var(--radius);
transition: color .15s;
flex-shrink: 0;
line-height: 0;
}
.theme-toggle:hover,
.nav-toggle:hover { color: var(--color-text); }
.theme-toggle svg { width: 1rem; height: 1rem; }
/* Three-way toggle icon: light (sun), dark (moon), system (monitor).
All three glyphs are in the markup and CSS reveals the one matching the
preference the head script stamps on <html>, so the button is painted
correctly on first paint. Injecting the SVG from deferred theme.js instead
left the button visibly empty until the script ran. System is the default,
so it also shows when JS is off and no preference attribute exists. */
.theme-toggle__glyph { display: none; }
:root[data-theme-pref="light"] .theme-toggle__glyph--light,
:root[data-theme-pref="dark"] .theme-toggle__glyph--dark,
:root:not([data-theme-pref="light"]):not([data-theme-pref="dark"]) .theme-toggle__glyph--system { display: inline; }
/* ── Mobile nav toggle (hamburger) ───────────────────────────────────────── */
.nav-toggle {
display: none;
justify-content: center;
}
.nav-toggle__bars {
display: flex;
flex-direction: column;
gap: 5px;
width: 1.1rem;
}
.nav-toggle__bar {
display: block;
height: 1px;
background: currentColor;
border-radius: 2px;
transform-origin: center;
transition: transform .25s ease, opacity .25s ease;
}
/* Morph top bar → top arm of X */
.nav-toggle.is-open .nav-toggle__bar:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
/* Fade out middle bar */
.nav-toggle.is-open .nav-toggle__bar:nth-child(2) {
opacity: 0;
transform: scaleX(0);
}
/* Morph bottom bar → bottom arm of X */
.nav-toggle.is-open .nav-toggle__bar:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
/* ── Search (header) ──────────────────────────────────────────────────────── */
.search-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-muted);
display: flex;
align-items: center;
padding: .25rem;
border-radius: var(--radius);
transition: color .15s;
flex-shrink: 0;
line-height: 0;
text-decoration: none;
}
.search-toggle:hover { color: var(--color-text); }
.search-toggle svg { width: 1rem; height: 1rem; }
/* ── Main content area ────────────────────────────────────────────────────── */
.site-main {
max-width: var(--max-content);
margin: 1.5rem auto;
padding: 0 1.5rem;
width: 100%;
}
/* Page one only, and only so `.home-intro`'s top margin can be read literally.
`.site-main` has no top padding or border, so a first child's top margin
collapses into this one and the larger of the two wins — which is why the
intro's top margin used to be written as `0` and made no difference either
way. Making the wrapper a block formatting context stops the collapse, so the
intro's 1rem is 1rem *added* to whatever margin the breakpoint gives here,
which is what makes it work at all three widths without restating the sum.
Scoped with `:has()` so post and page templates keep collapsing as before —
`.page, .post { margin-top: .7rem }` is swallowed on purpose. Browsers without
`:has()` fall back to the collapsed spacing, which is what shipped before. */
.site-main:has(.home-intro) { display: flow-root; }
.site-main--wide {
max-width: var(--max-wide);
}
/* ── Post / page ──────────────────────────────────────────────────────────── */
.page, .post { margin-top: .7rem }
.post__header,
.post__content {
margin-bottom: 2rem;
}
.post__title {
font-size: 1.5rem;
line-height: 1.1;
margin-bottom: .1rem;
}
/* Micropub context line (reply/like/repost/bookmark target) — above the body */
.post__contexts {
margin-bottom: 1rem;
}
.post__contexts .post__context {
margin: 0 0 .25rem;
font-size: .875rem;
color: var(--color-muted);
}
.post__contexts .post__context a {
word-break: break-all;
}
/* The bookmark symbol is drawn (Font Awesome) where ♺, ♥ and ↩ are typed — see
Post::CONTEXT_ICONS. Sized in em and coloured by inheritance so it tracks
whatever line it lands on: the post page sets .875rem, a list card .8rem.
Cap height rather than em box, so it reads at the weight of the glyphs it
stands among instead of towering over them. */
.post__context-icon {
height: .85em;
width: auto;
vertical-align: -.08em;
}
/* A titled post's featured image (u-featured) — between the header and the
body.
A reader sees no difference between this picture and one written into the
body, so it must not look like one: the shadow, radius, margins and
wide-viewport bleed all come from the rule shared with `.prose img` below,
rather than being restated here where the two could drift apart. Only the
figure wrapper and the lightbox anchor are its own. */
.post__featured {
margin: 0;
}
.post__featured-link {
display: block;
cursor: zoom-in;
}
/* Images inside a post: one written into the body, and a titled post's
featured image. Same object to a reader, so one definition.
In the critical half rather than beside the rest of the `.prose` rules
because the featured image is the Largest Contentful Paint element on a post
that has one — styling it from the deferred sheet would repaint the largest
thing on the page. Body images near the top of a post get the same benefit,
which they did not have before. */
.prose img,
.post__featured img {
box-shadow: 0 4px 20px rgba(var(--shadow-rgb), .10);
border-radius: var(--radius);
}
.prose img:not(.gallery img),
.post__featured img {
display: block;
max-width: 100%;
width: 100%;
height: auto;
margin: 2rem auto;
}
/* Wide enough to spare the room, and the picture steps out past the measure. */
@media (min-width: 880px) {
.prose img:not(.gallery img),
.post__featured img {
max-width: calc(100% + 2rem);
width: calc(100% + 2rem);
margin: 2rem -1rem;
}
}
/* No hover fade here, deliberately, though .post__photo has one: an image in
the body does not fade either, and matching the body is the point. Adding it
to both would have meant changing every picture on the site to settle a
difference in one. */
/* Micropub photo property (u-photo) — shown above the post body */
.post__photos {
display: grid;
gap: 0.35rem;
margin-bottom: 2rem;
}
/* Two or more pictures pair up into a gallery. Unlike the card gallery, these
are never cropped: the card is a teaser and can crop to a tidy grid, but a
reader who opened the post came to look at the pictures, so each keeps the
proportions it was shot in and the rows are allowed to sit ragged. */
.post__photos:not([data-count="1"]) {
grid-template-columns: 1fr 1fr;
align-items: start;
}
.post__photo { margin: 0; }
/* The picture is the point of a photo post, so it opens. The link carries the
full-size original and theme.js lifts it into the lightbox — same zoom-in
cursor and hover fade as the tiled gallery, so both answer the pointer alike. */
.post__photo-link {
display: block;
border-radius: var(--radius);
cursor: zoom-in;
}
.post__photo-link img { transition: opacity .15s; }
.post__photo-link:hover img { opacity: .88; }
.post__photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.post__author,
.post__date,
.post__reading-time {
display: inline;
font-size: .875rem;
color: var(--color-muted);
}
.post__author { text-decoration: none; }
.post__date::before,
.post__reading-time::before {
content: ' · ';
}
.post__date a {
color: inherit;
text-decoration: none;
}
/* ── Post taxonomy terms (categories + tags intermingled) ────────────────── */
.post__terms {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: .275rem;
margin: .5rem 0 0;
padding: 0;
}
.term {
display: inline-flex;
align-items: center;
padding: .1rem .5rem;
border-radius: var(--radius);
font-size: .75rem;
font-weight: 400;
line-height: 1.4;
text-decoration: none;
transition: color .15s, border-color .15s;
}
.term:hover { text-decoration: none; }
.term--category,
.term--tag {
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
}
.term--category:hover,
.term--tag:hover {
color: var(--color-text);
border-color: currentColor;
}
/* ── Prose styles ─────────────────────────────────────────────────────────── */
.prose {
font-family: var(--font-sans);
font-size: 1rem;
/* line-height: 1.6; */
}
.prose > * + * { margin-top: 1.3em; margin-bottom: 1.3em; }
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
font-family: var(--font-sans);
line-height: 1.25;
margin-top: 2em;
margin-bottom: .5em;
}
.prose h1 { font-size: 1.7rem; }
.prose h2 { font-size: 1.35rem; }
.prose h3 { font-size: 1.1rem; }
.prose h4, .prose h5, .prose h6 { font-size: 1rem; }
.prose p { margin-top: 1em; }
.prose ul,
.prose ol {
padding-left: 1.5em;
margin-top: 1em;
}
.prose blockquote {
font-style: italic;
position: relative;
padding: 1px 1em 1em 1em;
background: linear-gradient(135deg,var(--color-rule) 0%,var(--color-bg) 100%);
border-left: 3px solid var(--color-link);
margin: 1.5em 0;
border-radius: 0;
}
.prose blockquote * {
position: relative;
z-index: 2;
color: var(--color-text);
opacity: 1;
text-shadow: 0 1px 0 var(--color-bg);
font-style: normal;
}
.prose hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 3.5em 0;
max-width: 100px;
}
.prose strong { font-weight: 700; }
.prose em { font-style: italic; }
.prose u { text-decoration: none; border-bottom: 1px solid var(--color-muted) }
/* An external link in a post body trails a small arrow. Scoped to `.prose`, so
it marks a departure from the text the reader is *in*; the header and the
syndication row are chrome and already say where they go by name. A note card
carries `.prose` too, which is why the arrows show on the index as well — a
note is its own body, published whole.
Three things the selector has to get right, and each one was a wrong arrow:
• `[href^="http"]` on its own flags *internal* absolute links, which a
couple of imported posts still carry. The exclusion is written against the
authority — `//jimmitchell.org` — rather than as a bare substring: `*=`
alone would also swallow `https://bsky.app/profile/jimmitchell.org`, while
this form matches either scheme and leaves `micro.` and `social.`
external, which is what they are. It is the only host-specific string in
this file; the alternative is tagging the links server-side from the
configured site_url.
• `:not(:has(img))` keeps the arrow off the ~140 pictures in the archive
that are wrapped in a link. Hung off the corner of a photo it reads as
part of the picture rather than as a note about where it goes.
• The space is `\00a0`. An ordinary one is a break opportunity, so a link
ending a line drops its arrow alone onto the next.
`--color-muted`, not `--color-border-hover`: a border token set as text is
1.9:1 against the light card and 1.8:1 against the dark one, which is not a
faint arrow but an invisible one. Muted is 5.2:1 and 6.0:1.
Above =END CRITICAL= on purpose. It adds a glyph to running body copy, so
from the deferred sheet it would reflow the paragraphs of an article after
first paint. */
.prose a[href^="http"]:not([href*="//jimmitchell.org"]):not(:has(img))::after {
content: "\00a0↗";
/* Declared twice: the alt text stops a screen reader announcing "north
east arrow" after every link, and an engine that does not parse the
two-value syntax keeps the first declaration — losing the muting, not
the arrow. */
content: "\00a0↗" / "";
font-size: .8em;
color: var(--color-muted);
}
/* No selector beyond the hover is needed: an `::after` with no `content`
generates no box, so this reaches exactly the links drawn above. */
.prose a:hover::after { color: var(--color-link); }
.prose table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
font-size: .95rem;
margin-bottom: 1.5em;
}
@media (max-width: 600px) {
.prose table {
min-width: 300px;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
.prose th,
.prose td {
padding: .5em .75em;
border: 1px solid var(--color-border);
text-align: left;
}
.prose th { background: var(--color-code-bg); font-weight: 700; }
/* Code */
.prose code {
font-family: var(--font-mono);
font-size: .825em;
background: var(--color-code-bg);
border-radius: var(--radius);
padding: .1em .3em;
}
.prose pre {
background: var(--color-code-bg);
padding: 1em 1.2em;
overflow-x: auto;
line-height: 1.2;
font-size: 1.1rem;
}
/* Wrapper around every <pre> — provides the positioning context for the copy
button so it stays anchored to the visible corner even when the <pre>
scrolls horizontally. Fenced code ships with it already in place; theme.js
adds it to the rest (indented blocks, raw HTML in a post body).
.prose > * + * sets margins in em, so the wrapper — which takes the <pre>'s
place as the .prose child and leaves the <pre> with no margin of its own —
has to be sized like the <pre> or those margins come out 1.3 × 1rem instead
of 1.3 × 1.1rem. That is the ~5px per code block the page used to shift by
the moment theme.js ran. */
.code-block {
position: relative;
}
.prose > .code-block { font-size: 1.1rem; }
.code-copy {
position: absolute;
top: .3rem;
right: .3rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--color-muted);
cursor: pointer;
opacity: 0;
transition: opacity .15s, color .15s, border-color .15s;
}
.code-block:hover .code-copy { opacity: 1; }
.code-copy:hover {
color: var(--color-text);
border-color: var(--color-border);
background: var(--color-bg);
}
.code-copy--copied,
.code-copy--copied:hover,
.code-block:hover .code-copy--copied { color: #fff; background: #4C7A2E; border-color: #4C7A2E; opacity: 1; }
.code-copy svg { width: .8rem; height: .8rem; }
.prose pre code {
background: none;
padding: 0;
font-size: .7em;
}
/* ── Home intro (representative h-card) ───────────────────────────────────────
A greeting, set in the reading face because it is a sentence, not chrome.
Unframed at every width — no padding, border, radius or background. It has
been a panel (1.48.1 framed it on phones too); 1.50.1 takes the frame off
everywhere, so the intro reads as a line of text above the feed rather than
one more card in it. */
/* No rule under the greeting — whitespace does the separating. A line here reads
as a masthead and makes the intro feel like site furniture rather than a
sentence someone wrote. */
/* The 1rem top is not a taste value — it is page two's gap, copied. `.post-list`
opens with the h-feed's `<data class="p-name">`, which is empty but still takes
a grid cell, so every page's first card sits one `gap` (1rem) below the top of
the list. Page one is the only page with something above that list, and without
the same 1rem the intro would start higher than the card it replaces, so the
feed would appear to shift as you paged. If that `<data>` ever stops being a
grid item, this comes off with it — and see the `:has()` rule on `.site-main`,
without which this margin collapses away and does nothing. */
.home-intro {
margin: 1rem 0 1.5rem;
}
.home-intro__text {
font-family: var(--font-sans);
font-size: 1rem;
/* line-height: 1.55; */
text-wrap: pretty;
color: var(--color-text);
text-align: center;
}
.home-intro__text .p-name {
color: var(--color-text);
font-weight: 700;
text-decoration: none;
}
.home-intro__text .p-name:hover { color: var(--color-link); }
.home-intro__text .p-note { color: var(--color-muted); }
/* The links that go somewhere sit on their own line, so the eye lands on them
after the sentence rather than competing with it. Marks alone, like the
footer: the network names are still in the markup — they are what names each
link for a screen reader — but clipped away, so the row reads as a short strip
of marks under the greeting rather than a second sentence competing with the
first. With the words gone the marks carry the whole job, so they are drawn a
touch larger than the footer's and spaced further apart; the padding gives each
one a hit area taller than the glyph. The row is centred under the greeting,
with 2rem between marks. */
.home-intro__social {
margin-top: .8rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.home-intro__social .social-link { padding: .3rem 0; }
.home-intro__social .social-link svg {
width: 1rem;
height: 1rem;
}
/* Clipped, not display:none — the latter takes the name out of the accessibility
tree along with the layout, which would leave each link announced as nothing
but its URL. */
.home-intro__social .social-link__name {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
/* ── Post list / index ────────────────────────────────────────────────────── */
.post-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
align-items: stretch;
}
.site-main:not(.site-main--wide) .post-list {
grid-template-columns: 1fr;
}
/* ── Card family ──────────────────────────────────────────────────────────────
One frame, three weights. The kind is legible from the fill, the padding, and
whether the card carries a footer rule — no badges, no colour coding:
--note tinted and compact; the whole note is right there
--photo the image bleeds to the card edge and leads; caption beneath
--article a title, an excerpt, and a footer rule with the date and how
long the piece runs — the only kind with further to go.
Padding lives on .post-card__inner, never on .post-card, so a photo can reach
the frame's edge without the other kinds losing their gutters. */
.post-card {
position: relative;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow .15s ease, border-color .15s ease;
}
.post-card__inner {
display: flex;
flex-direction: column;
gap: .35rem;
padding: 1.5rem;
flex: 1;
}
.post-card:hover {
border-color: var(--color-border-hover);
box-shadow: 0 4px 12px rgba(var(--shadow-rgb), .10);
}
/* Notes are the everyday voice of the site and by far the most common card, so
they sit back: tighter gutters keep a long run of them from shouting and let
a titled post read as an event by contrast. Since 1.54.1 they share the
articles' surface fill rather than staying at page tone. */
.post-card--note .post-card__inner {
padding: 1.15rem 1.25rem;
}
.post-card--note {
background: var(--color-surface);
}
.post-card__title {
font-family: var(--font-sans);
font-size: 1.3rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
margin-bottom: .15rem;
}
.post-card__title a {
color: var(--color-text);
text-decoration: none;
}
.post-card__title a:hover { color: var(--color-link); }
.post-card:hover .post-card__title a { color: var(--color-link); }
/* Stretch the card's primary anchor over the whole card so the entire card is
one click target. Article cards use the title link; notes and photo posts use
the date link. */
.post-card__title a::after,
.post-card--note .post-card__date a::after,
.post-card--photo .post-card__date a::after {
content: "";