Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
4024 lines (3413 loc) · 147 KB
/
Copy pathindex.html
File metadata and controls
4024 lines (3413 loc) · 147 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Telepatch</title>
<link rel="icon" type="image/svg+xml" href="assets/logo.svg" />
<link rel="icon" type="image/png" href="data:image/png;base64,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">
<meta name="description" content="A publication assembled from a single Telegraph page. No server, no database.">
<style>
/* ---------------------------------------------------------------
Telepatch — telephone-exchange palette. Enamelled steel, bakelite
ink, patch-cord green, and one indicator lamp used only for live
and active states. System fonts only: the site makes zero
third-party requests, which is the point. The one script it loads,
highlight.js, is served from this site and only on code pages.
----------------------------------------------------------------*/
:root{
--paper:#E8EAE6;
--panel:#DEE1DC;
--ink:#14181A;
--graphite:#5C6562;
--hair:#C6CAC4;
--patch:#1C5A4E;
--lamp:#C4342A;
--serif:Georgia,"Iowan Old Style","Palatino Linotype",Palatino,serif;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
--col:52rem;
--pad:1.5rem;
}
@media (prefers-color-scheme:dark){
:root{
--paper:#0F1211; --panel:#171B1A; --ink:#DDE0DC; --graphite:#8A938F;
--hair:#2A302E; --patch:#6FBFA8; --lamp:#E46659;
}
}
*{box-sizing:border-box}
html{
-webkit-text-size-adjust:100%;
/* Reserve the scrollbar track always, or the centred column shifts
sideways every time page height crosses the scrolling threshold: a
short nav page against a long index, or the narration collapsing.
overflow-y works everywhere; scrollbar-gutter is the tidier modern
form and wins where it is supported. */
overflow-y:scroll;
scrollbar-gutter:stable;
scroll-behavior:smooth;
}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
body{
margin:0;
background:var(--paper);
color:var(--ink);
font-family:var(--serif);
font-size:17px;
line-height:1.6;
/* Footer sits at the bottom of the viewport when a page is short, and
flows normally when it is not. */
display:flex;flex-direction:column;
min-height:100vh;min-height:100dvh;
}
a{color:var(--patch)}
:focus-visible{outline:2px solid var(--lamp);outline-offset:3px}
/* THE column. Every element carrying .col resolves to an identical inline
box, so the site title in the bar and the content below it share one left
edge. Logical longhands only - a `padding` shorthand here would outrank
the element-selector rules further down and silently zero their vertical
padding, which is exactly what used to happen. Nothing else in this
stylesheet may set padding-inline or margin-inline on a .col element. */
.col{
width:min(100%,var(--col));
margin-inline:auto;
padding-inline:var(--pad);
}
/* ---- sticky bar: exactly one row of text tall ---- */
.bar{
position:sticky;top:0;z-index:20;
background:var(--paper);
border-bottom:1px solid var(--hair);
}
.bar .col{
display:flex;align-items:baseline;gap:1.25rem;
padding-block:.6rem;
position:relative;
}
.brand{
font-family:var(--serif);
font-size:1rem;font-weight:700;letter-spacing:-.01em;
color:var(--ink);text-decoration:none;
flex:0 1 auto;min-width:0;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* Telepatch's monogram, drawn rather than set: the face it is cut from is
not on anybody else's machine, so the glyphs are paths. Being paths it
also takes currentColor, which is what the cable photograph could not -
that needed a brightness filter to survive dark mode.
The publication's own title is a separate thing and keeps the left edge
it shares with the content, so this sits outside the column entirely,
pinned to the window in the gutter. */
.mark{
position:absolute;left:1rem;top:50%;
transform:translateY(-50%);
height:1.35rem;width:auto;
color:var(--ink);pointer-events:none;
}
/* Below this the gutter cannot hold it without crowding the title, and
crowding the title is the one thing it must not do. */
@media (max-width:62rem){ .mark{display:none} }
.pages{display:flex;gap:1rem;flex:1;min-width:0}
.pages a{
font-family:var(--sans);font-size:.8rem;
text-decoration:none;white-space:nowrap;
}
.pages a:hover{text-decoration:underline}
/* Categories, divider and globe are one group, with their own small gap.
The bar's 1.25rem is right between the title and the pages; between two
controls that belong together it is a gulf. */
.navtools{
margin-left:auto;flex:none;align-self:center;
display:flex;align-items:center;gap:.4rem;
}
.catbtn{
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.08em;
background:none;border:0;padding:0;cursor:pointer;
color:var(--graphite);white-space:nowrap;
}
.catbtn[aria-expanded="true"],.catbtn.on{color:var(--lamp)}
/* It separates this publication's own categories from the places outside
it, which is a real distinction. Nearly invisible when there is nothing
through there, plain when there is - so the bar tells you whether the
menu is worth opening before you open it. */
.sep{
width:1px;align-self:stretch;
background:var(--hair);opacity:.35;
flex:none;
transition:opacity .2s ease;
}
.sep.live{opacity:1}
/* Elsewhere: other Telepatch sites, and whatever else the publisher
wants within reach. Drawn rather than fetched, so it takes the bar's
own colour in either theme and stays sharp at any size - there is no
light version and dark version to keep in step. */
/* Centred against the text beside it. Baseline alignment was the first
attempt and does not survive contact with a <button>: browsers
synthesise a button's baseline from its bottom margin edge rather than
from its content, so the icon floated above the words. Centring is
decided by the box, not by the engine's opinion of it. */
.globe{
flex:none;display:flex;align-items:center;
background:none;border:0;padding:0;cursor:pointer;
color:var(--graphite);
}
.globe svg{width:1.05rem;height:1.05rem;display:block}
/* Always there, so the bar does not change shape between publications.
Dimmed until something is behind it, and not clickable then either -
an enabled control that does nothing is worse than a quiet one. */
.globe{opacity:.3;cursor:default;transition:opacity .2s ease}
.globe.live{opacity:1;cursor:pointer;color:var(--ink)}
.globe.live:hover{color:var(--patch)}
.globe.live[aria-expanded="true"]{color:var(--lamp)}
.globe .node{fill:var(--paper)}
/* drops into the right gutter, past the column edge where there's room */
.drop{
position:absolute;top:100%;right:var(--pad);
margin-right:-9rem;
width:15rem;max-width:calc(100vw - 2rem);
background:var(--panel);
border:1px solid var(--hair);
padding:.75rem 0;
display:none;
max-height:70vh;overflow-y:auto;
}
.drop.open{display:block}
.drop h2{
font-family:var(--mono);font-size:.65rem;font-weight:400;
text-transform:uppercase;letter-spacing:.1em;
color:var(--graphite);margin:.5rem 0 .35rem;padding:0 .9rem;
}
.drop h2:first-child{margin-top:0}
/* Between the collections and everything else. Faint, because the two
groups are already labelled - this only says where one ends. */
.drop h2.after{
margin-top:.75rem;padding-top:.7rem;
border-top:1px solid var(--hair);
}
.drop button,.drop a{
display:flex;justify-content:space-between;gap:1rem;width:100%;
font-family:var(--sans);font-size:.85rem;text-align:left;
background:none;border:0;cursor:pointer;
padding:.3rem .9rem;color:var(--ink);text-decoration:none;
}
.drop button:hover,.drop a:hover{background:var(--paper)}
.drop button.on{color:var(--lamp)}
.drop .n{font-family:var(--mono);font-size:.7rem;color:var(--graphite)}
.drop .inpanel{display:none}
/* ---- narration: the page reporting its own assembly ---- */
.boot{
font-family:var(--mono);font-size:.75rem;line-height:1.9;
color:var(--graphite);
margin:2.5rem 0 0;white-space:pre-wrap;word-break:break-word;
}
.boot .err{color:var(--lamp)}
.boot::after{
content:"";display:inline-block;
width:.5em;height:1em;margin-left:.15em;
background:var(--lamp);vertical-align:-.15em;
animation:blink 1s steps(1) infinite;
}
.boot.done::after{display:none}
@keyframes blink{50%{opacity:0}}
.boot.done{
opacity:.45;font-size:.68rem;line-height:1.7;
margin-bottom:2rem;overflow:hidden;
transition:opacity .6s ease,font-size .5s ease,
max-height .6s ease,margin .6s ease;
}
/* Retires itself once read. The masthead animates between the narration's
height and the author line's, so the two swap in one continuous move
instead of collapsing to nothing and then shoving content back down. */
.boot.fade{opacity:0}
#masthead{overflow:hidden;transition:max-height .6s ease}
.sitemeta{
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.09em;
color:var(--graphite);
/* Equal margins put the author line midway between the bar above it and
the About heading below. #masthead has overflow:hidden, so neither
margin collapses out and the two gaps really are the same. */
margin:2rem 0;
opacity:0;transition:opacity .5s ease .15s;
}
.sitemeta.in{opacity:1}
main{padding-block-end:6rem;min-height:0;flex:1 0 auto}
/* The publisher's own introduction, set larger than the index it heads.
Named lede to avoid colliding with #masthead, which wraps the narration. */
.lede{margin:0 0 2rem;font-size:1.12rem;line-height:1.6;max-width:42rem}
/* Heads the introduction. Same size as the prose it introduces, so it reads
as a run-in heading rather than a second label under the author line. */
.ledehead{
font-family:var(--serif);font-size:1.12rem;font-weight:700;
letter-spacing:-.01em;color:var(--ink);
margin:0 0 .35rem;
}
/* Spreads the lines of a short paragraph evenly instead of leaving two
words alone on the last one. Costs nothing and changes no type sizes;
browsers without it simply wrap normally. */
.lede p{margin:0 0 .9rem;text-wrap:balance}
.lede p:last-child{margin-bottom:0}
.lede h3{font-size:1.2rem;margin:1.4rem 0 .5rem}
.lede blockquote{
margin:1.2rem 0;padding-left:1.1rem;
border-left:2px solid var(--hair);color:var(--graphite);
}
/* ---- index ---- */
.tools{
display:flex;align-items:baseline;gap:1rem;
margin:0 0 2rem;padding-bottom:.6rem;
border-bottom:1px solid var(--hair);
}
.search{
flex:1;min-width:0;
font-family:var(--serif);font-size:1rem;color:var(--ink);
background:none;border:0;padding:0;
}
.search::placeholder{color:var(--graphite)}
.count{font-family:var(--mono);font-size:.7rem;color:var(--graphite);white-space:nowrap}
/* Repositories are not posts. They are standing resources rather than
things published on a date, and a site is meant to collect them freely,
so they sit above the reading list as a row that grows sideways instead
of pushing the writing down the page. */
.shelf{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin:0 0 2rem}
.tools.tight{margin-bottom:1rem}
/* One mark for the row rather than one per button: it labels where all of
these live, and repeating it would only crowd the addresses. */
.ghmark{
flex:none;width:1.15rem;height:1.15rem;
margin-right:.3rem;color:var(--graphite);
}
.chip{
display:inline-flex;align-items:baseline;
padding:.4rem .75rem;
background:var(--panel);
border:1px solid var(--hair);border-radius:.2rem;
font-family:var(--mono);line-height:1.25;
color:var(--ink);text-decoration:none;
}
.chip:hover{border-color:var(--patch);color:var(--patch)}
/* The owner is context; the repository is the name of the thing. */
.chip .ns{font-size:.75rem;color:var(--graphite)}
.chip .repo{font-size:.95rem;font-weight:700}
.chip:hover .ns{color:var(--patch)}
.index{list-style:none;margin:0;padding:0}
.item{padding:1.15rem 0;border-bottom:1px solid var(--hair)}
.item a{
display:block;font-size:1.3rem;line-height:1.3;
color:var(--ink);text-decoration:none;text-wrap:balance;
}
.item a:hover{color:var(--patch)}
.tags{margin-top:.4rem;display:flex;flex-wrap:wrap;gap:.6rem}
.tag{
font-family:var(--mono);font-size:.68rem;
text-transform:uppercase;letter-spacing:.07em;
background:none;border:0;padding:0;cursor:pointer;
color:var(--graphite);
}
.tag:hover{color:var(--patch)}
.tag.on{color:var(--lamp)}
.date{
font-family:var(--mono);font-size:.68rem;
text-transform:uppercase;letter-spacing:.07em;
color:var(--graphite);
}
/* Inherits the byline's type so it reads as part of the line, not a widget. */
.share{
font:inherit;letter-spacing:inherit;text-transform:inherit;
background:none;border:0;padding:0;cursor:pointer;color:var(--patch);
}
.share:hover{color:var(--lamp)}
.share[disabled]{color:var(--graphite);cursor:default}
/* Reading order comes from the master post, so the publisher controls it. */
.pager{
display:flex;justify-content:space-between;gap:2rem;
margin:3rem 0 0;padding-top:1.5rem;
border-top:1px solid var(--hair);
}
.pager a{
display:flex;flex-direction:column;gap:.3rem;
text-decoration:none;max-width:47%;
}
.pager .next{text-align:right;margin-left:auto}
.pager .dir{
font-family:var(--mono);font-size:.65rem;
text-transform:uppercase;letter-spacing:.09em;color:var(--graphite);
}
.pager .t{font-size:1.02rem;line-height:1.3;color:var(--ink)}
.pager a:hover .t{color:var(--patch)}
.excerpt{
margin:.4rem 0 0;font-size:.96rem;line-height:1.5;color:var(--graphite);
max-width:42rem;
}
.empty{font-family:var(--sans);font-size:.9rem;color:var(--graphite);padding:2rem 0}
/* ---- article, rendered here rather than sent away ---- */
.back{
display:inline-block;margin:2.5rem 0 1.25rem;
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.08em;
text-decoration:none;
}
.back:hover{color:var(--lamp)}
/* A nav page has no back link above it, so it carries its own top space
rather than hugging the sticky bar. */
.article.bare{margin-top:3.25rem}
.article h1{
font-size:2.3rem;line-height:1.12;letter-spacing:-.015em;
margin:0 0 .6rem;text-wrap:balance;
}
.byline a{color:var(--patch);text-decoration:none}
.byline a:hover{color:var(--lamp)}
.byline{
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.08em;
color:var(--graphite);
margin:0 0 .5rem;
}
.article .tags{margin:0 0 1.5rem}
/* An article's own intro: the first blockquote, lifted out and set as a
standfirst rather than left looking like a pull quote in the body. */
.standfirst{
font-size:1.18rem;line-height:1.55;color:var(--graphite);
margin:0 0 2rem;max-width:42rem;text-wrap:balance;
}
/* Contents, built from the h3 and h4 headings Telegraph allows. */
.toc{
margin:0 0 2.2rem;padding:.9rem 0 1rem;
border-top:1px solid var(--hair);border-bottom:1px solid var(--hair);
}
.toclabel{
font-family:var(--mono);font-size:.65rem;font-weight:400;
text-transform:uppercase;letter-spacing:.1em;
color:var(--graphite);margin:0 0 .55rem;
}
.toclist{list-style:none;margin:0;padding:0}
.toclist li{margin:.22rem 0}
.toclist li.sub{padding-left:1.2rem}
.toclist a{font-size:.98rem;line-height:1.35;color:var(--ink);text-decoration:none}
.toclist a:hover{color:var(--patch)}
/* Clear the sticky bar when jumping to a heading. */
.body h3,.body h4{scroll-margin-top:4rem}
.body{font-size:1.06rem;line-height:1.72}
.body p{margin:0 0 1.25rem}
.body h3{font-size:1.45rem;line-height:1.25;margin:2.4rem 0 .7rem}
.body h4{font-size:1.12rem;margin:2rem 0 .5rem}
.body ul,.body ol{margin:0 0 1.25rem;padding-left:1.4rem}
.body li{margin:.3rem 0}
.body blockquote{
margin:1.6rem 0;padding-left:1.2rem;
border-left:2px solid var(--hair);color:var(--graphite);
}
.body aside{
margin:2rem 0;text-align:center;font-style:italic;color:var(--graphite);
}
.body figure{margin:2.2rem 0;text-align:center}
.body img,.body video{max-width:100%;height:auto;display:block;margin:0 auto}
.body iframe{width:100%;aspect-ratio:16/9;border:0;display:block}
.body figcaption{
font-family:var(--sans);font-size:.8rem;color:var(--graphite);
margin-top:.6rem;
}
.body pre{
background:var(--panel);padding:1rem;overflow-x:auto;
font-family:var(--mono);font-size:.82rem;line-height:1.55;
}
.body code{font-family:var(--mono);font-size:.9em;background:var(--panel);padding:.1em .3em}
.body pre code{background:none;padding:0}
.body hr{border:0;border-top:1px solid var(--hair);margin:2.6rem 0}
.body a{overflow-wrap:anywhere}
/* Tables come from Markdown sources; a Telegraph page cannot hold one.
The wrapper scrolls so a wide table never widens the page itself. */
.body .tablewrap{overflow-x:auto;margin:0 0 1.5rem}
.body table{border-collapse:collapse;width:100%;font-size:.95rem}
.body th,.body td{
text-align:left;vertical-align:top;
padding:.5rem 1.2rem .5rem 0;
border-bottom:1px solid var(--hair);
}
.body th{
font-family:var(--mono);font-size:.66rem;font-weight:400;
text-transform:uppercase;letter-spacing:.08em;color:var(--graphite);
border-bottom:1px solid var(--ink);white-space:nowrap;
}
.body tbody tr:last-child td{border-bottom:0}
.body td code{white-space:nowrap}
/* Token colours drawn from the exchange palette rather than added to it,
and kept few. Quiet enough to read a whole file through. */
:root{
--tok-num:#7A5B2E; --tok-key:#5B3E6B;
--tok-fn:#2B5D7D; --tok-type:#7A3E5B;
}
@media (prefers-color-scheme:dark){
:root{
--tok-num:#C9A96A; --tok-key:#B79BC7;
--tok-fn:#8FC1E3; --tok-type:#D79BB8;
}
}
/* The fallback lexer's four classes. */
.tk-c{color:var(--graphite);font-style:italic}
.tk-s{color:var(--patch)}
.tk-n{color:var(--tok-num)}
.tk-k{color:var(--tok-key)}
/* highlight.js, wearing this site's colours instead of shipping its own
theme - a stylesheet built for a different palette would fight the page
in dark mode and win. */
.hljs-comment,.hljs-quote{color:var(--graphite);font-style:italic}
.hljs-string,.hljs-regexp,.hljs-addition,.hljs-char.escape_,
.hljs-template-variable,.hljs-subst{color:var(--patch)}
.hljs-number,.hljs-literal,.hljs-symbol,.hljs-bullet{color:var(--tok-num)}
.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-meta,
.hljs-doctag,.hljs-operator{color:var(--tok-key)}
.hljs-title,.hljs-section,.hljs-name,.hljs-selector-id,
.hljs-selector-class,.hljs-selector-attr,.hljs-selector-pseudo,
.hljs-attr,.hljs-attribute,.hljs-property,
.hljs-variable{color:var(--tok-fn)}
.hljs-type,.hljs-params,.hljs-tag,.hljs-title.class_{color:var(--tok-type)}
.hljs-deletion{color:var(--lamp)}
.hljs-emphasis{font-style:italic}
.hljs-strong{font-weight:700}
.hljs-link{text-decoration:underline}
/* ---- repository dashboard ---- */
/* A repository page is a denser thing than an article and wants more room.
--col is the only width control on this site, so this changes that one
value rather than introducing a second column: every .col element still
resolves to an identical inline box, and the bar and the content still
share one left edge. Nothing here sets padding-inline or margin-inline —
see the note above .col, which this must not break. */
body.wide{--col:68rem}
/* The sections of a repository. Mono uppercase like every other small label
here, and the lamp marks the section you are in — an active state, which
is the only kind of thing it is allowed to mark. */
.rail{
display:flex;gap:1.25rem;
border-bottom:1px solid var(--hair);
padding-block:.45rem;margin-bottom:1.2rem;
overflow-x:auto;scrollbar-width:none;
}
.rail::-webkit-scrollbar{display:none}
.rail a{
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.08em;
color:var(--graphite);text-decoration:none;white-space:nowrap;
}
.rail a:hover{color:var(--ink)}
.rail a[aria-current]{
color:var(--ink);
box-shadow:0 .4rem 0 -.32rem var(--lamp);
}
.rail sup{font-size:.85em;margin-left:.15rem}
/* Narrow enough that the rail cannot hold every section: it scrolls, and
the mask says so rather than a scrollbar nobody can see on a phone. */
@media (max-width:40rem){
.rail{
-webkit-mask-image:linear-gradient(90deg,#000 88%,transparent);
mask-image:linear-gradient(90deg,#000 88%,transparent);
}
}
.strip{
font-family:var(--mono);font-size:.65rem;
text-transform:uppercase;letter-spacing:.07em;
color:var(--graphite);
display:flex;gap:1rem;flex-wrap:wrap;
margin:0 0 .7rem;
}
.strip b{font-weight:400;color:var(--ink)}
.strip .live{color:var(--lamp)}
/* The dashboard's two tracks. Below the width that fits a 15rem rail the
grid collapses to one column and the rail's contents go above the list. */
/* minmax(0,…) rather than 1fr, which means minmax(auto,1fr) and so refuses
to shrink below the widest issue title. A row's title is set nowrap so it
can ellipsis, which makes that floor the full string: the track then
overflows its own container and the page scrolls sideways. Measured at a
700px column: 664px demanded against 652px available, 386px after. */
.dash{display:grid;grid-template-columns:minmax(0,1fr) 15rem;gap:1.6rem;align-items:start}
@media (max-width:48rem){ .dash{grid-template-columns:minmax(0,1fr);gap:1rem} }
.facts div{
display:flex;justify-content:space-between;gap:.75rem;
font-family:var(--mono);font-size:.66rem;
text-transform:uppercase;letter-spacing:.05em;
color:var(--graphite);
padding-block:.25rem;border-bottom:1px solid var(--hair);
}
.facts div b{font-weight:400;color:var(--ink);text-align:right}
.facts div:last-child{border-bottom:0}
.dash h3,.pane h3{
font-family:var(--mono);font-size:.65rem;font-weight:400;
text-transform:uppercase;letter-spacing:.09em;
color:var(--graphite);
display:flex;gap:.6rem;margin:1.4rem 0 .35rem;
}
.dash h3:first-child,.pane h3:first-child{margin-top:0}
/* The date of a release, and the "all →" of a block, both sit at the far
end of their heading rather than against the title. */
.dash h3 a,.pane h3 a,.pane h3 span{margin-left:auto;text-decoration:none}
/* Not scoped to .rows: the timeline and the release assets append rows
straight into their pane, and a row must look the same wherever it is. */
.row{
display:flex;gap:.55rem;align-items:baseline;
padding-block:.3rem;border-bottom:1px solid var(--hair);
}
.rows .row:last-child{border-bottom:0}
.row .n{
font-family:var(--mono);font-size:.7rem;color:var(--graphite);
flex:none;min-width:3.2rem;
}
.row .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row .t a{text-decoration:none}
.row .tag{
font-family:var(--mono);font-size:.6rem;flex:none;
text-transform:uppercase;letter-spacing:.06em;color:var(--graphite);
border:1px solid var(--hair);border-radius:2px;padding-inline:.25rem;
}
.row .when{font-family:var(--mono);font-size:.62rem;color:var(--graphite);flex:none}
/* ---- filtering ---- */
.find{
width:100%;font-family:var(--mono);font-size:.85rem;
background:none;color:var(--ink);
border:0;border-bottom:1px solid var(--ink);
padding-block:.3rem;
}
.find::placeholder{color:var(--graphite);opacity:1}
.active{
font-family:var(--mono);font-size:.62rem;
text-transform:uppercase;letter-spacing:.06em;
color:var(--graphite);display:flex;gap:.6rem;align-items:baseline;
margin:.45rem 0 .7rem;
}
.active .clear{
background:none;border:0;padding:0;cursor:pointer;
font:inherit;color:var(--lamp);margin-left:auto;
}
.tokenbox{max-width:34rem;margin-top:1.2rem}
.tokenbox .active{display:block;text-transform:none;letter-spacing:0;line-height:1.5}
.tokenbox .active b{color:var(--lamp);font-weight:400}
.tokenbox .clear{margin-left:.5rem}
.facets h4{
font-family:var(--mono);font-size:.62rem;font-weight:400;
text-transform:uppercase;letter-spacing:.09em;
color:var(--graphite);margin:.9rem 0 .25rem;
}
.facets h4:first-child{margin-top:0}
.facets a{
display:flex;justify-content:space-between;gap:.5rem;
font-family:var(--mono);font-size:.66rem;
color:var(--ink);text-decoration:none;
padding-block:.18rem;border-bottom:1px solid var(--hair);
}
.facets a span:last-child{color:var(--graphite)}
.facets a.on,.facets a.on span:last-child{color:var(--patch)}
.facets a.dim{opacity:.35}
/* Narrow: the facet rail becomes a chip row above the list. It is the same
markup restyled — one component, not a second one built for phones —
because a filter nobody can see is a filter nobody uses. */
@media (max-width:48rem){
.facets{display:flex;flex-wrap:wrap;gap:.35rem;order:-1;margin-bottom:.6rem}
.facets h4{display:none}
.facets a{
border:1px solid var(--hair);border-radius:2px;
padding:.06rem .35rem;font-size:.62rem;gap:.3rem;
}
.facets a.on{background:var(--patch);border-color:var(--patch);color:var(--paper)}
.facets a.on span:last-child{color:var(--paper);opacity:.65}
}
/* ---- repository listing ---- */
.crumb{
font-family:var(--mono);font-size:.7rem;
text-transform:uppercase;letter-spacing:.07em;
color:var(--graphite);margin:0 0 1.4rem;overflow-wrap:anywhere;
}
.crumb a{color:var(--patch);text-decoration:none}
.crumb a:hover{color:var(--lamp)}
.tree{list-style:none;margin:0;padding:0}
.tree li{
display:flex;gap:1rem;align-items:baseline;
padding:.4rem 0;border-bottom:1px solid var(--hair);
}
.tree a{
flex:1;min-width:0;font-family:var(--mono);font-size:.9rem;
color:var(--ink);text-decoration:none;overflow-wrap:anywhere;
}
.tree a:hover{color:var(--patch)}
.tree a.dir{font-weight:700}
.tree .size{
font-family:var(--mono);font-size:.68rem;color:var(--graphite);
white-space:nowrap;
}
/* A repository leads with its contents and carries the README underneath,
so the page answers "what is in here" before "what does it say". The
rule is what separates the two. */
.seclabel{
font-family:var(--mono);font-size:.65rem;font-weight:400;
text-transform:uppercase;letter-spacing:.1em;
color:var(--graphite);margin:0 0 .9rem;
}
.seclabel.rule{
margin-top:2.8rem;padding-top:1.3rem;border-top:1px solid var(--hair);
}
.body pre.code{
font-size:.8rem;line-height:1.6;
max-height:none;white-space:pre;
}
.origin{
margin:3.5rem 0 0;padding-top:1.2rem;border-top:1px solid var(--hair);
font-family:var(--sans);font-size:.8rem;color:var(--graphite);
}
.loading{font-family:var(--mono);font-size:.75rem;color:var(--graphite);margin:2.5rem 0}
/* ---- landing, when no publication is named ---- */
.intro h1{
font-size:2.6rem;line-height:1.05;letter-spacing:-.02em;
margin:3rem 0 1rem;text-wrap:balance;
}
.intro p{margin:0 0 1.1rem;max-width:34rem}
.intro .lead{font-size:1.15rem}
.intro code{font-family:var(--mono);font-size:.85em;background:var(--panel);padding:.12em .4em}
.intro .demo-link{display:flex;gap:.7rem;align-items:center;max-width:42rem;width:100%;margin:1.2rem 0 1.8rem;padding:.8rem 1rem;border:1px solid var(--hair);border-radius:.6rem;background:var(--panel)}
.intro .demo-link label{font-family:var(--sans);font-size:.8rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--graphite);white-space:nowrap}
.intro .demo-link input{flex:1;min-width:0;border:1px solid var(--hair);border-radius:.45rem;padding:.65rem .75rem;font:inherit;background:var(--paper);color:var(--ink)}
.intro .demo-link button{border:0;border-radius:.45rem;padding:.65rem .95rem;font:inherit;font-weight:600;background:var(--patch);color:var(--paper);cursor:pointer}
.intro .demo-link button:hover{filter:brightness(.95)}
.steps{list-style:none;margin:2rem 0;padding:0;border-top:1px solid var(--hair)}
.steps li{
display:grid;grid-template-columns:5.5rem 1fr;gap:1rem;
padding:.9rem 0;border-bottom:1px solid var(--hair);
}
.steps code{background:none;padding:0;color:var(--patch)}
.steps span{font-family:var(--sans);font-size:.92rem}
footer{
flex:0 0 auto;
border-top:1px solid var(--hair);
margin-top:4rem;
padding-block:1.6rem 3rem;
font-family:var(--sans);font-size:.75rem;color:var(--graphite);
}
footer p{margin:0 0 .4rem}
footer a{color:var(--graphite)}
/* Each sentence wraps as one unit. They sit on a single line when there is
room and stack into three when there is not. */
footer .fpart{display:inline-block;margin-right:.4rem}
/* The publisher's own footer, set in the reading face and a shade darker
than the attribution line beneath it. */
.sitefoot{
font-family:var(--serif);font-size:.95rem;line-height:1.55;
color:var(--ink);max-width:42rem;
margin:0 0 1.1rem;padding-bottom:1.1rem;
border-bottom:1px solid var(--hair);
}
.sitefoot p{margin:0 0 .6rem}
.sitefoot p:last-child{margin-bottom:0}
footer .fpart:last-child{margin-right:0}
/* The gutter offset only fits once the viewport clears the column plus
both margins; below that it would push the page sideways. */
.intro .hero{
display:flex;align-items:center;gap:.9rem;
margin:2.2rem 0 1.1rem;
}
.intro .hero-mark{
display:flex;align-items:center;justify-content:center;
width:2.4rem;height:2.4rem;flex:none;
color:var(--ink);
}
.intro .hero-mark svg{display:block;width:100%;height:100%}
.intro .hero h1{margin:0;font-size:clamp(1.8rem,4vw,2.4rem);line-height:1.1}
@media (max-width:62rem){ .drop{margin-right:0} }
@media (max-width:34rem){
:root{--pad:1.1rem}
.pages{display:none}
.drop .inpanel{display:block}
.intro h1{font-size:2rem}
.item a{font-size:1.15rem}
.article h1{font-size:1.75rem}
.pager{flex-direction:column;gap:1.5rem}
.pager a{max-width:100%}
.pager .next{text-align:left;margin-left:0}
}
@media (prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
}
</style>
</head>
<body>
<nav class="bar" hidden id="bar">
<svg class="mark" viewBox="24.3 42.6 137.8 140.1" aria-hidden="true" fill="currentColor">
<path d="m 42.65,79.85 -9.3,43.95 c -1.5,6.75 -1.5,7.2 -1.5,9.6 0,6 2.85,9.15 8.1,9.15 5.55,0 11.55,-2.7 19.95,-8.85 l 11.55,-8.55 -1.95,-3.9 -8.4,6 c -3.3,2.4 -5.25,3.15 -7.95,3.15 -2.85,0 -3.75,-1.05 -3.75,-4.2 0,-3.15 0.45,-6.45 1.35,-10.8 L 58.4,79.85 h 7.05 c 6.45,0 9.45,0.15 13.8,0.75 l 2.1,-8.4 -0.9,-0.75 -20.1,0.75 1.95,-8.4 c 1.05,-4.5 2.25,-8.7 4.65,-17.4 l -1.5,-1.8 -9.9,3.6 c -3,1.05 -5.25,1.65 -8.55,2.1 v 2.4 c 0,4.05 -0.45,7.5 -2.4,18.3 l -17.25,4.8 -1.05,4.05 z"/>
<path d="m 81.04989,180.65 c 1.95,-0.3 2.55,-0.45 4.8,-1.05 6.45,-1.35 9.3,-1.8 12,-2.1 0.6,-9.15 1.8,-14.7 7.2,-36 3.15,0.75 5.25,1.05 8.4,1.05 4.95,0 9.15,-0.9 12.9,-2.55 4.05,-1.95 12,-7.5 17.7,-12.3 9.75,-8.4 16.05,-25.35 16.05,-43.2 0,-9.9 -2.85,-14.85 -8.4,-14.85 -7.05,0 -14.1,3.15 -22.2,9.9 -6.45,5.55 -10.5,10.65 -17.55,22.5 l 1.35,-6.15 c 1.35,-5.85 2.1,-9 2.25,-9.75 1.2,-6.45 1.65,-9.15 1.65,-11.85 0,-3 -1.35,-4.65 -4.05,-4.65 -3.75,0 -8.55,2.25 -15.3,7.35 l -12.6,9.3 1.95,4.05 4.8,-3.3 c 2.4,-1.65 4.5,-2.55 6.15,-2.55 1.05,0 1.65,0.75 1.65,2.4 0,3.9 -1.8,15.75 -3.6,23.4 l -11.85,51 c -1.95,8.25 -3,12.3 -4.8,18.3 z m 27.9,-58.8 c 2.4,-10.5 4.2,-15.15 9.3,-22.95 7.05,-10.8 15,-17.55 20.55,-17.55 2.85,0 4.2,3 4.2,8.55 0,10.65 -4.35,27 -10.05,37.2 -2.85,5.1 -7.2,8.1 -12,8.1 -4.8,0 -9,-1.2 -14.1,-4.05 z"/>
</svg>
<div class="col">
<a class="brand" id="brand" href="./">Telepatch</a>
<span class="pages" id="pages"></span>
<span class="navtools">
<button class="catbtn" id="catbtn" aria-expanded="false" aria-controls="drop">Categories</button>
<span class="sep" id="sep"></span>
<button class="globe" id="globe" aria-expanded="false" aria-controls="elsedrop"
title="Elsewhere" aria-label="Elsewhere">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.05"
stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<ellipse cx="12" cy="12" rx="4" ry="9"/>
<ellipse cx="12" cy="12" rx="9" ry="4"/>
<circle class="node" cx="16.6" cy="7.9" r="1.7"/>
<circle class="node" cx="7.2" cy="11.9" r="1.7"/>
<circle class="node" cx="13" cy="17.4" r="1.7"/>
</svg>
</button>
</span>
<div class="drop" id="drop"></div>
<div class="drop" id="elsedrop"></div>
</div>
</nav>
<main class="col" id="main">
<div id="masthead">
<pre class="boot" id="boot" aria-live="polite"></pre>
<p class="sitemeta" id="sitemeta" hidden></p>
</div>
<div id="view"></div>
</main>
<footer class="col" id="foot" hidden></footer>
<script>
(() => {
"use strict";
const API = "https://api.telegra.ph";
const HOST = "https://telegra.ph";
const SOURCE = "https://github.com/babbworks/telepatch";
const BOT = "https://t.me/telepatchbot";
const TELEGRAPH_API = "https://telegra.ph/api";
/* Destination for the word "Assembled" in the footer — the page explaining
that these pages are built in the reader's browser. Empty until the
Telepatch project site exists; the word stays plain text until then, so
there is never a dead link. */
const ASSEMBLED = "";
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches;
const wait = ms => new Promise(r => setTimeout(r, REDUCED ? 0 : ms));
const $ = id => document.getElementById(id);
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
/* ---------- routing ----------
#master the index
#master/article one Telegraph article
#master/article/section a heading within it
#master/github.com/o/r a repository's README
#master/github.com/o/r/blob/HEAD/src/x.py one file
#master/github.com/o/r/tree/HEAD/src a directory listing
A GitHub resource is written the way github.com writes it, so its
separators are real "/" characters and the address is legible on sight.
The first version packed the whole thing into a single segment —
"gh:owner/repo" with the slashes percent-escaped — which broke as soon as
anything in the chain decoded %2F back to "/": a terminal, a chat client,
a URL normaliser. The route then split at the wrong places and the site
went looking for a Telegraph page called "gh:owner". Structural slashes
cannot be corrupted that way.
Internally a resource is still "gh:owner/repo/path" for a file and
"ght:owner/repo/dir" for a listing. Only the URL spelling changes. */
/* ---------- pure: github ---------- */
/* Everything between these markers is pure: no DOM, no fetch, no state. It
is sliced out and evaluated on its own by tests/site/extract.mjs, which is
only possible while that stays true. Anything needing a document or a
network belongs below the closing marker. */
const GH_HOST = "github.com";
const isGh = path => typeof path === "string" && path.startsWith("gh:");
const isTree = path => typeof path === "string" && path.startsWith("ght:");
const isResource = path => isGh(path) || isTree(path);
/* "gh:owner/repo" and nothing further is a repository; anything longer is
one file inside one. A section suffix is cut first, so a repository's
Development page still counts as that repository on the index rather
than being mistaken for a file. */
const isRepoPath = path =>
isGh(path) && path.slice(3).split("@")[0].split("/").length === 2;
/* The sections of a repository, spelled the way github.com spells them, so
that putting "https://" in front of any of these routes lands on the same
thing at the source. There is deliberately no "readme": github has no such
path, and the README lives beneath Overview as it does there. */
const GH_SECTIONS = ["issues", "releases", "pulse"];
// URL segments (after the master) -> internal resource id, else null.
function ghFromParts(parts) {
// Links written before this change arrive either whole or, if something
// decoded the escaped slashes, already split. Either way, rejoining is
// right: no Telegraph path begins "gh:".
const first = parts[0] || "";
if (first.startsWith("gh:") || first.startsWith("ght:")) return parts.join("/");
if (first !== GH_HOST || parts.length < 3) return null;
const owner = parts[1], repo = parts[2];
const base = "gh:" + owner + "/" + repo;
const kind = parts[3]; // "blob", "tree", a section, or nothing
if (kind === "tree") {
const rest = parts.slice(5).join("/"); // parts[4] is the git ref
return "ght:" + owner + "/" + repo + (rest ? "/" + rest : "");
}
if (kind === "blob") {
const rest = parts.slice(5).join("/");
return rest ? base + "/" + rest : base;
}
// A section carries no git ref, so nothing is skipped here: in
// "issues/144" the 144 is the section's argument, not a path under a ref.
if (GH_SECTIONS.includes(kind)) {
const arg = parts[4];
return base + "@" + kind + (arg ? "/" + arg : "");
}
// Any other github path — /settings, /actions, /wiki — is not something
// this site renders. Landing on the repository beats a dead page.
return base;
}
/* Internal resource id -> URL segments. The ref is spelled out as HEAD so
that putting "https://" in front of the result is a working github.com
address — the same page, at the source. */
function ghToParts(resource) {
if (isTree(resource)) {
const [owner, repo, ...rest] = resource.slice(4).split("/");
return [GH_HOST, owner, repo, "tree", "HEAD", ...rest];
}
const at = resource.indexOf("@");
if (at >= 0) {
const [owner, repo] = resource.slice(3, at).split("/");
return [GH_HOST, owner, repo, ...resource.slice(at + 1).split("/")];
}
const [owner, repo, ...rest] = resource.slice(3).split("/");
return rest.length ? [GH_HOST, owner, repo, "blob", "HEAD", ...rest]
: [GH_HOST, owner, repo];
}
/* A resource id -> which repository, which section, and its argument.
Returns null for anything that is not a repository page: a file or a
directory is content, not a section of the dashboard. */
function ghSection(resource) {
if (!isGh(resource)) return null;
const at = resource.indexOf("@");
const head = at < 0 ? resource.slice(3) : resource.slice(3, at);
if (head.split("/").length !== 2) return null; // a path to a file
if (at < 0) return { repo: head, section: "overview", arg: null };
const [name, arg = null] = resource.slice(at + 1).split("/");
const section = name === "issues" && arg ? "issue" : name;
return { repo: head, section, arg };
}
/* A GitHub token is recognised by its shape, the same way the bot
recognises a pasted Telegraph token. gho_ is what `gh auth token` hands
out and carries write access to private repositories; github_pat_ is the
fine-grained kind that can be scoped to public reads. The difference is
worth saying out loud to whoever pastes one. */
const GH_TOKEN = /^(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})$/;
const looksLikeGhToken = text =>
typeof text === "string" && GH_TOKEN.test(text.trim());
function ghTokenKind(text) {
if (!looksLikeGhToken(text)) return null;
return text.trim().startsWith("github_pat_") ? "fine" : "broad";
}
/* Each of the four calls may have failed on its own, so every source is
checked for shape before it is read. A section missing from a page is
better than a page that is not there. */
const listOf = value => (Array.isArray(value) ? value : []);
/* In GitHub's REST API a pull request is an issue carrying a pull_request
key, so one call answers both and the split happens here rather than in a
second request. */
function splitIssues(list) {
const all = listOf(list);
return {
issues: all.filter(i => !i.pull_request),
pulls: all.filter(i => !!i.pull_request)
};
}
const labelsOf = i => (i.labels || []).map(l => (typeof l === "string" ? l : l.name));
const authorOf = i => ((i && i.user) || {}).login || "";
/* GitHub's `state` is only ever open or closed, which flattens four things
a reader needs kept apart: a merged pull request is not a rejected one,
and an issue closed as not planned is not a job done. Every one of these
is already in the payload; showing "closed" for all of them is the
display being wrong rather than merely thin. */
function issueState(i) {
const item = i || {};
const pr = item.pull_request;
if (pr && pr.merged_at) return "merged";
if (pr && item.draft && item.state !== "closed") return "draft";