gage 0.3.6

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
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
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>gage</title>
<link rel="icon" href="/favicon.svg">
<style>
/* ---------------------------------------------------------------------------
   Design system.

   The rules below were already disciplined about the thing that matters most
   -- semantic colour is reserved for trust verdicts, so nothing decorative
   can borrow the colour that means "unverified" -- and undisciplined about
   everything else: thirteen font sizes including half-pixels, nine corner
   radii, fifteen arbitrary paddings. That is not a style, it is an accretion,
   and it reads as one.

   So: five type steps, one spacing scale, three radii, three weights. A value
   that is not on a scale is a decision someone has to re-make every time, and
   the eye notices the inconsistency long before anyone can name it.

   No webfont. This page is served under a CSP with no network access and
   loosening that for typography would be a poor trade -- so the system stack
   is tuned instead, with tabular numerals where figures must line up.

   Rethemed 2026-10-30 to the finish gage.sh shipped the same day: Apple
   neutrals, the system face first in the stack, larger radii, deeper
   shadows, pill buttons, a frosted top bar. The token layer is what made
   it a palette change rather than a rewrite -- and the invariant above
   still governs: the accent asserts nothing, and green stays a verdict.
--------------------------------------------------------------------------- */
:root{
  /* Type: 11 / 12.5 / 14 / 16 / 20. Five steps, each distinguishable at a
     glance. Anything needing a sixth is a sign the hierarchy is wrong. */
  --t1:11px; --t2:13px; --t3:14.5px; --t4:17px; --t5:24px;
  /* Weight: three. Regular reads, medium orients, semibold names things. */
  --w1:400; --w2:550; --w3:680;
  /* Space: a 4px base. */
  --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px;
  /* Radius: small controls, cards, pills. Three, and a circle is the pill
     taken to its limit rather than a fourth value spelled `50%`. */
  --r1:8px; --r2:16px; --r3:999px;
  --r:var(--r2);
  /* One rail width, fixed. It used to be minmax(348px,clamp(348px,26vw,440px))
     -- three numbers for one column, and the column that grew was the one
     holding the narrowest content on the page. */
  --rail:320px;
  /* One reading measure. There were three: 58ch, 62ch and a 760px column. */
  --measure:62ch;

  --bg:#f5f5f7; --sheet:#ffffff; --sunk:#ececf0; --line:#dedee3; --line2:#c9c9ce;
  --fg:#1d1d1f; --dim:#6e6e73; --faint:#86868b;
  /* Green is reserved. It is the colour of a verdict -- SENDER VERIFIED, a
     paired device, a settled thread -- and nothing a person can merely click
     may borrow it. Until 25 October the interactive accent was a green two
     degrees of hue from --ok (and in dark mode the identical value), so a
     filled Send button and the Verified badge were the same paint: the
     strongest claim this program makes, spent on an invitation to press
     something. The accent is now blue, which asserts nothing, and the
     verdict set keeps the traffic-light hues it has to keep.
     The mark stays green -- see packaging/icon -- because a logo is not a
     claim about a bundle. */
  --accent:#0066cc; --accent-ink:#ffffff; --accent-soft:#e6f0fb;
  /* The mark's own green, from packaging/icon: the logo is not chrome and
     not a verdict, and it has to go on matching the thing in the dock. */
  --brand:#1D6E47; --brand-ink:#f4f1e8;
  --ok:#248a3d; --warn:#b25000; --bad:#d70015;
  /* A bundle's kind is a fact about its contents, not a judgement of it, so
     --file is a slate: the same hue family as the accent at a fifth of its
     saturation. It was the accent green, which made a FILE tag read as a
     verified one. */
  --prompt:#b25000; --memory:#d70015; --decision:#5453c9; --file:#5f6b7a;
  --shadow:0 2px 6px rgba(0,0,0,.05), 0 20px 48px -20px rgba(0,0,0,.25);
  --sans:-apple-system,BlinkMacSystemFont,ui-sans-serif,system-ui,"Segoe UI",Roboto,sans-serif;
  --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
  --ease:140ms cubic-bezier(.2,.6,.3,1);
}
/* Dark is not the light palette inverted. The background goes deeper than the
   panels so the panels read as surfaces rather than as holes, and the
   neutrals keep a trace of warmth -- a pure grey dark theme looks clinical,
   which is the wrong register for something whose whole subject is trust. */
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
  --bg:#000000; --sheet:#1c1c1e; --sunk:#2c2c2e; --line:#333336; --line2:#48484a;
  --fg:#f5f5f7; --dim:#aeaeb2; --faint:#8e8e93;
  --accent:#2997ff; --accent-ink:#ffffff; --accent-soft:#10283f;
  --brand:#1D6E47; --brand-ink:#f4f1e8;
  --ok:#30d158; --warn:#ffb340; --bad:#ff6961;
  --prompt:#ffb340; --memory:#ff6961; --decision:#a8a8e4; --file:#a1a1a6;
  --shadow:0 2px 6px rgba(0,0,0,.5), 0 20px 48px -20px rgba(0,0,0,.85);
  color-scheme:dark;
}}
:root[data-theme=dark]{
  --bg:#000000; --sheet:#1c1c1e; --sunk:#2c2c2e; --line:#333336; --line2:#48484a;
  --fg:#f5f5f7; --dim:#aeaeb2; --faint:#8e8e93;
  --accent:#2997ff; --accent-ink:#ffffff; --accent-soft:#10283f;
  --brand:#1D6E47; --brand-ink:#f4f1e8;
  --ok:#30d158; --warn:#ffb340; --bad:#ff6961;
  --prompt:#ffb340; --memory:#ff6961; --decision:#a8a8e4; --file:#a1a1a6;
  --shadow:0 2px 6px rgba(0,0,0,.5), 0 20px 48px -20px rgba(0,0,0,.85);
  color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
/* The UA stylesheet gives [hidden] `display:none`, and ANY author rule with a
   `display` beats it -- `.filters{display:flex}` did, so the Chats filter bar
   stayed on screen on the Buddies tab with its `hidden` attribute faithfully
   set. Nothing in the API could show this and no assertion here was wrong;
   the first headless screenshot showed it immediately. */
[hidden]{display:none!important}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--sans);
  font-size:var(--t3);line-height:1.55;-webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility;font-variant-numeric:tabular-nums}
button,input,select,textarea{font:inherit;color:inherit}
button{background:none;border:0;cursor:pointer}
::selection{background:var(--accent-soft)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r1)}
/* One micro-label rule, rather than the same four declarations repeated at
   six different sizes. */
.state,.ty,.tag,.verdict,.bsec,.lanhead{font-size:var(--t1);letter-spacing:.07em;
  text-transform:uppercase;font-weight:var(--w3)}

/* One shell, three tabs. The tab bar is a strip across the top rather than a
   block inside the rail, so no tab can take the whole window and leave the
   person with a "<- Conversations" button where the navigation used to be.
   Under it the window is always the same two columns: a narrow rail that
   holds whatever this tab needs kept in view, and a pane that gets every
   remaining pixel.

   Rows: bar, the stale banner (zero-height until there is one), content. */
.app{display:grid;grid-template-columns:var(--rail) 1fr;
  grid-template-rows:auto auto 1fr;height:100dvh}
/* min-width:0 and the wrap because this bar spans both grid columns: its
   min-content width -- brand, three tabs, handle -- otherwise becomes the
   floor for the whole grid, and the panes below inherit a window wider than
   the window. */
.topbar{grid-column:1/-1;grid-row:1;display:flex;align-items:center;gap:var(--s3);
  flex-wrap:wrap;min-width:0;
  padding:var(--s3) var(--s4);border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--sheet) 80%,transparent);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px)}
.banners{grid-column:1/-1;grid-row:2;display:flex;flex-direction:column}
.left{grid-column:1;grid-row:3}
.right{grid-column:2;grid-row:3}
@media (max-width:880px){
  .app{grid-template-columns:1fr}
  .left,.right{grid-column:1;grid-row:3}
  .right{display:none}
  .app.detail .left{display:none}
  .app.detail .right{display:flex}
  /* Under 880px the window is one column, so the rail and the pane stack
     instead of sitting side by side. The rail is capped rather than hidden:
     on Buddies it holds the QR, which is the thing this screen exists to
     hand to somebody else. */
  .app.buds{grid-template-rows:auto auto minmax(0,auto) minmax(0,1fr)}
  .app.buds .left{grid-row:3;max-height:48dvh;border-right:0;
    border-bottom:1px solid var(--line)}
  .app.buds .right{grid-row:4;display:flex}
}

/* ---------- rail ---------- */
.left{border-right:1px solid var(--line);background:var(--sheet);
  display:flex;flex-direction:column;min-height:0;min-width:0}
/* The mark, on the same tile the app icon uses, so the thing in the dock and
   the thing at the top of the rail are recognisably one product. Square now
   that it holds a glyph rather than the word. */
.logo{width:var(--s6);height:var(--s6);border-radius:var(--r1);flex:none;
  display:grid;place-items:center;background:var(--brand);color:var(--brand-ink)}
.logo svg{width:var(--s5);height:var(--s5);display:block}
.wordmark{font-weight:var(--w3);font-size:var(--t4);letter-spacing:-.01em}
/* The handle is this machine's own, and the CLI accepts a longer one than
   the wizard's 40 characters. Unclamped it widened the topbar past the
   window and gave the whole page a horizontal scrollbar. */
.whoami{margin-left:auto;text-align:right;min-width:0;max-width:34ch;overflow:hidden}
.whoami .h,.whoami .k{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.whoami .h{font-size:var(--t2);font-weight:var(--w2);color:var(--dim)}
.whoami .k{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}

.tabs{display:flex;gap:var(--s1);margin-left:var(--s4)}
.tab{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);cursor:pointer;
  border:1px solid transparent;background:transparent;color:var(--dim);display:flex;
  align-items:center;gap:var(--s2);transition:background var(--ease),color var(--ease)}
.tab:hover{background:var(--sunk);color:var(--fg)}
.tab.on{background:var(--accent);border-color:transparent;color:var(--accent-ink);
  font-weight:var(--w2)}
.tab .bdg{font-size:var(--t1);min-width:var(--s4);height:var(--s4);border-radius:var(--r3);
  padding:0 var(--s1);
  display:grid;place-items:center;background:var(--bad);color:#fff;font-weight:var(--w3)}

.filters{display:flex;gap:var(--s1);padding:var(--s4) var(--s4) var(--s3)}
.chip{font-size:var(--t2);padding:var(--s1) var(--s3);border-radius:var(--r3);color:var(--dim);
  border:1px solid transparent;transition:background var(--ease),color var(--ease)}
.chip:hover{background:var(--sunk);color:var(--fg)}
.chip.on{background:var(--accent-soft);color:var(--accent);font-weight:var(--w2)}
.chip .n{font-family:var(--mono);font-size:var(--t1);opacity:.7;margin-left:var(--s1)}

.list{overflow-y:auto;flex:1;min-height:0;padding:0 var(--s3) var(--s4);scrollbar-width:thin}
.t{width:100%;text-align:left;padding:var(--s3);border-radius:var(--r2);
  display:grid;grid-template-columns:auto 1fr;gap:var(--s3);position:relative;
  transition:background var(--ease)}
.t:hover{background:var(--sunk)}
.t.sel{background:var(--accent-soft)}
.t + .t{margin-top:var(--s1)}
.avatar{width:var(--s6);height:var(--s6);border-radius:var(--r1);flex:none;display:grid;
  place-items:center;font-size:var(--t2);font-weight:var(--w3)}
.tbody{min-width:0}
.trow{display:flex;gap:var(--s2);align-items:baseline}
/* Two lines, not one with an ellipsis. Subjects are written by the sender
   and are routinely longer than any rail width, so a single line meant every
   row in the list ended in "..." and the list stopped distinguishing threads. */
.subj{font-weight:var(--w2);font-size:var(--t3);flex:1;letter-spacing:-.005em;
  display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
.when{font-family:var(--mono);font-size:var(--t1);color:var(--faint);flex:none}
.tmeta{display:flex;gap:var(--s2);align-items:center;margin-top:var(--s1);font-size:var(--t2);
  color:var(--dim);min-width:0}
.tmeta .peers{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pip{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--accent);flex:none}
.state{padding:var(--s1) var(--s2);border-radius:var(--r1);flex:none;white-space:nowrap}
.s-you{color:var(--warn);background:color-mix(in srgb,var(--warn) 13%,transparent)}
.s-them{color:var(--dim);background:var(--sunk)}
.s-settled{color:var(--ok);background:color-mix(in srgb,var(--ok) 13%,transparent)}
.s-closed{color:var(--faint);background:var(--sunk)}
.empty{padding:var(--s7) var(--s5);color:var(--faint);text-align:center;
  font-size:var(--t2);line-height:1.75}

/* ---------- conversation ---------- */
/* min-width:0 for the same reason as .budwrap: a grid item's automatic
   minimum is its content, so a wide config block widened the column rather
   than scrolling inside it, and the whole page gained a horizontal
   scrollbar. */
.right{display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--bg)}
.head{padding:var(--s4) var(--s5);border-bottom:1px solid var(--line);background:var(--sheet);
  display:flex;gap:var(--s3);align-items:center;flex:none}
.back{display:none;font-size:var(--t4);color:var(--dim);padding:var(--s1) var(--s2)}
@media (max-width:880px){.back{display:block}}
.head h1{font-size:var(--t4);font-weight:var(--w3);overflow:hidden;text-overflow:ellipsis;
  white-space:nowrap;letter-spacing:-.012em}
.head .sp{flex:1}
.headbtn{font-size:var(--t2);color:var(--dim);padding:var(--s2) var(--s3);border-radius:var(--r3);
  border:1px solid var(--line2);white-space:nowrap;transition:border-color var(--ease),color var(--ease)}
.headbtn:hover{border-color:var(--accent);color:var(--accent)}

.msgs{overflow-y:auto;flex:1;min-height:0;padding:var(--s5) var(--s5) var(--s3);
  display:flex;flex-direction:column;gap:var(--s5)}
/* Centred, not left-aligned against the rail: past about 1200px the column
   kept its measure but all the surplus piled up on the right, which read as
   a broken layout rather than a deliberate one. */
.m{max-width:760px;min-width:0;width:100%;margin-inline:auto}
.m.mine{margin-left:auto}
.mhead{display:flex;gap:var(--s2);align-items:center;margin-bottom:var(--s2);
  flex-wrap:wrap}
.m.mine .mhead{justify-content:flex-end}
.who{font-weight:var(--w3);font-size:var(--t2)}
.ty{padding:var(--s1) var(--s2);border-radius:var(--r1);background:var(--sunk);color:var(--dim)}
.ty.question,.ty.review-request{color:var(--warn)}
.ty.answer{color:var(--ok)}
.at{font-family:var(--mono);font-size:var(--t1);color:var(--faint)}
.dot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--accent)}

.sheet{background:var(--sheet);border:1px solid var(--line);border-radius:var(--r2);
  overflow:hidden;box-shadow:var(--shadow)}
.m.mine .sheet{background:var(--sunk);box-shadow:none}
/* The verdict keeps the weight of a headline. It is the product; a redesign
   that quietly turned it into a caption would be a regression, not a style.
   So it is the one place the micro-label rule is overridden UPWARD. */
.verdict{display:flex;gap:var(--s2);align-items:center;padding:var(--s3) var(--s4);
  font-size:var(--t2);letter-spacing:.06em}
.verdict .vdot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:currentColor;flex:none}
.v-ok{color:var(--ok);background:color-mix(in srgb,var(--ok) 10%,transparent)}
.v-warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 10%,transparent)}
.v-bad{color:var(--bad);background:color-mix(in srgb,var(--bad) 12%,transparent)}
.stale{margin:0 var(--s3) var(--s2);padding:var(--s2) var(--s3);border-radius:var(--r1);
  font-size:var(--t2);line-height:1.5;color:var(--bad);
  border:1px solid color-mix(in srgb,var(--bad) 35%,transparent);
  background:color-mix(in srgb,var(--bad) 10%,transparent)}
.connbar{margin:0 var(--s3) var(--s2);padding:var(--s2) var(--s3);border-radius:var(--r1);
  font-size:var(--t2);line-height:1.5;color:var(--ink);
  border:1px solid var(--line);background:var(--sheet);
  display:flex;flex-direction:column;gap:var(--s1)}
.connbar[hidden]{display:none}
.connrow{display:flex;align-items:center;gap:var(--s2);flex-wrap:wrap}
.connrow .sp{flex:1}
.bigfp{font-size:1.02rem;color:var(--ink);letter-spacing:.01em;padding:var(--s1) 0}
.dev.spot{border-color:var(--accent);
  box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 25%,transparent)}
.alert{padding:var(--s3) var(--s4);font-size:var(--t2);line-height:1.55;color:var(--warn);
  border-top:1px solid var(--line)}
.alert.bad{color:var(--bad)}
.alert b{font-weight:var(--w3)}
.alert code{font-family:var(--mono);font-size:var(--t1)}
.files{border-top:1px solid var(--line);padding:var(--s1) 0}
.f{display:flex;gap:var(--s3);align-items:center;padding:var(--s1) var(--s4);
  font-family:var(--mono);font-size:var(--t1)}
.tag{padding:var(--s1) 0;border-radius:var(--r1);width:calc(var(--s7) + var(--s4));text-align:center;flex:none;
  font-family:var(--sans);background:var(--sunk);letter-spacing:.06em}
.m.mine .tag{background:var(--sheet)}
.t-file{color:var(--file)} .t-prompt{color:var(--prompt)}
.t-memory{color:var(--memory)} .t-decision{color:var(--decision)}
.fp{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fb{color:var(--faint);flex:none}
.acts{display:flex;gap:var(--s2);align-items:center;flex-wrap:wrap;
  padding:var(--s3) var(--s4);border-top:1px solid var(--line)}
.btn{font-size:var(--t2);padding:var(--s2) var(--s4);border-radius:var(--r3);
  border:1px solid var(--line2);white-space:nowrap;
  transition:border-color var(--ease),color var(--ease),background var(--ease)}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.go{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);
  font-weight:var(--w2)}
.btn.go:hover{opacity:.88;color:var(--accent-ink)}
.btn.no{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,transparent)}
.btn.no:hover{border-color:var(--bad);color:var(--bad)}
.cmd{font-family:var(--mono);font-size:var(--t1);color:var(--faint);margin-left:auto;
  user-select:all;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55%}

/* ---------- composer ---------- */
.composer{flex:none;border-top:1px solid var(--line);background:var(--sheet);
  padding:var(--s4) var(--s5)}
.cwrap{max-width:720px;margin-inline:auto;display:flex;flex-direction:column;gap:var(--s2)}
.cto{font-size:var(--t2);color:var(--dim);display:flex;gap:var(--s2);align-items:center;
  flex-wrap:wrap}
.cto b{font-weight:var(--w3);color:var(--fg)}
.cto select{font-size:var(--t2);padding:var(--s1) var(--s2);border-radius:var(--r1);
  border:1px solid var(--line2);background:var(--bg)}
textarea{width:100%;min-height:64px;max-height:40vh;resize:vertical;
  background:var(--bg);border:1px solid var(--line2);border-radius:var(--r2);
  padding:var(--s3);font-size:var(--t3);line-height:1.6;
  transition:border-color var(--ease)}
textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
.crow{display:flex;gap:var(--s3);align-items:center}
.chint{font-size:var(--t1);color:var(--faint)}
.cerr{font-size:var(--t2);color:var(--bad)}
.cblocked{font-size:var(--t2);color:var(--dim);line-height:1.55}

/* ---------- panels: network and buddies ---------- */
.lanbox{border-top:1px solid var(--line);padding:var(--s4) var(--s3) var(--s4);flex:none;
  max-height:46dvh;overflow-y:auto}
.lanhead{display:flex;align-items:center;gap:var(--s2);color:var(--dim);
  margin:0 0 var(--s3) var(--s2)}
.lanhead .dot{width:var(--s2);height:var(--s2);border-radius:var(--r3);background:var(--faint);flex:none}
.lanhead .dot.on{background:var(--ok);box-shadow:0 0 0 var(--s1) color-mix(in srgb,var(--ok) 20%,transparent)}
/* Setup is one column. There is nothing to put beside a checklist, and the
   rail's three jobs -- the thread list, this machine's identity, the agent
   form -- each belong to one of the other tabs. */
.app.solo{grid-template-columns:1fr}
.app.solo .left{display:none}
/* The pane has to move with the column it lives in. Hiding the rail without
   this leaves .right in an implicit second column and the checklist pinned
   to the right half of an otherwise empty window. */
.app.solo .right{grid-column:1}
.setup{max-width:var(--measure);margin-inline:auto;width:100%}
/* The heading sits in the same column as the cards it introduces: .hint
   carries a side padding meant for prose standing alone under a pane. */
.setup > .hint{padding-inline:0;margin-top:0}
/* A step's sentence is prose. .devfp is monospace because what it usually
   holds is a key, and a paragraph set in it reads as output rather than as
   something written to be read. */
.stept{font-size:var(--t2);color:var(--dim);margin-top:var(--s2);line-height:1.65}
.step{display:flex;gap:var(--s3);align-items:flex-start}
.stepn{font-family:var(--mono);font-size:var(--t1);color:var(--faint);
  min-width:2ch;padding-top:.25em}
.stepb{flex:1;min-width:0}
/* A satisfied step recedes. It is still readable, and still states the fact
   that satisfies it, but it has stopped being something to do. */
.dev.done{background:var(--sheet)}
.dev{border:1px solid var(--line);border-radius:var(--r2);padding:var(--s3);
  margin-bottom:var(--s2);background:var(--bg);transition:border-color var(--ease)}
.dev.sel{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
/* A request is the only thing in the panel asking for a decision, so it is
   the only thing that gets a colour. */
.dev.ask{border-color:color-mix(in srgb,var(--warn) 45%,var(--line));background:var(--sunk)}
.devtop{display:flex;align-items:baseline;gap:var(--s2);flex-wrap:wrap}
.devname{font-weight:var(--w3);font-size:var(--t3);letter-spacing:-.005em}
.devtag{font-size:var(--t1);padding:var(--s1) var(--s2);border-radius:var(--r3);
  border:1px solid var(--line2);color:var(--dim);white-space:nowrap}
.devtag.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
/* Unconfirmed is the ordinary state of a new contact, not an error, so it
   takes the warn colour rather than the bad one -- and never the ok colour,
   which this page reserves for a verdict that has been earned. */
.devtag.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
/* Word-pairs are the thing a person compares character by character, so they
   get the monospace face, room to breathe, and no truncation ever. */
.devfp{font-size:var(--t2);color:var(--fg);margin-top:var(--s2);line-height:1.6;
  font-family:var(--mono);word-break:break-word;letter-spacing:-.01em}
.devrow{display:flex;gap:var(--s2);margin-top:var(--s3);flex-wrap:wrap}
.devbtn{font-size:var(--t2);padding:var(--s2) var(--s4);border-radius:var(--r3);cursor:pointer;
  border:1px solid var(--line2);background:transparent;color:var(--fg);
  transition:border-color var(--ease),color var(--ease)}
.devbtn:hover{border-color:var(--accent);color:var(--accent)}
/* A trust decision is not two identical grey boxes. The act that extends
   trust is filled; the one that records a refusal -- which is reversible,
   and withholds nothing that doing nothing does not already withhold -- is
   a quiet button. The fill is the same accent the tabs and the Send button
   already use, and the refusal is neutral rather than red, because red here
   means a verdict. The fill used to be a green a shade off the Verified
   badge's -- in dark mode exactly it -- which is why the palette now keeps
   green for verdicts and paints the accent blue. */
.devbtn.go{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);
  font-weight:var(--w2)}
.devbtn.go:hover{opacity:.88;color:var(--accent-ink);border-color:var(--accent)}
.devbtn.quiet{border-color:transparent;color:var(--dim)}
.devbtn.quiet:hover{border-color:transparent;color:var(--fg);background:var(--sunk)}
/* The armed state of a destructive button. Colour is the only signal that
   changes, and it changes on the button that does the thing rather than on
   the row, so the Cancel beside it stays ordinary. */
.devbtn.danger{border-color:var(--bad);color:var(--bad)}
.devbtn.danger:hover{background:var(--bad);color:#fff;border-color:var(--bad)}
.agkill{align-items:center}
.agkill .lansub{margin-top:0;flex:1 1 220px;min-width:0}
.lansub{font-size:var(--t2);color:var(--dim);margin-top:var(--s2);line-height:1.6}
.lanempty{font-size:var(--t2);color:var(--dim);line-height:1.6;margin-left:var(--s2)}
/* A panel that is switched off says so on one line and gets out of the way;
   the rail's job is the thread list. */
.lanrow{display:flex;align-items:center;gap:var(--s3);margin-left:var(--s2)}
.lanrow .lanempty{margin-left:0;flex:1;min-width:0;overflow:hidden;
  text-overflow:ellipsis;white-space:nowrap}
.netbox{max-height:none}
.lanin{font:inherit;font-size:var(--t2);padding:var(--s2);border-radius:var(--r1);
  border:1px solid var(--line2);background:var(--bg);color:var(--fg);margin-top:var(--s2);
  transition:border-color var(--ease)}
.lanin:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
.lanerr{font-size:var(--t2);color:var(--bad);margin-top:var(--s2);line-height:1.55}
.lanok{font-size:var(--t2);color:var(--ok);margin-top:var(--s2);line-height:1.55}
.drop{border:1px dashed var(--line2);border-radius:var(--r2);padding:var(--s4) var(--s3);
  text-align:center;font-size:var(--t2);color:var(--dim);cursor:pointer;margin-top:var(--s2);
  transition:border-color var(--ease),background var(--ease)}
.drop:hover,.drop.over{border-color:var(--accent);background:var(--accent-soft)}
.drop.has{border-style:solid;color:var(--fg)}

/* The rail's tab-dependent half. On Buddies it holds this machine's own
   identity -- the QR and the line that goes with it -- and on Agents the
   form that makes one. Both are the thing you came to the tab to hand to
   somebody; neither is ever below anything. */
.side{padding:var(--s4) var(--s4) var(--s6);overflow-y:auto;flex:1;min-height:0;
  min-width:0}
.side .hint{max-width:none;margin:0 0 var(--s5);padding:0;font-size:var(--t2)}
.side .hint h2{font-size:var(--t3);margin-bottom:var(--s2)}
.side .hint p{margin-bottom:var(--s3)}
.side .invite{margin-top:0}
.side .invite h2{font-size:var(--t3);margin-bottom:var(--s2)}
.side .invite p{font-size:var(--t2);margin-bottom:var(--s3);max-width:none}
.side .bsec:first-child{margin-top:0}
.side .budnote{max-width:none}
/* A disclosure, closed. The sentences are the ones that were always here;
   what changed is that they are no longer standing between a person and the
   decision the page exists for. */
.disc{border-top:1px solid var(--line);margin-top:var(--s4);padding-top:var(--s3)}
.disc > summary{font-size:var(--t2);color:var(--dim);cursor:pointer;
  font-weight:var(--w2);list-style:none}
.disc > summary::-webkit-details-marker{display:none}
.disc > summary::before{content:"\25B8\00a0"}
.disc[open] > summary::before{content:"\25BE\00a0"}
.disc > summary:hover{color:var(--fg)}
.disc .hint{margin-top:var(--s3)}

/* min-width:0 because this is a flex item: without it the item's automatic
   minimum is its content, so one wide <pre> stops scrolling and widens the
   whole page instead. */
.budwrap{width:100%;min-width:0}
.budwrap .dev{min-width:0}
.budgrid{display:grid;gap:var(--s3);
  grid-template-columns:repeat(auto-fill,minmax(336px,1fr));align-items:start}
.budgrid .dev{margin-bottom:0}
/* Agent cards carry a config block that is copied, not read, so they are
   given a wider minimum than a buddy card and still fill the pane. */
.aggrid{grid-template-columns:repeat(auto-fill,minmax(440px,1fr))}
.budnote{max-width:var(--measure)}
.hostpick{display:flex;gap:var(--s2);flex-wrap:wrap;margin-bottom:var(--s4)}
.copied{font-size:var(--t2);color:var(--ok)}
.copied.bad{color:var(--warn)}
/* A config block is copied, not read: it keeps its line breaks and scrolls
   rather than wrapping, because a wrapped JSON block invites a bad paste. */
.cfg{font-family:var(--mono);font-size:var(--t2);line-height:1.55;
  background:var(--sunk);border:1px solid var(--line);border-radius:var(--r1);
  padding:var(--s3);margin-top:var(--s2);overflow-x:auto;white-space:pre;
  color:var(--fg)}
.bsec{color:var(--dim);margin:var(--s5) 0 var(--s3) var(--s2);display:flex;
  align-items:baseline;gap:var(--s2)}
.bsec:first-child{margin-top:var(--s3)}
.bsect{color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit}
/* A button that reads as a link because it navigates rather than acts. The
   global reset already strips the chrome; this only adds the underline that
   says it can be clicked. */
.blink{color:var(--accent);text-decoration:underline;padding:0}
.bsect:hover{color:var(--fg)}
.bgate{margin-top:var(--s3);padding-top:var(--s3);border-top:1px dashed var(--line2)}
.bwhy{font-size:var(--t2);color:var(--dim);line-height:1.65;margin-bottom:var(--s2)}
.bdots{display:flex;gap:var(--s3);margin-top:var(--s2);flex-wrap:wrap}
.breach{font-size:var(--t1);color:var(--faint);display:flex;align-items:center;gap:var(--s1)}
.breach.on{color:var(--ok)}
.breach i{width:var(--s1);height:var(--s1);border-radius:var(--r3);background:currentColor;
  display:inline-block;opacity:.5}
.breach.on i{opacity:1}
.bnum{font-size:var(--t1);padding:var(--s1) var(--s2);border-radius:var(--r3);
  background:var(--accent);color:var(--accent-ink);font-weight:var(--w3)}
.berr{font-size:var(--t2);color:var(--bad);margin-top:var(--s3);line-height:1.6}
.bok{font-size:var(--t2);color:var(--ok);margin-top:var(--s3);line-height:1.6}

/* ---------- reading pane prose ---------- */
/* Anchored to the same left edge as the header, not centred in the pane.
   A centred column in a wide empty pane reads as a landing page; aligning it
   with the title above gives the pane a single spine, which is what makes an
   app feel like one surface rather than two. */
/* Centred for the same reason as .m: the empty state is the first thing a
   new person sees, and it was sitting against the rail with the rest of the
   window empty beside it. Inside the buddies page it stays left, under the
   cards it explains. */
.hint{max-width:var(--measure);margin:var(--s6) auto 0;padding:0 var(--s5);color:var(--dim);
  font-size:var(--t3);line-height:1.7}
.hint h2{font-size:var(--t5);font-weight:var(--w3);color:var(--fg);
  letter-spacing:-.022em;margin-bottom:var(--s4)}
.hint p{margin-bottom:var(--s4)}
/* `box-decoration-break:clone` so a command that wraps gets a complete box
   on each line instead of one box torn in half -- which is what a long
   `gage send ...` example did at this column width. */
.hint code{font-family:var(--mono);font-size:var(--t2);background:var(--sunk);
  border:1px solid var(--line);border-radius:var(--r1);padding:var(--s1) var(--s2);
  -webkit-box-decoration-break:clone;box-decoration-break:clone}
/* The QR is black modules on a white field, including in the dark theme.
   `fill` on the element is explicit too; this is the quiet zone around it. */
.invite{margin-top:var(--s5)}
.invite h2{font-size:var(--t4);font-weight:var(--w3);color:var(--fg);margin-bottom:var(--s3)}
.invite p{color:var(--dim);margin-bottom:var(--s4);max-width:var(--measure)}
.invite svg{display:block;width:min(260px,100%);height:auto;background:#fff;
  border:1px solid var(--line);border-radius:var(--r1)}
.wizard{position:fixed;inset:0;z-index:20;background:var(--bg);display:grid;place-items:center;
  padding:var(--s5);overflow:auto}
.wcard{width:min(680px,100%);background:var(--sheet);border:1px solid var(--line);
  border-radius:var(--r2);padding:var(--s6);box-shadow:var(--shadow)}
.wcard h1{font-size:var(--t5);margin-bottom:var(--s4)}
.wcard p{color:var(--dim);margin:0 0 var(--s4);max-width:var(--measure)}
.winput{width:100%;padding:var(--s3);border:1px solid var(--line2);border-radius:var(--r1);
  background:var(--bg);margin:0 0 var(--s3)}
.wwords{font-family:var(--mono);font-size:var(--t3);line-height:1.9;white-space:pre-wrap;
  background:var(--sunk);border-radius:var(--r1);padding:var(--s4);margin:var(--s3) 0}
.werror{color:var(--bad);margin:var(--s3) 0}
.wcheck{display:flex;gap:var(--s2);align-items:flex-start;color:var(--dim);margin:var(--s4) 0}
.wcheck input{margin-top:var(--s1)}

/* Four utilities, last so they win, in place of the inline `style="..."`
   attributes that carried widths and margins off every scale this file
   declares. The two that remain compute a colour from a key. */
.wide{width:100%}
.gap{margin-top:var(--s3)}
.note{min-height:var(--s7)}
.bwarn{color:var(--warn)}
</style>

<div class="app" id="app">
  <!-- The tab bar spans the window and belongs to no tab, so no section can
       take the screen and leave the person with a back button where the
       navigation used to be. -->
  <header class="topbar">
    <span class="logo"><svg viewBox="0 0 512 512" aria-hidden="true">
      <g fill="none" stroke="currentColor" stroke-linecap="round">
        <path d="M 340.85 374.85 A 120 120 0 1 0 171.15 374.85" stroke-width="46"/>
        <path d="M 256 290 L 310.5 212.2" stroke-width="40"/>
      </g><circle cx="256" cy="290" r="23" fill="currentColor"/></svg></span>
    <span class="wordmark" id="wordmark">gage</span>
    <nav class="tabs" id="tabs"></nav>
    <span class="whoami" id="me"></span>
  </header>
  <div class="banners"><div class="stale" id="stale" hidden></div><div class="connbar" id="connbar" hidden></div></div>
  <div class="left">
    <div class="filters" id="filters"></div>
    <div class="list" id="list"></div>
    <div class="side" id="budside" hidden></div>
    <div class="lanbox" id="lanbox"></div>
    <div class="lanbox netbox" id="netbox"></div>
  </div>
  <div class="right">
    <div class="head">
      <button class="back" onclick="back()" title="Back">&larr;</button>
      <h1 id="title">Conversations</h1><span class="sp"></span>
      <span id="hstate"></span>
      <button class="headbtn" id="settle" hidden onclick="act('close',SEL)">Mark settled</button>
    </div>
    <div class="msgs" id="msgs"></div>
    <div class="composer" id="composer" hidden></div>
    <div class="msgs" id="budpane" hidden></div>
    <div class="msgs" id="setuppane" hidden></div>
  </div>
</div>
<div class="wizard" id="wizard" hidden aria-live="polite"></div>

<script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kb=n=>n<1024?n+' B':n<1048576?(n/1024).toFixed(1)+' KB':(n/1048576).toFixed(1)+' MB';
const ago=iso=>{const d=(Date.now()-Date.parse(iso))/1000;
  if(!isFinite(d))return'';
  if(d<60)return'now'; if(d<3600)return Math.floor(d/60)+'m';
  if(d<86400)return Math.floor(d/3600)+'h'; return Math.floor(d/86400)+'d';};

// Every verdict the tool can produce is listed. The fallback shows the raw
// label rather than the nearest friendly word: an unrecognised trust verdict
// rendered as a recognised one is how a new, worse verdict gets displayed as
// an old, milder one.
const V=[['SENDER VERIFIED','Verified','v-ok'],
         // On your list, and nobody here ever compared its word-pairs. Warn
         // and not ok: every route onto the list except `gage confirm` can be
         // driven by software, so this is the verdict most contacts have, and
         // it must not be allowed to read as the one above it.
         ['SENDER ON YOUR LIST','Unconfirmed','v-warn'],
         ['SENDER NOT','Not on your list','v-warn'],
         // A key you looked at and refused. Warn and not bad: the state is
         // one the person chose, and it withholds nothing that "not on your
         // list" does not already withhold.
         ['SENDER REFUSED','Refused by you','v-warn'],
         ['SENDER KEY RETIRED','Retired key','v-warn'],
         ['SENDER KEY REJECTED','Key rejected','v-bad'],
         ['ARRIVAL UNRECORDED','Arrival unrecorded','v-bad'],
         ['SENDER UNVERIFIABLE','Unverifiable','v-bad'],
         // Not a judgement about a sender -- the absence of one, because the
         // observation step failed. Styled as bad, since the one thing it
         // must never look like is a pass.
         ['VERDICT UNAVAILABLE','Verdict unavailable','v-bad']];
// Per-contact confirm errors, keyed by name. Kept out of BUD because BUD
// is replaced wholesale on every poll and an error must survive that.
const CONFE={};
const vRow=t=>V.find(r=>(t||'').startsWith(r[0]));
const vClass=t=>{const r=vRow(t);return r?r[2]:'v-bad';};
const vShort=t=>{const r=vRow(t);return r?r[1]:(t||'No verdict');};
const sClass=s=>s.includes('YOU')?'s-you':s.includes('them')?'s-them':s==='closed'?'s-closed':'s-settled';
const sShort=s=>s.includes('YOU')?'needs you':s;

// A monogram coloured from the KEY, not the name. Two contacts who both call
// themselves "codex" get different marks, so an impersonation shows up as a
// changed colour before anyone reads a word. It is an aid and never a verdict:
// the verdict is always spelled out next to it.
function keyHue(k){let h=0;for(const c of String(k||'')) h=(h*31+c.charCodeAt(0))%360;return h;}
function avatar(m){
  const label=(m.known_as||m.claimed_name||'?').trim()[0]||'?';
  if(m.mine) return `<span class="avatar" style="background:var(--accent);color:var(--accent-ink)">${esc(label.toUpperCase())}</span>`;
  const h=keyHue(m.key||m.known_as||m.claimed_name);
  return `<span class="avatar" style="background:hsl(${h} 42% 88%);color:hsl(${h} 55% 28%)">${esc(label.toUpperCase())}</span>`;
}
const nameOf=m=>m.mine?'you':(m.known_as?m.known_as:`"${m.claimed_name}"`);

let DATA={threads:[],contacts:[]}, LERR='', SEL=null, FILTER='all', DRAFT={}, CERR='', SENDING=false;
let PASTED='', PNAME='', PKEY='', PPAIR=0;

function alerts(m){
  const out=[];
  if(m.tampered&&m.tampered.length) out.push(['bad',
    `Payload does not match the signed manifest (${esc(m.tampered.join(', '))}). Contents withheld.`]);
  if(!m.mine && m.known_as && m.known_as!==m.claimed_name) out.push(['bad',
    `Name mismatch. This key is <b>${esc(m.known_as)}</b> on your list, but the bundle claims "${esc(m.claimed_name)}". The claimed name is text the sender chose.`]);
  if(!m.mine && !m.known_as) out.push(['',
    `This key is not on your list. It claims the name "${esc(m.claimed_name)}", which is unverified.`]);
  if(m.thread_ok===false) out.push(['bad',
    `This message <b>claims</b> to belong to this conversation while answering something you do not hold. The thread field is written by the sender. Treat it as a separate message, and reply to the person you meant.`]);
  if(m.forwarded) out.push(['',`Forwarded, ${m.hops} hop(s). The sender is not the author, and any hop could have altered it before signing.`]);
  // Expiry and context are verdict facts, so they are alerts beside the
  // signature and the chain of custody rather than detail buried in the
  // sheet. Classified on the WORD the server sent, not on a boolean: an
  // unrecognised freshness must not fall through to the quiet styling, for
  // the same reason the verdict table above lists every verdict and shows an
  // unknown one raw.
  if(!m.mine) {
    if(m.freshness==='expired') out.push(['bad',
      `<b>Expired</b> ${esc(m.expires||'')}. The sender declared this no longer current, inside the signed manifest. <code>gage open</code> withholds the payload. It is still here: an expiry is their statement about their own message, not a decision to refuse the arrival.`]);
    else if(m.freshness==='unreadable') out.push(['bad',
      `<b>Expiry unreadable</b> (${esc(m.expires||'')}) &mdash; not an RFC 3339 timestamp, so it is treated as lapsed rather than as absent and the payload is withheld.`]);
    else if(m.freshness==='current'&&m.expires) out.push(['',
      `Expires ${esc(m.expires)}. After that the payload is withheld.`]);
    else if(m.freshness&&m.freshness!=='undeclared'&&m.freshness!=='current') out.push(['bad',
      `Unrecognised freshness "${esc(m.freshness)}". This viewer does not know what it means, so treat the bundle as not current.`]);
    // Four states, four renderings, classified on the WORD like freshness
    // above it. `undeclared` and `no-local-context` are the pair a reader
    // must not see merged: the second has a label waiting to be compared by
    // hand, the first has nothing to compare, and both are quiet states that
    // a single "no comparison was made" line would flatten into one.
    if(m.context_match==='mismatch') out.push(['bad',
      `<b>Context mismatch.</b> Built for "${esc(m.context||'')}", and this machine says it is in a different run. That is what a replayed handoff looks like &mdash; a good signature carrying work from a run that is over. Compare the two labels before acting on it.`]);
    else if(m.context_match==='match') out.push(['',
      `Context matches the run this machine says it is in ("${esc(m.context||'')}"). It rules out a replay from another run and authorises nothing: a label is not secret.`]);
    else if(m.context_match==='no-local-context') out.push(['',
      `Built for "${esc(m.context||'')}" &mdash; the sender's label for the run. Nothing here says what run this machine is in, so no comparison was made. Compare it yourself.`]);
    else if(m.context_match==='undeclared') out.push(['',
      `No run declared &mdash; this bundle names no run, so nothing was compared. Not a mismatch, and not a confirmation that this is the handoff you were waiting for.`]);
    else if(m.context_match) out.push(['bad',
      `Unrecognised context_match "${esc(m.context_match)}"${m.context?` on a bundle built for "${esc(m.context)}"`:''}. This viewer does not know what it means, so compare the run labels yourself before acting on it.`]);
  }
  if(m.memories) out.push(['bad',
    `${m.memories} memory file(s). Memory outlives the session that accepts it, so it is never merged automatically.`]);
  if(m.prompts) out.push(['',
    `${m.prompts} prompt(s): text written to steer a model. Read it with <code>gage open</code>, which frames it as data.`]);
  return out.map(([c,h])=>`<div class="alert ${c}">${h}</div>`).join('');
}

function msgHtml(m){
  const files=(m.items||[]).map(i=>`<div class="f">
      <span class="tag t-${esc(i.kind)}">${esc(i.kind)}</span>
      <span class="fp">${esc(i.path)}</span><span class="fb">${kb(i.bytes)}</span></div>`).join('');
  const acts = m.stage==='pending'
    ? `<div class="acts">
        <button class="btn go" onclick="act('accept','${esc(m.id)}')">Accept</button>
        <button class="btn no" onclick="act('reject','${esc(m.id)}')">Reject unread</button>
        <span class="cmd">arriving is not accepting</span></div>`
    : m.stage==='accepted'
      ? `<div class="acts">
          ${m.read?'':`<button class="btn" onclick="act('read','${esc(m.id)}')">Mark read</button>`}
          <span class="cmd">gage open ${esc(m.path)}</span></div>`
      : `<div class="acts"><span class="cmd">sent &middot; ${esc(m.id)}</span></div>`;
  return `<div class="m ${m.mine?'mine':''}">
    <div class="mhead">
      ${m.read?'':'<span class="dot"></span>'}
      <span class="who">${esc(nameOf(m))}</span>
      <span class="ty ${esc(m.type)}">${esc(m.type)}</span>
      <span class="at">${esc(m.created)}</span>
    </div>
    <div class="sheet">
      ${m.mine?'':`<div class="verdict ${vClass(m.trust)}"><span class="vdot"></span>${vShort(m.trust)}</div>`}
      ${alerts(m)}
      ${files?`<div class="files">${files}</div>`:''}
      ${acts}
    </div></div>`;
}

const TYPES=['answer','share','question','review-request','ack'];

function composerHtml(t){
  const r=t.reply||{};
  if(!r.can){
    return `<div class="cwrap"><div class="cblocked">
      Cannot reply from here: ${esc(r.why||'no recipient')}.
      ${r.to?`<br><code>gage reply ${esc(t.id)} --text "..."</code>`:''}
    </div></div>`;
  }
  const d=DRAFT[t.id]||{text:'',type:r.type||'answer'};
  return `<div class="cwrap">
    ${CERR?`<div class="cerr">${esc(CERR)}</div>`:''}
    <div class="cto">Reply to <b>${esc(r.to)}</b> as
      <select id="ctype" onchange="draft()">${TYPES.map(x=>
        `<option value="${x}"${x===d.type?' selected':''}>${x}</option>`).join('')}</select>
    </div>
    <textarea id="ctext" placeholder="Write a reply. It is scanned for credentials on the way out, like any file."
      oninput="draft()" onkeydown="ckey(event)">${esc(d.text)}</textarea>
    <div class="crow">
      <span class="chint">Delivered to ${esc(r.to)}'s inbox on this machine. ${SENDING?'Sending...':'&#8984;/Ctrl + Enter to send.'}</span>
      <button class="btn go" onclick="send()" ${SENDING?'disabled':''}>Send reply</button>
    </div></div>`;
}

function render(){
  const me=DATA.me;
  // When the inbox could not be recorded, every verdict in this payload was
  // withheld server-side. Saying why beats leaving the labels to explain
  // themselves.
  // A load failure takes the same banner, and takes it first: with no data
  // there is nothing else on the page to look at.
  const st=document.getElementById('stale');
  if(LERR){ st.hidden=false; st.textContent=LERR; }
  else st.hidden=!DATA.verdicts_stale;
  if(!LERR && DATA.verdicts_stale) st.textContent=
    'Verdicts withheld: your inbox could not be recorded, so a verdict here could read '+
    'milder than the truth. Usually another gage process is writing \u2014 it clears on '+
    'the next poll. Until then, read bundles with gage open in a terminal.';
  document.getElementById('me').innerHTML = me
    ? `<div class="h">${esc(me.handle)}</div><div class="k">${esc((me.key||'').slice(0,10))}&hellip;</div>`
    : `<div class="h">no identity</div><div class="k">run gage init</div>`;
  const th=DATA.threads||[];
  const needYou=th.filter(t=>t.state.includes('YOU')).length;
  const unread=th.reduce((a,t)=>a+t.unread,0);
  document.getElementById('filters').innerHTML=
    [['all','All',th.length],['you','Needs you',needYou],['unread','Unread',unread]]
    .map(([k,l,n])=>`<button class="chip ${FILTER===k?'on':''}" onclick="setF('${k}')">${l}<span class="n">${n}</span></button>`).join('');

  let shown=th;
  if(FILTER==='you') shown=th.filter(t=>t.state.includes('YOU'));
  if(FILTER==='unread') shown=th.filter(t=>t.unread>0);

  document.getElementById('list').innerHTML = shown.length ? shown.map(t=>{
    const last=t.messages[t.messages.length-1]||{};
    const other=[...t.messages].reverse().find(m=>!m.mine)||last;
    const who=[...new Set(t.messages.map(nameOf))].join(', ');
    return `<button class="t ${SEL===t.id?'sel':''}" onclick="sel('${esc(t.id)}')">
      ${avatar(other)}
      <span class="tbody">
        <span class="trow"><span class="subj">${esc(t.subject)}</span>
          <span class="when">${esc(ago(last.created))}</span></span>
        <span class="tmeta">
          ${t.unread?'<span class="pip"></span>':''}
          ${t.correspondents>1?'<span class="state s-you">'+t.correspondents+' senders</span>':''}
          <span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>
          <span class="peers">${esc(who)}</span></span>
      </span>
    </button>`;}).join('')
    : `<div class="empty">No bundles yet. Pair on this network, then ask your buddy to send you one.</div>`;

  // The right-hand pane and the header belong to whichever tab is showing.
  // This runs on every poll, so without the guard it reinstated the Chats
  // header two and a half seconds after `setTab` set the other one.
  if(TAB!=='chats') return;
  const t=th.find(x=>x.id===SEL);
  document.getElementById('title').textContent = t ? t.subject : 'Conversations';
  document.getElementById('hstate').innerHTML = t
    ? `<span class="state ${sClass(t.state)}">${esc(sShort(t.state))}</span>` : '';
  document.getElementById('settle').hidden = !t || t.state==='closed';
  const comp=document.getElementById('composer');
  comp.hidden = !t;
  document.getElementById('msgs').innerHTML = t
    ? t.messages.map(msgHtml).join('')
    : `<div class="hint">
        <h2>Pick a conversation</h2>
        <p>Threads group by the message that started them. What is read, and what
           counts as settled, is yours: a sender cannot mark their own message
           read in your inbox.</p>
        <p><code>gage send --to codex --type question --decision q.md</code><br>
           <code>gage reply &lt;id&gt; --text "..."</code></p>
        <p>Replies written here are delivered to a local route. Files you drop
           on a machine in <b>On this network</b> go straight to it, over the
           wifi, and land unread in its inbox. Sending through a <i>relay</i> is
           still <code>gage push</code> in a terminal, where you see the list
           first.</p>
      </div>`;
  if(t){
    const prev=document.activeElement&&document.activeElement.id;
    const caret=prev==='ctext'?document.activeElement.selectionStart:null;
    comp.innerHTML=composerHtml(t);
    if(prev==='ctext'){
      const el=document.getElementById('ctext');
      if(el){el.focus();if(caret!=null)el.setSelectionRange(caret,caret);}
    }
  }
}

function setF(f){FILTER=f;render();}
function sel(id){SEL=id;CERR='';document.getElementById('app').classList.add('detail');render();
  const m=document.getElementById('msgs');if(m)m.scrollTop=m.scrollHeight;}
function back(){document.getElementById('app').classList.remove('detail');SEL=null;render();}
function draft(){
  const te=document.getElementById('ctext'), ty=document.getElementById('ctype');
  if(SEL) DRAFT[SEL]={text:te?te.value:'',type:ty?ty.value:'answer'};
}
function ckey(e){ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send();} }

// The custom header is the point: a cross-site form cannot set one, and a
// cross-site fetch that tries must first pass a preflight the server does not
// answer. Without it, any page you visited could act on your inbox.
const post=(path,body)=>fetch(path,{method:'POST',headers:
  Object.assign({'X-Gage':'1'},body?{'Content-Type':'application/json'}:{}),
  body:body?JSON.stringify(body):undefined});

async function act(what,id){
  if(!id)return;
  const r=await post(`/api/${what}/${id}`);
  if(!r.ok){const j=await r.json().catch(()=>({}));CERR=j.error||'that did not work';}
  await load();
}
async function send(){
  draft();
  const d=DRAFT[SEL]; if(!d||!d.text.trim()||SENDING) return;
  SENDING=true;CERR='';render();
  try{
    const r=await post('/api/reply',{thread:SEL,text:d.text,type:d.type});
    const j=await r.json().catch(()=>({}));
    if(r.ok){ DRAFT[SEL]={text:'',type:d.type}; }
    else { CERR=j.error||'the reply was not sent'; }
  }catch(e){ CERR=String(e); }
  SENDING=false;
  await load();
}
async function load(){
  // Never return silently. The first version did, and when the server's own
  // CSP blocked this fetch the result was a blank page: no error, no clue,
  // and nothing in any log. A loader that fails invisibly is worse than one
  // that crashes.
  try{
    const r=await fetch('/api/threads');
    if(!r.ok)throw new Error('HTTP '+r.status);
    DATA=await r.json();
    LERR='';
  }catch(e){
    LERR='Could not load your inbox from this server: '+(e&&e.message?e.message:e)
      +'. The server is at '+location.origin+'; gage ui logs to the terminal it runs in.';
    render();
    return;
  }
  if(SEL&&!DATA.threads.some(t=>t.id===SEL))SEL=null;
  render();
}
// ---------------------------------------------------------------------------
// On this network
// ---------------------------------------------------------------------------
// The page never announces. It reads the list `gage lan` keeps, so the viewer
// cannot make a machine visible that the person did not choose to make
// visible -- which would be the one thing a button here must not be able to
// do.
let LAN={lan_running:false,devices:[]}, LSEL=null, FILES=[], LERR2='', LOK='', LBUSY=false;
// The relay the machine is joined to, or null. Polled with the device list.
let RELAY=null, RADDR='', RBUSY=false, RERR='', ROK='', RSHOW=false;
let LPAIR=null, LNAME='';

const PAIRHINT='Check all four pairs against what the other machine shows. They come '
  +'from its key, so nobody picks their own. If one word differs, it is not that key.';

function netRender(){
  const box=document.getElementById('netbox');
  if(!box)return;
  const on=!!RELAY;
  let h=`<div class="lanhead"><span class="dot${on?' on':''}"></span>Over the internet</div>`;
  if(on){
    h+=`<div class="lanrow"><span class="lanempty">${esc(RELAY)}</span>
        <button class="devbtn" onclick="relayOff()" ${RBUSY?'disabled':''}>
          ${RBUSY?'Working...':'Turn off'}</button></div>`;
  }else if(!RSHOW){
    // One line when it is off. Two panels in the rail, each explaining
    // itself at length, left three threads visible in a 760px-tall window.
    // The explanation belongs on the screen where you switch it on.
    h+=`<div class="lanrow"><span class="lanempty">Off</span>
        <button class="devbtn" onclick="RSHOW=true;netRender()">Turn on</button></div>`;
  }else{
    h+=`<div class="lansub">The address of the relay to use. It has to be an
        <b>https</b> address: this crosses networks nobody here controls, and
        without that there is nothing to say the relay is the relay.</div>
      <input class="lanin wide" aria-label="Relay address"
        value="${esc(RADDR)}" oninput="RADDR=this.value"
        onkeydown="if(event.key==='Enter')relayOn()">
      <div class="devrow">
        <button class="devbtn" onclick="relayOn()" ${RBUSY?'disabled':''}>
          ${RBUSY?'Registering...':'Register'}</button>
        <button class="devbtn" onclick="RSHOW=false;RERR='';netRender()">Cancel</button>
      </div>`;
  }
  if(RERR)h+=`<div class="lanerr">${esc(RERR)}</div>`;
  if(ROK)h+=`<div class="lanok">${esc(ROK)}</div>`;
  box.innerHTML=h;
}

async function relayPost(body){
  RBUSY=true;RERR='';ROK='';netRender();
  try{
    const r=await fetch('/api/relay',{method:'POST',
      headers:{'X-Gage':'1','Content-Type':'application/json'},body:JSON.stringify(body)});
    const j=await r.json().catch(()=>({}));
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
    ROK=(j.lines||[]).join(' ');
    RSHOW=false;
  }catch(e){ RERR=(e&&e.message)?e.message:String(e); }
  RBUSY=false; await lanLoad();
}
function relayOn(){ return relayPost({addr:RADDR}); }
function relayOff(){ return relayPost({off:true}); }

function lanRender(){
  const box=document.getElementById('lanbox');
  if(!box)return;
  const on=LAN.lan_running;
  let h=`<div class="lanhead"><span class="dot${on?' on':''}"></span>On this network</div>`;
  if(!LAN.devices.length){
    // One line, for the same reason as the panel below it: the rail is for
    // the thread list, and two panels each writing a paragraph about an
    // empty state left four threads visible in the window the app opens at.
    h+=`<div class="lanempty">${on
      ? 'Nobody else here yet. Run <b>gage lan</b> on the other machine too.'
      : 'Off. Run <b>gage lan</b> in a terminal.'}</div>`;
    box.innerHTML=h; return;
  }
  for(const d of LAN.devices){
    const sel=LSEL===d.label;
    h+=`<div class="dev${sel?' sel':''}">
      <div class="devtop">
        <span class="devname">${esc(d.paired_as||d.label)}</span>
        ${d.paired_as
          ? `<span class="devtag ok">paired</span>`
          : `<span class="devtag">not paired</span>`}
      </div>
      <div class="devfp">${esc(d.fingerprint)}</div>
      <div class="devrow">
        <button class="devbtn" onclick="lanPick('${esc(d.label)}')">${sel?'Chosen':'Send to this'}</button>
        ${d.paired_as?'':`<button class="devbtn" onclick="lanPair('${esc(d.label)}')">${
          LPAIR===d.label?'Cancel':'Pair'}</button>`}
      </div>
      ${LPAIR===d.label?`
        <div class="lansub">${esc(PAIRHINT)}</div>
        <input class="lanin" placeholder="Call it what on your list?" value="${esc(LNAME)}"
          oninput="LNAME=this.value">
        <div class="devrow">
          <button class="devbtn go" onclick="lanPairGo('${esc(d.label)}')" ${LBUSY?'disabled':''}>
            ${LBUSY?'Pairing...':'Those four pairs match'}</button>
        </div>`:''}
      </div>`;
  }
  const names=FILES.map(f=>f.name).join(', ');
  h+=`<div class="drop${FILES.length?' has':''}" id="drop"
        onclick="document.getElementById('fpick').click()">
      ${FILES.length?esc(names)+` &middot; ${kb(FILES.reduce((a,f)=>a+f.size,0))}`
                    :'Drop files here, or click to choose'}
    </div>
    <input type="file" id="fpick" multiple hidden onchange="lanFiles(this.files)">`;
  // Either a file or a note is enough to send. Requiring a file meant you
  // could not say "look at this machine" to someone standing next to you
  // without inventing a file to say it in.
  if(LSEL){
    h+=`<input class="lanin" id="lsubj" placeholder="Subject" value="${esc(LSUBJ)}"
           oninput="LSUBJ=this.value">
      <textarea class="lanin note" id="lnote"
        placeholder="Write a note (optional)" oninput="LNOTE=this.value">${esc(LNOTE)}</textarea>`;
    if(FILES.length||LNOTE.trim()){
      h+=`<div class="devrow">
        <button class="devbtn" onclick="lanSend()" ${LBUSY?'disabled':''}>
          ${LBUSY?'Sending...':'Send to '+esc((LAN.devices.find(x=>x.label===LSEL)||{}).paired_as||LSEL)}</button>
        <button class="devbtn" onclick="FILES=[];LNOTE='';lanRender()">Clear</button>
      </div>`;
    } else {
      h+=`<div class="lansub">Drop a file or write a note.</div>`;
    }
  } else if(FILES.length||LNOTE.trim()){
    h+=`<div class="lansub">Choose a machine above.</div>`;
  }
  if(LERR2)h+=`<div class="lanerr">${esc(LERR2)}</div>`;
  if(LOK)h+=`<div class="lanok">${esc(LOK)}</div>`;
  box.innerHTML=h;
  const dz=document.getElementById('drop');
  if(dz){
    dz.ondragover=e=>{e.preventDefault();dz.classList.add('over')};
    dz.ondragleave=()=>dz.classList.remove('over');
    dz.ondrop=e=>{e.preventDefault();dz.classList.remove('over');lanFiles(e.dataTransfer.files)};
  }
}
let LSUBJ='', LNOTE='';
function lanPick(l){ LSEL=(LSEL===l?null:l); LERR2='';LOK=''; lanRender(); }

async function lanFiles(fl){
  LERR2='';LOK='';
  const out=[];
  let total=0;
  for(const f of fl){
    total+=f.size;
    // Base64 in JSON inflates by a third, and the server caps the body at
    // 12MB. Refused here with the real number rather than sent and rejected.
    if(total*1.37>11*1024*1024){
      LERR2='That is too much to send through the page ('+kb(total)
        +'). Use gage send --dir in the terminal for anything large.';
      lanRender(); return;
    }
    out.push({name:f.name,size:f.size,data:await b64(f)});
  }
  FILES=out; lanRender();
}
const b64=f=>new Promise((res,rej)=>{
  const r=new FileReader();
  r.onerror=()=>rej(new Error('could not read '+f.name));
  r.onload=()=>res(String(r.result).split(',',2)[1]||'');
  r.readAsDataURL(f);
});

async function lanSend(){
  if(!LSEL||(!FILES.length&&!LNOTE.trim())||LBUSY)return;
  LBUSY=true;LERR2='';LOK='';lanRender();
  try{
    const r=await post('/api/send',{to:LSEL,subject:LSUBJ,type:'share',text:LNOTE,
      files:FILES.map(f=>({name:f.name,data:f.data}))});
    const j=await r.json().catch(()=>({}));
    if(r.ok){ LOK=(j.lines||[]).join('\n'); FILES=[]; LSUBJ=''; LNOTE=''; }
    // The outbound scan refuses here exactly as it does in the terminal, and
    // the page deliberately offers no override: --allow-findings is a reason
    // to go and look at the file, not a checkbox.
    else { LERR2=j.error||'that did not send'; }
  }catch(e){ LERR2=String(e); }
  LBUSY=false; lanRender();
}

// Pairing happens inline rather than through confirm()/prompt().
//
// Not only because blocking dialogs are unpleasant: the comparison IS the
// feature, and a browser dialog renders the four pairs in a cramped box the
// person dismisses by reflex. On the page they sit at the size everything
// else is read at, next to the name being filed, and the button says what it
// is agreeing to.
function lanPair(label){ LPAIR=(LPAIR===label?null:label); LNAME=''; LERR2='';LOK=''; lanRender(); }

async function lanPairGo(label){
  const d=LAN.devices.find(x=>x.label===label);
  if(!d||LBUSY)return;
  if(!LNAME.trim()){ LERR2='Give it a name to file it under.'; lanRender(); return; }
  LBUSY=true;LERR2='';LOK='';lanRender();
  try{
    // The fingerprint goes back up so the server can refuse if the list
    // changed under us between showing the words and clicking.
    const r=await post('/api/pair',{label:label,name:LNAME.trim(),fingerprint:d.fingerprint});
    const j=await r.json().catch(()=>({}));
    if(r.ok){ LOK=(j.lines||[]).join('\n'); LPAIR=null; LNAME=''; }
    else { LERR2=j.error||'that did not pair'; }
  }catch(e){ LERR2=String(e); }
  LBUSY=false;
  await lanLoad();
}

async function lanLoad(){
  try{
    const r=await fetch('/api/nearby');
    if(!r.ok)throw new Error('HTTP '+r.status);
    LAN=await r.json();
    if(LSEL&&!LAN.devices.some(d=>d.label===LSEL))LSEL=null;
    // The relay address lives in the same poll rather than a timer of its
    // own: it changes only when somebody presses the switch below it.
    const sr=await fetch('/api/state');
    if(sr.ok){const st=await sr.json();ST=st;RELAY=st.relay||null;
      // The suggested address comes from the binary. The page deliberately
      // contains no URL of any kind -- a test asserts it -- so that nothing
      // in the viewer can point a reader anywhere but this machine.
      if(!RADDR)RADDR=st.relay_default||'';}
  }catch(e){
    // Same rule as the inbox loader: never fail invisibly.
    LERR2='Could not read the network list: '+(e&&e.message?e.message:e);
  }
  lanRender();
  netRender();
  if(TAB==='setup')setupRender();
}
lanLoad();setInterval(lanLoad,2000);

// ---------------------------------------------------------------------------
// The buddy list
// ---------------------------------------------------------------------------
// Three states for a key, and the panel's whole job is that they look like
// three: on your list, asking, refused. A list that can only say yes and
// "not yet" makes refusing feel like ignoring, and the way to stop being
// asked becomes saying yes.
let BUD={buddies:[],requests:[],refused:[]}, TAB='chats';
// The last /api/state. The setup checklist is derived from it rather than
// from anything this tab keeps for itself.
let ST=null;
let AGENTS=null, AGNAME='', AGBUSY=false, AGERR='', AGHOST='claude_code', AGNEW=null;
// Which card has had Remove pressed once. Deleting an identity key is the
// one act in gage that cannot be undone, so the first press arms and the
// second does it -- no modal, which a WKWebView handles badly and which
// blocks the whole page, and no window.confirm for the same reason.
let AGKILL='';
let ASK=null, ANAME='', ACONF='', AERR='', AOK='', ABUSY=false, SHOWREF=false;
// Whether the standing explanation is open. The rail re-renders on every
// poll, so without this a <details> somebody opened shut itself 2.5s later.
let BWHY=false;
// The fingerprint, once the person has shown they read it somewhere else.
// Held per key and dropped on cancel, so it never survives into a different
// request's card.
let REVEAL=null;

// Said in full on every request, not once in a help page. The sentence that
// matters is the second one: typing a word-pair cannot prove the person
// compared it with the other screen, and a UI that implied otherwise would
// be selling a check it does not perform.
const ASKWHY='The word-pairs come from the key itself, so nobody picks their own. '
  +'This screen does not show them yet, on purpose: read the pair below off the '
  +'OTHER machine, where they must run gage fingerprint --signing-only. Get it '
  +'right and the rest are shown, so you can check all four before adding.';

// Said on the card, because it is a real limit on what this approval buys.
const ASKHALF='These words cover the SIGNING key only. A message does not carry its '
  +'sender\u2019s encryption key, so adding them here lets you verify what they '
  +'sign and not send them sealed mail. For both, get their `gage add` line and '
  +'compare the normal four pairs.';

// The limit, said where the decision is made rather than in a help page.
// A sender knows all four pairs of its own key, because they come from that
// key -- so it can write the asked-for pair into the message it sends you,
// and typing it from there satisfies this box and tells you nothing. A
// reviewer put it exactly: the server cannot tell the two sources apart.
const ASKTRAP='If this pair came from the message itself, or from anything this '
  +'key sent you, it proves nothing -- a key knows its own word-pairs and can '
  +'quote them. It only means something if it came from the other machine.';

function tabsRender(){
  const el=document.getElementById('tabs');
  if(!el)return;
  const n=BUD.requests.length;
  const open=setupOpenCount();
  el.innerHTML=
    `<button class="tab${TAB==='chats'?' on':''}" onclick="setTab('chats')">Chats</button>`
   +`<button class="tab${TAB==='buddies'?' on':''}" onclick="setTab('buddies')">Buddies`
   +(n?`<span class="bdg">${n}</span>`:'')+`</button>`
   +`<button class="tab${TAB==='agents'?' on':''}" onclick="setTab('agents')">Agents</button>`
    // The badge is the count of steps still open, in the same place and the
    // same shape as the pending-request badge. Once setup is done the tab
    // stays -- it is also the answer to "how do I do that again" -- but it
    // stops asking for anything.
   +`<button class="tab${TAB==='setup'?' on':''}" onclick="setTab('setup')">Setup`
   +(open?`<span class="bdg">${open}</span>`:'')+`</button>`;
}
// The add-line QR. Fetched once: it does not change while this page is
// open, and it must not ride the 2.5s poll. Inserted as markup. An image
// URL would be blocked by this page's CSP.
let INVITE=null, inviteSeq=0;
function inviteOk(t){
  if(typeof t!=='string'||t.length<20||t.length>200000)return false;
  if(!t.startsWith('<svg ')||!t.endsWith('</svg>'))return false;
  if((t.match(/<svg/g)||[]).length!==1)return false;
  if(/<\s*(script|img|object|embed|iframe|link|foreignObject)\b|<!DOCTYPE|<\?xml/i.test(t))return false;
  return true;
}
async function inviteLoad(force){
  if(!force&&INVITE!==null)return;
  const seq=++inviteSeq;
  let next='';
  try{
    const r=await fetch('/api/invite');
    if(r.ok){const t=await r.text();if(inviteOk(t))next=t;}
  }catch(e){}
  if(seq===inviteSeq)INVITE=next;
}
function inviteHtml(){
  if(!INVITE)return '';
  return `<div class="invite"><h2>The line the other person runs</h2>
    <p>This is the public <code>gage add</code> line <code>gage connect</code> prints.
    It names this machine and its public key, and nothing else.</p>
    ${INVITE}</div>`;
}
// The rail on the Buddies tab: this machine's own identity first, because
// the QR and the `gage add` line are the only things on this screen that
// another person needs, and they used to be fifth and below the fold. Then
// the invite coming the other way. The standing explanation is last and
// shut: the sentences are unchanged, they are simply no longer between the
// reader and the decision.
function budpaneHtml(){
  return inviteHtml()+pasteHtml()+`<details class="disc"${BWHY?' open':''}
    ontoggle="BWHY=this.open">
    <summary>Why this matters</summary>
    <div class="hint">
      <h2>Who can reach you</h2>
      <p>Your list is the trust boundary. A key on it is one you confirmed out of
        band; a key not on it is a decision you have not taken yet. Nothing here is
        automatic, and nothing a sender writes puts it on the list.</p>
      <p>Approving one asks for a word-pair you have to read off the other machine.
        That is not a formality: the pairs come from the key, so a key can quote its
        own in a message to you, and only the other machine's screen is evidence.</p>
      <p>Refusing records that you said no. It is not a block &mdash; nothing in gage
        can stop a bundle being delivered, and mail from a refused key still arrives
        and still waits for you to accept it.</p>
      <p><code>gage decline &lt;key&gt;</code> and <code>gage undecline &lt;key&gt;</code>
        do the same from a terminal.</p></div></details>`;
}
function pasteHtml(){
  let h=`<div class="hint"><h2>Paste an invite</h2>
    <p>Paste the other person's complete <code>gage add</code> line. The clipboard is read only when you press the button.</p>
    <button class="devbtn" onclick="pasteClipboard()">Read clipboard</button>
    <textarea class="lanin wide gap" aria-label="Invite text"
      placeholder="Paste or type the complete gage add line" oninput="PASTED=this.value">${esc(PASTED)}</textarea>
    <div class="devrow"><button class="devbtn go" onclick="pasteInvite()">Use this invite</button></div>
    ${AERR?`<div class="berr">${esc(AERR)}</div>`:''}${AOK?`<div class="bok">${esc(AOK)}</div>`:''}</div>`;
  if(PKEY)h+=`<div class="bwhy">Invite parsed. The existing request card now has the suggested name and pairing check.</div>`;
  return h;
}
async function pasteClipboard(){
  try{if(navigator.clipboard&&navigator.clipboard.readText){const t=await navigator.clipboard.readText();if(t){PASTED=t;budRender(true);}}}catch(e){/* Keep the editable paste field as the fallback. */}
}
async function pasteInvite(){
  AERR='';AOK='';PKEY='';
  try{
    const r=await fetch('/api/parse-invite',{method:'POST',headers:{'X-Gage':'1','Content-Type':'application/json'},body:JSON.stringify({text:PASTED})});
    const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||('HTTP '+r.status));
    PKEY=j.key;PNAME=j.suggested_name||'';PPAIR=j.ask_pair;ASK=PKEY;ANAME=PNAME;ACONF='';REVEAL=null;
  }catch(e){AERR=e&&e.message?e.message:String(e)}
  if(TAB==='buddies')budRender(true);
}
function setTab(t){
  TAB=t;
  // In the URL, so a reload keeps the tab you were on -- this page polls and
  // re-renders constantly, and losing your place on every refresh is the
  // kind of small thing that makes a tool feel untrustworthy.
  hashSync();
  const wide = (t==='buddies'||t==='agents');
  const solo = (t==='setup');
  // One shell. The rail never goes away: what changes is which half of it is
  // showing -- the thread list on Chats, this machine's own identity on
  // Buddies, the form that makes an agent one on Agents. The two network
  // panels live on Buddies, at the foot of that rail: the Chats rail is
  // conversations and nothing else, and being reachable -- this network,
  // the relay -- belongs with identity and pairing, which is what the
  // Buddies rail is for.
  for(const [id,show] of [['filters',t==='chats'],['list',t==='chats'],
                          ['lanbox',t==='buddies'],['netbox',t==='buddies'],['budside',wide],
                          // The right-hand pane belongs to whichever tab is
                          // showing. It used to keep the Chats empty state --
                          // "Pick a conversation" -- next to the buddy list,
                          // which reads as a page that half-loaded.
                          ['msgs',t==='chats'],['budpane',wide],['setuppane',solo]]){
    const el=document.getElementById(id); if(el)el.hidden=!show;
  }
  // The rail says the product name and keeps saying it; which section you
  // are in is the heading of the section, where a heading belongs.
  const appEl=document.getElementById('app');
  if(appEl)appEl.classList.toggle('buds',wide);
  // Setup has no rail: the column collapses rather than standing empty.
  if(appEl)appEl.classList.toggle('solo',solo);
  // Under 880px the window is one column and `detail` hides the rail so a
  // conversation can have the width. Buddies and Agents keep their controls
  // in that rail, so leaving an open conversation has to clear it -- or the
  // QR, the paste field and the create form are all display:none and the
  // only control left on the pane is the Back arrow. It stays set while a
  // conversation is open on Chats, so coming back lands where you left.
  if(appEl)appEl.classList.toggle('detail', t==='chats' && !!SEL);
  if(t==='agents'){
    document.getElementById('title').textContent='Agents';
    const c=document.getElementById('composer'); if(c)c.hidden=true;
    const se=document.getElementById('settle'); if(se)se.hidden=true;
    document.getElementById('hstate').textContent='';
    agentRender();
    if(AGENTS===null)agentLoad();
  }else if(t==='buddies'){
    document.getElementById('title').textContent='Buddies';
    const c=document.getElementById('composer'); if(c)c.hidden=true;
    const se=document.getElementById('settle'); if(se)se.hidden=true;
    document.getElementById('hstate').textContent='';
    budRender(true);
    if(INVITE===null)inviteLoad(false).then(()=>{if(TAB==='buddies'){
      budRender(true);
    }});
  }else if(t==='setup'){
    document.getElementById('title').textContent='Setup';
    const c=document.getElementById('composer'); if(c)c.hidden=true;
    const se=document.getElementById('settle'); if(se)se.hidden=true;
    document.getElementById('hstate').textContent='';
    // The agent list decides step 2, so this tab needs it even if the Agents
    // tab has never been opened.
    if(AGENTS===null)agentLoad().then(()=>{if(TAB==='setup'){setupRender();tabsRender()}});
    setupRender();
  }else{
    document.getElementById('title').textContent='Conversations';
    // Setup hides the composer and the settle button on its way in. Only
    // render() puts them back, and without this they stayed hidden until the
    // next inbox poll -- up to 2.5s of a conversation with no way to reply.
    render();
  }
  tabsRender(); budRender(true);
}
// The tab, and which decision is open, live in the URL. This page polls and
// re-renders constantly; losing your place on a refresh is the kind of small
// thing that makes a tool feel untrustworthy, and a decision you walked away
// from is exactly the one worth being able to come back to.
function hashSync(){
  let h='';
  if(TAB==='buddies')h = ASK ? '#buddies='+encodeURIComponent(ASK) : '#buddies';
  if(TAB==='agents')h = '#agents';
  if(TAB==='setup')h = '#setup';
  try{ history.replaceState(null,'',h||location.pathname); }catch(e){}
}
function hashRead(){
  const h=decodeURIComponent(location.hash||'');
  if(h==='#agents'){ TAB='agents'; return; }
  if(h==='#setup'){ TAB='setup'; return; }
  if(!h.startsWith('#buddies'))return;
  TAB='buddies';
  const i=h.indexOf('=');
  if(i>0)ASK=h.slice(i+1);
}
function askOpen(k){
  ASK=(ASK===k?null:k); ANAME='';ACONF='';AERR='';AOK='';
  if(!ASK||(REVEAL&&REVEAL.key!==ASK))REVEAL=null;
  hashSync(); budRender(true);
}

async function budReveal(k){
  ABUSY=true;AERR='';AOK='';budRender(true);
  try{
    const r=await fetch('/api/connect',{method:'POST',
      headers:{'X-Gage':'1','Content-Type':'application/json'},
      body:JSON.stringify({key:k,name:'',confirm:ACONF})});
    const j=await r.json().catch(()=>({}));
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
    // The confirmed pair is KEPT, in REVEAL, because the add call has to
    // resend it. The first version cleared ACONF here and then sent ACONF on
    // add, so the add always failed the server's check and the button was
    // dead. The API tests passed because they drove the endpoint directly;
    // only reading the page found it.
    REVEAL={key:k,fp:(j.lines||[])[1]||'',pair:ACONF};
    ACONF='';
  }catch(e){ AERR=(e&&e.message)?e.message:String(e); }
  ABUSY=false; budRender(true);
}

function reachRow(r){
  // Each path says what it knows and no more. `relay` especially: mail can be
  // left there, which is not the same as anybody collecting it, and this
  // panel must not become the presence roster the project has twice refused.
  // No per-contact recency, on purpose: a named list with an "Xs ago" that
  // refreshes is a presence roster whatever it is called, and this project
  // has refused one three times. Each label says what it is evidence of.
  const bits=[
    ['routed under this name', r.routed_by_name,
     'A local route is filed under this name, which is the name sending resolves too. '
     +'It is your label, not their identity.'],
    ['on this network now', r.lan_now,
     'This key is announcing itself on this network, which it chose to do by running '
     +'gage lan. Nothing here polls anybody.'],
    ['mail can be left', r.can_leave_mail,
     'You are joined to a relay and hold an encryption key for them, so mail CAN be '
     +'left. This is about your setup, not about them -- whether they ever collect it '
     +'is not something this machine can know.'],
  ];
  return `<div class="bdots">`+bits.map(([t,on,title])=>
    `<span class="breach${on?' on':''}" title="${esc(title)}"><i></i>${t}</span>`).join('')+`</div>`;
}

const AGHOSTS=[['claude_code','Claude Code'],['claude_desktop','Claude Desktop'],
               ['codex','Codex'],['generic','Other host']];

function agentRender(){
  const box=document.getElementById('budpane');
  const side=document.getElementById('budside');
  if(!box||!side||TAB!=='agents')return;
  // Same focus rule as the buddies page: the rail holds a text field, and
  // a render underneath somebody's typing loses the caret.
  const a=document.activeElement;
  if(a&&(box.contains(a)||side.contains(a))
     &&(a.tagName==='INPUT'||a.tagName==='TEXTAREA')&&!AGNEW)return;
  // The rail: the one thing this tab is for doing, and the one choice that
  // changes what every card below shows.
  side.innerHTML=`<div class="bsec">Connect an agent</div>
    <div class="lansub budnote">Each agent gets its own identity, so a verdict
      can say <i>which</i> agent sent something and a grant can name one of
      them. They are added to your list here without a word-pair compare:
      this page generated the key a moment ago, so there is no other party
      it could belong to. A key from anywhere else still goes through the
      compare on the Buddies tab.</div>
    <div class="devrow gap">
      <input class="lanin wide" placeholder="Name, e.g. codex"
        value="${esc(AGNAME)}" oninput="AGNAME=this.value"
        onkeydown="if(event.key==='Enter')agentCreate()">
      <button class="devbtn go" onclick="agentCreate()" ${AGBUSY?'disabled':''}>
        ${AGBUSY?'Creating...':'Create agent'}</button>
    </div>
    <div class="bsec">Host to configure</div>
    <div class="hostpick">`+AGHOSTS.map(([k,label])=>
      `<button class="chip${AGHOST===k?' on':''}" onclick="AGHOST='${k}';agentRender()">${label}</button>`
    ).join('')+`</div>`;

  let h=`<div class="budwrap">`;
  if(AGERR)h+=`<div class="lanerr">${esc(AGERR)}</div>`;

  const list=AGENTS||[];
  h+=`<div class="bsec">Agents on this machine<span class="bnum">${list.length}</span></div>`;
  if(!list.length){
    h+=`<div class="lansub budnote">None yet.</div>`;
  }
  h+=`<div class="budgrid aggrid">`;
  for(const g of list){
    const cfg=(g.configs&&g.configs[AGHOST])||'';
    h+=`<div class="dev">
      <div class="devtop"><span class="devname">${esc(g.name)}</span>
        <span class="devtag">${esc(g.handle)}</span></div>
      <div class="devfp">${esc(g.fingerprint)}</div>
      <div class="lansub">Identity in <code>${esc(g.home)}</code></div>
      <div class="lansub gap">Point the host at it:</div>
      <pre class="cfg" id="cfg-${esc(g.name)}">${esc(cfg)}</pre>
      <div class="devrow">
        <button class="devbtn" onclick="copyCfg('${esc(g.name)}')">Copy config</button>
        <span class="copied" id="copy-cfg-${esc(g.name)}"></span>
      </div>
      <div class="lansub gap">To let its messages be accepted
        without you, put this line in your <code>authority</code> file yourself.
        Nothing in gage writes that file: a path that grants authority by being
        asked is one an agent can be talked into asking for.</div>
      <pre class="cfg" id="grant-${esc(g.name)}">${esc(g.grant)}</pre>
      <div class="devrow">
        <button class="devbtn" onclick="copyGrant('${esc(g.name)}')">Copy line</button>
        <span class="copied" id="copy-grant-${esc(g.name)}"></span>
      </div>
      <div class="devrow agkill">${ AGKILL===g.name
        ? `<button class="devbtn danger" onclick="agentRemove('${esc(g.name)}')"
             ${AGBUSY?'disabled':''}>${AGBUSY?'Removing...':'Yes, remove '+esc(g.name)}</button>
           <button class="devbtn" onclick="AGKILL='';agentRender()">Cancel</button>
           <span class="lansub">Deletes its identity key. That cannot be undone.
             Anything it already sent stays readable and keeps its verdict.</span>`
        : `<button class="devbtn" onclick="AGKILL='${esc(g.name)}';agentRender()">Remove</button>`
      }</div>
    </div>`;
  }
  h+=`</div></div>`;
  box.innerHTML=h;
}

// Clipboard writes stay behind a press, like the paste button does.
//
// Two things were wrong with the first version and both were invisible.
// The text was interpolated into the onclick attribute with JSON.stringify,
// which emits a double-quoted string into a double-quoted attribute: the
// attribute ended at the first quote and the handler was a syntax error, so
// the button did nothing at all. And the failure was swallowed, so a button
// that could not work looked exactly like a button that had worked.
//
// Now the text is looked up by name at click time -- nothing user-visible
// goes through an attribute -- and every outcome says what happened. The
// fallback selects the block so Cmd-C or Ctrl-C finishes the job, because
// a WKWebView without clipboard access is a real case and "nothing
// happened" is the worst possible answer in it.
function copyCfg(name){
  const g=(AGENTS||[]).find(x=>x.name===name); if(!g)return;
  doCopy((g.configs&&g.configs[AGHOST])||'', 'copy-cfg-'+name, 'cfg-'+name);
}
function copyGrant(name){
  const g=(AGENTS||[]).find(x=>x.name===name); if(!g)return;
  doCopy(g.grant||'', 'copy-grant-'+name, 'grant-'+name);
}
async function doCopy(text, tagId, blockId){
  const tag=document.getElementById(tagId);
  const say=(m,bad)=>{ if(!tag)return; tag.textContent=m;
    tag.className='copied'+(bad?' bad':''); };
  if(!text){ say('nothing to copy', true); return; }
  try{
    if(!navigator.clipboard||!navigator.clipboard.writeText)throw new Error('no clipboard');
    await navigator.clipboard.writeText(text);
    say('Copied');
    setTimeout(()=>{ if(tag&&tag.textContent==='Copied')tag.textContent=''; },2500);
  }catch(e){
    // Select it instead, and say which key finishes the job.
    const el=document.getElementById(blockId);
    try{
      const r=document.createRange(); r.selectNodeContents(el);
      const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
      say(navigator.platform.indexOf('Mac')>=0 ? 'Selected -- press Cmd-C'
                                               : 'Selected -- press Ctrl-C', true);
    }catch(e2){ say('Could not copy; select the text above', true); }
  }
}

async function agentLoad(){
  try{
    const r=await fetch('/api/agents');
    if(!r.ok)throw new Error('HTTP '+r.status);
    AGENTS=(await r.json()).agents||[];
  }catch(e){ AGERR='Could not read the agent list: '+(e&&e.message?e.message:e); }
  agentRender();
}

async function agentRemove(name){
  AGBUSY=true;AGERR='';agentRender();
  try{
    const r=await fetch('/api/agent/remove',{method:'POST',
      headers:{'Content-Type':'application/json','X-Gage':'1'},
      body:JSON.stringify({name:name})});
    const j=await r.json();
    if(!r.ok||j.error)AGERR=j.error||('Could not remove '+name+'.');
    else AGERR='Removed '+name+'. Its key is retired, not forgotten, so what it '
      +'already sent keeps its verdict. If an MCP host still points at it, '
      +'remove it there too.';
  }catch(e){ AGERR='Could not remove '+name+': '+(e&&e.message?e.message:e); }
  AGBUSY=false; AGKILL=''; await agentLoad();
}
async function agentCreate(){
  AGBUSY=true;AGERR='';AGNEW=null;agentRender();
  try{
    const r=await fetch('/api/agent',{method:'POST',
      headers:{'X-Gage':'1','Content-Type':'application/json'},
      body:JSON.stringify({name:AGNAME.trim()})});
    const j=await r.json().catch(()=>({}));
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
    AGNAME='';AGNEW=j.name;
  }catch(e){ AGERR=(e&&e.message)?e.message:String(e); }
  AGBUSY=false; await agentLoad(); AGNEW=null;
}

function budRender(force){
  // The cards -- the decisions -- render into the wide pane. The rail beside
  // them holds this machine's identity and the explanation, so neither the
  // thing you act on nor the thing you hand over is in the narrow column by
  // accident.
  const box=document.getElementById('budpane');
  const side=document.getElementById('budside');
  if(!box||!side||TAB!=='buddies')return;
  let h='';
  const requests=(BUD.requests||[]).map(q=>q.key===PKEY?{...q,label:PNAME||q.label,
    ask_pair:PPAIR,pasted:true}:q);
  if(PKEY&&!requests.some(q=>q.key===PKEY))requests.unshift({key:PKEY,label:PNAME||'Pasted invite',
    bundles:0,verdict:'SENDER NOT ON YOUR LIST',first_observed:null,ask_pair:PPAIR,pasted:true});
  if(requests.length){
    h+=`<div class="bsec">Not on your list<span class="bnum">${requests.length}</span></div>`
     +`<div class="lansub budnote gap">${requests.some(q=>q.pasted)?'Pasted invites and keys that have sent you something are not on your list. ':''}A message is not a request for anything &mdash; deciding is yours, and doing nothing is a decision the list will keep offering.</div>`
     +`<div class="budgrid">`;
    for(const q of requests){
      const open=ASK===q.key;
      const shown=(REVEAL&&REVEAL.key===q.key)?REVEAL.fp:null;
      h+=`<div class="dev ask">
        <div class="devtop"><span class="devname">${esc(q.label)}</span>
          <span class="devtag">${q.bundles} message${q.bundles===1?'':'s'}</span>
          </div>
        <div class="bwhy gap">${esc(vShort(q.verdict))}
          ${q.first_observed?'&middot; first seen here '+esc(ago(q.first_observed)):''}
          &middot; pair 1 of its fingerprint is <b>${esc(q.label)}</b></div>
        ${shown?`<div class="devfp">${esc(shown)}</div>`:''}
        <div class="devrow">
          <button class="devbtn${open?'':' go'}" onclick="askOpen('${esc(q.key)}')">${open?'Cancel':'Add to my list'}</button>
          <button class="devbtn quiet" onclick="budDecline('${esc(q.key)}',false)" ${ABUSY?'disabled':''}
            title="Records that you said no. It does not block anything: mail from this key still arrives and still waits for you to accept it.">Refuse</button>
        </div>
        <div class="bwhy">Refusing records the decision so you are not asked again. It does
          not stop anything arriving.</div>`;
      if(open&&!shown){
        h+=`<div class="bgate">
          <div class="bwhy">${esc(ASKWHY)}</div>
          <div class="bwhy">${esc(ASKHALF)}</div>
          <div class="bwhy bwarn">${esc(ASKTRAP)}</div>
          ${q.pasted?`<div class="bwhy">Suggested name (edit as needed)</div>
            <input class="lanin wide" placeholder="Name for this contact" value="${esc(ANAME)}"
              oninput="ANAME=this.value">`:''}
          <div class="bwhy">Type pair <b>${q.ask_pair}</b> of four, as the other machine shows it.</div>
          <input class="lanin wide" placeholder="pair ${q.ask_pair}, e.g. two words" value="${esc(ACONF)}"
            oninput="ACONF=this.value" onkeydown="if(event.key==='Enter')budReveal('${esc(q.key)}')">
          <div class="devrow">
            <button class="devbtn go" onclick="budReveal('${esc(q.key)}')" ${ABUSY?'disabled':''}>
              ${ABUSY?'Checking...':'Check this pair'}</button>
          </div></div>`;
      }else if(open&&shown){
        h+=`<div class="bgate">
          <div class="bwhy">Pair ${q.ask_pair} matched. Now check <b>all four</b> above against
            the other machine before you add anything &mdash; one matching pair is 16 bits, and
            a key can be ground out to produce a chosen one.</div>
          <input class="lanin wide" placeholder="Call them what on your list?" value="${esc(ANAME)}"
            oninput="ANAME=this.value">
          <div class="devrow">
            <button class="devbtn go" onclick="budConnect('${esc(q.key)}')" ${ABUSY?'disabled':''}>
              ${ABUSY?'Adding...':'All four match -- add them'}</button>
          </div></div>`;
      }
      h+=`</div>`;
    }
    h+=`</div>`;
  }
  h+=`<div class="bsec">Your list<span class="bnum">${BUD.buddies.length}</span></div>`;
  if(!BUD.buddies.length){
    h+=`<div class="lansub budnote">Nobody yet. Pair with a buddy on this network to add their key, then confirm its word-pairs with them.</div>`;
  }
  // The agents this machine made are on the contact list too -- they have to
  // be -- but they are not people to compare word-pairs with over a call, and
  // showing them here asked you to. Counted rather than hidden, with their
  // unread, so nothing goes quiet.
  const ags=BUD.agents_on_list||[];
  if(ags.length){
    const un=ags.reduce((t,a)=>t+a.unread,0);
    h+=`<div class="lansub budnote">${ags.length===1?'One agent on this machine is':
      `${ags.length} agents on this machine are`} also on your list
      (${ags.map(a=>esc(a.name)).join(', ')}) &mdash; their keys came out of a keygen this
      machine ran, so there is nobody to compare pairs with. They are on
      <button class="blink" onclick="setTab('agents')">the Agents tab</button>.${
      un?` <b>${un} unread</b> from them, in Chats.`:''}</div>`;
  }
  h+=`<div class="budgrid">`;
  for(const b of BUD.buddies){
    h+=`<div class="dev">
      <div class="devtop"><span class="devname">${esc(b.name)}</span>
        ${b.unread?`<span class="bnum">${b.unread} unread</span>`:''}
        ${b.confirmed?'<span class="devtag ok" title="Somebody compared this key\'s four word-pairs against the other machine, over a channel neither machine controls.">confirmed</span>'
                     :`<span class="devtag warn" title="On your list, but nothing here records anybody comparing its word-pairs.">${b.stale?'confirmation stale':'unconfirmed'}</span>`}
        ${b.sealable?'<span class="devtag ok">sealable</span>'
                    :'<span class="devtag" title="A bundle does not carry its sender\'s encryption key, so a contact added from a message is verify-only.">verify only</span>'}</div>
      ${b.confirmed
        ? `<div class="devfp">${esc(b.fingerprint)}</div>`
        : `<div class="bwhy">${b.stale
            ? 'What was confirmed for this contact no longer covers the key on file &mdash; an encryption key was added after the comparison, and the words that were checked did not cover it. Confirm it again.'
            : 'Nobody has compared this key\'s word-pairs. It is on your list, so its mail arrives and is attributable &mdash; but nothing here records that the key belongs to the person whose name is beside it.'}
            Their mail reads <b>SENDER ON YOUR LIST, UNCONFIRMED</b> until somebody does.</div>
           <div class="bwhy">This key's device label is <b>${esc(b.label)}</b> &mdash; derived from the
             signing key alone, which is all a manifest carries, so it is <i>not</i> the first of the
             four pairs when there is an encryption key as well. It tells entries apart and nothing more.
             Compare the four pairs below with the other person — over a call, in
             person, any channel the machines do not control. Their machine shows
             the same four for this key: their own list, or
             <code>gage fingerprint${b.sealable?'':' --signing-only'}</code>.
             If all four match, the key is theirs.</div>
           <div class="devfp bigfp">${esc(b.fingerprint)}</div>
           <div class="devrow">
             <button class="devbtn go" onclick="budConfirm('${esc(b.name)}','${esc(b.fingerprint)}')" ${ABUSY?'disabled':''}>
               ${ABUSY?'Recording...':'The words match'}</button>
             <button class="devbtn" onclick="budMismatch('${esc(b.name)}')" ${ABUSY?'disabled':''}>They don't match</button>
           </div>
           ${CONFE[b.name]?`<div class="lanerr">${esc(CONFE[b.name])}</div>`:''}`}
      ${reachRow(b.reach)}
    </div>`;
  }
  h+=`</div>`;
  if(BUD.refused.length){
    h+=`<div class="bsec"><button class="bsect" onclick="SHOWREF=!SHOWREF;budRender(true)"
      >Refused (${BUD.refused.length}) ${SHOWREF?'&#9662;':'&#9656;'}</button></div>`;
    if(SHOWREF){
      h+=`<div class="lansub gap">Refusing records a decision. It does
        not block anything &mdash; nothing in gage can stop a bundle being delivered, and
        mail from these keys still arrives and still waits for you to accept it. What it
        buys is that you are not asked about these again. Their word-pairs are not shown
        here, so that un-refusing one puts you back in front of the same check.</div>
        <div class="budgrid">`;
      for(const r of BUD.refused){
        h+=`<div class="dev">
          <div class="devtop"><span class="devname">${esc(r.label)}</span>
            <span class="devtag">refused ${esc(r.at)}</span>
            ${r.holding?`<span class="devtag">${r.holding} here now</span>`:''}</div>
          <div class="devrow">
            <button class="devbtn" onclick="budDecline('${esc(r.key)}',true)" ${ABUSY?'disabled':''}>
              Un-refuse</button>
          </div></div>`;
      }
      h+=`</div>`;
    }
  }
  // AERR and AOK are printed by pasteHtml, which is concatenated below.
  // Printing them here as well put the same failure on the screen twice.

  // This page polls every 2.5 seconds, and replacing innerHTML destroys the
  // focused node. That was survivable while the only inputs here were the
  // pairing fields, whose text is held in ANAME and ACONF and restored on
  // the next render -- the caret moved, which was already wrong. Since the
  // paste textarea moved into this subtree it is not survivable at all:
  // typing an invite line lost focus mid-word every 2.5 seconds.
  //
  // So a poll that would clobber a field somebody is typing in does not
  // render. An action the person takes always calls budRender directly, and
  // those paths pass force, because the whole point of them is that the
  // answer changed.
  const active = document.activeElement;
  if (!force && active && (box.contains(active) || side.contains(active))
      && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return;
  box.innerHTML=`<div class="budwrap">${h}</div>`;
  // The rail is tall on this tab -- the QR, the paste field, the disclosure --
  // so a poll that redrew it must not also send it back to the top.
  const top=side.scrollTop;
  side.innerHTML=budpaneHtml();
  side.scrollTop=top;
}

async function budPost(path,body){
  ABUSY=true;AERR='';AOK='';budRender(true);
  try{
    const r=await fetch(path,{method:'POST',headers:{'X-Gage':'1','Content-Type':'application/json'},
      body:JSON.stringify(body)});
    const j=await r.json().catch(()=>({}));
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
    AOK=(j.lines||[]).filter(x=>x&&x.trim()).join(' ');
    ASK=null;ANAME='';ACONF='';REVEAL=null;
    if(path==='/api/connect'){PKEY='';PNAME='';PPAIR=0;PASTED='';}
  }catch(e){ AERR=(e&&e.message)?e.message:String(e); }
  ABUSY=false; await budLoad(); load();
}
function budConnect(k){
  const p = (REVEAL && REVEAL.key===k) ? REVEAL.pair : ACONF;
  budPost('/api/connect',{key:k,name:ANAME,confirm:p});
}
function budDecline(k,undo){ budPost('/api/decline',{key:k,undo:!!undo}); }

// Confirming is its own call rather than a flag on an add, because it happens
// at a different moment: the add is when a key arrives, the confirmation is
// after the comparison. The page shows the pairs and the person attests that
// they match what the other machine shows; the server checks the page is
// attesting to the words this key actually renders, and the record says the
// pairs were compared rather than typed. The error is kept per contact.
async function budConfirm(name,fp){
  CONFE[name]='';ABUSY=true;budRender(true);
  try{
    const r=await fetch('/api/confirm',{method:'POST',
      headers:{'X-Gage':'1','Content-Type':'application/json'},
      body:JSON.stringify({name:name,fingerprint:fp})});
    const j=await r.json().catch(()=>({}));
    if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
  }catch(e){ CONFE[name]=(e&&e.message)?e.message:String(e); }
  ABUSY=false; await budLoad(); load();
}
function budMismatch(name){
  CONFE[name]='Then this key is not theirs — or one of you is looking at the wrong entry. Do not confirm it. If you do not know whose key this is, refuse it instead.';
  budRender(true);
}

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
// Five steps, every one of them derived from the same state the rest of this
// page renders, and none of them from a flag this tab keeps for itself. The
// project already carries three written setup narratives -- the README, the
// first-tester packet, and the first-run guide below -- and a fourth written
// in here would be the copy nobody updates. A derived checklist cannot
// disagree with the panes it points at, because it is reading what they read.
//
// A satisfied step states the fact that satisfies it rather than showing a
// tick: "me, four word-pairs" is something a person can check, a tick is a
// claim. No step borrows a verdict colour. A step is not a trust decision,
// and in this app a green badge means a key was verified.
//
// Each open step names the one action and then sends you to the pane that
// already does it. It never restates a control or a ceremony, because two
// implementations of the pairing gate is how the gate shipped broken once.
// Whether enough has arrived to answer any of this. Rendering steps from
// state that has not loaded is how the checklist briefly told a machine with
// two agents and one buddy that it knew three people: AGENTS starts null, so
// the agent filter below was empty and this machine's own agents counted as
// somebody else. Unknown is a third answer, and it has to be said out loud
// rather than defaulted to either of the other two.
function setupKnown(){ return ST!==null && AGENTS!==null; }
function setupSteps(){
  const agentNames=(AGENTS||[]).map(a=>a.name);
  // Contacts and this machine's own agents share one list on disk, so the
  // people have to be separated out -- otherwise wiring an agent would
  // quietly satisfy "somebody else's key is on your list", which is the one
  // step no amount of local setup can satisfy.
  const people=((ST&&ST.contacts)||[]).filter(c=>!agentNames.includes(c.name));
  const routed=people.filter(c=>c.routed);
  const nearby=((LAN&&LAN.devices)||[]).length;
  const accepted=((ST&&ST.accepted)||[]).length;
  // Deliberately "exists", not "works". /api/agents reports a grant line for
  // every agent, but that line is the one you are meant to paste -- it is a
  // suggestion, not evidence that any authority file contains it, and this
  // page cannot see whether an assistant ever loaded the config. A step that
  // claimed otherwise would be a tick standing in for a fact.
  const agents=(AGENTS||[]);
  const names=a=>a.map(x=>x.name).join(', ');
  return [
    {t:'This machine has an identity',
     done:!!(ST&&ST.has_identity&&ST.me),
     fact:(ST&&ST.me)?ST.me.handle+' · '+ST.me.key.slice(0,12)+'…':'',
     mono:true,
     act:(ST&&ST.has_identity)
        ?'A key file is here but will not load, so this is not a first run '
         +'and no guide will open over it. Nothing else can be done until it '
         +'reads again -- check its permissions before anything writes.'
        :'Choose a handle and create a key. The guide that opens on a first '
         +'run does exactly this, and nothing else here can be done until it has.',
     go:null,goL:''},
    {t:'An agent identity exists here',
     done:agents.length>0,
     fact:agents.length
         ?names(agents)+' — whether your assistant has loaded the config '
          +'is not something this page can see'
         :'',
     act:'Create one on the Agents tab. It writes the config your assistant '
        +'reads, with this machine’s real paths already in it; pasting '
        +'that config into the assistant is the half that happens outside gage.',
     go:'agents',goL:'Open Agents'},
    {t:'Somebody else’s key is on your list',
     done:people.length>0,
     fact:people.length?(people.length+' '+(people.length===1?'person':'people')+': '+names(people)):'',
     act:'Hand them your invite from the Buddies tab, or paste theirs, and '
        +'compare all four word-pairs aloud before you add them.',
     go:'buddies',goL:'Open Buddies'},
    {t:'You have a way to reach them',
     // All of these are tied to somebody being on the list. A relay with
     // nobody to send to is a configured address, and a stranger announcing
     // on this network is not a way to reach anyone -- either one on its own
     // used to satisfy this step.
     //
     // And handing a sealed file over counts, which it did not: this step
     // asked for a route, a relay or a LAN neighbour, so the whole
     // file-handover path -- `gage seal` on one machine, `gage unseal && gage
     // open` on the other, which is the path the first-tester packet now
     // LEADS with -- could be completed end to end and leave this step
     // reading "not yet" for ever. The packet promises five of five. A
     // checklist with a step nobody can reach teaches people to ignore it.
     //
     // A completed exchange is better evidence than a configured address
     // anyway: an address is a claim that a path exists, an exchange is the
     // path having worked.
     done:routed.length>0
         ||(people.length>0&&(!!(ST&&ST.relay)||nearby>0||accepted>0||!!(ST&&ST.ever_sent))),
     fact:routed.length?(routed.length+' with a saved address: '+names(routed))
         :(people.length>0&&ST&&ST.relay)?'a relay is set, and somebody is on your list'
         :(people.length>0&&nearby)?(nearby+' device'+(nearby===1?'':'s')+' on this network')
         :(people.length>0&&(accepted>0||(ST&&ST.ever_sent)))
             ?'something has already made the trip, which is better evidence than an address'
             :'',
     act:'Three ways, and none is required to receive: hand a sealed file over '
        +'(gage seal, then gage unseal and gage open at the other end), run '
        +'gage lan on both machines on the same network, or set a relay in the '
        +'rail on Conversations.',
     go:'chats',goL:'Open Conversations'},
    {t:'You have exchanged something real',
     // Accepted, not merely arrived. Anyone can put a bundle in front of you,
     // including a stranger and a tampered one; accepting is the act that
     // took it. A pending bundle is somebody else's doing, not an exchange.
     done:!!(ST&&ST.ever_sent)||accepted>0,
     fact:(ST&&ST.ever_sent)
         ?(accepted?'you have sent, and accepted something':'you have sent')
         :(accepted?'you have accepted something':''),
     act:'Send one small thing to somebody on your list. Publishing an anchor '
        +'does not count: that is not handing work to a person.',
     go:'chats',goL:'Open Conversations'},
  ];
}
// The badge counts nothing until the state it would count has arrived.
function setupOpenCount(){ return setupKnown()?setupSteps().filter(s=>!s.done).length:0; }
// The explanation keeps its own open flag, for the reason the buddies panel
// does: this pane re-renders on a 2s poll, and a <details> rebuilt from
// scratch shuts the paragraph you are reading within one tick.
let SUWHY=false;
function setupRender(){
  const el=document.getElementById('setuppane'); if(!el)return;
  // Nothing is claimed before the state that would decide it has arrived.
  if(!setupKnown()){
    el.innerHTML='<div class="setup"><div class="hint"><h2>Reading this machine</h2>'
      +'<p>The steps below are derived from this machine’s own state, so '
      +'there is nothing to say until it has been read.</p></div></div>';
    return;
  }
  const steps=setupSteps(), open=steps.filter(s=>!s.done).length;
  let h='<div class="setup">';
  h+='<div class="hint"><h2>'
    +(open?esc(open+' of '+steps.length+' left'):'Nothing left to set up')
    +'</h2><p>'
    +(open
      ?'Each step reads this machine’s own state, so it answers for this '
       +'machine and not for the project in general.'
      // Exactly the five predicates, and no more than they establish. An
      // earlier version of this sentence said "an agent that can use it" and
      // "somebody else's confirmed key"; neither is checked here. A key goes
      // on your list when it is added, and comparing the word-pairs is a
      // separate act this page cannot witness.
      :'This machine has an identity, an agent identity, somebody else’s '
       +'key on its list, a way to reach them, and a send or an acceptance '
       +'behind it.')
    +'</p></div>';
  steps.forEach((st,i)=>{
    h+='<div class="dev'+(st.done?' done':'')+'"><div class="step">'
      +'<span class="stepn">'+(i+1)+'</span><div class="stepb">'
      +'<div class="devtop"><span class="devname">'+esc(st.t)+'</span>'
      +'<span class="devtag">'+(st.done?'done':'not yet')+'</span></div>';
    if(st.done&&st.fact)
      h+='<div class="'+(st.mono?'devfp':'stept')+'">'+esc(st.fact)+'</div>';
    if(!st.done){
      h+='<div class="stept">'+esc(st.act)+'</div>';
      if(st.go)h+='<div class="devrow"><button class="devbtn" onclick="setTab(\''
        +st.go+'\')">'+esc(st.goL)+'</button></div>';
    }
    h+='</div></div></div>';
  });
  h+='<details class="disc"'+(SUWHY?' open':'')+' ontoggle="SUWHY=this.open">'
    +'<summary>Where the longer instructions are</summary>'
    +'<div class="hint"><p>This page deliberately contains no link: a viewer '
    +'that can point you somewhere else is a browser, and a test asserts there '
    +'is no address anywhere in it. For the long form, run <code>gage help</code> '
    +'in a terminal, or read the project’s own first-tester packet, which '
    +'walks two people through this same list in about twenty minutes.</p></div>'
    +'</details>';
  h+='</div>';
  el.innerHTML=h;
}

// The connection banner: decisions waiting on a person, stated at the top
// of every tab rather than left for somebody to find in the Buddies list.
// State-based, not event-based -- it lists what is true now -- with a
// per-session dismissal for anybody who has seen an item and chosen to
// leave it. The underlying cards stay in Buddies either way.
const CONNDISMISS=new Set();
let CONNMAP={};
function connItems(){
  const out=[];
  for(const q of BUD.requests||[]){
    out.push({id:'req:'+q.key,
      text:`${q.label} sent you ${q.bundles===1?'a bundle':q.bundles+' bundles'} and is not on your list.`,
      btn:'Review',act:()=>{ setTab('buddies'); }});
  }
  for(const b of BUD.buddies||[]){
    if(b.confirmed)continue;
    out.push({id:'unconf:'+b.key,
      text:`${b.name} is on your list but not confirmed — comparing the words finishes the connection.`,
      btn:'Compare the words',act:()=>{ setTab('buddies');
        setTimeout(()=>{ const cards=document.querySelectorAll('#budpane .dev');
          for(const c of cards){ const n=c.querySelector('.devname');
            if(n&&n.textContent===b.name){ c.scrollIntoView({block:'center'});
              c.classList.add('spot');
              setTimeout(()=>c.classList.remove('spot'),1600); break; } } },60); }});
  }
  for(const d of LAN.devices||[]){
    if(d.paired_as)continue;
    out.push({id:'lan:'+d.label,
      text:`${d.label} is on this network and not paired.`,
      btn:'Pair',act:()=>{ setTab('buddies'); lanPair(d.label); }});
  }
  return out.filter(i=>!CONNDISMISS.has(i.id));
}
function connRender(){
  const bar=document.getElementById('connbar'); if(!bar)return;
  const items=connItems(); CONNMAP={};
  if(!items.length){ bar.hidden=true; bar.innerHTML=''; return; }
  const shown=items.slice(0,3);
  let h='';
  shown.forEach((it,ix)=>{ CONNMAP[ix]=it;
    h+=`<div class="connrow"><span>${esc(it.text)}</span><span class="sp"></span>`
      +`<button class="devbtn" onclick="connAct(${ix})">${esc(it.btn)}</button>`
      +`<button class="devbtn" onclick="connDismiss(${ix})" title="Not now">✕</button></div>`; });
  if(items.length>shown.length)
    h+=`<div class="connrow"><span>And ${items.length-shown.length} more waiting in Buddies.</span></div>`;
  bar.innerHTML=h; bar.hidden=false;
}
function connAct(ix){ const it=CONNMAP[ix]; if(it)it.act(); }
function connDismiss(ix){ const it=CONNMAP[ix]; if(it){ CONNDISMISS.add(it.id); connRender(); } }

async function budLoad(){
  try{
    const r=await fetch('/api/buddies');
    if(!r.ok)throw new Error('HTTP '+r.status);
    BUD=await r.json();
    if(ASK&&!BUD.requests.some(q=>q.key===ASK))ASK=null;
  }catch(e){
    // Same rule as every other loader here: never fail invisibly.
    AERR='Could not read your list: '+(e&&e.message?e.message:e);
  }
  tabsRender(); budRender(); connRender();
  // The agent list decides two of the five steps and was loaded exactly once,
  // so `gage agent new` in a terminal left this tab describing the machine as
  // it had been when the page opened. It is reloaded while this tab is the
  // one showing, and nowhere else.
  if(TAB==='setup'){ await agentLoad(); setupRender(); tabsRender(); }
}
budLoad();setInterval(budLoad,2500);
hashRead();
setTab(TAB);

// First-run guide. It uses the same local endpoints and nearby-device data as
// the console. The ceremony requires an explicit spoken-comparison checkbox
// before the fingerprint shown here is submitted to /api/pair.
let W={stage:'welcome',handle:'',words:[],warning:'',devices:[],chosen:'',name:'',compared:false,
  subject:'Hello from gage',message:'I have set up gage. Reply when you are ready.',error:'',busy:false};
function wRender(){
  const el=document.getElementById('wizard'); if(!el)return;
  const field=(id,val,placeholder)=>`<input class="winput" id="${id}" value="${esc(val)}" placeholder="${placeholder}" oninput="W.${id==='whandle'?'handle':id==='wname'?'name':id==='wsubject'?'subject':'message'}=this.value">`;
  let body='';
  if(W.stage==='welcome')body=`<h1>Welcome to gage</h1><p>Gage lets you exchange signed bundles with people you know. First choose a name for this machine, then compare word-pairs with a buddy before you send them a first bundle.</p><button class="btn go" onclick="W.stage='handle';wRender()">Get started</button>`;
  if(W.stage==='handle')body=`<h1>Choose a handle</h1><p>This name helps you and your buddies recognize this machine.</p>${field('whandle',W.handle,'Your handle')}<button class="btn go" onclick="wCreate()" ${W.busy?'disabled':''}>${W.busy?'Creating…':'Create identity'}</button>`;
  if(W.stage==='words')body=`<h1>Your word-pairs</h1><p>Keep these pairs available while you compare them with your buddy. They identify this machine.</p><div class="wwords">${esc(W.words.join('\n'))}</div><p>${esc(W.warning)}</p><p>For a backup, run <code>gage export-identity</code> in a terminal and keep the exported file safe.</p>`+inviteHtml()+`<button class="btn go" onclick="wNearby()">Continue to pairing</button>`;
  if(W.stage==='pair'){
    const d=W.devices.find(x=>x.label===W.chosen);
    body=`<h1>Pair with a buddy</h1><p>Both machines must be running <code>gage lan</code> on the same network. Select your buddy, read all four pairs aloud, and compare them with the other screen.</p>`+
      (W.devices.length?W.devices.map(x=>`<label class="wcheck"><input type="radio" name="wdevice" ${W.chosen===x.label?'checked':''} onchange="W.chosen='${esc(x.label)}';W.compared=false;wRender()"><span>${esc(x.paired_as||x.label)}</span></label>${W.chosen===x.label?`<div class="wwords">${esc(x.fingerprint)}</div>`:''}`).join(''):'<p>No nearby machines yet. Start <code>gage lan</code> on both machines, then refresh this step.</p>')+
      `<button class="btn" onclick="wNearby()">Refresh nearby machines</button>`+
      (d?`<label class="wcheck"><input type="checkbox" ${W.compared?'checked':''} onchange="W.compared=this.checked;wRender()"><span>We compared these aloud and all four pairs match.</span></label>${field('wname',W.name,'Name for this buddy')}<button class="btn go" onclick="wPair()" ${!W.compared||W.busy?'disabled':''}>${W.busy?'Pairing…':'Pair buddy'}</button>`:'');
  }
  if(W.stage==='send')body=`<h1>Send your first bundle</h1><p>Write a short message for ${esc(W.name)}. Gage will send it through the existing bundle flow.</p>${field('wsubject',W.subject,'Subject')}<textarea class="winput" id="wmessage" oninput="W.message=this.value">${esc(W.message)}</textarea><button class="btn go" onclick="wSend()" ${W.busy?'disabled':''}>${W.busy?'Sending…':'Send first bundle'}</button>`;
  if(W.stage==='done')body=`<h1>You’re set up</h1><p>Your first bundle was sent. You can continue in the gage console.</p><button class="btn go" onclick="document.getElementById('wizard').hidden=true">Open console</button>`;
  el.innerHTML=`<section class="wcard">${body}${W.error?`<div class="werror">${esc(W.error)}</div>`:''}</section>`;
}
async function wCreate(){
  W.busy=true;W.error='';wRender();
  try{const r=await post('/api/init',{handle:W.handle});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);
    W.handle=j.handle;W.words=Array.isArray(j.words)?j.words:(typeof j.words==='string'?j.words.split('\n'):[]);W.warning=j.backup_warning||'';W.stage='words';
    await inviteLoad(true);
  }catch(e){W.error=e.message||String(e)} W.busy=false;wRender();
}
async function wNearby(){
  W.error='';try{const r=await fetch('/api/nearby');if(!r.ok)throw Error(`HTTP ${r.status}`);const j=await r.json();W.devices=j.devices||[];W.chosen='';W.compared=false;W.stage='pair';}
  catch(e){W.error=e.message||String(e)}wRender();
}
async function wPair(){
  const d=W.devices.find(x=>x.label===W.chosen);if(!d||!W.compared)return;
  W.busy=true;W.error='';wRender();
  try{const r=await post('/api/pair',{label:d.label,name:W.name.trim(),fingerprint:d.fingerprint});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);W.stage='send';}
  catch(e){W.error=e.message||String(e)}W.busy=false;wRender();
}
function wB64(s){const bytes=new TextEncoder().encode(s);let bin='';for(const b of bytes)bin+=String.fromCharCode(b);return btoa(bin)}
async function wSend(){
  if(!W.message.trim())return;W.busy=true;W.error='';wRender();
  try{const r=await post('/api/send',{to:W.name.trim(),subject:W.subject.trim(),type:'share',files:[{name:'hello.txt',data:wB64(W.message)}]});const j=await r.json().catch(()=>({}));if(!r.ok)throw Error(j.error||`HTTP ${r.status}`);W.stage='done';}
  catch(e){W.error=e.message||String(e)}W.busy=false;wRender();
}
async function wStart(){try{const r=await fetch('/api/state');if(!r.ok)throw Error(`HTTP ${r.status}`);const s=await r.json();if(s.has_identity===false){document.getElementById('wizard').hidden=false;wRender()}}
  catch(e){/* Existing console loader reports API availability failures. */}}
wStart();

load();setInterval(load,2500);
</script>