dragoman 0.5.18

Server for scholarly metadata in commonmeta format stored in a SQLite database.
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
<script>
  import { Button }  from '$lib/components/ui/button/index.js'
  import { Input }   from '$lib/components/ui/input/index.js'
  import { Select }  from '$lib/components/ui/select/index.js'
  import { Card, CardContent } from '$lib/components/ui/card/index.js'
  import { setupI18n } from '$lib/i18n.js'
  import { _, locale } from '@sveltia/i18n'
  import Icon from '@iconify/svelte'
  import orcidSvg from './assets/orcid.svg'
  import rorSvg from './assets/ror.svg'
  import 'katex/dist/katex.min.css'
  import WorkCard from './WorkCard.svelte'
  import OrgCard from './OrgCard.svelte'
  import PersonCard from './PersonCard.svelte'
  import ContainerCard from './ContainerCard.svelte'
  import HistorySheet from './HistorySheet.svelte'
  import { BOOKMARKS_PARAM, decodeBookmarkIds } from '$lib/bookmark-link.js'
  import Homepage from './index.md'
  import { Pagination } from '$lib/components/ui/pagination/index.js'
  import { SEARCH_ALL, SEARCH_FACETS, SEARCH_KINDS, anySearchFilter, isSearchFilterPicked, isSearchableQuery, noSearchFilters, searchFacetsFor, searchFilterValues, searchHrefFor, searchStateFromSearch, toggleSearchFilter } from '$lib/search-url.js'
  import { typeLabel, languageLabel, formatCount, decodeEntities, fosCode, fosLabel, humanizeRelationType, isFosScheme, RELATION_TYPE_ORDER, WORK_TYPES, entityPath, bareBookmarkId, bookmarkRecord, recordId } from '$lib/bib-utils.js'

  // ── Entity page init (set by server via window.__DRAGOMAN_INIT__) ─────────
  const _initData = typeof window !== 'undefined' ? (window.__DRAGOMAN_INIT__ ?? null) : null
  const isEntityPage = Boolean(_initData)

  // ── App config ────────────────────────────────────────────────────────────
  let isDark = $state(document.documentElement.classList.contains('dark'))
  function toggleDark() {
    isDark = !isDark
    document.documentElement.classList.toggle('dark', isDark)
    localStorage.setItem('app-dark', String(isDark))
  }

  // ── Bibliography ──────────────────────────────────────────────────────────
  let bibDoi    = $state(
    (!isEntityPage && typeof window !== 'undefined')
      ? (new URLSearchParams(window.location.search).get('q') ?? '')
      : ''
  )
  let bibStyle  = $state(localStorage.getItem('dragoman-style')  ?? 'apa')
  const initialLocale = localStorage.getItem('dragoman-locale') ?? (() => {
    const supported = ['en-US','de-DE','fr-FR','es-ES','it-IT','ja-JP','ko-KR','nl-NL','pt-BR','sv-SE','zh-CN']
    const lang = navigator.language
    return supported.find(v => v === lang)
        ?? supported.find(v => v.startsWith(lang.split('-')[0] + '-'))
        ?? 'en-US'
  })()
  setupI18n(initialLocale)
  let bibLocale = $state(initialLocale)
  let bibliography   = $state([])
  const PAGE_SIZE    = 10
  const MAX_SAVED    = 50
  // Relation types shown as "versions" on the work card — always resolved in full
  // (like references) even on public deployments, since they are few and directly related.
  const VERSION_RELATION_TYPES = new Set(['HasVersion', 'IsVersionOf', 'IsNewVersionOf', 'IsPreviousVersionOf'])
  let liveWorksCount = $state(null)  // actual DB count for org/person index card
  let liveCiteCount  = $state(null)  // actual DB count for work citation card
  // A container entity page whose container is a Blog — label its works "Blog Posts".
  const isBlogContainer = $derived.by(() =>
    isEntityPage && bibliography.length > 0 && parseContainerMeta(bibliography[0].data)?.type === 'Blog'
  )
  const worksHeadingKey = $derived(isBlogContainer ? 'bibliography.blog_posts_heading' : 'bibliography.works_heading')
  let allRefDois     = $state([])   // all DOI strings from rawData.references
  let refCache       = $state({})   // { doi: entry } for fetched refs
  let refFetching    = $state(false)
  let allRelationDoisByType  = $state({})  // { relationType: string[] } from rawData.relations
  let relationCacheByType    = $state({})  // { relationType: { doi: entry } }
  let relationPageByType     = $state({})  // { relationType: number }
  let relationFetchingByType = $state({})  // { relationType: boolean }
  let refPage          = $state(1)
  let worksPage        = $state(1)
  let worksTypeFilter  = $state(null)
  let worksYearFilter  = $state(null)
  // { types: [{type, works}], years: [{year, works}] } counted over the whole
  // list, or null when the server did not count it — too long a list, or a
  // store that cannot answer. No counts means no filters offered.
  let worksFacets = $state(null)
  let refTypeFilter    = $state(null)
  let refYearFilter    = $state(null)
  let citeTypeFilter   = $state(null)
  let citeYearFilter   = $state(null)
  // A work's citations are paged, filtered and counted by the server, as an
  // entity's works are: /api/citations answers one page, /api/citations/facets
  // the counts of the whole list. Fetching every citing work one request at a
  // time to do this here ran a well-cited work's page into the request limit.
  let citePageEntries = $state([])    // the citing works on the current page
  let citeTotal       = $state(null)  // the list's length under the current filters
  let citeFacets      = $state(null)  // { counted, total, types, years } of the whole list
  let citeLoading     = $state(false)

  const orderedRelTypes = $derived.by(() => {
    const available = new Set(Object.keys(allRelationDoisByType))
    const ordered = RELATION_TYPE_ORDER.filter(t => available.has(t))
    const rest = [...available].filter(t => !RELATION_TYPE_ORDER.includes(t)).sort()
    return [...ordered, ...rest]
  })

  // The DOI whose citations the page lists: a work's, on its entity page.
  const citeDoi = $derived.by(() => {
    if (!isEntityPage || bibliography.length === 0) return null
    if (detectEntityType(bibliography[0].data) !== 'Work') return null
    const id = entityId(bibliography[0].data)
    return id?.startsWith('https://doi.org/') ? id : null
  })
  // Whether there is anything to list: the record names a citing work, or the
  // store counts one. A boolean, so the count arriving does not fetch again.
  const citeAny = $derived(
    (allRelationDoisByType['IsReferencedBy']?.length ?? 0) > 0 || (liveCiteCount ?? 0) > 0
  )

  let personWorksLoading = $state(false)
  // Entity works are paged by the server: one request per window of WORKS_PAGE,
  // shown PAGE_SIZE at a time. The pager spans the server's full count, so it
  // offers a page for every work the entity has, not for the ones loaded — page
  // 700 of 11,982 costs the one request that fetches its window.
  const WORKS_PAGE = 50
  let worksTotal = $state(0)
  // Offset of the loaded window: the works in `bibliography` are the entity's
  // worksWindow … worksWindow + WORKS_PAGE.
  let worksWindow = $state(0)
  // The entity whose works the server can export in one request, spelled as its
  // page path spells it (ROR id / ORCID iD / container ISSN, DOI or source id).
  let worksExportId = $state(null)
  // { exported, total } when the last export hit the server's ceiling.
  let exportTruncated = $state(null)
  let worksEndpoint = $state(null)
  let worksPageLoading = $state(false)
  let bibLoading        = $state(false)
  let bibFetchingRemote = $state(false)
  let bibError          = $state('')
  let _remoteTimer      = null
  let randomLoading = $state(false)
  let hasDb = $state(false)
  // What the store holds of each kind, for the line above the homepage. It
  // rides along on the status request rather than costing one of its own.
  let storeCounts = $state(null)
  fetch('/api/status')
    .then(r => r.json())
    .then(d => { hasDb = d.db; storeCounts = d.counts ?? null })
    .catch(() => {})
  const isLocalhost = typeof window !== 'undefined' &&
    (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
  let copiedCiteId = $state(null)
  let addedId      = $state(null)
  let historyOpen  = $state(false)
  // The header's options menu, which holds history, dark mode and language on
  // a phone, where the three no longer fit beside the brand.
  let optionsOpen  = $state(false)
  let historyTab   = $state('history')
  let searchInputEl = $state(null)
  let historySheet  = $state(null)
  // Which entity kind the search box queries. Works by default: Crossref and
  // DataCite records are the bulk of a store and the one kind every store has.
  let searchKind         = $state('works')
  // The select renders from SEARCH_KINDS too, so the ⌘K cycle order and the
  // list of options cannot drift apart.
  // The heading counts what was searched by name — "929 works", not "929
  // results" — so it agrees with the kind selector beside it. Singular and
  // plural are separate keys, as elsewhere in the UI.
  const SEARCH_COUNT_LABELS = {
    works:         ['search.count_work',         'search.count_works'],
    people:        ['search.count_person',       'search.count_people'],
    organizations: ['search.count_organization', 'search.count_organizations'],
    containers:    ['search.count_container',    'search.count_containers'],
  }
  const SEARCH_KIND_LABELS = {
    works:         'bibliography.search_works',
    people:        'bibliography.search_people',
    organizations: 'bibliography.search_organizations',
    containers:    'bibliography.search_containers',
  }

  // ── Search results ────────────────────────────────────────────────────────
  //
  // Full-text search over the tantivy index commonmeta builds beside the store,
  // rendered in the body: one page of ranked results, the facets of the whole
  // list beside them, and what the reader picked in the URL — so a result list
  // can be linked, reloaded and stepped back through. A typed PID still goes
  // straight to its page, resolved by the submit handler.
  const SEARCH_PAGE_SIZE = 10
  /// How many alternate names a result row lists before it trails off.
  const SEARCH_ALSO_KNOWN_AS = 3
  /// How many identifiers a container's row lists. Four covers a complete
  /// container — OpenAlex, ISSN, Wikidata and a DOI, less the one the row links
  /// by — and caps the ones that have collected a DOI per work: a container in
  /// this store carries 1,548 of them, and the row would be a wall of links.
  const SEARCH_IDENTIFIERS = 4
  let searchHits    = $state([])      // one page, from /api/search
  let searchEntries = $state([])      // the same page as records, for the cards
  let searchTotal   = $state(0)
  let searchFacets  = $state(null)
  let searchLoading = $state(false)
  let searchSettled = $state(false)
  let _searchTimer  = null

  // Which facet values are in force, and which page. The URL is what holds
  // them: these mirror it, and every change goes through the URL first.
  let searchFilters = $state(noSearchFilters())
  let searchPage    = $state(1)

  /// The URL this search reads as, defaulting to what is on screen now.
  const searchHref = ({ q = bibDoi, kind = searchKind, filters = searchFilters, page = searchPage } = {}) =>
    searchHrefFor({ q, kind, filters, page })

  /// Adopt the URL's state. Called on load and whenever the reader steps back.
  function applySearchUrl() {
    const state = searchStateFromSearch(window.location.search)
    bibDoi = state.q
    searchKind = state.kind
    searchFilters = state.filters
    searchPage = state.page
  }

  /// Replace whole facets. The page goes back to the first: page 7 of the
  /// unfiltered list is rarely a page of the filtered one, and an out-of-range
  /// page would land on nothing.
  function setSearchFilter(change) {
    searchFilters = { ...searchFilters, ...change }
    searchPage = 1
    history.pushState({}, '', searchHref())
  }

  /// Pick one value of one facet, or unpick it if it was already picked —
  /// which is what a click in the sidebar, or on a chip, means. The values of
  /// a facet are ORed, so this widens that facet and leaves the others alone.
  function toggleSearchFacet(key, value) {
    searchFilters = toggleSearchFilter(searchFilters, key, value)
    searchPage = 1
    history.pushState({}, '', searchHref())
  }

  function goToSearchPage(n) {
    searchPage = n
    history.pushState({}, '', searchHref())
  }

  function clearSearchFilters() {
    setSearchFilter(noSearchFilters())
  }

  const searchFiltersActive = $derived(anySearchFilter(searchFilters))
  const searchPageCount = $derived(Math.max(1, Math.ceil(searchTotal / SEARCH_PAGE_SIZE)))
  // Which of the kind's two count labels the total calls for.
  const searchCountLabel = $derived.by(() => {
    const labels = SEARCH_COUNT_LABELS[searchKind] ?? SEARCH_COUNT_LABELS.works
    return labels[searchTotal === 1 ? 0 : 1]
  })
  // The sidebar is drawn only where there is something to draw: works carry the
  // facets, and an index built before facets existed counts nothing and says so
  // (`counted`), which is not the same as a query whose results happen to share
  // one value.
  // The facets this kind carries, each with the heading the kind gives it.
  const searchKindFacets = $derived(searchFacetsFor(searchKind))
  const searchFacetsShown = $derived(
    searchFacets?.counted === true &&
    searchKindFacets.some(({ counts }) => (searchFacets[counts] ?? []).length > 0)
  )
  // The results take the place of the documentation, so they show while there
  // is a query to show them for — including while the first page is still
  // loading, so the reader sees the list being built rather than the docs
  // flashing past.
  // Stays up once a query has been answered, even when the answer is none —
  // but only while something is picked. A filter that matches nothing used to
  // take the heading, the sidebar and the filters themselves down with it,
  // which left no way to undo the filter: picking one looked like the page had
  // given up rather than narrowed. A query that simply matches nothing has
  // nothing to undo, so the page goes back to the documentation and the
  // no-results message is left to say what happened — "0 organizations" over
  // an empty list says it twice, and says it less clearly.
  const searchShown = $derived(
    !isEntityPage && isSearchableQuery(bibDoi) &&
    (searchHits.length > 0 || searchLoading || (searchSettled && searchFiltersActive))
  )

  /// One of a container's other identifiers, labelled and linked where it is
  /// described — the container card's own list, in its order.
  function searchIdentifierLink(identifier, type) {
    const bare = prefix => identifier.replace(prefix, '')
    if (type === 'DOI') {
      const url = identifier.startsWith('http') ? identifier : `https://doi.org/${identifier}`
      return { label: url, href: url, doi: true }
    }
    if (type === 'ISSN') {
      return { label: `ISSN ${identifier}`, href: `https://portal.issn.org/resource/ISSN/${identifier}` }
    }
    if (type === 'OpenAlex') {
      const id = bare('https://openalex.org/')
      return { label: `OpenAlex ${id}`, href: `https://openalex.org/${id}` }
    }
    if (type === 'DataCite') {
      const id = identifier.replace(/^https?:\/\/commons\.datacite\.org\/repositories\//, '')
      return { label: `DataCite ${id}`, href: `https://commons.datacite.org/repositories/${id}` }
    }
    // A type the card has no case for — Wikidata, ISNI — reads as the type and
    // the id, not as a bare URL: "Wikidata Q27726163".
    const id = identifier.startsWith('http')
      ? identifier.replace(/\/$/, '').split('/').pop()
      : identifier
    return {
      label: `${type} ${id}`.trim(),
      href: identifier.startsWith('http') ? identifier : null,
    }
  }

  /// A ROR type with no label of its own, shown as the card shows it.
  function capitalizeWord(s) {
    return s ? s.charAt(0).toUpperCase() + s.slice(1) : s
  }

  /// A result row's location. The server sends a place name where the record
  /// has one and the country code where it does not, so a bare code is turned
  /// into the country's name in the page's language — "Germany", not "DE".
  function searchLocationLabel(value, locale) {
    if (!/^[A-Z]{2}$/.test(value)) return value
    try {
      return new Intl.DisplayNames([locale], { type: 'region' }).of(value) ?? value
    } catch {
      return value
    }
  }

  /// The value a facet's link asks for, which is what the index counted
  /// except for a subject: the facet counts the OECD vocabulary's code, and an
  /// index built before it counted the term. Either way the link carries the
  /// code, so a shared link outlives the next reindex.
  function searchFacetValue(key, value) {
    return key === 'fos' ? fosCode(value) || value : value
  }

  /// A facet value as a reader reads it: a type, a language and an OECD
  /// subject have names in the page's locale, a year is already a word.
  function searchFacetLabel(key, value) {
    // An organization's types have their own labels; a work's are commonmeta
    // types. The index counts both under one root, so the kind decides.
    if (key === 'type') {
      return searchKind === 'organizations'
        ? _(`org.type.${value}`, { default: capitalizeWord(value) })
        : typeLabel(value, bibLocale)
    }
    if (key === 'lang') return languageLabel(value, bibLocale) || value
    // A subject is counted by the vocabulary's code, which names the field in
    // every language it is worded in.
    if (key === 'fos') return fosLabel(value, bibLocale)
    if (key === 'country') return searchLocationLabel(value, bibLocale)
    // A year is never grouped: 2024, not 2,024.
    return value
  }
  let savedItems = $state((() => {
    try { return normalizeSaved(JSON.parse(localStorage.getItem('dragoman-saved') ?? '[]')) }
    catch { return [] }
  })())

  // Bookmarks saved before every bookmark held one record under its PID URL:
  // entity cards stored the page's one-element array, and a container, which
  // has no `id`, went in under the card's random entry id.
  function normalizeSaved(items) {
    const seen = new Set()
    return items.flatMap(item => {
      const record = bookmarkRecord(item.data)
      const id = recordId(record)
      if (!id || seen.has(id)) return []
      seen.add(id)
      return [{ id, data: JSON.stringify(record) }]
    })
  }

  $effect(() => { localStorage.setItem('dragoman-saved',  JSON.stringify(savedItems)) })
  $effect(() => { localStorage.setItem('dragoman-style',  bibStyle)  })
  $effect(() => { localStorage.setItem('dragoman-locale', bibLocale); locale.set(bibLocale) })

  // One page of results, under whatever the reader picked. A newer request
  // supersedes an older one still in flight, so a quick run of clicks — or of
  // keystrokes — shows the page asked for last.
  let _searchRequest = 0
  $effect(() => {
    // Read synchronously so the effect tracks them: a read inside the debounce
    // callback below runs after the effect returns and would not register, so
    // changing the selector or a facet would not re-run the search.
    const q = bibDoi.trim()
    const kind = searchKind
    const filters = { ...searchFilters }
    const page = searchPage
    clearTimeout(_searchTimer)
    if (!isSearchableQuery(q) || isEntityPage) {
      searchHits = []
      searchEntries = []
      searchTotal = 0
      searchSettled = false
      return
    }
    if (!hasDb) {
      // No DB: settle after the debounce so the no-results message can appear.
      _searchTimer = setTimeout(() => { searchSettled = true }, 250)
      return
    }
    const request = ++_searchRequest
    searchLoading = true
    // Typing is debounced; a facet or a page is not — it was clicked, and the
    // list should move at once.
    _searchTimer = setTimeout(async () => {
      const params = new URLSearchParams({
        q, kind,
        limit: String(SEARCH_PAGE_SIZE),
        offset: String((page - 1) * SEARCH_PAGE_SIZE),
      })
      for (const { key, param } of SEARCH_FACETS) {
        // One parameter per value picked: the server reads every occurrence,
        // and within a facet they ask for any of them.
        for (const value of searchFilterValues(filters, key)) params.append(param, value)
      }
      try {
        const res = await fetch(`/api/search?${params}`)
        if (res.ok) {
          const total = Number(res.headers.get('X-Total-Count') ?? 0)
          const hits = await res.json()
          if (request !== _searchRequest) return
          searchHits = Array.isArray(hits) ? hits : []
          searchTotal = Number.isFinite(total) ? total : searchHits.length
          // Works are shown as the cards every other list uses, which needs the
          // records themselves; the index stores only a title and a pid.
          if (kind === 'works' && searchHits.length > 0) {
            const works = await fetchWorks(searchHits.map(h => h.id), true)
            if (request !== _searchRequest) return
            searchEntries = works
              .map((work, i) => (work ? workEntry(work, searchHits[i].id) : null))
              .filter(Boolean)
          } else {
            searchEntries = []
          }
        }
      } catch { /* a failed search shows no results, like a failed lookup did */ }
      if (request === _searchRequest) {
        searchLoading = false
        searchSettled = true
      }
    }, 250)
  })

  // The counts of the whole list, once per query — they do not depend on which
  // facet values are in force, so picking one does not refetch them.
  $effect(() => {
    const q = bibDoi.trim()
    const kind = searchKind
    // Asked for alongside the page, not after it. A query too broad to count is
    // refused by the server from the term dictionary — before it walks
    // anything — so the page need not be waited on to find out, and the answer
    // is the same whether the query arrived bare or with a facet value already
    // picked. `counted` says which happened; a facet with no values is simply
    // left out of the sidebar.
    if (!isSearchableQuery(q) || isEntityPage || !hasDb || searchFacetsFor(kind).length === 0) {
      searchFacets = null
      return
    }
    let current = true
    const timer = setTimeout(() => {
      fetch(`/api/search/facets?q=${encodeURIComponent(q)}&kind=${kind}`)
        .then(r => r.ok ? r.json() : null)
        .then(f => { if (current) searchFacets = f })
        .catch(() => {})
    }, 250)
    return () => { current = false; clearTimeout(timer) }
  })

  // ── Export ────────────────────────────────────────────────────────────────
  let expFormat  = $state('commonmeta')
  let expError   = $state('')
  let expLoading = $state(false)
  let expCopied  = $state(false)

  // ── Style picker ──────────────────────────────────────────────────────────
  let stylePickerOpen = $state(false)
  let styleSearch     = $state('')

  // ── Data ──────────────────────────────────────────────────────────────────
  const commonStyles = [
    { value: 'apa',                       label: 'American Psychological Association 7th edition' },
    { value: 'chicago-author-date',       label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
    { value: 'harvard-cite-them-right',   label: 'Cite Them Right 12th Edition (Harvard)' },
    { value: 'ieee',                      label: 'IEEE' },
    { value: 'modern-language-association', label: 'MLA Handbook 9th Edition' },
    { value: 'vancouver',                 label: 'Vancouver' },
  ]

  const allCitationStyles = [
    { value: 'apa',                                              label: 'American Psychological Association 7th edition' },
    { value: 'alphanumeric',                                     label: 'Alphanumeric' },
    { value: 'american-anthropological-association',             label: 'American Anthropological Association' },
    { value: 'american-chemical-society',                        label: 'American Chemical Society' },
    { value: 'american-geophysical-union',                       label: 'American Geophysical Union' },
    { value: 'american-institute-of-aeronautics-and-astronautics', label: 'American Institute of Aeronautics and Astronautics' },
    { value: 'american-institute-of-physics',                    label: 'American Institute of Physics' },
    { value: 'american-medical-association',                     label: 'American Medical Association' },
    { value: 'american-meteorological-society',                  label: 'American Meteorological Society' },
    { value: 'american-physics-society',                         label: 'American Physical Society' },
    { value: 'american-physiological-society',                   label: 'American Physiological Society' },
    { value: 'american-political-science-association',           label: 'American Political Science Association' },
    { value: 'american-society-for-microbiology',                label: 'American Society for Microbiology' },
    { value: 'american-society-of-civil-engineers',              label: 'American Society of Civil Engineers' },
    { value: 'american-society-of-mechanical-engineers',         label: 'American Society of Mechanical Engineers' },
    { value: 'american-sociological-association',                label: 'American Sociological Association' },
    { value: 'angewandte-chemie',                                label: 'Angewandte Chemie International Edition' },
    { value: 'annual-reviews',                                   label: 'Annual Reviews' },
    { value: 'annual-reviews-author-date',                       label: 'Annual Reviews (Author-Date)' },
    { value: 'associacao-brasileira-de-normas-tecnicas',         label: 'Associação Brasileira de Normas Técnicas' },
    { value: 'association-for-computing-machinery',              label: 'Association for Computing Machinery' },
    { value: 'biomed-central',                                   label: 'BioMed Central' },
    { value: 'bmj',                                              label: 'BMJ' },
    { value: 'bristol-university-press',                         label: 'Bristol University Press' },
    { value: 'cell',                                             label: 'Cell' },
    { value: 'chicago-author-date',                              label: 'Chicago Manual of Style 18th Edition (Author-Date)' },
    { value: 'chicago-notes',                                    label: 'Chicago Manual of Style 18th Edition (Notes & Bibliography)' },
    { value: 'chicago-shortened-notes',                          label: 'Chicago Manual of Style 18th Edition (Shortened Notes)' },
    { value: 'copernicus',                                       label: 'Copernicus Publications' },
    { value: 'council-of-science-editors',                       label: 'Council of Science Editors (Citation-Sequence)' },
    { value: 'council-of-science-editors-author-date',           label: 'Council of Science Editors (Name-Year)' },
    { value: 'current-opinion',                                  label: 'Current Opinion' },
    { value: 'deutsche-gesellschaft-für-psychologie',            label: 'Deutsche Gesellschaft für Psychologie (Deutsch)' },
    { value: 'deutsche-sprache',                                 label: 'Deutsche Sprache (Deutsch)' },
    { value: 'elsevier-harvard',                                 label: 'Elsevier (Harvard)' },
    { value: 'elsevier-vancouver',                               label: 'Elsevier (Vancouver)' },
    { value: 'elsevier-with-titles',                             label: 'Elsevier (Numeric, With Titles)' },
    { value: 'frontiers',                                        label: 'Frontiers' },
    { value: 'future-medicine',                                  label: 'Future Medicine' },
    { value: 'future-science',                                   label: 'Future Science Group' },
    { value: 'gb-7714-2005-numeric',                             label: 'GB/T 7714-2005 (Numeric)' },
    { value: 'gb-7714-2015-author-date',                         label: 'GB/T 7714-2015 (Author-Date)' },
    { value: 'gb-7714-2015-note',                                label: 'GB/T 7714-2015 (Note)' },
    { value: 'gb-7714-2015-numeric',                             label: 'GB/T 7714-2015 (Numeric)' },
    { value: 'gost-r-705-2008-numeric',                          label: 'GOST R 7.0.5-2008 (Numeric)' },
    { value: 'harvard-cite-them-right',                          label: 'Cite Them Right 12th Edition (Harvard)' },
    { value: 'ieee',                                             label: 'IEEE' },
    { value: 'institute-of-physics-numeric',                     label: 'Institute of Physics (Numeric)' },
    { value: 'iso-690-author-date',                              label: 'ISO 690 (Author-Date)' },
    { value: 'iso-690-numeric',                                  label: 'ISO 690 (Numeric)' },
    { value: 'karger',                                           label: 'Karger' },
    { value: 'mary-ann-liebert-vancouver',                       label: 'Mary Ann Liebert (Vancouver)' },
    { value: 'modern-humanities-research-association',           label: 'MHRA Style Guide 4th Edition (Notes)' },
    { value: 'mla',                                              label: 'MLA Handbook 9th Edition' },
    { value: 'multidisciplinary-digital-publishing-institute',   label: 'Multidisciplinary Digital Publishing Institute' },
    { value: 'nature',                                           label: 'Nature' },
    { value: 'vancouver',                                        label: 'Vancouver' },
    { value: 'vancouver-superscript',                            label: 'NLM/Vancouver (Superscript)' },
    { value: 'pensoft',                                          label: 'Pensoft Journals' },
    { value: 'plos',                                             label: 'Public Library of Science' },
    { value: 'royal-society-of-chemistry',                       label: 'Royal Society of Chemistry' },
    { value: 'sage-vancouver',                                   label: 'SAGE (Vancouver)' },
    { value: 'sist02',                                           label: 'SIST02' },
    { value: 'spie',                                             label: 'SPIE' },
    { value: 'springer-basic',                                   label: 'Springer Basic (Numeric)' },
    { value: 'springer-basic-author-date',                       label: 'Springer Basic (Author-Date)' },
    { value: 'springer-fachzeitschriften-medizin-psychologie',   label: 'Springer Fachzeitschriften Medizin Psychologie (Deutsch)' },
    { value: 'springer-humanities-author-date',                  label: 'Springer Humanities (Author-Date)' },
    { value: 'springer-lecture-notes-in-computer-science',       label: 'Springer Lecture Notes in Computer Science' },
    { value: 'springer-mathphys',                                label: 'Springer MathPhys (Numeric)' },
    { value: 'springer-socpsych-author-date',                    label: 'Springer SocPsych (Author-Date)' },
    { value: 'springer-vancouver',                               label: 'Springer (Vancouver)' },
    { value: 'taylor-and-francis-chicago-author-date',           label: 'Taylor & Francis (Chicago Author-Date)' },
    { value: 'taylor-and-francis-national-library-of-medicine',  label: 'Taylor & Francis (NLM/Vancouver)' },
    { value: 'the-institution-of-engineering-and-technology',    label: 'Institution of Engineering and Technology' },
    { value: 'the-lancet',                                       label: 'The Lancet' },
    { value: 'thieme',                                           label: 'Thieme' },
    { value: 'trends',                                           label: 'Trends' },
    { value: 'turabian-author-date',                             label: 'Chicago Manual of Style 17th Edition (Author-Date)' },
    { value: 'turabian-fullnote-8',                              label: 'Chicago Manual of Style 17th Edition (Notes & Bibliography)' },
  ]

  const locales = [
    { value: 'en-US', label: 'English (US)' },
    { value: 'de-DE', label: 'Deutsch' },
    { value: 'fr-FR', label: 'Français' },
    { value: 'es-ES', label: 'Español' },
    { value: 'it-IT', label: 'Italiano' },
    { value: 'ja-JP', label: '日本語' },
    { value: 'ko-KR', label: '한국어' },
    { value: 'nl-NL', label: 'Nederlands' },
    { value: 'pt-BR', label: 'Português (Brasil)' },
    { value: 'sv-SE', label: 'Svenska' },
    { value: 'zh-CN', label: '中文(简体)' },
  ]

  const expFormats = [
    { value: 'bibtex',       label: 'BibTeX' },
    { value: 'commonmeta',   label: 'Commonmeta' },
    { value: 'crossref',     label: 'Crossref' },
    { value: 'crossref_xml', label: 'Crossref XML' },
    { value: 'csl',          label: 'CSL-JSON' },
    { value: 'datacite',     label: 'DataCite JSON' },
    { value: 'datacite_xml', label: 'DataCite XML' },
    { value: 'citation',     i18n: 'format.citation' },
    { value: 'inveniordm',   label: 'InvenioRDM' },
    { value: 'ris',          label: 'RIS' },
    { value: 'schemaorg',    label: 'Schema.org' },
  ]

  // ── Helpers ───────────────────────────────────────────────────────────────
  function cleanDoi(raw) {
    return raw.trim().replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
  }

  // Returns { type, id } for the given raw identifier string, or null if empty.
  // Supports CURIEs (doi:, ror:, orcid:) and full URLs.
  function parseIdentifier(raw) {
    const s = raw.trim()
    if (!s) return null

    // Self-referential URL: strip own origin or canonical commonmeta.org
    let selfPath = null
    if (s.startsWith(location.origin + '/')) selfPath = s.slice(location.origin.length + 1)
    else { const m = s.match(/^https?:\/\/commonmeta\.org\/(.+)/); if (m) selfPath = m[1] }
    if (selfPath) return parseIdentifier(selfPath)

    // ROR: ror:0xxxxxxx  or  https://ror.org/0xxxxxxx
    const rorM = s.match(/^(?:ror:|https?:\/\/ror\.org\/)([a-z0-9]+)\/?$/i)
    if (rorM) return { type: 'ror', id: rorM[1] }

    // ORCID: bare XXXX-XXXX-XXXX-XXXZ, orcid:..., or https://orcid.org/...
    const orcidM = s.match(/^(?:orcid:|https?:\/\/orcid\.org\/)?(\d{4}-\d{4}-\d{4}-\d{3}[\dX])\/?$/i)
    if (orcidM) return { type: 'orcid', id: orcidM[1] }

    // DOI: doi:xxx  or  URL  or  bare 10.xxx
    const doi = s.replace(/^doi:/i, '').replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
    return { type: 'doi', id: doi }
  }

  function stripHtml(html) {
    const doc = new DOMParser().parseFromString(html, 'text/html')
    return doc.body.textContent ?? ''
  }

  function parseOrgMeta(cmData) {
    try {
      const raw = JSON.parse(cmData)
      const d = Array.isArray(raw) ? raw[0] : raw
      if (!d?.id?.startsWith('https://ror.org/')) return null
      return {
        entityType:       'Organization',
        id:               d.id ?? '',
        name:             d.name ?? d.title ?? '',
        additional_names: [d.acronym, ...(d.additional_names ?? [])].filter(Boolean),
        country:          d.country ?? '',
        location:         d.location ?? null,
        urls:             (d.urls ?? []).filter(u => u?.url),
        identifiers:      (d.identifiers ?? []),
        types:            d.types ?? [],
        relations:        d.relations ?? [],
      }
    } catch { return null }
  }

  function entityId(cmData) {
    return recordId(bookmarkRecord(cmData)) ?? ''
  }

  // Keep-predicate: a citation that is itself a version of another work (relation
  // IsVersionOf) is a duplicate of its concept/parent record — drop it so citations
  // aren't listed once per version. Malformed data errs toward keeping the entry.
  function notAVersion(entry) {
    try { return !(JSON.parse(entry.data)?.relations ?? []).some(r => r.type === 'IsVersionOf') }
    catch { return true }
  }

  function parsePersonAffiliations(cmData) {
    try {
      const raw = JSON.parse(cmData)
      const d = Array.isArray(raw) ? raw[0] : raw
      return (d?.affiliations ?? []).filter(a => a.name)
    } catch { return [] }
  }

  function formatAffDate(iso) {
    if (!iso) return ''
    const parts = iso.split('-')
    if (parts.length === 1) return parts[0]
    try {
      const y = parseInt(parts[0], 10)
      const m = parseInt(parts[1], 10) - 1
      if (parts.length === 2) {
        return new Intl.DateTimeFormat(bibLocale, { year: 'numeric', month: 'long' }).format(new Date(y, m, 1))
      }
      const d = parseInt(parts[2], 10)
      return new Intl.DateTimeFormat(bibLocale, { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(y, m, d))
    } catch { return iso }
  }

  function countWorksFor(id) {
    if (!id) return 0
    return savedItems.filter(wi => detectEntityType(wi.data) === 'Work' && wi.data.includes(id)).length
  }

  // A commonmeta container entity has no top-level `id` (it is keyed by its
  // identifiers) and carries an `identifiers` array — distinct from a work,
  // which always has an `id`.
  function isContainerObj(d) {
    return Boolean(d) && !d.id && Array.isArray(d.identifiers) && d.identifiers.length > 0
  }

  function parseContainerMeta(cmData) {
    try {
      const raw = JSON.parse(cmData)
      const d = Array.isArray(raw) ? raw[0] : raw
      if (!isContainerObj(d)) return null
      return {
        entityType:        'Container',
        title:             d.title ?? '',
        additional_titles: d.additional_titles ?? [],
        type:              d.type ?? '',
        publisher:         d.publisher ?? null,
        country:           d.country ?? '',
        url:               d.url ?? '',
        identifiers:       d.identifiers ?? [],
      }
    } catch { return null }
  }

  function detectEntityType(cmData) {
    try {
      const raw = JSON.parse(cmData)
      const d = Array.isArray(raw) ? raw[0] : raw
      if (d?.id?.startsWith('https://ror.org/')) return 'Organization'
      if (d?.id?.startsWith('https://orcid.org/') || d?.given_name || d?.family_name) return 'Person'
      if (isContainerObj(d)) return 'Container'
      return 'Work'
    } catch { return 'Work' }
  }

  function bibGroups() {
    const groups = []
    bibliography.forEach((entry, i) => {
      const type = detectEntityType(entry.data)
      if (type === 'Work' && groups.length > 0 && groups[groups.length - 1].type === 'Work') {
        groups[groups.length - 1].entries.push({ entry, idx: i })
      } else {
        groups.push({ type, entries: [{ entry, idx: i }] })
      }
    })
    return groups
  }

  function parseMeta(cmData) {
    try {
      const d = JSON.parse(cmData)

      // Build raw author list (no affiliation indices yet)
      const rawAuthors = (d.contributors ?? [])
        .filter(c => !c.roles?.length || c.roles.includes('Author'))
        .map(c => {
          if (c.type === 'Person' && c.person) {
            const p = c.person
            const name = decodeEntities([p.given_name, p.family_name].filter(Boolean).join(' '))
            if (!name) return null
            return { name, orcid: p.id?.startsWith('https://orcid.org/') ? p.id : '', rawAffs: p.affiliations ?? [] }
          }
          const name = decodeEntities(c.organization?.name ?? '')
          return name ? { name, orcid: '', rawAffs: [] } : null
        })
        .filter(Boolean)

      // Truncate to display set: first 19 + last if > 20
      const truncated = rawAuthors.length > 20
      const displayRaw = truncated
        ? [...rawAuthors.slice(0, 19), rawAuthors[rawAuthors.length - 1]]
        : rawAuthors

      // Build affiliations only from displayed authors
      const affMap = new Map()
      const affiliations = []
      function affIndex(aff) {
        const key = aff.id || aff.name
        if (!key) return null
        if (!affMap.has(key)) {
          affiliations.push({ name: decodeEntities(aff.name || aff.id), ror: aff.id?.startsWith('https://ror.org/') ? aff.id : '' })
          affMap.set(key, affiliations.length)
        }
        return affMap.get(key)
      }
      const toAuthor = r => ({ name: r.name, orcid: r.orcid, affIndices: r.rawAffs.map(affIndex).filter(Boolean) })

      const authorList = truncated
        ? [...displayRaw.slice(0, 19).map(toAuthor), { isEllipsis: true }, toAuthor(displayRaw[displayRaw.length - 1])]
        : displayRaw.map(toAuthor)

      const funders = (d.funding_references ?? [])
        .filter(f => f.funder_name || f.funder_id)

      // Link the container to its entity page when it has a title and a resolvable
      // identifier. The container router accepts a bare ISSN, DOI, OpenAlex source
      // id, or DataCite client id — search all identifiers (not just the first) and
      // strip any URL prefix. A blog post from Rogue Scholar embeds its blog by
      // title alone and names it by an IsPartOf relation to the blog's DOI, which
      // opens the blog's page as well.
      const containerTitle = decodeEntities(d.container?.title ?? '')
      const linkableId = (d.container?.identifiers ?? []).find(i =>
        ['ISSN', 'DOI', 'OPENALEX', 'DATACITE'].includes((i.identifier_type ?? '').toUpperCase()))
      const blogDoi = d.type === 'BlogPost'
        ? (d.relations ?? []).find(r => r.type === 'IsPartOf' && r.id?.startsWith('https://doi.org/'))?.id
        : undefined
      const containerId = linkableId?.identifier ?? blogDoi ?? ''
      const containerPagePath = (containerTitle && containerId)
        ? entityPath(containerId
            // commonmeta names an ISSN by the URI of its linked data profile
            // since 0.9.115, and by the portal's URL before that.
            .replace(/^https?:\/\/(?:portal\.)?issn\.org\/resource\/ISSN\//, '')
            .replace(/^https?:\/\/openalex\.org\/(?:sources\/)?/, ''))
        : ''
      return {
        title:          d.title ?? '',
        type:           d.type ?? '',
        version:        d.version ?? '',
        datePublished:  d.date_published ?? '',
        containerTitle,
        containerPagePath,
        authorList,
        affiliations,
        funders,
        description:    d.description ?? '',
        // A subject reads as its label, and carries the FOS term the search
        // can filter by when its scheme says Fields of Science — which is what
        // decides, since the term alone is no evidence of the classification.
        subjects:       (d.subjects ?? [])
          .filter(s => s?.subject)
          .map(s => ({
            label: decodeEntities(s.subject),
            // The vocabulary's code, which is what the facet counts; a term
            // under a FOS scheme that the vocabulary does not hold gets none,
            // and so is shown without a link rather than with a dead one.
            fos: isFosScheme(s.scheme ?? '', s.scheme_uri ?? '') ? fosCode(s.subject) : '',
          })),
        referenceCount: (d.references ?? []).length,
        relationCounts: (d.relations ?? []).reduce((acc, r) => {
          if (r?.type) acc[r.type] = (acc[r.type] ?? 0) + 1
          return acc
        }, {}),
        language:       d.language ?? '',
        // The files a record points at, each with the media type it declares.
        // A work may list the same type more than once — Wiley deposits a PDF
        // twice, once as its text-and-data-mining endpoint and once as the
        // page a reader opens — and both are its own row.
        files:          (d.files ?? [])
          .filter(f => f?.url)
          .map(f => ({ url: f.url, mimeType: (f.mime_type ?? '').trim() })),
        licenseTitle:   decodeEntities(d.license?.title ?? ''),
        licenseId:      d.license?.id ?? '',
        licenseUrl:     d.license?.url ?? '',
        isVersionOf:    (d.relations ?? []).find(r => r.type === 'IsVersionOf')?.id ?? '',
        identicalIds:   (d.relations ?? []).filter(r => r.type === 'IsIdenticalTo').map(r => r.id).filter(Boolean),
        pmid:           (() => {
          const v = (d.identifiers ?? []).find(i => i.identifier_type === 'PMID')?.identifier ?? ''
          const m = v.match(/(\d+)\/?$/)  // bare number or pubmed URL
          return m ? m[1] : ''
        })(),
        openalex:       (() => {
          const v = (d.identifiers ?? []).find(i => i.identifier_type === 'OpenAlex')?.identifier ?? ''
          const m = v.match(/(W\d+)\/?$/i)  // bare W… or an openalex.org URL
          return m ? m[1].toUpperCase() : ''
        })(),
        pmcid:          (() => {
          const v = (d.identifiers ?? []).find(i => i.identifier_type === 'PMCID')?.identifier ?? ''
          const m = v.match(/(PMC\d+)\/?$/i)  // PMC-prefixed or pmc URL
          if (m) return m[1].toUpperCase()
          const n = v.match(/^(\d+)$/)  // bare digits → add PMC prefix
          return n ? `PMC${n[1]}` : ''
        })(),
        id:             d.id ?? '',
      }
    } catch { return null }
  }

  // ── Navigation ────────────────────────────────────────────────────────────
  $effect(() => {
    function handleNavClick(e) {
      // A click already handled in the page is not ours to navigate. The facet
      // links and the pager are real links so they can be opened in a new tab
      // or shared, and they call preventDefault to swap the list in place
      // instead — without this they were followed as well, reloading the page,
      // and to whichever address the link had come to carry: a facet just
      // picked rewrites its own href to the one that *un*picks it, so the
      // reload landed on the unfiltered list, with the unfiltered address in
      // the bar. Picking a facet looked like it did nothing at all.
      if (e.defaultPrevented) return
      const a = e.target.closest('a[href]')
      if (!a) return
      if (a.target === '_blank') return
      let url
      try { url = new URL(a.href) } catch { return }
      if (url.origin !== location.origin) return
      if (url.pathname === location.pathname && url.search === location.search) return
      e.preventDefault()
      bibFetchingRemote = true
      const dest = a.href
      requestAnimationFrame(() => requestAnimationFrame(() => { location.href = dest }))
    }
    document.addEventListener('click', handleNavClick)
    return () => document.removeEventListener('click', handleNavClick)
  })

  // Escape clears the box, which clears the result list with it. There is no
  // list to walk with the arrow keys any more: the results are in the page, so
  // Tab reaches them and a screen reader reads them in order.
  function onSearchKeydown(e) {
    if (e.key === 'Escape' && bibDoi) {
      e.preventDefault()
      bibDoi = ''
    }
  }

  // A new query is a new list: the page goes back to the first and the facet
  // values picked for the old one are dropped, since a year that had results
  // under the old query may have none under this one. Written with
  // replaceState, so a run of keystrokes leaves one history entry rather than
  // one per letter — the facets and pages below use pushState, because those
  // are steps worth going back through.
  let _searchLastQuery = null
  $effect(() => {
    if (isEntityPage || typeof window === 'undefined') return
    const key = `${searchKind}\n${bibDoi.trim()}`
    if (_searchLastQuery === null) { _searchLastQuery = key; return }
    if (_searchLastQuery === key) return
    _searchLastQuery = key
    const cleared = noSearchFilters()
    searchFilters = cleared
    searchPage = 1
    history.replaceState(
      {},
      '',
      searchHref({ q: bibDoi, kind: searchKind, filters: cleared, page: 1 }),
    )
  })

  // Back and forward step through the search the same way they step through an
  // entity's works: the URL is the state, and whatever it names is shown.
  $effect(() => {
    if (isEntityPage || typeof window === 'undefined') return
    const onPopState = () => {
      applySearchUrl()
      _searchLastQuery = `${searchKind}\n${bibDoi.trim()}`
    }
    window.addEventListener('popstate', onPopState)
    return () => window.removeEventListener('popstate', onPopState)
  })

  // The server sends an id it cannot resolve back to search, as /?q=<id>, and a
  // ?q= is resolved as an id on load (below) — so the two would hand the same
  // word back and forth forever, the page reloading as fast as it can. The id
  // the page last sent the reader to is remembered for the tab; if it comes
  // back as ?q=, it is searched for instead.
  const NAV_KEY = 'dragoman-navigated-to'

  function navigateToId(parsed) {
    if (!parsed?.id) return
    try { sessionStorage.setItem(NAV_KEY, parsed.id) } catch { /* storage blocked: no guard */ }
    bibLoading = true
    bibFetchingRemote = false
    clearTimeout(_remoteTimer)
    _remoteTimer = setTimeout(() => { bibFetchingRemote = true }, 300)
    window.location.href = entityPath(parsed.id)
  }

  /// Whether `parseIdentifier` really recognised something, as opposed to
  /// falling through.
  ///
  /// Its last branch returns every unrecognised string as a DOI, so a typed
  /// word comes back as `{ type: 'doi', id: 'autophagy' }`. Acted on, that
  /// sends the reader to /autophagy, which the server bounces back to
  /// /?q=autophagy — and the bounce keeps only `q`, so a shared link's facet
  /// values and page were dropped on the way. Only a DOI-shaped id counts,
  /// which is the same rule the server resolves by.
  function isResolvableIdentifier(parsed) {
    if (!parsed?.id) return false
    if (parsed.type !== 'doi') return true
    return /^10\.\d{4,9}\/\S+$/.test(parsed.id)
  }

  function goToId(e) {
    e.preventDefault()
    // Submitting an empty box asks for nothing, so nothing happens. It is the
    // clear button's job to empty the query, and that already rewrites the
    // address; without this, Enter on an empty box on a record's page would
    // leave it for the plain homepage.
    if (!bibDoi.trim()) return
    const parsed = parseIdentifier(bibDoi.trim())
    if (isResolvableIdentifier(parsed)) {
      navigateToId(parsed)
      return
    }
    // A text query is searched where the reader is: the results are already
    // being fetched, so submitting only commits the search to the URL.
    //
    // Except on a record's own page, where nothing is searching — the list and
    // its sidebar belong to the search page — so submitting goes there, the
    // way following a link would. Committing the query to this page's address
    // instead left the record on screen under the URL of a search that had
    // never run, which is no answer to pressing Enter.
    if (isEntityPage) {
      bibLoading = true
      bibFetchingRemote = false
      clearTimeout(_remoteTimer)
      _remoteTimer = setTimeout(() => { bibFetchingRemote = true }, 300)
      window.location.href = searchHref({ page: 1 })
      return
    }
    searchPage = 1
    history.pushState({}, '', searchHref({ page: 1 }))
  }

  // On initial load, resolve a ?q= identifier (DOI, ROR, ORCID, ISSN, …) directly
  // instead of running a text search that would show "no results".
  if (!isEntityPage && typeof window !== 'undefined') {
    const initialQuery = new URLSearchParams(window.location.search).get('q') ?? ''
    const parsed = parseIdentifier(initialQuery.trim())
    let bounced = false
    try {
      bounced = parsed?.id != null && sessionStorage.getItem(NAV_KEY) === parsed.id
      sessionStorage.removeItem(NAV_KEY)
    } catch { /* storage blocked: no guard */ }
    if (isResolvableIdentifier(parsed) && !bounced) navigateToId(parsed)
    // A search, not an identifier: adopt the kind, the facet values and the
    // page the link carried, so a shared result list opens as it was.
    else applySearchUrl()
  }
  // Arrived: the id resolved, so a later ?q= for it is a fresh request.
  if (isEntityPage) {
    try { sessionStorage.removeItem(NAV_KEY) } catch { /* storage blocked */ }
  }

  async function initEntityPage(rawData) {
    liveWorksCount = null
    liveCiteCount  = null
    refPage         = 1
    worksPage       = 1
    worksWindow     = 0
    worksTypeFilter = null
    worksYearFilter = null
    refTypeFilter   = null
    refYearFilter   = null
    citeTypeFilter  = null
    citeYearFilter  = null
    citePageEntries = []
    citeTotal       = null
    citeFacets      = null
    const rawArr = Array.isArray(rawData) ? rawData : null
    const firstId = rawArr?.[0]?.id ?? ''
    const isEntityArray = rawArr && rawArr.length > 0 && (
      firstId.startsWith('https://ror.org/') || firstId.startsWith('https://orcid.org/')
      || isContainerObj(rawArr[0])
    )
    if (isEntityArray) {
      const entityEntry = {
        id:   crypto.randomUUID(),
        doi:  firstId,
        data: JSON.stringify([rawArr[0]]),
        url:  null,
        html: '',
      }
      const workEntries = rawArr.slice(1, 1001).map(work => ({
        id:   crypto.randomUUID(),
        doi:  (work.id ?? '').replace(/^https?:\/\/doi\.org\//, ''),
        data: JSON.stringify(work),
        url:  work.url ?? null,
        html: '',
      }))
      // No bulk formatting here. A work card renders from its record, and the
      // formatted citation is read by one thing only — a "citation" export —
      // which formats what it needs on demand. Formatting up front cost the
      // server a request per work on every page load: a thousand of them for a
      // large container, about a minute of work nobody had asked for.
      bibliography = [entityEntry, ...workEntries]
    } else {
      const cmData = JSON.stringify(rawData)
      const entryId = crypto.randomUUID()
      const doi = typeof rawData?.id === 'string' && rawData.id.startsWith('https://doi.org/')
        ? rawData.id.replace('https://doi.org/', '')
        : ''
      const url = rawData?.url ?? null
      // Not formatted here, as for the entity arrays above: the card renders
      // from the record, and only a "citation" export reads the formatted
      // citation, formatting it on demand. Waiting for /api/bibliography left
      // the page empty for a round trip before the card appeared — a flicker
      // on every work page.
      const mainEntry = { id: entryId, doi, data: cmData, url, html: '' }
      bibliography = [mainEntry]

      // Extract DOI lists — actual fetching is done lazily by page via $effect
      allRefDois = (rawData?.references ?? [])
        .map(r => typeof r?.id === 'string' && r.id.startsWith('https://doi.org/')
          ? r.id.replace('https://doi.org/', '') : null)
        .filter(Boolean)
      refCache = {}

      // Group rawData.relations by type; only DOI-resolvable ids get a card.
      // IsCitedBy and IsSupplementTo are aliases for citations (merged into IsReferencedBy).
      // Cites and IsSupplementedBy are aliases for references (merged into allRefDois).
      const CITE_ALIASES = new Set(['IsCitedBy', 'IsSupplementTo'])
      const REF_ALIASES  = new Set(['Cites', 'IsSupplementedBy'])
      const typeGroups = {}
      const extraRefDois = []
      for (const r of (rawData?.relations ?? [])) {
        if (!r?.type || typeof r?.id !== 'string' || !r.id.startsWith('https://doi.org/')) continue
        if (r.type === 'References') continue  // handled by dedicated references section
        // A blog post IsPartOf its blog, which its card already names and links
        // (see containerPagePath), so the blog is not listed again as a related work.
        if (r.type === 'IsPartOf' && rawData?.type === 'BlogPost') continue
        const doi = r.id.replace('https://doi.org/', '')
        if (REF_ALIASES.has(r.type)) {
          if (!extraRefDois.includes(doi)) extraRefDois.push(doi)
          continue
        }
        const groupKey = CITE_ALIASES.has(r.type) ? 'IsReferencedBy' : r.type
        if (!typeGroups[groupKey]) typeGroups[groupKey] = []
        if (!typeGroups[groupKey].includes(doi)) typeGroups[groupKey].push(doi)
      }
      allRelationDoisByType  = typeGroups
      relationCacheByType    = Object.fromEntries(Object.keys(typeGroups).map(t => [t, {}]))
      relationPageByType     = Object.fromEntries(Object.keys(typeGroups).map(t => [t, 1]))
      relationFetchingByType = Object.fromEntries(Object.keys(typeGroups).map(t => [t, false]))
      // Append Cites + IsSupplementedBy DOIs to the references section (dedup against existing)
      if (extraRefDois.length > 0) {
        const existing = new Set(allRefDois)
        for (const d of extraRefDois) { if (!existing.has(d)) allRefDois = [...allRefDois, d] }
      }
      // Exclude references that are versions of this work (e.g. Zenodo versioned records)
      const versionDois = new Set(typeGroups['IsVersionOf'] ?? [])
      if (versionDois.size > 0) allRefDois = allRefDois.filter(d => !versionDois.has(d))
    }

  }

  $effect(() => { if (isEntityPage) initEntityPage(_initData) })

  // Fetch actual counts from DB for entity pages (works for org/person, citations for DOI).
  // Runs once hasDb is true — avoids the race where bibliography is set before /status resolves.
  $effect(() => {
    if (!isEntityPage || !hasDb || bibliography.length === 0) return
    const id = entityId(bibliography[0].data)
    if (!id) return
    fetch(`/api/count?id=${encodeURIComponent(id)}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (d?.works    != null) liveWorksCount = d.works
        if (d?.citations != null) liveCiteCount  = d.citations
      })
      .catch(() => {})
  })

  // Lazy fetch: load the current page of refs from SQLite, batch of PAGE_SIZE
  $effect(() => {
    const page = refPage
    const dois = allRefDois
    const pageDois = dois.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
    if (pageDois.length === 0) return
    void fetchDoiPage(pageDois, refCache, newEntries => { refCache = { ...refCache, ...newEntries } },
      () => { refFetching = true }, () => { refFetching = false }, true)
  })

  // Lazy fetch: load the current page for each relation type group. Citations
  // (IsReferencedBy) are not among them: the server pages those (below).
  $effect(() => {
    for (const [type, dois] of Object.entries(allRelationDoisByType)) {
      if (type === 'IsReferencedBy') continue
      const page = relationPageByType[type] ?? 1
      const pageDois = dois.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
      if (pageDois.length === 0) continue
      const cache = relationCacheByType[type] ?? {}
      void fetchDoiPage(pageDois, cache,
        newEntries => { relationCacheByType = { ...relationCacheByType, [type]: { ...cache, ...newEntries } } },
        () => { relationFetchingByType = { ...relationFetchingByType, [type]: true } },
        () => { relationFetchingByType = { ...relationFetchingByType, [type]: false } },
        VERSION_RELATION_TYPES.has(type))
    }
  })

  // One page of a work's citations, under the filters chosen. A newer request
  // supersedes an older one still in flight, so a quick run of clicks shows
  // the page clicked last.
  let _citeRequest = 0
  $effect(() => {
    const doi = citeDoi
    if (!doi || !citeAny) return
    const page = relationPageByType['IsReferencedBy'] ?? 1
    const params = new URLSearchParams({
      id: doi, limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE),
    })
    if (citeTypeFilter) params.set('type', citeTypeFilter)
    if (citeYearFilter) params.set('year', String(citeYearFilter))
    const request = ++_citeRequest
    citeLoading = true
    fetch(`/api/citations?${params}`)
      .then(async resp => {
        if (request !== _citeRequest) return
        if (!resp.ok) { citePageEntries = []; citeTotal = 0; return }
        const total = Number(resp.headers.get('X-Total-Count') ?? 0)
        const works = await resp.json()
        if (request !== _citeRequest) return
        citePageEntries = Array.isArray(works) ? works.map(workEntry) : []
        citeTotal = Number.isFinite(total) ? total : citePageEntries.length
      })
      .catch(() => {})
      .finally(() => { if (request === _citeRequest) citeLoading = false })
  })

  // The counts of a work's whole citation list, once per work.
  $effect(() => {
    const doi = citeDoi
    if (!doi || !citeAny) return
    fetch(`/api/citations/facets?id=${encodeURIComponent(doi)}`)
      .then(r => r.ok ? r.json() : null)
      .then(f => { if (citeDoi === doi) citeFacets = f?.counted ? f : null })
      .catch(() => {})
  })

  // For ORCID person / ROR org / container pages: when the server returns only the
  // entity record (no inline works), its works are fetched separately after the page
  // is visible. Containers are keyed by the URL path (ISSN / DOI / OpenAlex source id).
  $effect(() => {
    if (!isEntityPage || bibliography.length === 0) return
    const id = entityId(bibliography[0].data)
    const isOrcid = id?.startsWith('https://orcid.org/')
    const isRor   = id?.startsWith('https://ror.org/')
    const isContainer = !isOrcid && !isRor && detectEntityType(bibliography[0].data) === 'Container'
    let endpoint = null
    let exportId = null
    if (isOrcid) {
      exportId = id.replace('https://orcid.org/', '')
      endpoint = `/api/person/${exportId}/works`
    } else if (isRor) {
      exportId = id.replace('https://ror.org/', '')
      endpoint = `/api/org/${exportId}/works`
    } else if (isContainer) {
      const path = (typeof window !== 'undefined' ? window.location.pathname : '').replace(/^\//, '')
      if (path) {
        exportId = path
        endpoint = `/api/container/works?id=${encodeURIComponent(path)}`
      }
    }
    // Loading a window replaces `bibliography`, which this effect reads, so it
    // runs again on every page change; starting over for an endpoint already
    // being served would refetch for as long as the page stayed open.
    if (!endpoint || endpoint === worksEndpoint) return
    worksEndpoint = endpoint
    worksExportId = exportId
    worksTotal = 0
    worksWindow = 0
    // A link into the middle of a list, filtered or not, opens where it points:
    // the filters are in the URL beside the page, because they decide what the
    // page is a page of.
    const scope = worksScopeFromUrl()
    worksTypeFilter = scope.type
    worksYearFilter = scope.year
    worksPage = scope.page
    void loadWorksPage(endpoint, worksBlockFor(worksPage))
    void loadWorksFacets(exportId)
  })

  // Back and forward step through what the reader did to the list — paging it
  // and filtering it both. The URL is the state: whatever it names is shown.
  $effect(() => {
    if (!isEntityPage || typeof window === 'undefined') return
    const onPopState = () => {
      const scope = worksScopeFromUrl()
      const sameFilter = scope.type === worksTypeFilter && scope.year === worksYearFilter
      if (sameFilter) {
        goToWorksPage(scope.page, false)
        return
      }
      // The filter changed, so the list did: start it again at the page the
      // URL names, without pushing an entry for a step the browser just took.
      worksTypeFilter = scope.type
      worksYearFilter = scope.year
      worksPage = scope.page
      worksWindow = 0
      worksTotal = 0
      if (worksEndpoint) void loadWorksPage(worksEndpoint, worksBlockFor(scope.page))
    }
    window.addEventListener('popstate', onPopState)
    return () => window.removeEventListener('popstate', onPopState)
  })

  // Fetch the window of an entity's works at `offset` and show it. The server
  // caps a window at WORKS_PAGE and reports the entity's full count in
  // X-Total-Count, which is what the pager spans.
  async function loadWorksPage(endpoint, offset) {
    const sep = endpoint.includes('?') ? '&' : '?'
    let url = `${endpoint}${sep}limit=${WORKS_PAGE}&offset=${offset}`
    // The filters are the server's: it reads them from the relation rows, so
    // the page that comes back is a page of the filtered list and the total
    // beside it is the filtered total.
    if (worksTypeFilter) url += `&type=${encodeURIComponent(worksTypeFilter)}`
    if (worksYearFilter) url += `&year=${worksYearFilter}`
    const initial = offset === 0 && bibliography.length <= 1
    if (initial) personWorksLoading = true
    else worksPageLoading = true
    try {
      const response = await fetch(url)
      if (!response.ok) return
      const works = await response.json()
      const total = Number(response.headers.get('X-Total-Count') ?? 0)
      // A filtered list can be empty, so 0 is an answer here, not a missing one.
      if (Number.isFinite(total)) worksTotal = total
      if (!Array.isArray(works)) return
      const workEntries = works.map(work => ({
        id:   crypto.randomUUID(),
        doi:  (work.id ?? '').replace(/^https?:\/\/doi\.org\//, ''),
        data: JSON.stringify(work),
        url:  work.url ?? null,
        html: '',
      }))
      if (workEntries.length === 0) {
        // A window past the end. The count and the pages come from the same
        // query, so an offset below the count always has rows — but a link can
        // name a page the list no longer reaches, having been shared when the
        // entity had more works. With nothing on screen yet, open at the start
        // rather than on nothing; otherwise keep the window already shown and
        // put the page number back where the content is.
        if (offset > 0) {
          if (bibliography.length <= 1) {
            worksPage = 1
            void loadWorksPage(endpoint, 0)
          } else {
            worksPage = worksWindow / PAGE_SIZE + 1
          }
          // The address bar should name the page the reader is on.
          if (typeof window !== 'undefined') {
            window.history.replaceState({ worksPage }, '', worksPageHref(worksPage))
          }
        }
        return
      }
      worksWindow = offset
      bibliography = [bibliography[0], ...workEntries]
      // A link can name a page past the end of a list that has since grown
      // shorter. The window it asked for still has works — just not that far in
      // — so the page is pulled back to the last one that does.
      const lastPage = Math.max(1, Math.ceil(worksTotal / PAGE_SIZE))
      if (worksPage > lastPage) {
        worksPage = lastPage
        if (typeof window !== 'undefined') {
          window.history.replaceState({ worksPage }, '', worksPageHref(worksPage))
        }
        const block = worksBlockFor(worksPage)
        if (block !== worksWindow) void loadWorksPage(endpoint, block)
      }
    } catch {
      // Leave what is already shown in place.
    } finally {
      if (initial) personWorksLoading = false
      else worksPageLoading = false
    }
  }

  // The works page lives in the URL, so a page is a link: it can be opened in a
  // new tab or shared, and the browser's back button steps through the pages a
  // reader visited. Page 1 is the bare entity page, with no parameter to it.
  // The page and the filters that decide what it is a page of, as the URL holds
  // them. `page`, `type` and `year` are managed; everything else in the query is
  // kept exactly as served — building it through URLSearchParams would rewrite
  // the valueless `?noredirect` every internal link carries into `?noredirect=`.
  function worksScopeHref({ page = 1, type = null, year = null } = {}) {
    if (typeof window === 'undefined') return ''
    const { pathname, search, hash } = window.location
    const managed = ['page=', 'type=', 'year=']
    const kept = search.replace(/^\?/, '').split('&')
      .filter(part => part && !managed.some(m => part.startsWith(m)))
    if (type) kept.push(`type=${encodeURIComponent(type)}`)
    if (year) kept.push(`year=${year}`)
    if (page > 1) kept.push(`page=${page}`)
    return `${pathname}${kept.length > 0 ? `?${kept.join('&')}` : ''}${hash}`
  }

  function worksPageHref(p) {
    return worksScopeHref({ page: p, type: worksTypeFilter, year: worksYearFilter })
  }

  function worksScopeFromUrl() {
    if (typeof window === 'undefined') return { page: 1, type: null, year: null }
    const params = new URLSearchParams(window.location.search)
    const page = parseInt(params.get('page') ?? '', 10)
    const year = parseInt(params.get('year') ?? '', 10)
    return {
      page: Number.isFinite(page) && page > 0 ? page : 1,
      type: params.get('type') || null,
      year: Number.isFinite(year) && year > 999 ? year : null,
    }
  }

  function worksPageFromUrl() {
    return worksScopeFromUrl().page
  }

  // The counts the filters are offered from, over the entity's whole list.
  async function loadWorksFacets(id) {
    worksFacets = null
    if (!id) return
    try {
      const response = await fetch(`/api/facets?id=${encodeURIComponent(id)}`)
      if (!response.ok) return
      const body = await response.json()
      if (body?.counted) worksFacets = body
    } catch {
      // No counts, so no filters offered — the list itself is unaffected.
    }
  }

  // Apply a filter: it changes what the list is, so it starts at its first page
  // and goes into the URL, which is what a reload or a shared link reads back.
  function setWorksFilter({ type = worksTypeFilter, year = worksYearFilter }) {
    worksTypeFilter = type || null
    worksYearFilter = year || null
    worksPage = 1
    worksWindow = 0
    worksTotal = 0
    if (typeof window !== 'undefined') {
      window.history.pushState(
        { worksPage: 1 },
        '',
        worksScopeHref({ page: 1, type: worksTypeFilter, year: worksYearFilter }),
      )
    }
    if (worksEndpoint) void loadWorksPage(worksEndpoint, 0)
  }

  // The window of works holding display page `p`. A declaration, not a const:
  // the effect that opens the page the URL names calls it, and is written above.
  function worksBlockFor(p) {
    return Math.floor(((p - 1) * PAGE_SIZE) / WORKS_PAGE) * WORKS_PAGE
  }

  // Show page `p` of the works list. Unfiltered, the pages run over the entity's
  // whole list, so one outside the loaded window fetches the window holding it.
  // `push` is false when the browser already moved us — a back or forward step,
  // which must not push its own entry back onto the stack.
  function goToWorksPage(p, push = true) {
    worksPage = p
    if (!worksEndpoint) return
    if (push && typeof window !== 'undefined') {
      window.history.pushState({ worksPage: p }, '', worksPageHref(p))
    }
    const block = worksBlockFor(p)
    if (block !== worksWindow) void loadWorksPage(worksEndpoint, block)
  }

  async function fetchDoiPage(pageDois, cache, mergeCache, setLoading, clearLoading, fullFetch = false) {
    const missing = pageDois.filter(doi => !(doi in cache))
    if (missing.length === 0) return
    // Merge null sentinels immediately so effect re-runs triggered by other types completing
    // see these DOIs as in-flight and don't start duplicate fetches
    const newEntries = Object.fromEntries(missing.map(doi => [doi, null]))
    mergeCache(newEntries)
    setLoading()
    try {
      // The page's works in one request (see fetchWorks). Not formatted: the
      // cards render from the records, and an export formats what it exports.
      const works = await fetchWorks(missing, !(isLocalhost || fullFetch))
      missing.forEach((doi, i) => {
        if (works[i]) newEntries[doi] = workEntry(works[i], doi)
      })
      mergeCache(newEntries)
    } finally {
      clearLoading()
    }
  }

  // The commonmeta records of `dois` from POST /api/works, in order, null for
  // one that resolved to nothing. One request per fifty works: a request each
  // sent a page with a few hundred related works into the request limit.
  const WORKS_BATCH = 50
  async function fetchWorks(dois, localOnly) {
    const out = []
    for (let i = 0; i < dois.length; i += WORKS_BATCH) {
      const ids = dois.slice(i, i + WORKS_BATCH)
      let works = null
      try {
        const resp = await fetch(`/api/works${localOnly ? '?local_only=true' : ''}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ids }),
          signal: AbortSignal.timeout(20000),
        })
        if (resp.ok) works = await resp.json()
      } catch { /* a failed batch shows no cards, like a failed lookup did */ }
      out.push(...ids.map((_, j) => (Array.isArray(works) ? works[j] : null) ?? null))
    }
    return out
  }

  // A list entry for a work record; `doi` stands in when the record has none.
  function workEntry(work, doi = '') {
    const id = work?.id ?? ''
    return {
      id:   crypto.randomUUID(),
      doi:  id.startsWith('https://doi.org/') ? id.replace('https://doi.org/', '') : doi,
      data: JSON.stringify(work),
      url:  work?.url ?? null,
      html: '',
    }
  }

  const entityTitle = $derived.by(() => {
    if (!isEntityPage || bibliography.length === 0) return ''
    try {
      const d = JSON.parse(bibliography[0].data)
      const obj = Array.isArray(d) ? d[0] : d
      return obj?.name || obj?.title || ''
    } catch { return '' }
  })

  // Record entity page in history once title resolves
  $effect(() => {
    if (entityTitle) {
      const id = entityId(bibliography[0]?.data ?? '')
      historySheet?.pushEntry(window.location.pathname, entityTitle, id)
    }
  })

  // Cmd-K / Ctrl-K: focus search, then cycle the entity kind; Cmd-Y / Ctrl-Y: toggle history; Cmd-B / Ctrl-B: bookmarks; Escape: close
  $effect(() => {
    function onKeydown(e) {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault()
        if (document.activeElement === searchInputEl) {
          // Already in the box, so step the kind instead of re-focusing, and
          // leave the query untouched: repeated presses re-scope a search in
          // progress rather than selecting the text out from under the caret.
          // An unknown kind lands on index 0, which is works.
          const next = (SEARCH_KINDS.indexOf(searchKind) + 1) % SEARCH_KINDS.length
          searchKind = SEARCH_KINDS[next]
        } else {
          // First press only: focus and select, so one press never changes
          // what is being searched.
          searchInputEl?.focus()
          searchInputEl?.select()
        }
      }
      if ((e.metaKey || e.ctrlKey) && e.key === 'y') {
        e.preventDefault()
        if (historyOpen && historyTab === 'bookmarks') {
          historyTab = 'history'
        } else {
          historyTab = 'history'
          historyOpen = !historyOpen
        }
      }
      if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
        e.preventDefault()
        if (historyOpen && historyTab === 'bookmarks') {
          historyOpen = false
        } else {
          historyTab = 'bookmarks'
          historyOpen = true
        }
      }
      if (e.key === 'Escape' && historyOpen) {
        e.preventDefault()
        historyOpen = false
      }
    }
    window.addEventListener('keydown', onKeydown)
    return () => window.removeEventListener('keydown', onKeydown)
  })

  // ORCID/ROR paths get redirected by handle_pid without preserving query params;
  // use the prefixed API paths directly so format=commonmeta reaches the right handler
  function commonmetaPath(id, path) {
    if (id?.startsWith('https://ror.org/')) return '/ror/' + id.replace('https://ror.org/', '')
    if (id?.startsWith('https://orcid.org/')) return '/orcid/' + id.replace('https://orcid.org/', '')
    return path ?? '/' + bareBookmarkId(id)
  }

  async function fetchCommonmeta(path) {
    const url = isLocalhost ? `${path}?format=commonmeta` : `${path}?format=commonmeta&local_only=true`
    const resp = await fetch(url)
    if (!resp.ok) return null
    const raw = await resp.json()
    const d = bookmarkRecord(raw)
    return recordId(d) ? d : null
  }

  async function addFromHistory(entry) {
    try {
      const d = await fetchCommonmeta(commonmetaPath(entry.id, entry.path))
      if (d) { addToSaved(recordId(d), JSON.stringify(d)); return true }
    } catch (e) { console.error('addFromHistory:', e) }
    return false
  }

  // A bookmark link (?bookmarks=…) opens the Bookmarks panel and asks before
  // adding anything: the ids it carries are looked up here, not trusted as data.
  let linkImport = $state(null)
  if (typeof window !== 'undefined') {
    const param = new URLSearchParams(window.location.search).get(BOOKMARKS_PARAM)
    if (param !== null) {
      decodeBookmarkIds(param).then(ids => {
        if (!ids) { forgetLinkImport(); return }
        linkImport = ids
        historyTab = 'bookmarks'
        historyOpen = true
      })
    }
  }

  function forgetLinkImport() {
    linkImport = null
    const url = new URL(window.location.href)
    url.searchParams.delete(BOOKMARKS_PARAM)
    history.replaceState(history.state, '', url.pathname + url.search + url.hash)
  }

  async function importFromLink() {
    const ids = linkImport ?? []
    const records = await Promise.all(ids.map(id =>
      fetchCommonmeta(commonmetaPath(id)).catch(() => null)))
    const found = records.filter(Boolean)
    const { added, skipped } = importSaved(found)
    forgetLinkImport()
    return { added, skipped, failed: ids.length - found.length }
  }

  async function pickRandom() {
    randomLoading = true
    bibError = ''
    try {
      const resp = await fetch('/api/random')
      if (!resp.ok) throw new Error(await resp.text().catch(() => resp.statusText))
      const { id } = await resp.json()
      bibDoi = id
    } catch (err) {
      bibError = String(err)
    } finally {
      randomLoading = false
    }
  }

  async function formatItems(items, style, locale) {
    const resp = await fetch('/api/bibliography', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items, style, locale }),
    })
    if (!resp.ok) {
      const text = await resp.text().catch(() => resp.statusText)
      throw new Error(`${resp.status}: ${text}`)
    }
    return (await resp.json()).items
  }

  function onBibStyleChange(e) {
    const val = e.target.value
    if (val === '__more__') {
      e.target.value = bibStyle
      stylePickerOpen = true
      styleSearch = ''
      return
    }
    bibStyle = val
    if (bibliography.length > 0) reformatBibliography(val, bibLocale)
  }

  function pickMoreStyle(style) {
    bibStyle = style.value
    stylePickerOpen = false
    styleSearch = ''
    if (bibliography.length > 0) reformatBibliography(style.value, bibLocale)
  }

  function onBibLocaleChange(e) {
    const locale = e.target.value
    bibLocale = locale
    if (bibliography.length > 0) reformatBibliography(bibStyle, locale)
  }

  async function reformatBibliography(style, locale) {
    bibLoading = true
    try {
      const relEntries = Object.values(relationCacheByType).flatMap(c => Object.values(c).filter(Boolean))
      const allItems = [
        ...bibliography.map(e => ({ id: e.id, data: e.data })),
        ...Object.values(refCache).filter(Boolean).map(e => ({ id: e.id, data: e.data })),
        ...relEntries.map(e => ({ id: e.id, data: e.data })),
      ]
      const formatted = await formatItems(allItems, style, locale)
      const byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
      bibliography = bibliography.map(e => ({ ...e, html: byId[e.id] ?? e.html }))
      refCache = Object.fromEntries(Object.entries(refCache).map(([doi, e]) => [doi, e ? { ...e, html: byId[e.id] ?? e.html } : null]))
      relationCacheByType = Object.fromEntries(
        Object.entries(relationCacheByType).map(([type, cache]) => [
          type, Object.fromEntries(Object.entries(cache).map(([doi, e]) => [doi, e ? { ...e, html: byId[e.id] ?? e.html } : null]))
        ])
      )
    } catch (err) {
      bibError = String(err)
    } finally {
      bibLoading = false
    }
  }

  function addToSaved(id, data) {
    if (!id) return
    if (!savedItems.some(item => item.id === id) && savedItems.length < MAX_SAVED) {
      savedItems = [...savedItems, { id, data: JSON.stringify(bookmarkRecord(data)) }]
    }
    addedId = id
    setTimeout(() => { addedId = null }, 2000)
  }

  // Add the records of an imported Commonmeta file, as many as fit under the
  // cap. A record already bookmarked is skipped rather than overwritten.
  function importSaved(records) {
    let next = savedItems
    for (const r of records) {
      const id = recordId(r)
      if (next.length >= MAX_SAVED || next.some(item => item.id === id)) continue
      next = [...next, { id, data: JSON.stringify(r) }]
    }
    const added = next.length - savedItems.length
    savedItems = next
    return { added, skipped: records.length - added }
  }

  function moveSaved(from, to) {
    const next = [...savedItems]
    const [item] = next.splice(from, 1)
    next.splice(to, 0, item)
    savedItems = next
  }

  function removeFromSaved(id) {
    savedItems = savedItems.filter(item => item.id !== id)
  }

  function deleteEntry(id) {
    bibliography = bibliography.filter(e => e.id !== id)
  }

  function deleteBibliography() {
    bibliography = bibliography.slice(0, 1)
    allRefDois = []; refCache = {}
    allRelationDoisByType = {}; relationCacheByType = {}; relationPageByType = {}; relationFetchingByType = {}
  }

  async function copyCitation(entry) {
    const raw = JSON.parse(entry.data)
    // Persons and orgs are stored as JSON arrays [entity, ...works]; copy only the entity.
    const obj = Array.isArray(raw) ? raw[0] : raw
    await navigator.clipboard.writeText(JSON.stringify(obj, null, 2))
    copiedCiteId = entry.id
    setTimeout(() => { copiedCiteId = null }, 2000)
  }



  // ── Export actions ────────────────────────────────────────────────────────
  const expExtensions = { citation: 'txt', bibtex: 'bib', ris: 'ris', csl: 'json', commonmeta: 'json',
    crossref: 'json', crossref_xml: 'xml', datacite: 'json', datacite_xml: 'xml',
    inveniordm: 'json', schemaorg: 'json' }

  async function fetchBibExport(items, exportId) {
    // An entity's works are paged, so `items` holds only the pages loaded so
    // far. When the server can export the entity instead, ask it for the whole
    // set in one request rather than one request per loaded work. `exportId` is
    // the entity's id, or { id, list: 'citations' } for a work's citations.
    if (exportId) {
      const target = typeof exportId === 'string' ? { id: exportId } : exportId
      const citations = target.list === 'citations'
      const params = new URLSearchParams({ id: target.id, format: expFormat })
      if (citations) params.set('list', 'citations')
      if (expFormat === 'citation') {
        params.set('style', bibStyle)
        params.set('locale', bibLocale)
      }
      // Export the list as shown: the filters go with it.
      const typeFilter = citations ? citeTypeFilter : worksTypeFilter
      const yearFilter = citations ? citeYearFilter : worksYearFilter
      if (typeFilter) params.set('type', typeFilter)
      if (yearFilter) params.set('year', String(yearFilter))
      const resp = await fetch(`/api/export?${params}`)
      if (!resp.ok) {
        const t = await resp.text().catch(() => resp.statusText)
        throw new Error(`${resp.status}: ${t}`)
      }
      const text = await resp.text()
      if (resp.headers.get('X-Export-Truncated')) {
        exportTruncated = {
          exported: Number(resp.headers.get('X-Exported-Count') ?? 0),
          total: Number(resp.headers.get('X-Total-Count') ?? 0),
        }
      }
      // The citation writer renders HTML, as the bibliography endpoint does, and
      // an export has always been plain text.
      return expFormat === 'citation' ? stripHtml(text) : text
    }
    if (expFormat === 'citation') {
      // Formatted on demand: entity pages no longer format their works up
      // front, so this is where the work happens — for the items being
      // exported, not for every work on the page.
      const missing = items.filter(e => !e.html)
      let byId = {}
      if (missing.length > 0) {
        const formatted = await formatItems(
          missing.map(e => ({ id: e.id, data: e.data })),
          bibStyle, bibLocale,
        )
        byId = Object.fromEntries(formatted.map(i => [i.id, i.html]))
      }
      return items.map(e => stripHtml(e.html || byId[e.id] || '')).join('\n\n')
    }
    // Works with no entity to export them by — a work's references, its
    // related works — go to the server by DOI, all in one request: a request
    // each ran an export of more than a few into the request limit.
    const resp = await fetch('/api/export', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ids: items.map(e => e.doi).filter(Boolean), format: expFormat }),
    })
    if (!resp.ok) {
      const t = await resp.text().catch(() => resp.statusText)
      throw new Error(`${resp.status}: ${t}`)
    }
    return resp.text()
  }

  async function exportBibliography(items, exportId) {
    if (!items.length) return
    expLoading = true
    expError = ''
    exportTruncated = null
    try {
      const combined = await fetchBibExport(items, exportId)
      const ext = expExtensions[expFormat] ?? 'txt'
      const url = URL.createObjectURL(new Blob([combined], { type: 'text/plain' }))
      Object.assign(document.createElement('a'), { href: url, download: `bibliography.${ext}` }).click()
      URL.revokeObjectURL(url)
    } catch (err) { expError = String(err) }
    finally { expLoading = false }
  }

  async function copyBibExport(items, exportId) {
    if (!items.length) return
    expLoading = true
    expError = ''
    expCopied = false
    exportTruncated = null
    try {
      const combined = await fetchBibExport(items, exportId)
      await navigator.clipboard.writeText(combined)
      expCopied = true
      setTimeout(() => { expCopied = false }, 2000)
    } catch (err) { expError = String(err) }
    finally { expLoading = false }
  }
</script>

<svelte:window onkeydown={e => { if (e.key === 'Escape') optionsOpen = false }} />

<div class="min-h-screen flex flex-col bg-background text-foreground antialiased">

  <!-- Header -->
  <header class="bg-[#f2f2f2] dark:bg-gray-800 shrink-0 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
    <!-- One row from md up. On a phone, two: the brand and the controls, then
         the search box across the full width — squeezed into one row, the
         search field had no room left at all. -->
    <div class="max-w-full md:max-w-3xl lg:max-w-5xl mx-auto px-4 sm:px-6 py-2 md:py-0 md:h-14 flex flex-wrap md:flex-nowrap items-center gap-x-6 gap-y-2">
      <!-- Brand -->
      <div class="flex items-center gap-2 shrink-0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6 text-primary shrink-0">
          <path d="M12 .75a8.25 8.25 0 0 0-4.135 15.39c.686.398 1.115 1.143 1.115 1.942V18h5.25v-.008c0-.799.43-1.544 1.115-1.942A8.25 8.25 0 0 0 12 .75Z" />
          <path fill-rule="evenodd" d="M9.013 19.9a.75.75 0 0 1 .877-.597 11.319 11.319 0 0 0 4.22 0 .75.75 0 1 1 .28 1.473 12.819 12.819 0 0 1-4.78 0 .75.75 0 0 1-.597-.876ZM9 22.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5A.75.75 0 0 1 9 22.5Z" clip-rule="evenodd" />
        </svg>
        <a href="/" class="text-base font-bold tracking-tight text-gray-900 dark:text-white hover:underline">Commonmeta</a>
      </div>

      <!-- Search form -->
      <div class="relative order-last md:order-none w-full md:w-80 lg:w-[32rem] md:mx-auto min-w-0">
      <form onsubmit={goToId} class="w-full">
        <div class="flex items-center border border-input rounded-md bg-background overflow-hidden transition-colors focus-within:border-primary">
          <!-- The caret is ours, not the browser's: a native one is drawn
               against the right edge of the control, which here is the divider
               the search field starts at, with no room between the two. -->
          <div class="relative shrink-0 border-r border-input">
            <select
              bind:value={searchKind}
              aria-label={_('bibliography.search_in')}
              class="h-9 pl-3 pr-9 text-sm bg-transparent outline-none text-muted-foreground cursor-pointer appearance-none"
            >
              {#each SEARCH_KINDS as kind}
                <option value={kind}>{_(SEARCH_KIND_LABELS[kind])}</option>
              {/each}
            </select>
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
                 aria-hidden="true"
                 class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground">
              <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
            </svg>
          </div>
          <input
            type="text"
            bind:value={bibDoi}
            bind:this={searchInputEl}
            placeholder={_('bibliography.placeholder')}
            autocomplete="off"
            autocorrect="off"
            autocapitalize="none"
            spellcheck="false"
            onkeydown={onSearchKeydown}
            class="flex-1 h-9 px-3 text-sm font-mono placeholder:font-sans bg-transparent outline-none min-w-0"
          />
          {#if bibDoi}
            <button
              type="button"
              aria-label="Clear"
              onclick={() => { bibDoi = '' }}
              class="h-9 w-8 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors shrink-0"
            >
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5">
                <path d="M18 6 6 18M6 6l12 12"/>
              </svg>
            </button>
          {/if}
          <button
            type="submit"
            disabled={bibLoading || !bibDoi.trim()}
            aria-label={_('bibliography.go')}
            class="h-9 px-3 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 shrink-0 flex items-center justify-center transition-colors"
          >
            {#if bibLoading}
              <span class="block w-4 h-4 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
            {:else}
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4">
                <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
              </svg>
            {/if}
          </button>
        </div>
      </form>
      </div>

      <!-- Controls -->
      <div class="flex items-center gap-3 justify-end min-w-0 ml-auto md:ml-0">
        <!-- From md up, the three controls; on a phone, the options menu below. -->
        <button
          type="button"
          onclick={() => { historyOpen = true }}
          aria-label="Navigation history"
          class="hidden md:inline-flex text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
        >
          <Icon icon="fa6-solid:clock-rotate-left" width="18" height="18" />
        </button>
        <button
          type="button"
          onclick={toggleDark}
          aria-label="Toggle dark mode"
          class="hidden md:inline-flex text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
        >
          {#if isDark}
            <Icon icon="fa6-solid:sun" width="18" height="18" />
          {:else}
            <Icon icon="fa6-solid:moon" width="18" height="18" />
          {/if}
        </button>
        <Select bind:value={bibLocale} onchange={onBibLocaleChange}
          class="hidden md:flex h-7 w-36 text-xs py-0">
          {#each locales as l}
            <option value={l.value}>{l.label}</option>
          {/each}
        </Select>
        <div class="relative md:hidden">
          <button
            type="button"
            onclick={() => { optionsOpen = !optionsOpen }}
            aria-label={_('menu.options')}
            aria-haspopup="menu"
            aria-expanded={optionsOpen}
            class="flex items-center justify-center w-9 h-9 -mr-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-black/5 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
          >
            <Icon icon="fa6-solid:ellipsis-vertical" width="18" height="18" />
          </button>
          {#if optionsOpen}
            <div class="fixed inset-0 z-40" role="presentation" onclick={() => { optionsOpen = false }}></div>
            <div role="menu" class="absolute right-0 top-full mt-2 z-50 w-60 rounded-md border border-border bg-background shadow-lg py-1">
              <button
                type="button"
                role="menuitem"
                onclick={() => { optionsOpen = false; historyOpen = true }}
                class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-accent"
              >
                <Icon icon="fa6-solid:clock-rotate-left" width="16" height="16" class="text-muted-foreground" />
                {_('sheet.tab_history')}
              </button>
              <button
                type="button"
                role="menuitem"
                onclick={toggleDark}
                class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-accent"
              >
                <Icon icon={isDark ? 'fa6-solid:sun' : 'fa6-solid:moon'} width="16" height="16" class="text-muted-foreground" />
                {isDark ? _('menu.light_mode') : _('menu.dark_mode')}
              </button>
              <div class="mt-1 border-t border-border px-4 pt-2.5 pb-3">
                <label for="options-locale" class="block text-xs text-muted-foreground mb-1">{_('container.language')}</label>
                <Select id="options-locale" bind:value={bibLocale} onchange={onBibLocaleChange} class="h-8 text-sm py-0">
                  {#each locales as l}
                    <option value={l.value}>{l.label}</option>
                  {/each}
                </Select>
              </div>
            </div>
          {/if}
        </div>
      </div>
    </div>
  </header>

  <div class="h-1 w-full shrink-0" aria-live="polite">
    {#if bibFetchingRemote}
      <div class="fetch-progress h-full bg-primary"></div>
    {/if}
  </div>

  {#if bibError}
    <div class="max-w-full md:max-w-3xl lg:max-w-5xl w-full mx-auto px-4 sm:px-6 pt-4">
      <div class="px-4 py-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive" role="alert">
        {bibError}
      </div>
    </div>
  {/if}

  <!-- Main -->
  <!-- An entity page fills in its lists after its first paint, so at least a
       window's height: the footer then starts below the fold instead of being
       pushed out of view when they arrive — a layout shift on every page. -->
  <main class="flex-1 max-w-full md:max-w-3xl lg:max-w-5xl w-full mx-auto px-4 sm:px-6 space-y-12 {isEntityPage ? 'min-h-[calc(100svh-3.5rem)]' : ''}">

    <!-- ── Bibliography ─────────────────────────────────────────────────── -->
    <!-- The cards every list on the site is drawn with: an entity page's
         works, references and citations, and the search results below. They
         sit outside the lists that use them so both can reach them. -->
    <!-- A related work or a reference can be a container: a blog post IsPartOf
         its blog's DOI, which resolves to the blog's record. That record has
         no work's title or authors, so as a work card it rendered empty; it
         gets the container's card, which links to the container's page. -->
    {#snippet listedCard(entry, index, hideVersionLink = false)}
      {@const container = parseContainerMeta(entry.data)}
      {#if container}
        <li>
          <ContainerCard
            {entry} {container} {index} {bibLocale} {copiedCiteId} {addedId}
            isSaved={savedItems.some(item => item.id === entityId(entry.data))}
            oncopy={() => copyCitation(entry)}
            onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
          />
        </li>
      {:else}
        <WorkCard
          {entry} meta={parseMeta(entry.data)} {index}
          {bibLocale} {copiedCiteId} {addedId}
          isSaved={savedItems.some(item => item.id === entityId(entry.data))}
          {hideVersionLink}
          oncopy={() => copyCitation(entry)}
          ondelete={undefined}
          onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
        />
      {/if}
    {/snippet}
    <!-- Rows that hold a list's place while its records load, each about
         as tall as the card that replaces it (the median card: 250 px on
         a desktop, 415 px on a phone). A list that drew a spinner and then
         grew by two thousand pixels pushed everything below it down and
         out of view — the layout shift that rated a work's page poor. -->
    {#snippet cardPlaceholders(n)}
      <ol class="divide-y divide-border" aria-busy="true">
        <!-- `_row`, not `_`: an each binding named `_` shadows the i18n
             `_` inside the loop, so the `sr-only` text below called the array
             item and threw `_(...) is not a function`, taking the whole list
             down with it. -->
        {#each Array.from({ length: n }) as _row, i (i)}
          <li class="px-5 py-4 h-[26rem] sm:h-64 animate-pulse">
            <div class="h-4 w-3/4 rounded bg-muted"></div>
            <div class="mt-3 h-3 w-1/2 rounded bg-muted"></div>
            <div class="mt-3 h-3 w-5/6 rounded bg-muted"></div>
            {#if i === 0}<span class="sr-only">{_('bibliography.loading')}</span>{/if}
          </li>
        {/each}
      </ol>
    {/snippet}
    {#snippet bibFooter(items, exportId)}
      <div class="border-t border-border px-4 py-3 bg-muted flex items-center gap-2 flex-wrap">
        <Select bind:value={expFormat} class="w-40 h-8 text-xs py-0">
          {#each expFormats as f}
            <option value={f.value}>{f.i18n ? _(f.i18n) : f.label}</option>
          {/each}
        </Select>
        {#if expFormat === 'citation'}
          <div class="relative flex-1 min-w-36">
            <Select value={bibStyle} onchange={onBibStyleChange} class="w-full h-8 text-xs py-0">
              {#if !commonStyles.some(s => s.value === bibStyle)}
                <option value={bibStyle}>{allCitationStyles.find(s => s.value === bibStyle)?.label ?? bibStyle}</option>
                <option disabled>──────────────────────</option>
              {/if}
              {#each commonStyles as s}
                <option value={s.value}>{s.label}</option>
              {/each}
              <option disabled>──────────────────────</option>
              <option value="__more__">{_('sheet.more_styles')}</option>
            </Select>
            {#if stylePickerOpen}
              <div class="fixed inset-0 z-40" role="presentation" onclick={() => { stylePickerOpen = false; styleSearch = '' }}></div>
              <div class="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg">
                <div class="p-2 border-b border-border">
                  <!-- svelte-ignore a11y_autofocus -->
                  <input
                    bind:value={styleSearch}
                    placeholder={_('sheet.search_styles')}
                    autofocus
                    class="w-full text-sm px-3 py-1.5 rounded border border-input bg-background outline-none focus:ring-1 focus:ring-ring"
                    onkeydown={e => { if (e.key === 'Escape') { stylePickerOpen = false; styleSearch = '' } }}
                  />
                </div>
                <ul class="max-h-72 overflow-y-auto py-1">
                  {#each allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())) as s}
                    <li>
                      <button
                        type="button"
                        class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted {s.value === bibStyle ? 'font-semibold text-primary' : ''}"
                        onclick={() => pickMoreStyle(s)}
                      >{s.label}</button>
                    </li>
                  {/each}
                  {#if allCitationStyles.filter(s => !styleSearch || s.label.toLowerCase().includes(styleSearch.toLowerCase())).length === 0}
                    <li class="px-3 py-2 text-sm text-muted-foreground">{_('sheet.no_styles')}</li>
                  {/if}
                </ul>
              </div>
            {/if}
          </div>
        {/if}
        <Button variant="default" onclick={() => exportBibliography(items, exportId)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
          {#if expLoading}
            <span class="w-3.5 h-3.5 rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground animate-spin"></span>
          {:else}
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
              <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
            </svg>
          {/if}
          {_('bibliography.export')}
        </Button>
        <Button variant="outline" onclick={() => copyBibExport(items, exportId)} disabled={expLoading} class="h-8 gap-1.5 text-xs px-3">
          {#if expCopied}
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
              <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
            </svg>
            {_('bibliography.copied')}
          {:else}
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5 shrink-0">
              <path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
            </svg>
            {_('bibliography.copy')}
          {/if}
        </Button>
        {#if expError}
          <p class="w-full text-xs text-destructive">{expError}</p>
        {/if}
        {#if exportTruncated}
          <p class="w-full text-xs text-muted-foreground">
            {_('bibliography.export_truncated', { values: { count: exportTruncated.exported, total: exportTruncated.total } })}
          </p>
        {/if}
      </div>
    {/snippet}

    <!-- main spaces its children by three rems, which separates lists of
         cards. On the homepage this section holds nothing and has no height, so
         that gap became the space above the documentation — far more than a
         line of text needs. Narrowed there, and left alone everywhere else,
         where the section has content below it. -->
    <section class={!isEntityPage && !searchShown ? 'mb-4' : ''}>
      <!-- Bibliography list -->
      {#if bibliography.length > 0}

        <div class="mt-4 space-y-2">
          {#each bibGroups() as group (group.entries[0].entry.id)}
            {#if group.type === 'Work'}
              {#if isEntityPage && group.entries[0].idx === 0}
                {@const { entry } = group.entries[0]}
                {@const meta = parseMeta(entry.data)}
                <div class="border border-border rounded-md overflow-hidden">
                  <div class="px-5 py-3 border-b border-border bg-muted/50">
                    <h2 class="text-lg font-semibold text-primary">{typeLabel(meta.type, bibLocale)}</h2>
                  </div>
                  <ol>
                    <WorkCard
                      {entry} meta={{ ...meta, referenceCount: allRefDois.length, relationCounts: Object.fromEntries(Object.entries(allRelationDoisByType).map(([t, dois]) => [t, t === 'IsReferencedBy' ? (citeFacets?.total ?? liveCiteCount ?? dois.length) : dois.length])) }} index={undefined} largeTitle={true}
                      {bibLocale} {copiedCiteId} {addedId}
                      isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                      oncopy={() => copyCitation(entry)}
                      ondelete={undefined}
                      onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                    />
                  </ol>
                </div>
                {#each orderedRelTypes.filter(t => t !== 'IsReferencedBy') as relType (relType)}
                  {@const relDois    = allRelationDoisByType[relType] ?? []}
                  {@const relCache   = relationCacheByType[relType]   ?? {}}
                  {@const relPage    = relationPageByType[relType]    ?? 1}
                  {@const relFetching = relationFetchingByType[relType] ?? false}
                  {@const relPageDois  = relDois.slice((relPage - 1) * PAGE_SIZE, relPage * PAGE_SIZE)}
                  {@const relSlice     = relPageDois.map(doi => relCache[doi]).filter(Boolean)}
                  {@const relHasMore   = relDois.length > relPage * PAGE_SIZE}
                  {@const relTotalPages = Math.max(relPage + (relHasMore ? 1 : 0), Math.ceil(Object.keys(relCache).length / PAGE_SIZE), 1)}
                  {#if relFetching || relSlice.length > 0}
                    <div class="border border-border rounded-md overflow-hidden">
                      <div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2">
                        <h2 class="text-base font-semibold">{humanizeRelationType(relType)}</h2>
                        {#if relFetching}
                          <svg class="animate-spin h-4 w-4 shrink-0 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                          </svg>
                        {/if}
                      </div>
                      {#if relSlice.length > 0}
                        <ol class="divide-y divide-border">
                          {#each relSlice as entry, idx (entry.id)}
                            {@render listedCard(entry, (relPage - 1) * PAGE_SIZE + idx, relType === 'HasVersion')}
                          {/each}
                        </ol>
                      {:else if relFetching}
                        {@render cardPlaceholders(relPageDois.length)}
                      {/if}
                      {#if relTotalPages > 1}
                        <div class="px-5 pb-4">
                          <Pagination page={relPage} totalPages={relTotalPages} locale={bibLocale} onpage={p => { relationPageByType = { ...relationPageByType, [relType]: p } }} />
                        </div>
                      {/if}
                      {@render bibFooter(Object.values(relCache))}
                    </div>
                  {/if}
                {/each}
                {#if allRefDois.length > 0}
                  {@const WORK_YEAR = parseInt(meta.datePublished ?? '', 10) || new Date().getFullYear()}
                  {@const refAllCached = allRefDois.map(doi => refCache[doi]).filter(Boolean)}
                  {@const refTopTypes = (() => {
                    const counts = new Map()
                    for (const entry of refAllCached) {
                      try {
                        const d = JSON.parse(entry.data)
                        if (refYearFilter && parseInt(d?.date_published ?? '', 10) !== refYearFilter) continue
                        const t = d?.type ?? ''
                        if (t && WORK_TYPES.includes(t)) counts.set(t, (counts.get(t) ?? 0) + 1)
                      } catch {}
                    }
                    return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3)
                  })()}
                  {@const refTopYears = (() => {
                    const counts = new Map()
                    for (const entry of refAllCached) {
                      try {
                        const d = JSON.parse(entry.data)
                        if (refTypeFilter && d?.type !== refTypeFilter) continue
                        const y = parseInt(d?.date_published ?? '', 10)
                        if (y === WORK_YEAR || y === WORK_YEAR - 1 || y === WORK_YEAR - 2) counts.set(y, (counts.get(y) ?? 0) + 1)
                      } catch {}
                    }
                    return [...counts.entries()].sort((a, b) => a[0] - b[0])
                  })()}
                  {@const refFiltered = (refTypeFilter || refYearFilter)
                    ? refAllCached.filter(entry => {
                        try {
                          const d = JSON.parse(entry.data)
                          const typeOk = !refTypeFilter || d?.type === refTypeFilter
                          const yearOk = !refYearFilter || parseInt(d?.date_published ?? '', 10) === refYearFilter
                          return typeOk && yearOk
                        } catch { return false }
                      })
                    : null}
                  {@const refSlice = (refFiltered ?? refAllCached).slice((refPage - 1) * PAGE_SIZE, refPage * PAGE_SIZE)}
                  {@const refHasMore = refFiltered ? false : allRefDois.length > refPage * PAGE_SIZE}
                  {@const refTotalPages = refFiltered
                    ? Math.max(1, Math.ceil(refFiltered.length / PAGE_SIZE))
                    : Math.max(refPage + (refHasMore ? 1 : 0), Math.ceil(Object.keys(refCache).length / PAGE_SIZE), 1)}
                  {#if refFetching || refAllCached.length > 0}
                  <div class="border border-border rounded-md overflow-hidden">
                    <div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
                      <h2 class="text-base font-semibold mr-auto">{_('bibliography.references_heading')}</h2>
                      {#if refTopTypes.length > 0}
                        <!-- On a phone only the years, as for an entity's works;
                             a type filter in force stays. -->
                        {#each refTopTypes as [type, count]}
                          <span class={refTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
                            <Button
                              type="button"
                              variant={refTypeFilter === type ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => { refTypeFilter = refTypeFilter === type ? null : type; refPage = 1 }}
                            >{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          </span>
                        {/each}
                      {/if}
                      {#if refTopYears.length > 0}
                        <div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
                          {#each refTopYears as [year, count]}
                            <Button
                              type="button"
                              variant={refYearFilter === year ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => { refYearFilter = refYearFilter === year ? null : year; refPage = 1 }}
                            >{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          {/each}
                        </div>
                      {/if}
                    </div>
                    {#if refFetching && refSlice.length === 0}
                      {@render cardPlaceholders(Math.min(PAGE_SIZE, Math.max(0, allRefDois.length - (refPage - 1) * PAGE_SIZE)))}
                    {:else}
                      <ol class="divide-y divide-border">
                        {#each refSlice as entry, idx (entry.id)}
                          {@render listedCard(entry, (refPage - 1) * PAGE_SIZE + idx)}
                        {/each}
                      </ol>
                    {/if}
                    {#if refTotalPages > 1}
                      <div class="px-5 pb-4">
                        <Pagination page={refPage} totalPages={refTotalPages} locale={bibLocale} onpage={p => refPage = p} />
                      </div>
                    {/if}
                    {@render bibFooter(refFiltered ?? refAllCached)}
                  </div>
                  {/if}
                {/if}
                <!-- A work's citations, paged and filtered by the server. The
                     filter counts are the whole list's and describe it
                     unfiltered, as an entity's works filters do. -->
                {#each orderedRelTypes.filter(t => t === 'IsReferencedBy') as relType (relType)}
                  {@const relPage      = relationPageByType[relType]    ?? 1}
                  {@const citeTopTypes = (citeFacets?.types ?? [])
                    .filter(({ type }) => WORK_TYPES.includes(type))
                    .slice(0, 3)
                    .map(({ type, works }) => [type, works])}
                  {@const NOW_YEAR = new Date().getFullYear()}
                  {@const citeTopYears = (citeFacets?.years ?? [])
                    .filter(({ year }) => year >= NOW_YEAR - 2 && year <= NOW_YEAR)
                    .map(({ year, works }) => [year, works])
                    .sort((a, b) => a[0] - b[0])}
                  {@const relSlice     = citePageEntries}
                  {@const relTotalPages = Math.max(1, Math.ceil((citeTotal ?? 0) / PAGE_SIZE))}
                  {#if citeLoading || (citeTotal ?? 0) > 0 || citeTypeFilter || citeYearFilter}
                  <div class="border border-border rounded-md overflow-hidden">
                    <div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
                      <h2 class="text-base font-semibold mr-auto">{humanizeRelationType(relType)}</h2>
                      {#if citeTopTypes.length > 0}
                        <!-- On a phone only the years, as for an entity's works;
                             a type filter in force stays. -->
                        {#each citeTopTypes as [type, count]}
                          <span class={citeTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
                            <Button
                              type="button"
                              variant={citeTypeFilter === type ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => { citeTypeFilter = citeTypeFilter === type ? null : type; relationPageByType = { ...relationPageByType, [relType]: 1 } }}
                            >{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          </span>
                        {/each}
                      {/if}
                      {#if citeTopYears.length > 0}
                        <div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
                          {#each citeTopYears as [year, count]}
                            <Button
                              type="button"
                              variant={citeYearFilter === year ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => { citeYearFilter = citeYearFilter === year ? null : year; relationPageByType = { ...relationPageByType, [relType]: 1 } }}
                            >{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          {/each}
                        </div>
                      {/if}
                    </div>
                    {#if citeLoading && citePageEntries.length === 0}
                      {@render cardPlaceholders(Math.min(PAGE_SIZE, citeTotal ?? Math.max(allRelationDoisByType['IsReferencedBy']?.length ?? 0, liveCiteCount ?? 0)))}
                    {:else}
                      <ol class="divide-y divide-border">
                        {#each relSlice as entry, idx (entry.id)}
                          {@const meta = parseMeta(entry.data)}
                          <WorkCard
                            {entry} {meta} index={idx}
                            {bibLocale} {copiedCiteId} {addedId}
                            isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                            hideVersionLink={false}
                            oncopy={() => copyCitation(entry)}
                            ondelete={undefined}
                            onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                          />
                        {/each}
                      </ol>
                    {/if}
                    {#if relTotalPages > 1}
                      <div class="px-5 pb-4">
                        <Pagination page={relPage} totalPages={relTotalPages} locale={bibLocale} onpage={p => { relationPageByType = { ...relationPageByType, [relType]: p } }} />
                      </div>
                    {/if}
                    {@render bibFooter(citePageEntries, { id: citeDoi, list: 'citations' })}
                  </div>
                  {/if}
                {/each}
              {:else}
                <!-- In the order the server sent them. It pages the whole list
                     by publication year, so sorting a page of fifty here by the
                     full date ordered each page against itself and none against
                     the next: Zenodo's first 764 pages are all one year, where
                     that sort rearranged every one of them and a reader stepping
                     between pages saw the dates jump about. -->
                {@const pageWorks = group.entries}
                <!-- Whatever the server sent, in its order and at its length.
                     Dropping the versions of other works here is what emptied
                     the later pages of a blog: the page had been sliced at a
                     position in a list that was 1,292 long, and what was left
                     after the drop was 649. The list that leaves them out is
                     the one the server pages, counts and totals. -->
                {@const filteredWorks = pageWorks}
                {@const isPaginatedWorks = isEntityPage && filteredWorks[0]?.idx > 0}
                {@const CURRENT_YEAR = new Date().getFullYear()}
                <!-- The counts are the whole list's, from /api/facets, and they
                     describe it unfiltered: how many works of this type the
                     entity has, not how many are left after the other filter.
                     A list the server would not count offers no filters. -->
                {@const topTypes = (worksFacets?.types ?? [])
                  .filter(({ type }) => WORK_TYPES.includes(type))
                  .slice(0, 3)
                  .map(({ type, works }) => [type, works])}
                {@const topYears = (worksFacets?.years ?? [])
                  .filter(({ year }) => year === CURRENT_YEAR || year === CURRENT_YEAR - 1 || year === CURRENT_YEAR - 2)
                  .map(({ year, works }) => [year, works])
                  .sort((a, b) => a[0] - b[0])}
                <!-- The server returns the filtered list, so what arrives is
                     already what the filters selected. -->
                {@const typeFilteredWorks = filteredWorks}
                <!-- The pages span whatever list the server is paging — the
                     entity's, or what a filter selected — and the slice is taken
                     at the page's place within the loaded window. -->
                {@const worksPageCount = !isPaginatedWorks
                  ? 1
                  : Math.max(1, Math.ceil(Math.max(worksTotal, worksWindow + filteredWorks.length) / PAGE_SIZE))}
                {@const worksSliceStart = (worksPage - 1) * PAGE_SIZE - worksWindow}
                {@const worksSlice = isPaginatedWorks ? typeFilteredWorks.slice(worksSliceStart, worksSliceStart + PAGE_SIZE) : typeFilteredWorks}
                <div class="border border-border rounded-md overflow-hidden">
                  {#if isPaginatedWorks}
                    <div class="px-5 py-3 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
                      <h2 class="text-base font-semibold mr-auto">{_(worksHeadingKey)}</h2>
                      {#if topTypes.length > 1}
                        <!-- On a phone only the years are offered: the type
                             filters took two more rows above the list. A
                             type filter in force stays, so it can be seen and
                             cleared. -->
                        {#each topTypes as [type, count]}
                          <span class={worksTypeFilter === type ? 'contents' : 'hidden sm:contents'}>
                            <Button
                              type="button"
                              variant={worksTypeFilter === type ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => setWorksFilter({ type: worksTypeFilter === type ? null : type })}
                            >{typeLabel(type, bibLocale)}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          </span>
                        {/each}
                      {/if}
                      {#if topYears.length > 0}
                        <div class="sm:ml-3 flex items-center gap-1.5 sm:border-l border-border sm:pl-3">
                          {#each topYears as [year, count]}
                            <Button
                              type="button"
                              variant={worksYearFilter === year ? 'default' : 'outline'}
                              size="sm"
                              onclick={() => setWorksFilter({ year: worksYearFilter === year ? null : year })}
                            >{year}<span class="ml-1 tabular-nums opacity-70 text-xs">{count}</span></Button>
                          {/each}
                        </div>
                      {/if}
                    </div>
                  {/if}
                  <ol class="divide-y divide-border transition-opacity {worksPageLoading ? 'opacity-50' : ''}">
                    {#each worksSlice as { entry }, sliceIdx (entry.id)}
                      {@const meta = parseMeta(entry.data)}
                      <WorkCard
                        {entry} {meta} index={(worksPage - 1) * PAGE_SIZE + sliceIdx}
                        {bibLocale} {copiedCiteId} {addedId}
                        isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                        oncopy={() => copyCitation(entry)}
                        ondelete={undefined}
                        onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                      />
                    {/each}
                  </ol>
                  {#if worksPageCount > 1}
                    <div class="px-5 pb-4">
                      <Pagination
                        page={worksPage}
                        totalPages={worksPageCount}
                        locale={bibLocale}
                        onpage={goToWorksPage}
                        href={worksPageHref}
                      />
                    </div>
                  {/if}
                  <!-- The server exports the list as shown, filters and all. -->
                  {@render bibFooter(typeFilteredWorks.map(e => e.entry), worksExportId)}
                </div>
              {/if}
            {:else if group.type === 'Organization'}
              {#each group.entries as { entry, idx } (entry.id)}
                {@const org = parseOrgMeta(entry.data)}
                {@const worksCount = isEntityPage && idx === 0 ? Math.max(liveWorksCount ?? 0, bibliography.length - 1) : 0}
                <div class="border border-border rounded-md overflow-hidden">
                  {#if isEntityPage && idx === 0}
                    <div class="px-5 py-3 border-b border-border bg-muted/50">
                      <h2 class="text-lg font-semibold text-primary">{_('entity.organization')}</h2>
                    </div>
                  {/if}
                  <OrgCard {entry} {org} {bibLocale} {worksCount} {copiedCiteId} {addedId}
                    isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                    oncopy={() => copyCitation(entry)}
                    ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
                    onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                  />
                </div>
              {/each}
            {:else if group.type === 'Container'}
              {#each group.entries as { entry, idx } (entry.id)}
                {@const container = parseContainerMeta(entry.data)}
                {@const worksCount = isEntityPage && idx === 0
                  ? Math.max(worksTotal, bibliography.slice(1).filter(notAVersion).length)
                  : 0}
                <div class="border border-border rounded-md overflow-hidden">
                  {#if isEntityPage && idx === 0}
                    <div class="px-5 py-3 border-b border-border bg-muted/50">
                      <h2 class="text-lg font-semibold text-primary">{container?.type ? _(`container.type.${container.type}`, { default: container.type }) : _('entity.container')}</h2>
                    </div>
                  {/if}
                  <ContainerCard {entry} {container} {bibLocale} {worksCount} {copiedCiteId} {addedId}
                    isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                    oncopy={() => copyCitation(entry)}
                    ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
                    onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                  />
                </div>
              {/each}
            {:else}
              {#each group.entries as { entry, idx } (entry.id)}
                {@const worksCount = isEntityPage && idx === 0 ? Math.max(liveWorksCount ?? 0, bibliography.length - 1) : 0}
                <div class="border border-border rounded-md overflow-hidden">
                  {#if isEntityPage && idx === 0}
                    <div class="px-5 py-3 border-b border-border bg-muted/50">
                      <h2 class="text-lg font-semibold text-primary">{_('entity.person')}</h2>
                    </div>
                  {/if}
                  <PersonCard {entry} {bibLocale} {worksCount} {copiedCiteId} {addedId}
                    isSaved={savedItems.some(item => item.id === entityId(entry.data))}
                    oncopy={() => copyCitation(entry)}
                    ondelete={idx === 0 ? undefined : () => deleteEntry(entry.id)}
                    onadd={(id, data) => addToSaved(id, data)} savedFull={savedItems.length >= MAX_SAVED}
                  />
                </div>
              {/each}
              {#if isEntityPage && group.entries.length > 0}
                {@const personAffs = parsePersonAffiliations(group.entries[0].entry.data)}
                {#if personAffs.length > 0}
                  <div class="border border-border rounded-md overflow-hidden">
                    <div class="px-5 py-3 border-b border-border bg-muted/50">
                      <h2 class="text-base font-semibold">{_('person.affiliations')}</h2>
                    </div>
                    <div>
                      {#each personAffs as aff}
                        <div class="px-5 py-4 space-y-0.5">
                          <p class="text-sm font-medium inline-flex items-center gap-0.5">
                            {#if aff.identifier?.startsWith('https://ror.org/')}
                              <a href={entityPath(aff.identifier)} class="hover:underline">{aff.name}</a><img src={rorSvg} width="14" height="14" alt="ROR" class="ml-0.5 inline-block align-middle opacity-70 shrink-0" />
                            {:else}
                              {aff.name}
                            {/if}
                          </p>
                          {#if aff.role}
                            <p class="text-sm text-muted-foreground">{aff.role}</p>
                          {/if}
                          {#if aff.start_date || aff.end_date}
                            <div class="text-sm text-muted-foreground">
                              {#if aff.start_date && aff.end_date}
                                {formatAffDate(aff.start_date)} – {formatAffDate(aff.end_date)}
                              {:else if aff.start_date}
                                {_('person.affiliation_started')} {formatAffDate(aff.start_date)}
                              {:else}
                                {_('person.affiliation_ended')} {formatAffDate(aff.end_date)}
                              {/if}
                            </div>
                          {/if}
                        </div>
                      {/each}
                    </div>
                  </div>
                {/if}
              {/if}
            {/if}
          {/each}

          {#if personWorksLoading}
            <div class="border border-border rounded-md overflow-hidden">
              <div class="px-5 py-3 border-b border-border bg-muted/50">
                <h2 class="text-base font-semibold">{_(worksHeadingKey)}</h2>
              </div>
              <!-- A page of works is PAGE_SIZE cards, fewer for an entity
                   known to have fewer. -->
              {@render cardPlaceholders(Math.min(PAGE_SIZE, liveWorksCount ?? PAGE_SIZE))}
            </div>
          {/if}

        </div>
      {/if}

      <!-- No-results info message -->
      {#if !isEntityPage && searchSettled && searchHits.length === 0 && bibliography.length === 0}
        <div class="mt-4 rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 px-5 py-4 text-sm text-blue-800 dark:text-blue-300">
          {_('bibliography.no_results')} "{bibDoi}"
          {#if searchFiltersActive}
            <button type="button" onclick={clearSearchFilters} class="ml-1 underline hover:no-underline">
              {_('search.clear_filters')}
            </button>
          {/if}
        </div>
      {/if}

    </section>

    <!-- ── Search results ───────────────────────────────────────────────── -->
    <!-- The facets on the left, the page of results on the right. The sidebar
         is hidden below lg, where it would push the results off the screen;
         there the filters in force still show as chips above the list, so one
         picked on a desktop can be seen and cleared on a phone. -->
    {#if searchShown}
      <section class="flex gap-8 items-start">
        {#if searchFacetsShown}
          <aside class="w-52 shrink-0 hidden lg:block sticky top-20 self-start" aria-label={_('search.filters')}>
            {#each searchKindFacets as facet}
              {@const counts = searchFacets[facet.counts] ?? []}
              {#if counts.length > 0}
                <div class="mb-5">
                  <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{_(facet.heading)}</p>
                  <ul class="space-y-0.5 text-sm">
                    {#each counts as count}
                      <!-- What the link asks for. A subject is asked for by
                           its code, which is what an index built since counts
                           and what a link carries either way; everything else
                           is asked for as it was counted. -->
                      {@const value = searchFacetValue(facet.key, count.value)}
                      {@const picked = isSearchFilterPicked(searchFilters, facet.key, value)}
                      <li>
                        <!-- A real link, so a filtered list can be opened in a
                             new tab or shared; the click is handled here and the
                             URL written by toggleSearchFacet. A box rather than
                             a plain row, because several values of one facet
                             can be picked at once and nothing else would say
                             so. -->
                        <a
                          href={searchHref({ filters: toggleSearchFilter(searchFilters, facet.key, value), page: 1 })}
                          onclick={e => {
                            if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
                            e.preventDefault()
                            toggleSearchFacet(facet.key, value)
                          }}
                          aria-current={picked ? 'true' : undefined}
                          class="flex items-start gap-2 rounded px-1.5 py-1 -mx-1.5 transition-colors
                                 {picked ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
                        >
                          <span
                            aria-hidden="true"
                            class="mt-[3px] shrink-0 w-3.5 h-3.5 rounded-sm border flex items-center justify-center
                                   {picked ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40'}"
                          >
                            {#if picked}
                              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" class="w-2.5 h-2.5">
                                <path d="M20 6 9 17l-5-5" />
                              </svg>
                            {/if}
                          </span>
                          <span class="flex-1 min-w-0 break-words">{searchFacetLabel(facet.key, value)}</span>
                          <span class="shrink-0 text-xs tabular-nums opacity-70 mt-px">{formatCount(count.count, bibLocale)}</span>
                        </a>
                      </li>
                    {/each}
                  </ul>
                </div>
              {/if}
            {/each}
          </aside>
        {/if}

        <div class="flex-1 min-w-0">
          <div class="flex items-baseline gap-3 flex-wrap mb-3">
            <h2 class="text-base font-semibold">
              {_(searchCountLabel, { values: { count: formatCount(searchTotal, bibLocale) } })}
            </h2>
            {#if searchPageCount > 1}
              <!-- ml-auto: the page sits against the right edge of the list,
                   away from the count, and wraps under it on a narrow screen. -->
              <span class="ml-auto text-sm text-muted-foreground">
                {_('search.page_of', { values: { page: formatCount(searchPage, bibLocale), pages: formatCount(searchPageCount, bibLocale) } })}
              </span>
            {/if}
          </div>

          <!-- What the list is narrowed by, above the list and at every width.
               It used to show below lg only, on the reasoning that the sidebar
               said it already — but the sidebar's counts stay deliberately
               unnarrowed, so picking a value left it looking untouched, and a
               value whose count is most of the list (696 of 708 Berlin
               organizations are German) changed little else. The filters now
               say so themselves, and each chip clears its own. -->
          {#if searchFiltersActive}
            <div class="flex flex-wrap items-center gap-1.5 mb-3">
              <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{_('search.filters')}</span>
              {#each searchKindFacets as facet}
                <!-- One chip per value, each clearing its own: a facet may hold
                     several, and a chip that cleared the whole facet would take
                     away more than it names. -->
                {#each searchFilterValues(searchFilters, facet.key) as value}
                  <button
                    type="button"
                    onclick={() => toggleSearchFacet(facet.key, value)}
                    aria-label={`${_(facet.heading)}: ${searchFacetLabel(facet.key, value)} — ${_('search.clear_filters')}`}
                    class="inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:border-primary/60"
                  >
                    <span class="text-muted-foreground">{_(facet.heading)}</span>
                    {searchFacetLabel(facet.key, value)}
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="w-3 h-3 opacity-60">
                      <path d="M18 6 6 18M6 6l12 12" />
                    </svg>
                  </button>
                {/each}
              {/each}
              <button
                type="button"
                onclick={clearSearchFilters}
                class="ml-1 text-xs text-primary hover:underline"
              >{_('search.clear_filters')}</button>
            </div>
          {/if}

          {#if searchHits.length > 0 || searchLoading}
          <div class="border border-border rounded-md overflow-hidden">
            {#if searchLoading && searchEntries.length === 0 && searchHits.length === 0}
              {@render cardPlaceholders(Math.min(SEARCH_PAGE_SIZE, 5))}
            {:else if searchKind === 'works'}
              <!-- The same cards as every other list of works, numbered as a
                   list of references is: the ordinals run on across the pages,
                   so the fourth page starts at 31 rather than at 1 again.
                   No rule between them — the numbers already say where one
                   work ends and the next begins, and a list of ten rules
                   reads as a table of one column. -->
              <ol>
                {#each searchEntries as entry, i (entry.id)}
                  {@render listedCard(entry, (searchPage - 1) * SEARCH_PAGE_SIZE + i)}
                {/each}
              </ol>
            {:else}
              <!-- A person, an organization or a container: named and linked,
                   since none of them is a work and the work card would render
                   empty for them. -->
              <ol>
                <!-- Keyed by position, not by id: two container rows can
                     still map to the same ISSN while the store holds
                     duplicates, and a repeated key would throw and blank the
                     list. The page is replaced whole, so position is the
                     stable key here. -->
                {#each searchHits as hit, i (i)}
                  <li class="flex items-start gap-3 px-5 py-4">
                    <!-- The ordinal in the work card's own shape, so the four
                         kinds of result read alike: it runs on across the
                         pages, and the column grows rather than clipping once
                         a list runs into the thousands. -->
                    <span class="text-xs text-muted-foreground min-w-5 shrink-0 pt-0.5 text-right tabular-nums">{formatCount((searchPage - 1) * SEARCH_PAGE_SIZE + i + 1, bibLocale)}.</span>
                    <div class="flex-1 min-w-0 space-y-0.5">
                      <p class="text-base font-semibold leading-snug">
                        <a href={entityPath(hit.id)} class="hover:underline">{hit.title || hit.id}</a>
                      </p>
                      <!-- Headed fields, in the organization card's own shape,
                           so a result and the page it leads to read alike. The
                           names are the acronym and ROR's aliases, capped,
                           because a university can carry seven and a row is a
                           row, not a record. -->
                      <!-- A container's other titles, unlabelled under its own,
                           as the container card has them. -->
                      {#if hit.entity_type === 'Container' && hit.also_known_as?.length > 0}
                        <p class="text-sm text-muted-foreground">{hit.also_known_as.slice(0, SEARCH_ALSO_KNOWN_AS).join(' · ')}{hit.also_known_as.length > SEARCH_ALSO_KNOWN_AS ? ' …' : ''}</p>
                      {/if}
                      <!-- The names, then the biography, then where they are —
                           the person card's own order. Two lists rather than one
                           because the biography sits between them and a `dl` may
                           not hold a bare paragraph; the spacing is the same
                           either way. -->
                      {#if hit.given_name || hit.family_name || (hit.also_known_as?.length > 0 && hit.entity_type !== 'Container')}
                        <dl class="mt-2 space-y-2 text-sm">
                          {#if hit.given_name}
                            <div>
                              <dt class="font-semibold text-foreground">{_('person.given_name')}</dt>
                              <dd class="text-muted-foreground">{hit.given_name}</dd>
                            </div>
                          {/if}
                          {#if hit.family_name}
                            <div>
                              <dt class="font-semibold text-foreground">{_('person.family_name')}</dt>
                              <dd class="text-muted-foreground">{hit.family_name}</dd>
                            </div>
                          {/if}
                          {#if hit.also_known_as?.length > 0 && hit.entity_type !== 'Container'}
                            <div>
                              <!-- Both cards call this the same thing; the key
                                   differs only in which half of the file it is in. -->
                              <dt class="font-semibold text-foreground">{_(hit.entity_type === 'Person' ? 'person.additional_names' : 'org.additional_names')}</dt>
                              <dd class="text-muted-foreground">{hit.also_known_as.slice(0, SEARCH_ALSO_KNOWN_AS).join(' · ')}{hit.also_known_as.length > SEARCH_ALSO_KNOWN_AS ? ' …' : ''}</dd>
                            </div>
                          {/if}
                        </dl>
                      {/if}
                      <!-- A person's biography, unlabelled as the card leaves it
                           — it reads as prose, not as a field. Three lines in a
                           list where the card allows six: a row is a row. -->
                      {#if hit.description}
                        <p class="mt-2 text-sm text-muted-foreground line-clamp-3">{hit.description}</p>
                      {/if}
                      {#if hit.publisher || hit.location || hit.country || hit.types?.length > 0 || hit.identifiers?.length > 0}
                        <dl class="mt-2 space-y-2 text-sm">
                          {#if hit.publisher}
                            <div>
                              <dt class="font-semibold text-foreground">{_('container.publisher')}</dt>
                              <dd class="text-muted-foreground">
                                <!-- A ROR-identified publisher links to its page
                                     here, as on the card; any other id is text. -->
                                {#if hit.publisher_id?.startsWith('https://ror.org/')}
                                  <a href={entityPath(hit.publisher_id)} class="hover:underline">{hit.publisher}</a><img src={rorSvg} width="14" height="14" alt="ROR" class="ml-0.5 inline-block align-middle opacity-70 shrink-0" />
                                {:else}
                                  {hit.publisher}
                                {/if}
                              </dd>
                            </div>
                          {/if}
                          {#if hit.location}
                            <!-- The place, then the country it is in, which
                                 leads to the other organizations there — the
                                 organization card's own split. ROR names the
                                 country in the place itself ("Berlin, State of
                                 Berlin, Germany"), so the last part is dropped
                                 in favour of the code's own name where the row
                                 carries the code. -->
                            {@const parts = hit.location.split(', ')}
                            {@const place = hit.country ? parts.slice(0, -1).join(', ') : hit.location}
                            {@const country = hit.country ? searchLocationLabel(hit.country, bibLocale) : ''}
                            <div>
                              <dt class="font-semibold text-foreground">{_('org.location')}</dt>
                              <dd class="text-muted-foreground">{#if place}{searchLocationLabel(place, bibLocale)}{/if}{#if place && country}{', '}{/if}{#if country}<a href={searchHrefFor({ q: SEARCH_ALL, kind: 'organizations', filters: { country: [hit.country] } })} class="hover:underline">{country}</a>{/if}</dd>
                            </div>
                          {/if}
                          {#if hit.country && hit.entity_type !== 'Organization'}
                            <div>
                              <dt class="font-semibold text-foreground">{_('entity.country')}</dt>
                              <!-- A person's country leads to everyone in it, as
                                   their own card's does. Only a person's: a
                                   container carries a country too, and there is
                                   no facet to count containers by. -->
                              <dd class="text-muted-foreground">{#if hit.entity_type === 'Person'}<a href={searchHrefFor({ q: SEARCH_ALL, kind: 'people', filters: { country: [hit.country] } })} class="hover:underline">{searchLocationLabel(hit.country, bibLocale)}</a>{:else}{searchLocationLabel(hit.country, bibLocale)}{/if}</dd>
                            </div>
                          {/if}
                          {#if hit.types?.length > 0}
                            <div>
                              <dt class="font-semibold text-foreground">{_('org.types')}</dt>
                              <dd class="text-muted-foreground">{hit.types.map(t => _(`org.type.${t}`, { default: capitalizeWord(t) })).join(' · ')}</dd>
                            </div>
                          {/if}
                          <!-- What else this container is known as: every
                               identifier but the one the row links by. -->
                          {#if hit.identifiers?.length > 0}
                            <div>
                              <dt class="font-semibold text-foreground">{_('container.also_known_as')}</dt>
                              <dd class="text-muted-foreground space-y-0.5">
                                {#each hit.identifiers.slice(0, SEARCH_IDENTIFIERS) as id}
                                  {@const link = searchIdentifierLink(id.identifier, id.identifier_type)}
                                  <div class="leading-5">
                                    {#if link.href}
                                      <a href={link.href} target="_blank" rel="noreferrer" class="text-primary hover:underline wrap-anywhere max-w-full inline-flex items-center gap-1 [&>svg]:shrink-0">{#if link.doi}<Icon icon="academicons:doi" width="16" height="16" color="#fab608" class="shrink-0" />{/if}{link.label}</a>
                                    {:else}
                                      {link.label}
                                    {/if}
                                  </div>
                                {/each}
                                {#if hit.identifiers.length > SEARCH_IDENTIFIERS}
                                  <div class="leading-5">…</div>
                                {/if}
                              </dd>
                            </div>
                          {/if}
                        </dl>
                      {/if}
                      <!-- How many works the entity holds, versions left out —
                           the number its own page shows. A blog's are blog
                           posts, as on the card. Drawn only when the store knows
                           of some: most organizations have no works edges, and
                           "0 works" would contradict a page that fetches them
                           from Crossref. -->
                      {#if hit.works > 0}
                        {@const worksKey = hit.entity_type === 'Organization'
                          ? 'org.works'
                          : hit.container_type === 'Blog' ? 'container.blog_posts' : 'container.works'}
                        <p class="mt-2 text-sm text-muted-foreground/60">
                          {_(worksKey, { values: { count: hit.works } }).replace(String(hit.works), formatCount(hit.works, bibLocale))}
                        </p>
                      {/if}
                      <!-- The identifier in full, linked where it is described,
                           with its logo — the same way each card ends. -->
                      {#if hit.entity_type === 'Organization'}
                        <a href={`https://ror.org/${hit.id}`} target="_blank" rel="noreferrer"
                           class="text-sm text-primary hover:underline inline-flex items-center gap-1.5 pt-1 wrap-anywhere max-w-full">
                          <img src={rorSvg} width="14" height="14" alt="ROR" class="inline-block align-middle shrink-0" />https://ror.org/{hit.id}
                        </a>
                      {:else if hit.entity_type === 'Person'}
                        <a href={`https://orcid.org/${hit.id}`} target="_blank" rel="noreferrer"
                           class="text-sm text-primary hover:underline inline-flex items-center gap-1.5 pt-1 wrap-anywhere max-w-full">
                          <img src={orcidSvg} width="14" height="14" alt="ORCID" class="inline-block align-middle shrink-0" />https://orcid.org/{hit.id}
                        </a>
                      {:else if hit.entity_type !== 'Container'}
                        <p class="text-xs text-muted-foreground font-mono">{hit.id}</p>
                      {/if}
                    </div>
                  </li>
                {/each}
              </ol>
            {/if}
          </div>
          {/if}

          {#if searchPageCount > 1}
            <Pagination
              page={searchPage}
              totalPages={searchPageCount}
              locale={bibLocale}
              href={p => searchHref({ page: p })}
              onpage={p => goToSearchPage(p)}
            />
          {/if}
        </div>
      </section>
    {/if}

    <!-- ── Docs (homepage only) ─────────────────────────────────────────── -->
    <!-- Stood down while a search is being read: the results are what the
         reader asked for, and the documentation below them was a long scroll
         between the last result and the footer. -->
    {#if !isEntityPage && !searchShown}
    <div class="flex gap-10 items-start">
      <aside class="w-48 shrink-0 hidden lg:block sticky top-20 self-start">
        <nav class="text-sm">
          <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Contents</p>
          <ul class="space-y-1.5">
            <li><a href="#inspiration" class="text-muted-foreground hover:text-foreground transition-colors block">Inspiration</a></li>
            <li><a href="#implementations" class="text-muted-foreground hover:text-foreground transition-colors block">Implementations</a></li>
            <li><a href="#json-schema" class="text-muted-foreground hover:text-foreground transition-colors block">JSON Schema</a></li>
            <li><a href="#local-pid-database" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.database.title')}</a></li>
            <li><a href="#search" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.search.title')}</a></li>
            <li><a href="#history-bookmarks" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.saved.title')}</a></li>
            <li><a href="#keyboard-shortcuts" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.shortcuts.title')}</a></li>
            <li><a href="#supported-formats" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.formats.title')}</a></li>
            <li><a href="#content-negotiation" class="text-muted-foreground hover:text-foreground transition-colors block">{_('docs.content_negotiation.title')}</a></li>
          </ul>
        </nav>
      </aside>
      <div class="flex-1 min-w-0">
        <!-- What there is to search, in the numbers a search would return —
             they come from the index, so they say what can be found rather
             than what is stored. In the documentation's column rather than the
             page's, so it starts where the prose below it does, and outside
             the prose itself, which would size it as body text. Drawn only
             once the counts arrive, so the line does not appear and then
             change. -->
        {#if storeCounts && storeCounts.works > 0}
          <p class="mb-2 text-base font-semibold text-muted-foreground">
            {_('search.store_counts', { values: {
              works:         formatCount(storeCounts.works, bibLocale),
              people:        formatCount(storeCounts.people, bibLocale),
              organizations: formatCount(storeCounts.organizations, bibLocale),
              containers:    formatCount(storeCounts.containers, bibLocale),
            } })}
          </p>
        {/if}
        <div class="prose prose-sm prose-slate max-w-none
                    [&_table]:block [&_table]:overflow-x-auto
                    [&_h2]:text-primary [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-1 [&_h2]:scroll-mt-20
                    [&_h3]:text-foreground
                    [&_a]:text-primary [&_pre]:bg-muted [&_pre]:border [&_pre]:border-border
                    [&_pre]:text-sm [&_pre]:text-gray-800 dark:[&_pre]:text-gray-200">
          <Homepage />
        </div>
      </div>
    </div>
    {/if}

  </main>

  <!-- Footer -->
  <footer class="py-6 text-xs text-gray-700 dark:text-gray-300 leading-5">
    <div class="max-w-full md:max-w-3xl lg:max-w-5xl mx-auto px-4 sm:px-6 flex items-start justify-between">
      <span class="[&_a]:hover:text-gray-900 dark:[&_a]:hover:text-gray-100 [&_a]:transition-colors">
        <span class="block">{@html _('footer.copyright')}</span>
        <span class="block">{@html _('footer.built_with')}</span>
      </span>
      <span class="flex items-center gap-3 pl-3 shrink-0">
        <a href="https://docs.commonmeta.org" target="_blank" rel="noreferrer"
           class="whitespace-nowrap hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
          {_('footer.documentation')}
        </a>
        <a href="mailto:info@front-matter.de" aria-label="Email"
           class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
          </svg>
        </a>
        <a href="https://hachyderm.io/@mfenner" target="_blank" rel="noreferrer" aria-label="Mastodon"
           class="hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
            <path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a4 4 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522q0-1.288.66-2.046c.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764q.662.757.661 2.046z"/>
          </svg>
        </a>
      </span>
    </div>
  </footer>

</div>

<HistorySheet bind:open={historyOpen} bind:tab={historyTab} bind:this={historySheet} onadd={addFromHistory} {savedItems} onremovesaved={removeFromSaved} onreordersaved={moveSaved} onclearsaved={() => { savedItems = [] }} onimportsaved={importSaved} {linkImport} onlinkimport={importFromLink} ondismisslinkimport={forgetLinkImport} savedFull={savedItems.length >= MAX_SAVED} locale={bibLocale} />

<style>
  @keyframes fetch-fill {
    from { width: 0%; }
    to   { width: 100%; }
  }
  .fetch-progress {
    width: 0%;
    animation: fetch-fill 1s ease-out forwards;
  }
</style>