File size: 64,655 Bytes
3cbd956
 
 
 
 
 
 
 
 
91ce4c8
 
3cbd956
 
 
 
91ce4c8
 
3cbd956
 
 
8193284
3cbd956
91ce4c8
 
 
 
 
 
 
 
 
 
 
 
 
 
3cbd956
91ce4c8
 
 
 
 
 
 
 
 
 
 
 
 
 
8193284
 
91ce4c8
 
 
 
 
 
 
8193284
 
 
 
 
3866bed
8193284
f657b3d
8193284
 
 
91ce4c8
 
 
3cbd956
91ce4c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cbd956
91ce4c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3866bed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6788ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
043a903
f6788ad
 
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cbd956
 
 
 
f6788ad
 
 
1f9fa09
 
 
 
4e37c2b
 
1f9fa09
 
 
 
 
f6788ad
3cbd956
 
91ce4c8
3cbd956
f6788ad
043a903
f6788ad
 
 
 
3cbd956
f6788ad
 
 
043a903
a7b43cf
3cbd956
 
91ce4c8
1f9fa09
f6788ad
3866bed
f6788ad
3866bed
91ce4c8
3cbd956
 
 
f6788ad
 
1f9fa09
043a903
1f9fa09
 
3866bed
1f9fa09
f6788ad
3866bed
 
f6788ad
1f9fa09
f6788ad
3cbd956
91ce4c8
 
8193284
3866bed
8193284
3866bed
8193284
 
 
91ce4c8
 
 
f6788ad
91ce4c8
3866bed
91ce4c8
 
3cbd956
 
 
f6788ad
3cbd956
ddfb9e0
 
 
 
 
 
 
 
 
 
 
 
 
3cbd956
f6788ad
1f9fa09
3cbd956
 
91ce4c8
3cbd956
 
 
 
3866bed
f6788ad
3cbd956
91ce4c8
f6788ad
 
3cbd956
f6788ad
 
 
 
 
 
 
 
043a903
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f9fa09
 
91ce4c8
 
 
3866bed
f6788ad
3866bed
f6788ad
3866bed
91ce4c8
f6788ad
3866bed
 
f6788ad
 
 
 
 
3cbd956
f6788ad
3866bed
f6788ad
 
91ce4c8
f6788ad
 
 
3866bed
f6788ad
1f9fa09
f6788ad
3866bed
91ce4c8
1f9fa09
 
 
 
 
 
 
 
3866bed
 
 
f6788ad
 
 
3866bed
f6788ad
 
 
 
 
 
 
 
 
 
 
 
91ce4c8
3866bed
 
 
 
 
 
 
 
 
f6788ad
 
3866bed
 
 
 
 
 
 
 
 
 
 
f657b3d
 
3866bed
 
 
f657b3d
3866bed
f657b3d
3866bed
 
 
f6788ad
 
91ce4c8
f6788ad
 
 
 
 
 
 
 
 
3866bed
 
 
 
91ce4c8
f6788ad
8193284
 
 
91ce4c8
f6788ad
 
043a903
 
f6788ad
 
 
1f9fa09
 
 
 
 
 
 
f6788ad
 
 
 
1f9fa09
f6788ad
 
 
 
 
3cbd956
f6788ad
ddfb9e0
f6788ad
1f9fa09
f6788ad
043a903
f6788ad
 
3cbd956
f6788ad
 
043a903
f6788ad
 
ddfb9e0
043a903
f6788ad
 
 
bfcbe6e
1f9fa09
 
 
f6788ad
 
 
1f9fa09
 
 
 
 
 
f6788ad
 
1f9fa09
3cbd956
3866bed
 
f6788ad
 
 
9705463
f6788ad
1f9fa09
f6788ad
1f9fa09
 
 
 
 
 
 
f6788ad
1f9fa09
3866bed
f6788ad
9705463
 
 
 
 
 
 
 
f6788ad
91ce4c8
 
 
 
 
1f9fa09
 
 
 
 
 
 
 
 
91ce4c8
 
 
1f9fa09
3866bed
 
 
 
91ce4c8
f6788ad
91ce4c8
1f9fa09
 
 
f6788ad
91ce4c8
 
f6788ad
91ce4c8
f6788ad
a7b43cf
1f9fa09
 
f6788ad
1f9fa09
 
 
043a903
f6788ad
 
91ce4c8
1f9fa09
3866bed
91ce4c8
 
 
1f9fa09
91ce4c8
3cbd956
 
f6788ad
 
 
 
 
1f9fa09
f6788ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91ce4c8
f6788ad
91ce4c8
 
 
3cbd956
 
3866bed
f6788ad
91ce4c8
1f9fa09
3866bed
f6788ad
1f9fa09
91ce4c8
 
 
8193284
91ce4c8
1f9fa09
91ce4c8
1f9fa09
3866bed
91ce4c8
 
3866bed
91ce4c8
1f9fa09
91ce4c8
f6788ad
91ce4c8
 
3cbd956
3866bed
 
 
 
3cbd956
91ce4c8
f6788ad
 
 
 
 
 
91ce4c8
 
 
 
 
 
3cbd956
8193284
 
91ce4c8
3866bed
f6788ad
3866bed
 
 
 
 
 
f6788ad
91ce4c8
3866bed
 
 
f6788ad
 
3cbd956
91ce4c8
 
 
f6788ad
3866bed
91ce4c8
 
 
 
 
f6788ad
 
 
 
 
 
91ce4c8
 
 
1f9fa09
 
 
 
 
 
ddfb9e0
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9705463
 
 
 
 
 
 
 
1f9fa09
 
9705463
 
 
 
1f9fa09
 
 
 
3866bed
1f9fa09
3866bed
043a903
1f9fa09
3866bed
 
1f9fa09
 
3866bed
f6788ad
3866bed
 
 
 
 
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
 
1f9fa09
 
a7b43cf
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
 
 
 
 
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
 
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
1f9fa09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7b43cf
f6788ad
 
1f9fa09
 
f6788ad
1f9fa09
 
 
 
 
f6788ad
1f9fa09
 
3866bed
 
 
f6788ad
043a903
 
 
 
 
 
 
 
 
3866bed
f6788ad
ddfb9e0
1f9fa09
 
 
 
 
3866bed
1f9fa09
 
 
 
 
 
 
 
3866bed
 
3cbd956
 
 
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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Topic Feed Lab</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
  --bg:#FBFBFA; --ink:#1F2328; --muted:#656D76; --faint:#9AA1A9; --line:#E3E5E8; --soft:#F1F3F5;
  --bar:#D3D7DC; --accent:#3B6EA5; --ok:#2E7D4F; --bad:#B0453A;
  color-scheme:light;
}
@media (prefers-color-scheme: dark){
  :root{
    --bg:#111418; --ink:#E4E7EB; --muted:#9BA3AD; --faint:#6B737D; --line:#2A2F36; --soft:#1A1E24;
    --bar:#3A4048; --accent:#7FA9D8; --ok:#5DBB84; --bad:#E07A6E; color-scheme:dark;
  }
}
*{box-sizing:border-box}
[hidden]{display:none !important}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 "IBM Plex Sans",system-ui,-apple-system,Segoe UI,sans-serif}
.wrap{max-width:1180px;margin:0 auto;padding:24px 16px 56px}
.mono,.num{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
h1{font-size:22px;font-weight:600;margin:0}
h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0;font-weight:600}
.lede{color:var(--muted);margin:2px 0 0;max-width:78ch;font-size:14px}
a{color:inherit}
button{font:inherit;color:inherit;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.row{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}
.btn{font-size:14px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);border-radius:6px;padding:6px 13px;font-weight:500}
.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}
.btn.small{font-size:12.5px;padding:3px 9px}
.link{border:0;background:none;padding:0;color:var(--muted);font-size:13px;text-decoration:underline;text-underline-offset:2px}
.status{font-size:13px;color:var(--muted)}
input,select{font:inherit;font-size:14px;background:transparent;color:var(--ink);border:1px solid var(--line);border-radius:5px;padding:4px 7px}

/* controls */
.controls{display:grid;gap:12px;margin:18px 0 8px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.seg{display:inline-flex;gap:14px}
.seg button{border:0;background:none;padding:2px 0;font-size:14px;color:var(--muted);border-bottom:2px solid transparent}
.seg button[aria-pressed="true"]{color:var(--ink);font-weight:600;border-color:var(--ink)}
.topicbar{display:flex;flex-wrap:wrap;gap:4px 16px;align-items:baseline}
.tt{border:0;background:none;padding:1px 0;font-size:14px;color:var(--muted)}
.tt[aria-pressed="true"]{color:var(--ink);font-weight:500;box-shadow:inset 0 -2px 0 var(--accent)}
.tt .c{font-size:12px;color:var(--faint);margin-left:4px}

/* topic editor */
.editor{display:grid;gap:6px;padding:12px 0 4px}
.erow{display:grid;grid-template-columns:20px minmax(0,14rem) minmax(0,1fr) auto;gap:8px;align-items:center}
.erow input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);margin:0}
.erow input{width:100%}
.erow input.name{font-weight:500}
.erow input[aria-invalid="true"]{border-color:var(--bad)}
.erow .err{grid-column:1/-1;color:var(--bad);font-size:12.5px;margin-top:-2px}
.erow .x{border:0;background:none;color:var(--faint);font-size:18px;line-height:1;padding:2px 6px}
.erow .x:hover{color:var(--bad)}
.presets{font-size:13px;color:var(--muted)}
@media (max-width:640px){.erow{grid-template-columns:20px minmax(0,1fr) auto}.erow .desc{grid-column:2/3;grid-row:2}}
.summary{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline}
.summary .ft{font-size:14px}
.summary .ft .c{font-size:12px;color:var(--faint);margin-left:3px}
.tl{display:grid;border-top:1px solid var(--line)}
.tr{display:grid;grid-template-columns:minmax(0,1fr) 2rem 1.6rem 1.6rem 4.6rem auto;gap:8px;align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.tr .num{font-size:12px;color:var(--muted);text-align:right}
.tr .nm{overflow-wrap:anywhere}
.tlh{font-size:11px;color:var(--faint);border-bottom:0;padding:0}
button.tn{border:0;background:none;padding:0;text-align:left;font-size:13px}
button.tn:hover{color:var(--accent);text-decoration:underline}

/* layout */
.layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,360px);gap:36px;align-items:start;margin-top:10px}
@media (max-width:900px){.layout{grid-template-columns:minmax(0,1fr)}}
.feedhead{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;padding:6px 0}

/* feed items */
.item{padding:14px 0 12px;border-top:1px solid var(--line);display:grid;gap:6px}
.item .ttl{font-weight:600;text-decoration:none;overflow-wrap:anywhere}
.item .ttl:hover{text-decoration:underline}
.meta{font-size:12px;color:var(--faint);overflow-wrap:anywhere;margin-left:8px}
.snip{font-size:13.5px;color:var(--muted);margin:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.scores{display:grid;gap:1px;margin-top:2px}
.srow{display:grid;grid-template-columns:22px minmax(0,13rem) minmax(60px,160px) 2.6rem;gap:8px;align-items:center;font-size:13px}
.srow .tn{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar{position:relative;height:6px;background:var(--soft)}
.bar i{position:absolute;left:0;top:0;bottom:0;background:var(--bar)}
.bar i.on{background:var(--accent)}
.bar b{position:absolute;top:-3px;bottom:-3px;width:1px;background:var(--ink);opacity:.55}
.srow .num{font-size:12px;color:var(--muted);text-align:right}
.srow.on .tn,.srow.on .num{color:var(--ink)}
.tg{width:18px;height:18px;border:1.5px solid var(--faint);border-radius:4px;background:none;padding:0;font-size:12px;line-height:14px;text-align:center;color:transparent}
.tg[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.item.done .tg[aria-pressed="true"]{background:var(--ok);border-color:var(--ok);color:var(--bg)}
.item.done .tg[aria-pressed="false"]{border-color:var(--line)}
.ifoot{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;font-size:12.5px;color:var(--muted);margin-top:2px}
.ifoot .state{color:var(--faint)}
.item.done .ifoot .state{color:var(--ok)}
.ifoot .also{margin-left:auto;color:var(--faint)}
.empty{color:var(--muted);font-size:14px;padding:18px 0;border-top:1px solid var(--line)}

/* side */
aside{display:grid;gap:26px;position:sticky;top:12px}
.side{display:grid;gap:9px}
.side .hd{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.strip{width:100%;height:46px;display:block}
input[type=range]{width:100%;margin:0;accent-color:var(--accent);border:0;padding:0}
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:-4px}
.note{font-size:12.5px;color:var(--muted);margin:0}
.queue{display:grid;max-height:360px;overflow:auto;border-top:1px solid var(--line)}
.q{display:grid;grid-template-columns:auto minmax(0,1fr) 2.4rem;gap:8px;align-items:center;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
.q a{text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.q a:hover{text-decoration:underline}
.q .num{font-size:12px;color:var(--muted);text-align:right}
.yn{display:inline-flex;gap:3px}
.yn button{width:22px;height:20px;border:1px solid var(--line);background:none;border-radius:4px;padding:0;font-size:12px;line-height:18px;color:var(--faint)}
.yn .y[aria-pressed="true"]{background:var(--ok);border-color:var(--ok);color:var(--bg)}
.yn .n[aria-pressed="true"]{background:var(--bad);border-color:var(--bad);color:var(--bg)}
.meter{height:4px;background:var(--soft)}
.meter i{display:block;height:100%;background:var(--ok)}
.tsum{width:100%;border-collapse:collapse;font-size:12.5px}
.tsum td,.tsum th{padding:2px 0;text-align:left;font-weight:400}
.tsum th{color:var(--faint);font-size:11px}
.tsum td.num,.tsum th.num{text-align:right;width:3rem}
.warn{color:var(--bad)}

.cgroups{display:grid;gap:6px}
.cgroups details{border-top:1px solid var(--line);padding:6px 0 2px}
.cgroups summary{cursor:pointer;font-size:13px;color:var(--muted);list-style-position:outside}
.cgroups summary b{font-weight:600;color:var(--ink);letter-spacing:.02em}
.cgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(15rem,1fr));gap:2px 18px;padding:6px 0 4px}
.cgrid label{display:flex;gap:7px;align-items:center;font-size:13.5px;cursor:pointer}
.cgrid input{accent-color:var(--accent);margin:0}
.cust{font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);margin-left:4px}
.badge{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);margin-right:8px}
.badge.updated{color:var(--accent)}
.stream{font-size:12px;color:var(--faint);padding:2px 0 6px}
.flow{border-top:1px solid var(--line);padding:8px 0 10px;display:grid;gap:4px}
.flow canvas{width:100%;display:block}
.tr .src{font-size:11px;color:var(--faint);text-align:right}
.tr .src.marks{color:var(--ok)}
.tr .src.manual{color:var(--accent)}

.modes{display:inline-flex;gap:18px}
.modes button{border:0;background:none;padding:2px 0;font-size:16px;color:var(--muted);border-bottom:2px solid transparent;font-weight:500}
.modes button[aria-pressed="true"]{color:var(--ink);font-weight:600;border-color:var(--ink)}
.bctl{display:grid;gap:10px}
.chipx{display:inline-flex;gap:4px;align-items:baseline;font-size:14px}
.chipx button{border:0;background:none;padding:0 2px;color:var(--faint);font-size:15px;line-height:1}
.chipx button:hover{color:var(--bad)}
.sumbox{display:grid;gap:4px;padding:8px 0 10px;font-size:13px;color:var(--muted)}
.sumbox .co button{border:0;background:none;padding:0;color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px;font-size:13px}
.sumbox .co button:hover{text-decoration-color:var(--accent)}
.divider{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);padding:10px 0 2px}
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--accent);opacity:.35}
.srcl{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-right:8px}
.more{margin:12px 0}
.badge.co{color:var(--bad)}
.new-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:6px;vertical-align:middle}
input[type=search]{min-width:14rem}
.ticker{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);padding:4px 0 2px}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--faint);flex:none}
.pulse.on{background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.item.fresh{animation:fresh 5s ease-out}
@keyframes fresh{0%{background:color-mix(in srgb,var(--accent) 14%,transparent);transform:translateY(-6px)}15%{transform:none}100%{background:transparent}}
@media (prefers-reduced-motion: reduce){.pulse.on{animation:none}.item.fresh{animation:none;box-shadow:inset 3px 0 0 var(--accent)}}
.item.dim{opacity:.55}
.tlabels{font-size:12px;color:var(--faint)}
.tlabels b{font-weight:500;color:var(--muted)}
.tlabels b.mine{color:var(--accent)}
.gsel{font-size:12px;margin-left:8px}
.about{margin-top:10px;font-size:13.5px;color:var(--muted);max-width:80ch}
.about summary{cursor:pointer;color:var(--ink);font-size:13px}
.about p,.about ul{margin:6px 0}
.about ul{padding-left:18px}
</style>
</head>
<body>
<div class="wrap">
  <header class="row" style="justify-content:space-between;align-items:baseline">
    <div>
      <h1>Topic Feed Lab</h1>
      <p class="lede">New Hub models, datasets and papers, tagged with 48 topics. Follow the topics you care about in <b>Live</b>, or dig into one in <b>Browse</b>. Confirm the guesses on items to tune each topic for you.</p>
      <details class="about"><summary>About: how it works</summary>
        <p>A small open model, <a href="https://huggingface.co/fastino/GLiNER2.5-Decide" target="_blank" rel="noopener">fastino/GLiNER2.5-Decide</a>, reads each item once and gives it a score from 0 to 1 for each of 48 topics, in one call. It runs on CPU. It is a classifier, not an LLM: it does not write text.</p>
        <ul>
          <li><b>What is scored:</b> new and updated public Hub models and datasets that have a real card (at least a few sentences), and papers from Hugging Face Daily Papers. The first week also includes arXiv computer-science papers from a one-off backfill. Quantized and converted models take their base model's topics. Items with no README or an empty template card are skipped.</li>
          <li><b>When it shows up:</b> a worker process in this Space follows the Hub, checks whether each card changed, scores changed items and stores the scores, usually within a minute or two of the change. The page reads the stored scores.</li>
          <li><b>Thresholds:</b> an item is tagged with a topic when its score reaches that topic's threshold. The defaults are set so each topic tags about as many items as a larger LLM did on a reference sample.</li>
          <li><b>Your confirmations:</b> tick or untick the topics on an item and press Confirm. After 3 ✓ for a topic, its threshold is fitted to your confirmations. You can also set it by hand under calibrate. This is stored in your browser only.</li>
          <li><b>Caveats:</b> the topics are zero-shot, so some tags are wrong, and broad topics catch loosely related items. Likes and downloads are a snapshot from when the item was scored.</li>
        </ul>
      </details>
    </div>
  </header>

  <section class="controls" aria-label="Controls">
    <div class="row" style="justify-content:space-between">
      <div class="modes" id="modes" role="tablist" aria-label="Mode"></div>
      <span class="row" style="gap:12px"><span class="status" id="status">Connecting…</span><button class="link" id="copyLink" title="Copy a link to this view">copy link</button></span>
    </div>
    <div class="summary" id="summary"><h2 style="margin-right:4px">Your feed</h2><span id="feedTopics" class="summary"></span><button class="link" id="editBtn" aria-expanded="false">choose topics</button></div>
    <div class="bctl" id="bctl" hidden>
      <div class="summary"><h2 style="margin-right:4px">Browse</h2><span id="browseTopics" class="summary"></span><button class="link" id="bEditBtn" aria-expanded="false">add topics</button></div>
      <div class="row">
        <span class="seg" id="daysSeg" role="group" aria-label="Time range"></span>
        <span class="row" id="srcBoxes" style="gap:10px" role="group" aria-label="Sources"></span>
        <input type="search" id="q" placeholder="search title, id, snippet, tags" aria-label="Search">
        <label class="status" title="Repos whose card is up but that have no data or weight files yet"><input type="checkbox" id="coBrowse"> include card-only (no data yet)</label>
        <label class="status">sort <select id="bsort"><option value="score">topic score</option><option value="newest">newest</option><option value="likes">likes</option><option value="downloads">downloads / upvotes</option><option value="trending">trending</option></select></label>
      </div>
    </div>
    <div class="editor" id="editor" hidden>
      <div class="row"><h2 id="editorTitle">Choose your feed topics</h2><span class="gsel">select <button class="link" data-all="*">all</button> · <button class="link" data-none="*">none</button></span></div>
      <p class="note">All 48 catalogue topics are scored together, so their scores are comparable. Hover a name to see what it covers.</p>
      <div class="cgroups" id="cgroups"></div>
      <p class="note">Custom topics need the model, so they are coming via the worker.</p>
      <div class="row"><button class="btn small" id="doneBtn" style="margin-left:auto">Done</button></div>
    </div>
  </section>

  <div class="layout">
    <section aria-label="Items">
      <div class="feedhead"><h2 id="listTitle">Live</h2><span class="note row" style="gap:10px">
        <span class="seg" id="scopeSeg" role="group" aria-label="Which items"></span>
        <label class="status" title="Repos whose card is up but that have no data or weight files yet"><input type="checkbox" id="coLive"> card-only</label>
        <span id="feedCount"></span><button class="link" id="flowBtn" aria-expanded="true">hide how it works</button></span></div>
      <div class="ticker" id="tickerBox"><span class="pulse" id="pulse"></span><span id="ticker">Checking the worker…</span></div>
      <div class="stream mono" id="stream"></div>
      <div class="flow" id="flowBox">
        <canvas id="flow" height="160" aria-label="Replay of the latest arrivals moving through the worker, the model and your topics" role="img"></canvas>
        <div class="row note mono" style="justify-content:space-between"><span id="flowStats"></span><button class="link" id="replay">replay</button></div>
      </div>
      <div class="sumbox" id="sumbox" hidden></div>
      <div id="feed"><div class="empty">Loading about 28K scored items…</div></div>
      <button class="btn ghost small more" id="more" hidden>Show more</button>
    </section>

    <aside>
      <section class="side" id="topicPanel" aria-label="Feed topics">
        <div class="hd"><h2>Feed topics</h2><button class="link" id="editBtn2">choose</button></div>
        <div class="tl" id="tlist"></div>
        <p class="note">An item enters your feed when a topic's score reaches its threshold. The threshold comes from your confirmations once a topic has 3 ✓; before that it is the catalogue default. Moving the slider sets it by hand.</p>
      </section>
      <section class="side" id="calPanel" aria-label="Calibrate" hidden>
        <div class="hd"><h2>Calibrating: <span id="calName" style="color:var(--ink);text-transform:none;letter-spacing:0;font-size:14px"></span></h2><button class="btn ghost small" id="calDone">Done</button></div>
        <svg class="strip" id="strip" viewBox="0 0 300 46" preserveAspectRatio="none" role="img" aria-label="Score distribution"></svg>
        <input type="range" id="thr" min="0.01" max="0.99" step="0.01" aria-label="Threshold">
        <div class="axis mono"><span>0</span><span>threshold <span id="thrVal"></span></span><span>1</span></div>
        <p class="note">Ticks are this topic's scores for the items you have checked and the items on screen. Top row: you said it belongs. Bottom row: it doesn't. Middle: not checked.</p>
        <p class="note mono" id="calStats"></p>
        <div class="row"><button class="link" id="resetThr">use automatic threshold</button><button class="link" id="copy">copy my data</button></div>
        <div class="queue" id="queue"></div>
      </section>
    </aside>
  </div>
</div>

<script type="module">
// JSON API: GET /api/<name>. Arrays become repeated params (?topics=a&topics=b); objects are sent as JSON strings.
async function api(name, params = {}){
  const q = new URLSearchParams();
  for (const [k, v] of Object.entries(params)){
    if (v === undefined || v === null) continue;
    if (Array.isArray(v)) v.forEach(x => q.append(k, x));
    else if (typeof v === "object") q.append(k, JSON.stringify(v));
    else q.append(k, String(v));
  }
  const r = await fetch(`api/${name}?${q}`, {headers: {Accept: "application/json"}});
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
  return r.json();
}

const MIN_POS_FOR_MARKS = 3;   // positives needed before a topic's threshold comes from the user's confirmations
const PAGE = 30, POLL_MS = 20000, EVENTS_MS = 8000;

const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const store = {
  get(k, d){ try { const v = localStorage.getItem("tfl-" + k); return v ? JSON.parse(v) : d; } catch(e){ return d; } },
  set(k, v){ try { localStorage.setItem("tfl-" + k, JSON.stringify(v)); } catch(e){} }
};
const reduceMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const fmt = n => n == null ? "" : n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(n);

/* ---------- state ---------- */
let CAT = null, narrowOf = {};
let mode = store.get("mode", "live");
let selected = store.get("selected", ["agents and tool use", "cultural heritage and history", "documents and OCR"]);
let browseSel = store.get("browseSel", ["robotics"]);
let bDays = store.get("bDays", 7), bSources = store.get("bSources", ["model", "dataset", "paper"]), bSort = store.get("bSort", "score");
let manualThr = {};
// confirmed: {itemKey: {title, url, text, src, topics: [positives], considered: [topics judged], scores, cat}}
let confirmed = store.get("confirmed", {});
const prevVisit = store.get("lastVisit", null);   // shown as "new since your last visit"
let items = [], total = 0, offset = 0, lastLive = null, lastBrowse = null, calTopic = null, editing = null;
let marksThr = {};
let liveAll = store.get("liveAll", false), flowOpen = store.get("flowOpen", true), cardOnly = store.get("cardOnly", false);

/* ---------- deep links ----------
   ?view=live|browse&topics=a,b&items=your|all&days=7&sources=model,dataset&sort=likes&q=...&how=closed&cardonly=1
   URL params set this page view only; saved preferences change only when the user edits something. */
const SPACE_URL = "https://huggingface.co/spaces/davanstrien/topic-feed-lab";   // forwards its query to this app
(function readUrl(){
  const u = new URLSearchParams(location.search), list = k => (u.get(k) || "").split(",").map(x => x.trim()).filter(Boolean);
  if (["live", "browse"].includes(u.get("view"))) mode = u.get("view");
  if (u.has("topics")) (mode === "browse" ? (browseSel = list("topics")) : (selected = list("topics")));
  if (u.has("items")) liveAll = u.get("items") === "all";
  if (u.has("days") && [0, 1, 3, 7].includes(+u.get("days"))) bDays = +u.get("days");
  if (u.has("sources")) bSources = list("sources").filter(x => ["model", "dataset", "paper"].includes(x));
  if (["score", "newest", "likes", "downloads", "trending"].includes(u.get("sort"))) bSort = u.get("sort");
  if (u.has("how")) flowOpen = u.get("how") !== "closed";
  if (u.has("cardonly")) cardOnly = u.get("cardonly") === "1";
  if (u.has("q")) queueMicrotask(() => { $("q").value = u.get("q"); });
})();
function viewQuery(){
  const q = new URLSearchParams();
  q.set("view", mode);
  const ts = mode === "live" ? selected : browseSel;
  if (ts.length) q.set("topics", ts.join(","));
  if (mode === "live"){ if (liveAll) q.set("items", "all"); if (!flowOpen) q.set("how", "closed"); }
  else {
    q.set("days", bDays);
    if (bSources.length && bSources.length < 3) q.set("sources", bSources.join(","));
    if (bSort !== "score") q.set("sort", bSort);
    if ($("q").value.trim()) q.set("q", $("q").value.trim());
  }
  if (cardOnly) q.set("cardonly", "1");
  return "?" + q.toString();
}
function syncUrl(){
  const qs = viewQuery();
  try { history.replaceState(null, "", location.pathname + qs); } catch (e) {}
  // Embedded on huggingface.co: ask the Space page to show the same query in its address bar.
  if (window.parent !== window) try { window.parent.postMessage({queryString: qs}, "https://huggingface.co"); } catch (e) {}
}
async function copyText(t){
  try { await navigator.clipboard.writeText(t); return true; } catch (e) {}
  const ta = document.createElement("textarea"); ta.value = t; ta.style.position = "fixed"; ta.style.opacity = "0";
  document.body.appendChild(ta); ta.select();
  let ok = false; try { ok = document.execCommand("copy"); } catch (e) {}
  ta.remove(); return ok;
}
const drafts = {}, draftRows = {};
let seenKeys = new Set(), freshKeys = new Set();
const byKey = new Map();

const saveConfirmed = () => store.set("confirmed", confirmed);
const thrKey = () => "thr-" + (CAT?.catalogue_version || "none");
const key = it => it.source + ":" + it.id;
const raw = (scores, t) => scores?.[t] ?? 0;
const eff = (scores, t) => Math.max(raw(scores, t), ...(narrowOf[t] || []).map(n => raw(scores, n)));   // broad = max(broad, narrow)
const sc = (it, t) => eff(it.scores, t);
const judged = (k, t) => { const r = confirmed[k]; return r && r.considered.includes(t) ? r.topics.includes(t) : undefined; };
const defaultThr = t => CAT?.thresholds?.[t] ?? 0.21;
const thrSource = t => t in manualThr ? "manual" : marksThr[t] != null ? "marks" : "default";
const thr = t => manualThr[t] ?? marksThr[t] ?? defaultThr(t);
const thrMap = ts => Object.fromEntries(ts.map(t => [t, thr(t)]));
const topicNames = () => CAT?.labels || [];
const feedTopics = () => selected.filter(t => topicNames().includes(t));
const browseTopics = () => browseSel.filter(t => topicNames().includes(t));
const shown = () => mode === "live" ? feedTopics() : browseTopics();

/* ---------- topic pickers ---------- */
const openGroups = new Set(["domains", "modalities", "methods"]);
function target(){ return editing === "browse" ? browseSel : selected; }
function setTarget(v){ if (editing === "browse"){ browseSel = v; store.set("browseSel", v); } else { selected = v; store.set("selected", v); } }
function renderEditor(){
  if (!CAT || !editing) return;
  $("editorTitle").textContent = editing === "browse" ? "Choose topics to browse" : "Choose your feed topics";
  const sel = target();
  $("cgroups").innerHTML = Object.entries(CAT.groups).map(([g, ts]) => {
    const n = ts.filter(t => sel.includes(t)).length;
    return `<details data-g="${esc(g)}" ${openGroups.has(g) ? "open" : ""}><summary><b>${esc(g[0].toUpperCase() + g.slice(1))}</b> · ${n} of ${ts.length} chosen <span class="gsel"><button class="link" data-all="${esc(g)}">all</button> · <button class="link" data-none="${esc(g)}">none</button></span></summary>
      <div class="cgrid">${ts.map(t => `<label title="${esc(CAT.notes[t] || "")}"><input type="checkbox" data-t="${esc(t)}" ${sel.includes(t) ? "checked" : ""}>${esc(t)}</label>`).join("")}</div></details>`;
  }).join("");
}
$("editor").addEventListener("click", e => {
  const b = e.target.closest("[data-all],[data-none]"); if (!b) return;
  e.preventDefault(); e.stopPropagation();
  const g = b.dataset.all ?? b.dataset.none, ts = g === "*" ? CAT.labels : CAT.groups[g];
  const sel = target();
  setTarget("all" in b.dataset ? [...new Set([...sel, ...ts])] : sel.filter(t => !ts.includes(t)));
  renderEditor(); renderToggles();
});
$("cgroups").addEventListener("toggle", e => { const g = e.target.dataset?.g; if (g) e.target.open ? openGroups.add(g) : openGroups.delete(g); }, true);
$("cgroups").addEventListener("change", e => {
  const t = e.target.dataset.t; if (!t) return;
  const sel = target();
  setTarget(e.target.checked ? [...new Set([...sel, t])] : sel.filter(x => x !== t));
  renderEditor(); renderToggles();
});
function setEditing(which){
  editing = which;
  $("editor").hidden = !which;
  $("summary").hidden = which === "feed" || mode !== "live";
  $("editBtn").setAttribute("aria-expanded", which === "feed"); $("bEditBtn").setAttribute("aria-expanded", which === "browse");
  if (which){ renderEditor(); $("editor").scrollIntoView({block: "nearest"}); }
  else { renderAll(); reload(); }
}
$("editBtn").onclick = () => setEditing("feed");
$("editBtn2").onclick = () => setEditing("feed");
$("bEditBtn").onclick = () => setEditing("browse");
$("doneBtn").onclick = () => setEditing(null);

/* ---------- thresholds from confirmations ---------- */
function recScores(k, r){
  if (r.scores && r.cat === CAT?.catalogue_version) return r.scores;
  return byKey.get(k)?.scores || null;
}
function judgedRows(t){
  const out = [];
  for (const [k, r] of Object.entries(confirmed)){
    if (!r.considered.includes(t)) continue;
    const s = recScores(k, r); if (!s) continue;
    out.push({k, s: eff(s, t), v: r.topics.includes(t)});
  }
  return out;
}
function counts(t){
  let pos = 0, neg = 0;
  for (const r of Object.values(confirmed)) if (r.considered.includes(t)) r.topics.includes(t) ? pos++ : neg++;
  return {pos, neg};
}
function fitMarks(t){
  const rows = judgedRows(t);
  if (rows.filter(r => r.v).length < MIN_POS_FOR_MARKS) return null;
  let bestF = -1, plateau = [];
  for (let i = 1; i <= 99; i++){           // max-F1 thresholds; take the middle of the best plateau
    const th = i / 100; let tp = 0, fp = 0, fn = 0;
    for (const r of rows){ const p = r.s >= th; if (p && r.v) tp++; else if (p) fp++; else if (r.v) fn++; }
    const f = 2 * tp / Math.max(1, 2 * tp + fp + fn);
    if (f > bestF + 1e-9){ bestF = f; plateau = [th]; } else if (Math.abs(f - bestF) <= 1e-9) plateau.push(th);
  }
  return plateau[Math.floor((plateau.length - 1) / 2)];
}
function refitMarks(){ marksThr = {}; for (const t of topicNames()) marksThr[t] = fitMarks(t); }

/* ---------- summaries + topic panel ---------- */
const passCount = t => items.length ? items.filter(it => sc(it, t) >= thr(t)).length : null;
function renderToggles(){
  const feed = feedTopics();
  $("feedTopics").innerHTML = (feed.length ? feed.map(t => `<span class="ft">${esc(t)}</span>`).join('<span class="note">·</span>')
    : `<span class="note">No topics chosen.</span>`) + `<span class="note">(${feed.length} of ${topicNames().length})</span>`;
  const bt = browseTopics();
  $("browseTopics").innerHTML = bt.length ? bt.map(t => `<span class="chipx">${esc(t)}<button data-rm="${esc(t)}" aria-label="Remove ${esc(t)}" title="Remove">×</button></span>`).join("")
    : `<span class="note">All topics (add some to narrow down).</span>`;
  $("tlist").innerHTML = `<div class="tr tlh"><span>topic</span><span class="num">here</span><span class="num">✓</span><span class="num">✗</span><span class="num">threshold</span><span></span></div>` +
    (feed.length ? feed.map(t => { const n = mode === "live" ? passCount(t) : null, c = counts(t), src = thrSource(t);
      return `<div class="tr"><span class="nm" title="${esc(CAT?.notes?.[t] || t)}">${esc(t)}</span><span class="num">${n ?? "–"}</span>
        <span class="num">${c.pos}</span><span class="num">${c.neg}</span>
        <span class="src ${src}" title="${src === "marks" ? "Fitted on your confirmations" : src === "manual" ? "Set by hand" : `Catalogue default; needs ${MIN_POS_FOR_MARKS} ✓ to use yours`}"><span class="mono">${thr(t).toFixed(2)}</span> ${src === "marks" ? "yours" : src}</span>
        <button class="link" data-cal="${esc(t)}">calibrate</button></div>`; }).join("")
    : `<p class="note" style="padding:6px 0">Choose topics with <b>choose</b>.</p>`);
}
$("browseTopics").addEventListener("click", e => { const t = e.target.dataset.rm; if (!t) return; browseSel = browseSel.filter(x => x !== t); store.set("browseSel", browseSel); renderToggles(); reload(); });
function openCal(t){ calTopic = t; renderCal(); $("calPanel").scrollIntoView({block: "nearest"}); }
$("tlist").addEventListener("click", e => { const b = e.target.closest("[data-cal]"); if (b) openCal(b.dataset.cal); });
$("calDone").onclick = () => { calTopic = null; renderCal(); };

/* ---------- modes + browse controls ---------- */
function renderControls(){
  $("coLive").checked = $("coBrowse").checked = cardOnly;
  $("coLive").closest("label").hidden = mode !== "live";
  $("modes").innerHTML = [["live","Live"],["browse","Browse"]].map(([k,n]) => `<button role="tab" aria-pressed="${k===mode}" data-k="${k}">${n}</button>`).join("");
  $("summary").hidden = mode !== "live" || editing === "feed"; $("bctl").hidden = mode !== "browse";
  $("listTitle").textContent = mode === "live" ? "Live · newest first" : "Browse";
  $("flowBtn").hidden = mode !== "live";
  $("flowBox").hidden = mode !== "live" || !flowOpen; if ($("flowBox").hidden) cancelAnimationFrame(flowRAF);
  $("flowBtn").textContent = flowOpen ? "hide how it works" : "how it works"; $("flowBtn").setAttribute("aria-expanded", flowOpen);
  $("stream").hidden = $("tickerBox").hidden = mode !== "live"; $("sumbox").hidden = mode !== "browse";
  $("scopeSeg").hidden = mode !== "live";
  $("scopeSeg").innerHTML = [[false, "your topics"], [true, "all items"]].map(([v, n]) => `<button aria-pressed="${v === liveAll}" data-v="${v}">${n}</button>`).join("");
  $("daysSeg").innerHTML = [[1,"today"],[3,"3 days"],[7,"7 days"],[0,"all"]].map(([d,n]) => `<button aria-pressed="${d===bDays}" data-d="${d}"${d ? ` title="Hub items: the last ${d * 24} h. Papers: the latest ${d} paper day(s)."` : ""}>${n}</button>`).join("");
  $("srcBoxes").innerHTML = [["model","models"],["dataset","datasets"],["paper","papers"]].map(([s,n]) => `<label class="status"><input type="checkbox" data-s="${s}" ${bSources.includes(s) ? "checked" : ""}> ${n}</label>`).join("");
  $("bsort").value = bSort;
}
$("modes").addEventListener("click", e => { const b = e.target.closest("button"); if (!b || b.dataset.k === mode) return; mode = b.dataset.k; store.set("mode", mode); if (editing) setEditing(null); renderControls(); renderToggles(); reload(); });
$("scopeSeg").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; liveAll = b.dataset.v === "true"; store.set("liveAll", liveAll); renderControls(); reload(); });
$("daysSeg").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; bDays = +b.dataset.d; store.set("bDays", bDays); renderControls(); reload(); });
$("srcBoxes").addEventListener("change", e => { const s = e.target.dataset.s; bSources = e.target.checked ? [...new Set([...bSources, s])] : bSources.filter(x => x !== s); store.set("bSources", bSources); reload(); });
$("bsort").onchange = e => { bSort = e.target.value; store.set("bSort", bSort); reload(); };
let qTimer = null;
$("q").addEventListener("input", () => { clearTimeout(qTimer); qTimer = setTimeout(reload, 300); });

/* ---------- data ---------- */

let reqId = 0;
async function reload(append = false, poll = false){
  if (!CAT || editing) return;
  if (!append && !poll) syncUrl();
  const my = ++reqId;
  if (!append){ offset = 0; }
  try {
    let d;
    if (mode === "live"){
      d = (await api("live", {topics: feedTopics(), thresholds: thrMap(feedTopics()), limit: PAGE, offset, since: prevVisit || "", all_items: liveAll, card_only: cardOnly}));
      lastLive = d;
    } else {
      d = (await api("browse", {topics: browseTopics(), thresholds: thrMap(browseTopics()), days: bDays, sources: bSources,
        q: $("q").value, sort: bSort, limit: PAGE, offset, match: "any", card_only: cardOnly}));
      lastBrowse = d;
    }
    if (my !== reqId) return;   // a newer request superseded this one
    for (const it of d.items) if (it.s){ it.scores = Object.fromEntries(CAT.labels.map((l, i) => [l, it.s[i]])); delete it.s; }
    if (mode === "live" && !append && poll && seenKeys.size) freshKeys = new Set(d.items.map(key).filter(k => !seenKeys.has(k)));
    else if (!append) freshKeys = new Set();
    if (mode === "live" && !append) seenKeys = new Set(d.items.map(key));
    items = append ? [...items, ...d.items] : d.items; total = d.total;
    for (const it of d.items) byKey.set(key(it), it);
    renderStatus(d.status); refitMarks(); renderAll();
    if (mode === "live" && !append){ store.set("lastVisit", new Date().toISOString()); if (!$("flowBox").hidden && !poll) flowStart(); }
  } catch (e) {
    const msg = e?.message || String(e);
    $("status").textContent = "Could not load items: " + msg;
    if (!append && !items.length) $("feed").innerHTML = `<div class="empty">Could not load items (${esc(msg)}). The Space may be starting; reload the page in a minute.</div>`;
  }
}
$("more").onclick = () => { offset += PAGE; reload(true); };
setInterval(() => { if (mode === "live" && !document.hidden && !Object.keys(drafts).length && offset === 0) reload(false, true); }, POLL_MS);

const ago = iso => { if (!iso) return ""; const m = (Date.now() - Date.parse(iso)) / 60000;
  return m < 1 ? "just now" : m < 60 ? `${Math.round(m)} min ago` : m < 48 * 60 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`; };
const hhmm = iso => iso ? new Date(iso).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}) : "–";
function renderStatus(st){
  if (!st) return;
  lastStatus = st; lastListAt = Date.now(); renderStatusLine();
  const ws = st.worker_status;
  let worker = "backfill only (no worker yet)";
  if (ws && !ws.error){
    const srcs = Object.values(ws.sources || {});
    const scoredH = srcs.reduce((a, x) => a + (x.last_hour?.scored || 0), 0);
    const lat = ws.sources?.model?.latency_modified_to_scored_s?.last_hour_p50 ?? ws.sources?.model?.latency_modified_to_scored_s?.p50;
    const stale = ws.updated_at && Date.now() - Date.parse(ws.updated_at) > 15 * 60000;
    worker = `worker ${stale ? `silent since ${ago(ws.updated_at)}` : esc(ws.state || "running")} · ${scoredH} scored in the last hour`
      + (ws.queue?.total != null ? ` · queue ${ws.queue.total.toLocaleString()}` : "")
      + (lat ? ` · typical delay ${Math.round(lat / 60)} min after a change` : "");
  }
  $("stream").textContent = `stream · ${st.last_24h.toLocaleString()} items changed in 24 h · ${worker}`;
}

let lastStatus = null, lastListAt = 0;
function renderStatusLine(){
  const st = lastStatus; if (!st) return;
  const s = Math.round((Date.now() - lastListAt) / 1000);
  $("status").textContent = st.error ? `Data problem: ${st.error}` : `${st.rows.toLocaleString()} items · newest ${ago(st.newest)} · list refreshed ${s < 60 ? s + " s" : Math.round(s / 60) + " min"} ago`;
}
setInterval(renderStatusLine, 1000);

/* ---------- items ---------- */
function stateOf(k, it, t){
  if (drafts[k]) return drafts[k].has(t);
  const j = judged(k, t);
  return j !== undefined ? j : sc(it, t) >= thr(t);
}
// Topics shown as rows on an item (and judged by Confirm). With many topics chosen (e.g. all 48), only the ones
// above threshold or already judged are shown, at most 6; the set is frozen while the item is being edited.
function rowTopics(it){
  const k = key(it), sel = shown();
  if (draftRows[k]) return draftRows[k];
  if (sel.length <= 6) return sel;
  return sel.filter(t => sc(it, t) >= thr(t) || judged(k, t) !== undefined).sort((a, b) => sc(it, b) - sc(it, a)).slice(0, 6);
}
function itemStatus(k, it){
  if (drafts[k]) return "edited, not saved";
  const r = confirmed[k];
  if (!r) return "model guess";
  return rowTopics(it).every(t => r.considered.includes(t)) ? "confirmed" : "partly confirmed";
}
function viaNarrow(it, t){
  const b = raw(it.scores, t), n = (narrowOf[t] || []).find(x => raw(it.scores, x) > b && raw(it.scores, x) === sc(it, t));
  return n ? ` title="Score from narrower topic: ${esc(n)}"` : "";
}
const isNew = it => prevVisit && it.t && Date.parse(it.t) > Date.parse(prevVisit);
function itemHTML(it){
  const k = key(it), sel = rowTopics(it), status = itemStatus(k, it), done = status === "confirmed";
  const above = topicNames().filter(t => sc(it, t) >= thr(t)).sort((a, b) => (sc(it, b) - thr(b)) - (sc(it, a) - thr(a)));
  const mine = new Set(shown());
  const rows = sel.map(t => { const s = sc(it, t), on = s >= thr(t), p = stateOf(k, it, t), via = viaNarrow(it, t);
    return `<div class="srow ${on ? "on" : ""}">
      <button class="tg" aria-pressed="${p}" data-t="${esc(t)}" title="${p ? "Belongs" : "Doesn't belong"}: click to change" aria-label="${esc(t)}: belongs">✓</button>
      <button class="tn" data-cal="${esc(t)}" title="Calibrate ${esc(t)}">${esc(t)}</button>
      <span class="bar" aria-hidden="true"><i class="${on ? "on" : ""}" style="width:${(s*100).toFixed(1)}%"></i><b style="left:${(thr(t)*100).toFixed(1)}%"></b></span>
      <span class="num"${via}>${s.toFixed(2)}${via ? "*" : ""}</span></div>`; }).join("");
  const pop = it.source === "paper" ? (it.upvotes ? `▲ ${fmt(it.upvotes)}` : "") : [it.trend ? `↑ ${fmt(it.trend)} trending` : "", it.likes ? `♥ ${fmt(it.likes)}` : "", it.downloads ? `↓ ${fmt(it.downloads)}` : ""].filter(Boolean).join(" ");
  const when = it.date_only ? new Date(it.t).toLocaleDateString([], {month: "short", day: "numeric", timeZone: "UTC"})
    : it.badge === "new" && it.created ? `created ${ago(it.created)}` : ago(it.modified || it.t);
  const badge = it.source === "paper" ? "" : `<span class="badge ${esc(it.badge)}">${esc(it.badge)}</span>`;
  const labels = above.length ? `<div class="tlabels">topics: ${above.slice(0, 6).map(t => `<b class="${mine.has(t) ? "mine" : ""}">${esc(t)}</b>`).join(" · ")}${above.length > 6 ? ` · +${above.length - 6}` : ""}</div>`
    : `<div class="tlabels">no topic above threshold</div>`;
  return `<article class="item ${done ? "done" : ""} ${above.length ? "" : "dim"} ${freshKeys.has(k) ? "fresh" : ""}" data-k="${esc(k)}">
    <div>${mode === "live" && isNew(it) ? `<span class="new-dot" title="New since your last visit"></span>` : ""}<span class="srcl">${esc(it.source)}</span>${badge}${it.card_only ? `<span class="badge co" title="Card only: no data or weight files yet">card only</span>` : ""}<span class="badge">${esc(when)}</span>
      <a class="ttl" href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a>
      <span class="meta mono">${esc(pop)}${it.tags?.length ? " · " + esc(it.tags.slice(0, 5).join(" · ")) : ""}</span></div>
    ${it.snip ? `<p class="snip">${esc(it.snip)}</p>` : ""}
    ${labels}
    ${sel.length ? `<div class="scores">${rows}</div>
    <div class="ifoot"><button class="btn small ${done ? "ghost" : ""}" data-a="confirm">${done ? "Confirmed" : "Confirm"}</button>
      <button class="btn ghost small" data-a="none">None of these</button>
      ${confirmed[k] ? `<button class="link" data-a="clear">undo</button>` : ""}
      <span class="state">${status}</span></div>` : ""}
  </article>`;
}
function renderFeed(){
  if (!CAT) return;
  $("feedCount").textContent = items.length ? `${items.length} of ${total.toLocaleString()}` : "";
  $("more").hidden = items.length >= total;
  if (mode === "browse") renderSummary();
  if (!items.length){
    $("feed").innerHTML = `<div class="empty">${mode === "live" ? (liveAll ? "No items yet." : feedTopics().length ? "No items pass the thresholds for your topics yet. Try “all items”." : "Choose some topics for your feed, or look at “all items”.") : "No items match. Widen the time range or remove a filter."}</div>`;
    return;
  }
  let html = "", marked = false;
  const newCount = mode === "live" ? items.filter(isNew).length : 0;
  items.forEach((it, i) => {
    if (mode === "live" && prevVisit && !marked && !isNew(it) && i > 0){
      html += `<div class="divider">earlier than your last visit (${hhmm(prevVisit)})</div>`; marked = true;
    }
    html += itemHTML(it);
  });
  if (mode === "live" && newCount) html = `<div class="divider">${lastLive?.new_since ?? newCount} new since your last visit</div>` + html;
  $("feed").innerHTML = html;
}
function renderSummary(){
  const s = lastBrowse?.summary; if (!s){ $("sumbox").innerHTML = ""; return; }
  const src = ["model", "dataset", "paper"].filter(k => s.by_source[k]).map(k => `${s.by_source[k].toLocaleString()} ${k}s`).join(" · ");
  $("sumbox").innerHTML = `<div><span class="mono" style="color:var(--ink)">${s.total.toLocaleString()}</span> items${src ? ": " + src : ""}</div>
    ${s.co_topics.length ? `<div class="co">${browseTopics().length ? "Also about" : "Most common topics"}: ${s.co_topics.slice(0, 8).map(c => `<button data-add="${esc(c.topic)}" title="Add ${esc(c.topic)} to the browse topics">${esc(c.topic)}</button> <span class="mono">${c.n}</span>`).join(" · ")}</div>` : ""}`;
}
$("sumbox").addEventListener("click", e => { const t = e.target.dataset.add; if (!t) return; browseSel = [...new Set([...browseSel, t])]; store.set("browseSel", browseSel); renderToggles(); reload(); });
function rerenderItem(k){
  const el = $("feed").querySelector(`.item[data-k="${CSS.escape(k)}"]`);
  const it = byKey.get(k); if (el && it) el.outerHTML = itemHTML(it);
}

const snapshot = it => ({scores: it.scores, cat: CAT.catalogue_version});
const recFor = (it, k) => ({title: it.title, url: it.url, text: (it.title + ". " + (it.snip || "")).slice(0, 500), src: it.source, ...snapshot(it)});
function saveItem(k, positives){
  const it = byKey.get(k), old = confirmed[k], sel = rowTopics(it);
  const keepPos = (old?.topics || []).filter(t => !sel.includes(t));
  confirmed[k] = {...recFor(it, k), topics: [...new Set([...keepPos, ...positives])], considered: [...new Set([...(old?.considered || []), ...sel])]};
  delete drafts[k]; delete draftRows[k]; saveConfirmed(); afterMarks(k);
}
$("feed").addEventListener("click", e => {
  const el = e.target.closest(".item"); if (!el) return;
  const cal = e.target.closest("[data-cal]"); if (cal){ openCal(cal.dataset.cal); return; }
  const k = el.dataset.k, it = byKey.get(k), tg = e.target.closest(".tg"), a = e.target.closest("[data-a]")?.dataset.a;
  const sel = rowTopics(it);
  if (tg){
    draftRows[k] ||= sel;
    const d = drafts[k] ||= new Set(sel.filter(t => stateOf(k, it, t)));
    d.has(tg.dataset.t) ? d.delete(tg.dataset.t) : d.add(tg.dataset.t);
    rerenderItem(k);
  } else if (a === "confirm") saveItem(k, sel.filter(t => stateOf(k, it, t)));
  else if (a === "none") saveItem(k, []);
  else if (a === "clear"){ delete confirmed[k]; delete drafts[k]; delete draftRows[k]; saveConfirmed(); afterMarks(k); }
});
function afterMarks(k){ refitMarks(); if (k) rerenderItem(k); renderToggles(); renderCal(); }

/* ---------- calibration ---------- */
function metrics(t, th){
  let tp = 0, fp = 0, fn = 0;
  const rows = judgedRows(t);
  for (const r of rows){ const p = r.s >= th; if (p && r.v) tp++; else if (p) fp++; else if (r.v) fn++; }
  return {n: rows.length, p: tp+fp ? tp/(tp+fp) : null, r: tp+fn ? tp/(tp+fn) : null};
}
function renderStrip(t){
  // Checked items (stored scores) plus unchecked items on screen.
  const pts = judgedRows(t).map(r => ({s: r.s, v: r.v}));
  for (const it of items) if (judged(key(it), t) === undefined) pts.push({s: sc(it, t), v: undefined});
  const ticks = pts.map(p => {
    const x = (p.s * 300).toFixed(1);
    const [y1, y2, c, o] = p.v === true ? [2, 14, "var(--ok)", 1] : p.v === false ? [32, 44, "var(--bad)", 1] : [18, 28, "var(--faint)", .6];
    return `<line x1="${x}" x2="${x}" y1="${y1}" y2="${y2}" stroke="${c}" stroke-opacity="${o}" stroke-width="1.5" vector-effect="non-scaling-stroke"/>`;
  }).join("");
  const tx = (thr(t) * 300).toFixed(1);
  $("strip").innerHTML = `<line x1="0" x2="300" y1="23" y2="23" stroke="var(--line)" vector-effect="non-scaling-stroke"/>${ticks}
    <line x1="${tx}" x2="${tx}" y1="0" y2="46" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>`;
}
function renderCal(){
  if (calTopic && !topicNames().includes(calTopic)) calTopic = null;
  $("calPanel").hidden = !calTopic; $("topicPanel").hidden = !!calTopic;
  if (!calTopic) return;
  const t = calTopic, src = thrSource(t), c = counts(t);
  $("calName").textContent = t;
  $("thr").value = thr(t); $("thrVal").textContent = thr(t).toFixed(2) + (src === "marks" ? " (yours)" : src === "manual" ? " (by hand)" : " (default)");
  $("resetThr").hidden = src !== "manual";
  renderStrip(t);
  const m = metrics(t, thr(t)), pct = x => x === null ? "–" : Math.round(x * 100) + "%";
  const auto = marksThr[t] != null ? `from your marks ${marksThr[t].toFixed(2)}` : `needs ${MIN_POS_FOR_MARKS - Math.min(c.pos, MIN_POS_FOR_MARKS)} more ✓ for yours`;
  $("calStats").textContent = `${c.pos} ✓ · ${c.neg} ✗ · precision ${pct(m.p)} · recall ${pct(m.r)} · ${auto}`;
  const byScore = items.slice().sort((a, b) => sc(b, t) - sc(a, t));
  const top = byScore.slice(0, 10);
  const near = byScore.slice(10).sort((a, b) => Math.abs(sc(a, t) - thr(t)) - Math.abs(sc(b, t) - thr(t))).slice(0, 15);
  $("queue").innerHTML = [...top, ...near].map(it => { const k = key(it), v = judged(k, t);
    return `<div class="q" data-k="${esc(k)}"><span class="yn"><button class="y" aria-pressed="${v === true}" data-v="1" aria-label="belongs" title="Belongs to ${esc(t)}">✓</button><button class="n" aria-pressed="${v === false}" data-v="0" aria-label="doesn't belong" title="Doesn't belong">✗</button></span>
      <a href="${esc(it.url)}" target="_blank" rel="noopener" title="${esc(it.snip)}">${esc(it.title)}</a><span class="num">${sc(it, t).toFixed(2)}</span></div>`; }).join("")
    || `<p class="note" style="padding:6px 0">No items on screen. Browse this topic to find some.</p>`;
}
$("queue").addEventListener("click", e => {
  const b = e.target.closest(".yn button"); if (!b) return;
  const k = b.closest(".q").dataset.k, v = b.dataset.v === "1", t = calTopic, it = byKey.get(k);
  const r = confirmed[k] ||= {...recFor(it, k), topics: [], considered: []};
  Object.assign(r, snapshot(it));
  if (judged(k, t) === v){ r.considered = r.considered.filter(x => x !== t); r.topics = r.topics.filter(x => x !== t); }
  else { if (!r.considered.includes(t)) r.considered.push(t); r.topics = v ? [...new Set([...r.topics, t])] : r.topics.filter(x => x !== t); }
  if (!r.considered.length) delete confirmed[k];
  delete drafts[k]; saveConfirmed(); afterMarks(k);
});
let thrTimer = null;
$("thr").oninput = e => { manualThr[calTopic] = +e.target.value; store.set(thrKey(), manualThr); renderToggles(); renderFeed(); renderCal();
  clearTimeout(thrTimer); thrTimer = setTimeout(() => reload(), 500); };
$("resetThr").onclick = () => { delete manualThr[calTopic]; store.set(thrKey(), manualThr); renderAll(); reload(); };
$("copy").onclick = () => navigator.clipboard.writeText(JSON.stringify({catalogue_version: CAT?.catalogue_version, manual_thresholds: manualThr, marks_thresholds: marksThr,
    items: Object.entries(confirmed).map(([k, r]) => ({key: k, text: r.text, src: r.src, topics: r.topics, considered: r.considered}))}, null, 1))
  .then(() => { $("copy").textContent = "copied"; setTimeout(() => $("copy").textContent = "copy my data", 1500); })
  .catch(() => { $("copy").textContent = "copy failed"; });

/* ---------- worker ticker ---------- */
let evts = [], evSeen = new Set(), lastEvT = "", evInfo = null;
const agoS = iso => { const s = (Date.now() - Date.parse(iso)) / 1000; return s < 60 ? `${Math.max(0, Math.round(s))} s ago` : ago(iso); };
async function pollEvents(){
  if (!CAT || mode !== "live" || document.hidden) return;
  try {
    const d = await api("events", {topics: feedTopics(), thresholds: thrMap(feedTopics()), since: lastEvT, limit: 800});
    evInfo = d;
    const fresh = d.events.filter(e => { const k = e.t + "|" + e.source + "|" + e.id + "|" + e.event; if (evSeen.has(k)) return false; evSeen.add(k); return true; });
    if (fresh.length){
      evts = [...evts, ...fresh].slice(-2000); lastEvT = fresh[fresh.length - 1].t;
      if (flow.kind === "events") flowAdd(fresh);
      else if (flowOpen && evts.length >= 20 && flow.kind !== "events") flowStart();
    }
  } catch (e) { /* the ticker shows the last known state */ }
  renderTicker();
}
function renderTicker(){
  if (mode !== "live") return;
  const d = evInfo;
  if (!d){ $("ticker").textContent = "Checking the worker…"; return; }
  const on = d.active, ws = d.worker_status || {};
  $("pulse").className = "pulse" + (on ? " on" : "");
  if (!d.last_event_at){ $("ticker").textContent = "backfill only · no worker activity yet"; return; }
  const lastScored = [...evts].reverse().find(e => e.stage === "scored");
  const n = d.last_10min || {};
  if (!on){
    $("ticker").textContent = `paused · showing the latest data (last scored ${lastScored ? ago(lastScored.t) : ago(d.last_event_at)})`;
    return;
  }
  // Most worker traffic is skips (unchanged or empty cards), so show the latest event of any kind, not only scores.
  const last = [...evts].reverse().find(e => e.stage !== "detected") || evts[evts.length - 1];
  const tNew = Date.parse(evts[evts.length - 1]?.t || 0);
  const perMin = evts.filter(e => e.stage === "detected" && Date.parse(e.t) > tNew - 60000).length;
  const inflight = ws.queue?.inflight;
  $("ticker").innerHTML = `${last ? `<span class="mono">${esc(last.id)}</span> ${esc(STAGE_WORDS[last.event] || STAGE_WORDS[last.stage] || last.stage)} ${agoS(last.t)}` : "working"}`
    + ` · ${perMin} changes checked in the last minute · +${n.scored || 0} scored in 10 min`
    + (inflight ? ` · scoring now: <span class="mono">${esc(inflight)}</span>` : "")
    + (ws.queue?.total != null ? ` · queue ${ws.queue.total.toLocaleString()}` : "");
}
const STAGE_WORDS = {scored: "scored", skip_no_readme: "skipped (no card)", skip_thin_card: "skipped (thin card)",
  skip_yaml_only: "skipped (only metadata changed)", skip_same_etag: "skipped (card unchanged)", skip_unchanged_etag: "skipped (card unchanged)",
  skip_private_or_disabled: "skipped (private)", superseded_in_queue: "replaced by a newer change", enqueued: "queued", queued: "queued",
  detected: "detected", error: "failed", filtered: "skipped", unchanged: "skipped (card unchanged)"};
setInterval(pollEvents, EVENTS_MS);
setInterval(renderTicker, 1000);

/* ---------- how it works: real worker events, or a replay of the latest arrivals ---------- */
let flowRAF = null;
const flow = {kind: null};
$("flowBtn").onclick = () => {
  flowOpen = !flowOpen; store.set("flowOpen", flowOpen); renderControls(); syncUrl();
  if (flowOpen) flowStart(); else { cancelAnimationFrame(flowRAF); flowRAF = null; }
};
$("replay").onclick = () => flowStart();

function flowSetup(stations){
  const cv = $("flow"), css = getComputedStyle(document.documentElement), col = n => css.getPropertyValue(n).trim();
  const C = {ink: col("--ink"), muted: col("--muted"), faint: col("--faint"), line: col("--line"), accent: col("--accent")};
  const lanes = feedTopics().slice(0, 8);
  const W = cv.clientWidth || 600, laneTop = 58, laneH = 15, notY = laneTop + lanes.length * laneH + 14, H = notY + 16;
  const dpr = window.devicePixelRatio || 1;
  cv.width = W * dpr; cv.height = H * dpr; cv.style.height = H + "px";
  const ctx = cv.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  Object.assign(flow, {ctx, C, lanes, W, H, laneTop, laneH, notY, mainY: 34, xEnd: W - 26, stations: stations.map(([f, s]) => [W * f, s]),
    parts: [], byId: new Map(), pile: {}, begin: performance.now()});
  flow.xLast = flow.stations[flow.stations.length - 1][0];
}
// A particle moves along `path` (list of [x, y] points) from `start` (ms since begin) over `dur` ms.
function flowPart(p){ flow.parts.push(p); return p; }
function laneTarget(fate){
  const f = flow;
  const lane = typeof fate === "number" ? fate : "not";
  const slot = f.pile[lane] = (f.pile[lane] || 0) + 1;
  return {lane, x: Math.max(f.xLast + 12, f.xEnd - (slot - 1) * 2.5), y: lane === "not" ? f.notY : f.laneTop + fate * f.laneH};
}
function pathFor(fate, fromX, dropX){
  const f = flow, jit = (Math.random() - 0.5) * 6, y0 = f.mainY + jit;
  // Repeated points = a short rest at a station, so a still frame is rarely empty.
  if (fate === "filtered" || fate === "unchanged"){ const t = laneTarget(fate); return {pts: [[fromX, y0], [dropX, y0], [dropX, y0], [t.x, t.y]], lane: t.lane}; }
  if (fate === "queued"){ const x = f.qx + (Math.random() - 0.5) * 16; return {pts: [[fromX, y0], [x, f.mainY + 8 + Math.random() * 10]], lane: "queue"}; }
  const t = laneTarget(fate);
  return {pts: [[fromX, y0], [f.xDecide, y0], [f.xDecide, y0], [f.xLast, y0], [t.x, t.y]], lane: t.lane};
}
function at(p, now){
  const u = Math.min(1, Math.max(0, (now - p.start) / p.dur)), pts = p.pts;
  const seg = Math.min(pts.length - 2, Math.floor(u * (pts.length - 1))), v = u * (pts.length - 1) - seg;
  const [x0, y0] = pts[seg], [x1, y1] = pts[seg + 1];
  return {x: x0 + (x1 - x0) * v, y: y0 + (y1 - y0) * v, done: u >= 1};
}
function flowDraw(now){
  const f = flow, ctx = f.ctx, C = f.C;
  ctx.clearRect(0, 0, f.W, f.H);
  ctx.font = "11px 'IBM Plex Sans', system-ui, sans-serif"; ctx.textBaseline = "middle"; ctx.lineWidth = 1;
  ctx.strokeStyle = C.line; ctx.beginPath(); ctx.moveTo(0, f.mainY); ctx.lineTo(f.xLast, f.mainY); ctx.stroke();
  f.stations.forEach(([x, s]) => { ctx.beginPath(); ctx.moveTo(x, 18); ctx.lineTo(x, f.mainY + 10); ctx.stroke();
    ctx.fillStyle = C.muted; ctx.textAlign = "center"; ctx.fillText(typeof s === "function" ? s() : s, x, 10); });
  ctx.textAlign = "left";
  f.lanes.forEach((t, i) => { const y = f.laneTop + i * f.laneH; ctx.strokeStyle = C.line;
    ctx.beginPath(); ctx.moveTo(f.xLast + 8, y); ctx.lineTo(f.xEnd, y); ctx.stroke();
    ctx.fillStyle = C.muted; ctx.fillText(t.length > 28 ? t.slice(0, 27) + "…" : t, f.xLast + 8, y - 6); });
  ctx.strokeStyle = C.line; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.moveTo(f.stations[0][0], f.notY); ctx.lineTo(f.xEnd, f.notY); ctx.stroke(); ctx.setLineDash([]);
  ctx.fillStyle = C.faint; ctx.fillText("not for you", f.stations[0][0] + 4, f.notY - 7);
  const done = {}; let moving = false;
  for (const p of f.parts){
    if (now < p.start) { moving = true; continue; }
    const q = at(p, now); if (!q.done) moving = true; else done[p.lane] = (done[p.lane] || 0) + 1;
    if (!q.done && !reduceMotion()){   // short fading trail behind moving items
      ctx.fillStyle = C.faint;
      [[90, .35], [180, .2], [270, .1]].forEach(([dt, a]) => { if (now - dt < p.start) return;
        const g = at(p, now - dt); ctx.globalAlpha = a; ctx.fillRect(g.x - 1, g.y - 1.5, 2, 3); });
    }
    const fading = p.lane === "not" && q.done;
    ctx.globalAlpha = fading ? 0.45 : 1;
    ctx.fillStyle = typeof p.lane === "number" ? C.accent : p.lane === "queue" ? C.muted : q.done ? C.faint : C.ink;
    ctx.fillRect(q.x - 1, q.y - (q.done ? 4 : 2), 2, q.done ? 8 : 4);
  }
  ctx.globalAlpha = 1; ctx.textAlign = "right"; ctx.fillStyle = C.muted;
  f.lanes.forEach((t, i) => ctx.fillText(String(done[i] || 0), f.W - 2, f.laneTop + i * f.laneH));
  ctx.fillStyle = C.faint; ctx.fillText(String(done.not || 0), f.W - 2, f.notY);
  return moving;
}
function flowLoop(){
  if (flowRAF) return;
  const tick = ts => { const moving = flowDraw(ts - flow.begin); flowRAF = moving && flowOpen && mode === "live" ? requestAnimationFrame(tick) : null; };
  if (reduceMotion()){ flowDraw(1e12); return; }   // static final state
  flowRAF = requestAnimationFrame(tick);
}
const DUR = 3400;
function fateOf(e){
  if (e.stage === "filtered") return "filtered";
  if (e.stage === "unchanged" || e.stage === "superseded") return "unchanged";
  if (e.stage === "scored") return e.lane && flow.lanes.includes(e.lane) ? flow.lanes.indexOf(e.lane) : "unmatched";
  return "queued";   // detected / queued: waiting for Decide
}
// Events: one particle per item; a later event for the same item moves it on (e.g. queued -> scored).
function flowEvent(e, startAt){
  const f = flow, k = e.source + ":" + e.id, fate = fateOf(e);
  const p = f.byId.get(k);
  if (p){
    if (fate === "queued" || p.final) return;
    const rest = p.pts[p.pts.length - 1], t = laneTarget(fate);
    const pts = typeof fate === "number" || fate === "unmatched"
      ? [rest, [f.xDecide, f.mainY], [f.xDecide, f.mainY], [f.xLast, f.mainY], [t.x, t.y]] : [rest, [t.x, t.y]];
    Object.assign(p, {pts, lane: t.lane, start: Math.max(startAt, p.start + p.dur), dur: DUR * 0.6, final: true});
    return;
  }
  const np = pathFor(fate, 0, f.stations[0][0]);
  const q = flowPart({pts: fate === "queued" || fate === "filtered" || fate === "unchanged" ? np.pts : [[0, np.pts[0][1]], [f.qx, np.pts[0][1]], ...np.pts.slice(1)],
    lane: np.lane, start: startAt, dur: DUR, final: fate !== "queued"});
  f.byId.set(k, q);
}
function flowStart(){
  cancelAnimationFrame(flowRAF); flowRAF = null;
  if (!flowOpen || mode !== "live") return;
  if (evts.length >= 20) flowFromEvents(); else flowReplay();
}
function flowFromEvents(){
  const ws = () => evInfo?.worker_status?.queue?.total;
  flowSetup([[0.14, "card checks"], [0.33, () => `queue${ws() != null ? " · " + ws().toLocaleString() : ""}`], [0.49, `Decide · ${CAT?.labels.length || 48} topics`], [0.62, "your topics"]]);
  flow.kind = "events"; flow.qx = flow.stations[1][0]; flow.xDecide = flow.stations[2][0];
  const recent = evts.filter(e => e.stage !== "detected").slice(-500);
  const t0 = Date.parse(recent[0]?.t), t1 = Date.parse(recent[recent.length - 1]?.t), REPLAY = 10000;
  recent.forEach((e, i) => flowEvent(e, t1 > t0 ? (Date.parse(e.t) - t0) / (t1 - t0) * REPLAY : i * 20));
  flowStats(recent);
  flowLoop();
}
function flowAdd(fresh){
  const now = performance.now() - flow.begin;
  fresh.filter(e => e.stage !== "detected").forEach((e, i) => flowEvent(e, now + i * 150));
  flowStats(evts.filter(e => e.stage !== "detected").slice(-500));
  if (flowOpen) flowLoop();
}
function flowStats(recent){
  const c = {}; for (const e of recent) c[e.stage] = (c[e.stage] || 0) + 1;
  const mins = recent.length ? (Date.parse(recent[recent.length - 1].t) - Date.parse(recent[0].t)) / 60000 : 0;
  const kept = recent.filter(e => e.stage === "scored" && e.lane && flow.lanes.includes(e.lane)).length;
  $("flowStats").textContent = `real worker events, last ${Math.max(1, Math.round(mins))} min: ${c.unchanged || 0} unchanged cards, ${c.filtered || 0} filtered (no card or thin card), `
    + `${c.queued || 0} queued, ${c.scored || 0} scored by Decide, ${kept} for your topics`;
}
// No worker events yet: replay the latest scored items; filtered marks are estimated from the backfill rate.
function flowReplay(){
  flowSetup([[0.16, "cheap filters"], [0.38, `Decide · ${CAT?.labels.length || 48} topics`], [0.58, "your topics"]]);
  flow.kind = "replay"; flow.qx = flow.stations[0][0]; flow.xDecide = flow.stations[1][0];
  const arr = (lastLive?.arrivals || []).filter(a => a.t);
  const bs = lastLive?.status?.backfill_stats?.fetch || {};
  const listed = ["model", "dataset"].reduce((a, s) => a + (bs[s]?.listed || 0), 0), keptN = ["model", "dataset"].reduce((a, s) => a + (bs[s]?.kept || 0), 0);
  const keepRate = listed ? keptN / listed : 1;
  const tArr = arr.map(a => Date.parse(a.t)), tMin = Math.min(...tArr), tMax = Math.max(...tArr);
  const nDrop = Math.round(arr.filter(a => a.source !== "paper").length * (1 / keepRate - 1));
  const all = [...arr.map(a => ({t: Date.parse(a.t), fate: a.lane && flow.lanes.includes(a.lane) ? flow.lanes.indexOf(a.lane) : "unmatched"})),
    ...Array.from({length: nDrop}, () => ({t: tMin + Math.random() * (tMax - tMin), fate: "filtered"}))].sort((a, b) => a.t - b.t);
  all.forEach((p, i) => { const np = pathFor(p.fate, 0, flow.stations[0][0]);
    flowPart({pts: np.pts, lane: np.lane, dur: DUR, start: tMax > tMin ? (p.t - tMin) / (tMax - tMin) * 10000 : i * 20}); });
  const matched = all.filter(p => typeof p.fate === "number").length, mins = (tMax - tMin) / 60000;
  $("flowStats").textContent = arr.length ? `replay of the last ${arr.length} scored items (${Math.round(mins)} min) · cheap filters keep ~${Math.round(keepRate * 100)}% (backfill rate; dropped marks estimated) · your topics kept ${matched}` : "No items yet.";
  flowLoop();
}

/* ---------- start ---------- */
function renderAll(){ renderToggles(); renderFeed(); renderCal(); }
for (const id of ["coLive", "coBrowse"]) $(id).addEventListener("change", e => { cardOnly = e.target.checked; store.set("cardOnly", cardOnly); renderControls(); reload(); });
$("copyLink").onclick = async () => {
  const url = SPACE_URL + viewQuery();
  const ok = await copyText(url);
  $("copyLink").textContent = ok ? "link copied" : "copy failed";
  if (!ok) window.prompt("Copy this link:", url);
  setTimeout(() => { $("copyLink").textContent = "copy link"; }, 1800);
};

async function init(){
  renderControls();
  try { CAT = await api("catalogue"); }
  catch (e) {
    $("status").textContent = "Could not connect: " + (e?.message || e);
    $("feed").innerHTML = `<div class="empty">Could not reach the app. If the Space was asleep it may still be starting; reload the page in a minute.</div>`;
    return;
  }
  narrowOf = {}; for (const [b, n] of CAT.pairs) (narrowOf[b] ||= []).push(n);
  manualThr = store.get(thrKey(), null);
  if (manualThr === null){   // new catalogue version: keep hand-set thresholds from older versions (defaults are recomputed)
    manualThr = {};
    for (const v of ["cat48-v1"]) Object.assign(manualThr, store.get("thr-" + v, {}));
    store.set(thrKey(), manualThr);
  }
  $("status").textContent = `Loading about 28K scored items… (catalogue ${CAT.catalogue_version})`;
  refitMarks(); renderControls(); renderAll(); await reload(); pollEvents();
}
init();
</script>
</body>
</html>