summaryrefslogtreecommitdiff
path: root/docs/design.typ
blob: f6e2e2404caf6fe3ad670067ff737774b6d8418e (plain) (blame)
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
// docs/design.pdf is a retained test fixture, not regenerated by normal builds.
#import "@preview/cetz:0.5.1"

#set page(paper: "a4", margin: (x: 1.5cm, y: 1.8cm), columns: 2, numbering: "1")
#set text(font: "New Computer Modern", size: 8.8pt)
#set par(justify: true, leading: 0.55em)
#set heading(numbering: "1.1")
#show heading: set block(above: 1.15em, below: 0.6em)
#show heading.where(level: 1): set text(size: 10.2pt)
#show heading.where(level: 2): set text(size: 9.2pt)
#show heading.where(level: 3): set text(size: 8.8pt, style: "italic")

#show raw.where(block: true): it => block(
  width: 100%,
  fill: luma(246),
  inset: (x: 0.5em, y: 0.45em),
  radius: 1pt,
  breakable: true,
  text(size: 7.2pt, it),
)
#show raw.where(block: false): set text(size: 8.1pt)

#show figure.caption: set text(size: 7.6pt)
#set figure(gap: 0.55em)
#set table(stroke: 0.4pt, inset: 0.35em)

#let wide(caption: none, body) = place(
  top,
  scope: "parent",
  float: true,
  clearance: 1.2em,
  figure(body, caption: caption),
)

#let diagram(body) = [
  #show raw.where(block: false): set text(size: 5.9pt)
  #body
]

#place(top + center, scope: "parent", float: true, clearance: 1.5em)[
  #text(16pt)[*Pardes: A Text Environment*]

  #text(8.8pt)[Architecture and implementation of the rewrite]
]

#place(top + center, scope: "parent", float: true, clearance: 1.4em)[
  #block(width: 82%, inset: (x: 0pt))[
    #set par(justify: true, leading: 0.52em)
    #text(8.2pt)[
      *Abstract.* Pardes is a text environment in the acme tradition: columns of
      panes, each pane a tag line plus a body, the body a live terminal, a file,
      an image or a PDF. It is one program with five thin shells — a terminal
      over libvaxis, an SDL3 window, a browser tab driven by vanilla JavaScript,
      an AppKit application, and an ESP32-P4 microcontroller — where the
      prototype had three parallel implementations of one editor. Two seams
      carry that. Outward, the core is a state machine over plain values:
      `Event` in, `Surface` and a queue of `Effect` out, and nothing that cannot
      be said in those types exists in pardes. Downward, `Host.VTable` is
      optional function pointers, with in-process fallbacks,
      so the zero-method host is both the test harness and the browser. A
      session can also be a daemon: the core keeps the ptys and the disk and N
      frontends carry only a screen, a keyboard and a clipboard.
    ]
  ]
]

= What it is

== The acme inheritance

Columns of panes; each pane is a tag line plus a body; the body is a live
terminal, a file, an image, or a PDF. The mouse carries meaning — left selects,
middle executes, right looks. Everything on screen is text and all text is
equally alive, whether the shell printed it or the user typed it.

Two acme mechanisms are reproduced rather than reinterpreted. A *dump* is the
session as data: `pardes -l state.zon` on every platform reconstructs the panes
— terminals by replaying their raw VT streams into fresh emulators, files,
images and PDFs from their bytes. Editable tag tails are stored separately from
their dynamic live prefixes, and image records retain PETSCII, palette, and
ASCII renderer choices. A PDF rides the dump's `image` kind, carrying its path,
its bytes and the page it was on. The reader validates weights, scroll ranges,
pane references, and bounded tag tails before constructing anything; invalid
base64 fails instead of silently becoming empty content. A PDF record whose path
cannot be opened — or a build without MuPDF — falls back to an ordinary file
pane with those exact embedded bytes and its original editable tail.

That is what collapses the prototype's *two* applications into one. Its browser
build was a separate 800-line read-only replay viewer; here loading another
instance's dump is a first-class feature of the one application, and the web
shell is that application with an embedded dump and `spawn` left unanswered.
Same core, no viewer fork.

The other acme mechanism is a control filesystem, `src/fs.zig`
(@fs).

== One core, five shells

Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and
events, and you read state out of it. Every platform owns its own event loop and
its own renderer; the core owns everything the user would recognize as pardes.

*The core* owns layout, panes, modes, selection, click semantics, themes, and the
text of the UI. Pure state machine: `update(event)` mutates, `render(arena)`
returns the surface. No rendering, no event loop, and no IO that has an effect
for it — but "no syscalls" was never true and is less true now: `look` reads a
file a Look opened, walks a directory for Find and reads every candidate for
Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those are the
ones that are cheaper done in place than round-tripped through an effect and
back; everything with a lifetime — a pty, a window, the clipboard — is still
asked for.

*A shell* is one MODULE per platform: tty is one file, gui adds the CRT and
gamepad files plus a C font loader and eight GLSL shaders, and web, macOS and the
board each add a host language or a host repository. It owns the event loop,
translates native input into core events, renders the core's surface, and
performs the core's requested effects — spawn a shell, write a pty, open a link.

The five, and what each one actually is: the terminal (libvaxis); a native SDL3
window (the steamdeck); the browser (a freestanding wasm core driven by vanilla
JavaScript and rendered as HTML/CSS); a native macOS app (an AppKit and CoreText
shell over a static `libpardes.a`); and an ESP32-P4 microcontroller, a
freestanding riscv32 *object* that the sibling `05-zig-p4` toolchain links beside its own
`_start`, linker script and UART driver (`src/esp32p4.zig` header; the
`pardes-esp32p4` object `build.zig` emits). `pardes.Platform` is
`enum { tty, gui, web, macos, esp32p4 }`.

Native shells share `host_io.Shell`'s OSC 133 startup snippets, but not their
files. Each host owns a private `mkstemp` pair for its lifetime, writes and
closes both before the first fork, passes those unpredictable paths directly
in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS
launches therefore cannot truncate, source, or replace one another's startup
files.

== A shell need not share the process

`pardes --detach[=name]` runs the core with no terminal of its own and
`pardes --attach[=name]` makes a frontend of it over a unix socket, so one
session can carry a terminal and an SDL window at the same time and outlive
both. `main.nativeMain` dispatches `--detach` before it switches on the
platform, because it is not a shell: the tty and gui builds can both be asked
for one. The two flags together are refused. Local and detached sessions both
serve 9P by default. Both flags take `=name` and never a separate word, so
`pardes --attach README` opens `README` in a fresh session instead of attaching
to one called `README`. See @detached and `docs/detached.md`.

= The seam <seam>

#wide(caption: [The core/shell seam. `Event` is the only way in; `Surface` and a
queue of `Effect` are the only ways out. `Host.VTable` is how an `Effect`
reaches a resource, and every method a host leaves null is answered by
`host_io.Fallback` inside the same process.])[
  #diagram[
    #cetz.canvas(length: 0.995cm, {
      import cetz.draw: *
      set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))

      // ---- the core ----
      rect((6.0, 0.9), (11.6, 4.3), name: "core")
      content((8.8, 4.02), text(7.6pt)[*the core* --- `src/pardes.zig`])
      line((6.0, 3.78), (11.6, 3.78))
      content((8.8, 3.42), text(6.2pt)[`update(ev)` --- one dispatch, mutates])
      content((8.8, 2.92), text(6.2pt)[`render(arena)` #sym.arrow.r `*Surface`])
      content((8.8, 2.42), text(6.2pt)[`emit(e)` #sym.arrow.r fixed ring, `limits.effect_cap`])
      content((8.8, 1.92), text(6.2pt)[`nextEffect()` #sym.arrow.r `perform(e)`])
      content((8.8, 1.32), text(6.2pt)[single-threaded by construction])

      // ---- native input ----
      rect((0, 3.0), (4.3, 4.3), name: "in")
      content((2.15, 3.98), text(7.0pt)[*native input*])
      content((2.15, 3.52), text(6.0pt)[termios+vaxis / SDL / DOM /])
      content((2.15, 3.19), text(6.0pt)[AppKit / a byte sink on a UART])
      line("in.east", (6.0, 3.65), mark: (end: "stealth"))
      content((5.15, 3.92), text(6.4pt)[`Event`])
      content((5.15, 3.5), text(5.6pt)[16 arms])

      // ---- surface out ----
      rect((13.3, 3.0), (18.0, 4.3), name: "paint")
      content((15.65, 3.98), text(7.0pt)[*paint the grid*])
      content((15.65, 3.52), text(6.0pt)[vaxis cell-by-cell / glyph atlas /])
      content((15.65, 3.19), text(6.0pt)[DOM cells / CoreText / ANSI])
      line((11.6, 3.65), "paint.west", mark: (end: "stealth"))
      content((12.5, 3.92), text(6.4pt)[`Surface`])
      content((12.5, 3.5), text(5.6pt)[cells, cursor,])
      content((12.5, 3.16), text(5.6pt)[images, tracks])

      // ---- effect out ----
      rect((13.3, 1.1), (18.0, 2.5), name: "perf")
      content((15.65, 2.2), text(7.0pt)[*perform*])
      content((15.65, 1.78), text(6.0pt)[fork a pty, write a path, mark a])
      content((15.65, 1.45), text(6.0pt)[directory, put text on a clipboard])
      line((11.6, 1.8), "perf.west", mark: (end: "stealth"))
      content((12.45, 2.05), text(6.4pt)[`Effect`])
      content((12.45, 1.62), text(5.6pt)[18 arms])

      // ---- the vtable ----
      rect((6.0, -1.0), (11.6, 0.4), name: "vt")
      content((8.8, 0.14), text(7.0pt)[`Host.VTable` --- optional callbacks])
      content((8.8, -0.32), text(6.0pt)[one host owns each core])
      content((8.8, -0.68), text(6.0pt)[null callbacks use core fallbacks])
      line((8.8, 0.9), (8.8, 0.4), mark: (end: "stealth"))
      line("vt.east", (13.3, 1.5), mark: (end: "stealth"))

      // ---- fallback ----
      rect((0, -1.0), (4.3, 0.9), name: "fb")
      content((2.15, 0.62), text(7.0pt)[`host_io.Fallback`])
      content((2.15, 0.18), text(6.0pt)[every null method, answered here:])
      content((2.15, -0.18), text(6.0pt)[a virtual filesystem over the])
      content((2.15, -0.52), text(6.0pt)[embedded source, a virtual])
      content((2.15, -0.84), text(6.0pt)[clipboard, silent ptys])
      line("vt.west", "fb.east", mark: (end: "stealth"))

      // ---- answers loop back. Routed through the one corridor that is free of
      // every box (x between `fb`/`in` at 4.3 and the core column at 6.0) and
      // below the lowest box edge, so the canvas stays inside 0..18 and cannot
      // bleed into the page margins.
      line((15.65, 1.1), (15.65, -1.6), (5.72, -1.6), (5.72, 3.58),
        mark: (end: "stealth"), stroke: (dash: "dashed"))
      content((9.0, -1.94), text(5.8pt)[an answer returns as an ordinary `Event`: `paste`, `lsp_resp`, `pipe_resp`, `file_changed`])
    })
  ]
]

The boundary is three plain data types and one struct of function pointers.

== `Event`: what comes in

Sixteen arms (`pardes.Event`), in declaration order:
`key`, `mouse`, `resize`, `output`, `eof`, `lsp_resp`, `pipe_resp`,
`file_changed`, `paste`, `command`, `pdf_scroll`, `pinch`, `touch_scroll`,
`pointer_leave`, `tick`, `fs_req`.

`key` carries typed `text`. `mouse` carries press/release/motion/drag; button
left, middle, right and the four wheel directions; a cell position; and the
`ctrl` flag, because Ctrl-left-click is goto-definition. `resize` carries cols,
rows, and — in a MuPDF build — the pixel size of one cell. `output` is bytes a
pty produced, tagged with the pane id. `command` is one builtin line arriving
from another process over the nested socket. `pointer_leave` exists because an
out-of-range motion must clamp onto the last grid cell and a pointer that has
left the drawable area must not: otherwise leaving the window previews whatever
is under the final cell. `fs_req` is one filesystem request from a process that
opened a file under the control mount, and its answer leaves as an
`Effect.fs_reply` in the same update.

Four of the arms are answers to something the core asked for; @effect
names the asks.

Touch policy belongs to the shell: web maps a one-finger tap to right-button
LOOK and a drag past its tap slop to natural scrolling; native SDL keeps its
two-finger gestures. A joystick cursor is a motion plus buttons. The shells do
this translation and nothing else with input: one event vocabulary, five
dialects translated at the door.

`postEvent` is a value queue of 64 and asserts what cannot survive the trip
(`Pardes.postEvent`): `output`, `paste`, `lsp_resp`, `pipe_resp`,
`file_changed`, `command` and `fs_req` all carry a borrowed slice, so they are
`unreachable` there and must be handed to `update` directly inside the host's
borrow window. That is also what keeps the pty read path copy-free.

== `Surface`: what goes out

`cols`, `rows`, a grid of cells, a cursor, pixel attachments, and a bounded list
of plain panel-transition tracks (`pardes.Surface`). This is the
*canonical interface*: it is literally what the tty shell hands to vaxis, cell
by cell. SDL rasterizes the same grid through a glyph atlas; the browser reads a
packed copy and patches native DOM cells. If it cannot be expressed in the
surface, it does not exist in pardes.

Pixel images ride along as a list of PLACEMENTS rather than one per pane — a PDF
pane contributes every page its viewport intersects, and pages can be
arbitrarily short. The SDL shells blit RGBA; the tty shell uses kitty graphics
when available and falls back to the petscii matcher. Transition tracks identify
their pane by slot and serial and carry only phase, effect, frame, and from/to
cell boxes. Canonical layout is committed immediately; tracks are finite
presentation data.

== `Effect`: what is asked for <effect>

Eighteen arms (`pardes.Effect`). The core never performs IO for any of
them; it asks.

```zig
pub const Effect = union(enum) {
    spawn: struct { pane: u8, cwd: Buf(256) },
    write: struct { pane: u8, bytes: Buf(64) },
    resize_pty: struct {
        pane: u8, cols: u16, rows: u16 },
    open_link: Buf(256),
    save_file: struct { pane: u8 },
    save_text: struct {
        pane: u8, serial: u32, path: Buf(256) },
    write_dump,
    set_clipboard,
    read_clipboard,
    lsp: struct { id: u32, kind: lsp.Kind,
        pane: u8, offset: u32, arg: Buf(128) },
    pipe: struct { id: u32 },
    watch: struct { pane: u8, on: bool },
    theme_file: struct {
        generation: u32, on: bool },
    dump_themes: struct { pane: u8 },
    fs_reply: filesystem.Reply,
    attach: struct {
        pane: u8, name: Buf(attach_name_max) },
    detach: struct { pane: u8 },
    quit,
};
// doc comments elided; see the file
```

Every arm is a fixed-size value, and that is the constraint the widths record.
Unbounded content is never in the union: `save_file` names a pane and the shell
reads the bytes off the core, and `save_text` carries the *path* — bounded
exactly like a spawn's cwd, so two saves armed in one batch cannot cross — while
the bytes are read at drain time. `save_text` also carries the pane's `serial`,
so a slot freed and reused before the drain writes nothing rather than another
pane's text to that path. `attach_name_max` is 256 for the same reason `spawn`'s
cwd is, and reusing that width is why the arm costs the ring nothing:
`save_text` is still the widest member (`pardes.attach_name_max`).

Four asks have an answer coming back. `read_clipboard` returns an ordinary
`paste` event — or nothing at all when the shell cannot read the clipboard, which
is most terminals, since they refuse the OSC 52 read. `lsp` returns `lsp_resp`.
`pipe` returns `pipe_resp`. `watch` returns `file_changed` whenever the shell
notices a text file or PDF moved under it.

`theme_file` carries only a generation because the path lives in the core's fixed
request buffer, which keeps an already-large ring compact. `fs_reply` carries no
bytes either: `payload` says where they live — a staging buffer in the core, or a
range of a pane's live text — and `fsPayload` resolves it during the drain, so a
megabyte read costs one `writev` and no copy.

`attach` and `detach` are the two arms no host method performs directly. `attach`
must not tear anything down (@swap); `detach` is served only by a
detached core's host, and the null case is the point rather than an oversight —
a local tty or SDL shell has no session to leave, so `perform` reports that on
the pane's message row instead of quietly quitting something (the `.detach` arm of
`Pardes.perform`).

The watch path, in full, because it is the ask with the most machinery behind
it. The tty and SDL hosts and the detached daemon implement it in
`src/file_watch.zig`, with Linux inotify or a macOS kqueue behind one
mark/reconcile transaction; the native macOS host watches with debounced
DispatchSources instead, then restats the exact path under a pane-generation
guard. All three macOS watchers mark the same two things for the same reason:
the file catches in-place writes while the parent directory follows rename-over
saves, and the file mark is re-armed once such a save has moved the inode.
Text snapshots are filtered by their content hash;
PDFs use bounded inode/size/time identity and commit it only when equal stats
bracket a successful transactional MuPDF reopen (`file_watch.Generation`). A
mismatched transaction gets one bounded self-retry. This catches rename-over
saves without reading a large PDF merely to notice it changed, or spinning on a
malformed one. A PDF response retains its reading position and pane settings.
Web has no filesystem watcher, and nothing in the core waits for one.

== The effect ring

```zig
pub fn emit(p: *Pardes, e: Effect) void {
    if (p.effects_len == p.effects.len) return;
    const tail = (p.effects_head +
        p.effects_len) % p.effects.len;
    p.effects[tail] = e;
    p.effects_len += 1;
}

pub fn nextEffect(p: *Pardes) ?Effect {
    if (p.effects_len == 0) {
        p.effects_head = 0;
        return null;
    }
    const e = p.effects[p.effects_head];
    p.effects_head =
        (p.effects_head + 1) % p.effects.len;
    p.effects_len -= 1;
    return e;
}
```

`emit` REFUSES when full and never evicts (`Pardes.emit`). Byte
order is the reason: a `.write` dropped from the middle of a run would reorder a
pty's input, and a `.write` dropped from the tail merely truncates it.

Capacity is 4096 on every hosted platform and 128 on the board
(`limits.effect_cap`). No capacity can deadlock the drain, because `pump` empties
the ring on every iteration with an unconditional `while (nextEffect())` —
including effects `perform` itself queues — so the only question a capacity
answers is how large a single-pump *burst* may be. The one producer that can
burst is `emitWrite`, which chunks arbitrary bytes into 64-byte `.write` effects
for a pty; everything else queues O(1) effects per event, and the input queue
holds at most 64 events per pump, so 128 leaves two effects per queued event. A
build with no terminal panes has no pty to write to at all.

== `Host.VTable`: who serves the core

`host_io.Host` holds a context pointer and optional callbacks. macOS enters
its native shell through the separate `Runtime` C ABI.

```zig
pub const VTable = struct {
    /// The ONLY place the process may sleep.
    wait_input: ?*const fn (
        ctx: ?*anyopaque,
        timeout_ms: u32,
    ) void = null,
    present: ?*const fn (
        ctx: ?*anyopaque,
        surface: *const pardes.Surface,
    ) void = null,
    // ...
    tty_taken: ?*const fn (
        ctx: ?*anyopaque,
        pane: u8,
    ) bool = null,
    // ...
};
```

A null method is not an error: the core substitutes a default backed by ordinary
data structures in the same process (`host_io.Fallback`). A `Save` lands in a real
file under the tty host and in `Fallback.files` under a host that never wrote a
filesystem method, and every path above that behaves identically. Two
consequences were taken on purpose. The zero-method host IS the test harness: a
`Host{}` is a complete, deterministic, in-process pardes with a virtual
filesystem, a virtual clipboard and silent ptys. And `Fallback` lives on the
`Pardes` instance rather than on the host, so cores keep independent state.

The fallback filesystem is not empty. It is pardes's own source, embedded
(`src/fs.zig`), with `files` holding only what this session WROTE,
so a Save shadows the built-in copy and reading it back returns the edit. That is
what makes a host with no file methods a usable pardes rather than one staring
at an empty buffer.

What is deliberately NOT in the vtable: whether a capability EXISTS in this
build. That stays comptime and stays next to the code it shapes
(`pardes.platform`, `pardes.hosted`, `pardes.pdf_enabled`,
`pardes.can_attach`, `pardes.terminal_panes`, `builtins.capabilities`,
`PdfSlot`). A vtable cannot make a field zero-sized or a builtin absent from an
enum. Comptime decides what a build HAS; the vtable decides who SERVES it at
runtime.

`pardes.can_attach` is the sharpest of those, because it exists to close a gap
the coarser gate left open. `Attach` and `Detach` were gated on `pardes.hosted`,
and macOS is hosted: it has a unix socket and it compiles `detached/`. What it
does not do is POLL. `takeAttach` has to be a poll rather than a host method
precisely because attaching replaces the core the call is running inside
(@swap), and `src/macos.zig` never calls it — so there the word
parsed, queued an effect, stored a request in `attach_buf`, and then did nothing
at all, for ever, silently. `can_attach` admits exactly the tty and gui
platforms, which is exactly the pair `main.zig` accepts `--attach` for: the same
question asked at the command line instead of in a tag. A capability that a
build cannot serve should not be a word that build offers.

=== Callback ownership

Each core has one host. The detached host explicitly broadcasts shared state
and routes clipboard reads and link opening to the originating frontend.
There is no name-based fan-out layer.

== The frame, as a frontend writes it

```zig
pub fn pump(p: *Pardes, h: Host) !void {
    p.host = h;
    const v = h.vtable;
    if (v.wait_input) |f|
        f(h.ctx, if (p.animationActive())
            animation.frame_ms else 0);
    while (p.nextQueued()) |ev| p.update(ev);
    while (p.nextEffect()) |e| p.perform(e);
    // A quitting frame has already freed
    // what it would draw.
    if (p.quit) return;
    if (v.poll_frame) |f| f(h.ctx);
    _ = p.frame_arena.reset(.retain_capacity);
    const surface = try p.render(
        p.frame_arena.allocator());
    if (v.present) |f| f(h.ctx, surface);
    if (v.post_present) |f| f(h.ctx);
    // ...
}
```

`Pardes.pump`, eighteen lines in the source, and the order of them is the
architecture. Input first, in whichever host owns the sleep;
then every queued event, to completion; then every effect, to completion,
including effects `perform` queued; then one arena reset and one render; then
present, then post-present.

Animation time is not spent in here. `wait_input` was told how long it may
sleep, and a display clock wakes faster than that on input, so only the host
knows when a real frame interval has passed. Each spends it by handing back one
`.tick`.

`post_present` is split from `present` because it must observe a frame
the user has actually seen: panel-presentation acknowledgement and pointer
refresh both depend on that, and a hook that ran before the pixels landed would
acknowledge a frame that was never shown.

== Platform divergence is comptime

There is one deliberately platform-divergent file by design: `look.zig` holds
path and `:line` resolution, URL detection, and the per-platform outcomes. The
divergence is a comptime switch on `pardes.platform`, used the way the stdlib
switches on `os.tag`, so every platform's behaviour sits in the same screenful.

```zig
const platform_has_fs = !pardes.isolated and
    switch (pardes.platform) {
        .tty, .gui, .macos => true,
        // The browser's filesystem is the embedded
        // source archive; the P4 firmware's is
        // whatever the serial host answers for,
        // through the Host vtable — never a path
        // this process opens.
        .web, .esp32p4 => false,
    };
```

`look.platform_has_fs`. An isolated build has no filesystem *by construction* —
the option is comptime, so every libc path below is dead code the compiler
removes rather than a branch that could be taken by accident.

The core's one `pointerOperand` primitive owns click-word expansion and is shared
verbatim by right-click and the delayed hover preview; that policy stays beside
input because it also observes live pane selections and wrapped grid coordinates.

`panes.zig` keeps each pane kind's storage and operations together.
`layout.zig` owns placement and presentation state. `pardes.zig` handles input
and cross-pane state directly, without a pane vtable. Integration fixtures live
in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`.

= State

== One struct, fixed where it can be

The whole state is one struct. Panes themselves are heap-allocated on demand and
their contents (file bytes, the yank register, PDF rasters, tree-sitter state)
grow with what you open; everything else is sized at init.

```zig
Pardes
  ncol + col_weight[6], col_panes[6][16], col_n[6]
  panes: [16]?*Pane
  active + drag + config.Runtime
  rects: [16]layout.Rect
  presentation: layout.Presentation
  effects: [limits.effect_cap]Effect + head/len
  in_q:    [64]Event + head/len
  fallback: host_io.Fallback
  fs: fs.Namespace

Pane
  serial + mode + vweight
  terminal: ?*panes.Terminal.State
  file: ?panes.File.State
  image: ?panes.Image.State
  pdf: PdfSlot
  cwd: none | inherited(*Pane) | owned([]u8)
  tag_tail + prompt + cursor + selections
  ovl: ?panes.Terminal.EditBuffer

Terminal.State = VT + stream + replay + reply
File.State = path + bytes + line index + syntax + undo
Image.State = decoded pixels + render cache
Pdf.State = document + layout + raster cache + search
```

`MAX_PANES` is 16 and `MAX_COLS` is 6 (`pardes.MAX_PANES`, `pardes.MAX_COLS`). Sixteen panes
is also why the jump stack's depth is what it is: the depth that matters is
"more visits than you can hold in your head", not vim's hundred.

== Cells

```zig
pub const CellStyle = struct {
    fg: Color = .default,
    bg: Color = .default,
    bold: bool = false,
    dim: bool = false,
    italic: bool = false,
    blink: bool = false,
    reverse: bool = false,
    invisible: bool = false,
    strikethrough: bool = false,
    ul: enum { off, single, double,
               curly, dotted, dashed } = .off,
    font_role: FontRole = .body,
};

/// One surface cell. `default = true` means
/// "never painted this frame": the shell renders
/// it as the terminal's default cell (vaxis clear
/// semantics).
pub const Cell = struct {
    text: [7]u8 = @splat(' '),
    len: u8 = 1,
    style: CellStyle = .{},
    default: bool = true,

    pub fn grapheme(c: *const Cell) []const u8 {
        return c.text[0..c.len];
    }
    // ...
};
```

`pardes.CellStyle` and `pardes.Cell`. Seven bytes of `text` because a cell holds a
complete grapheme cluster and not a codepoint: keeping the cluster is what makes
combining marks visible and keeps ZWJ, modifier, flag and Indic sequences in the
same screen cell that cursor and edit maths treat as one unit. `len` bounds it,
and `default` distinguishes "a space was painted here" from "nothing was".

That distinction is load-bearing twice over. `visuallyEqual` compares only what
a shell can present — bytes past `len` are scratch left by earlier graphemes and
must never manufacture a diff, and an unpainted default cell has no visible
style or text either. And `printableAscii` returns `' '` for a default cell but
`null` for a painted multi-byte one, which is what admits a cell to the ASCII
transition walk (@diff).

== Columns and panes are arithmetic

Layout is arithmetic, not objects: columns are weights over the width, panes are
weights over the column.

#figure(
  placement: auto,
  caption: [The layout model. Each pane is a narrow gutter strip (move box and
  scrollbar) plus a tag row plus a body. `col_weight` is 64-bit fixed point with
  32 fractional bits, so every possible column split divides an initial weight
  exactly; `vweight` is an `f32` over its own column. `col_terms[c][k]` is the
  pane in slot position `k` of column `c`, and `rects[id]` is where that pane
  landed this frame.],
  diagram[
    #cetz.canvas(length: 1cm, {
      import cetz.draw: *
      set-style(stroke: 0.35pt, mark: (fill: black, scale: 0.3))
      let w = 7.6
      let h = 4.5

      rect((0, 0), (w, h))
      line((0, h - 0.28), (w, h - 0.28))
      content((w / 2, h - 0.14), text(4.9pt, style: "italic")[topbar, execute-only])

      line((2.8, 0), (2.8, h - 0.28))
      line((5.2, 0), (5.2, h - 0.28))
      line((2.8, 1.8), (5.2, 1.8))

      let pane(x0, y0, x1, y1, kind) = {
        line((x0 + 0.26, y0), (x0 + 0.26, y1 - 0.26))
        line((x0, y1 - 0.26), (x1, y1 - 0.26))
        content(((x0 + x1) / 2, y1 - 0.13), text(4.9pt, style: "italic")[tag: #kind])
      }
      pane(0, 0, 2.8, h - 0.28, [file])
      pane(2.8, 1.8, 5.2, h - 0.28, [terminal])
      pane(2.8, 0, 5.2, 1.8, [output])
      pane(5.2, 0, w, h - 0.28, [PDF])
      content((1.45, 2.3), text(4.9pt, style: "italic")[body])
      content((6.4, 2.0), text(4.9pt, style: "italic")[body])

      line((0.04, -0.3), (2.76, -0.3), mark: (start: "stealth", end: "stealth"))
      content((1.4, -0.58), text(5.4pt)[`col_weight[0]`])
      line((2.84, -0.3), (5.16, -0.3), mark: (start: "stealth", end: "stealth"))
      content((4.0, -0.58), text(5.4pt)[`col_weight[1]`])
      line((5.24, -0.3), (w - 0.04, -0.3), mark: (start: "stealth", end: "stealth"))
      content((6.4, -0.58), text(5.4pt)[`col_weight[2]`])

      line((5.0, 1.86), (5.0, h - 0.34), stroke: (dash: "dotted"),
        mark: (start: "stealth", end: "stealth"))
      content((4.2, 3.0), text(5.2pt)[`vweight`])
      line((5.0, 0.06), (5.0, 1.74), stroke: (dash: "dotted"),
        mark: (start: "stealth", end: "stealth"))
      content((4.2, 0.9), text(5.2pt)[`vweight`])
    })
  ],
)

Horizontal layout weights are fixed-point integers and geometry rounds
cumulative boundaries. Splitting a column replaces only its weight $W$ by
$A + B = W$ at the same position. Therefore every boundary outside the source
column is bit-identical before and after the split, including at awkward
non-dyadic screen widths; only the source and new column can receive movement
tracks. Vertical splits apply the corresponding rule to the source pane's
weight.

`splitBelow` shrinks only the source pane; `absorbVWeight` gives a dying pane's
weight to one sibling; an emptied column hands its width to a neighbour.
Minimal motion is the invariant: an operation on one pane may not move panes it
does not touch.

== Runtime settings are one table

User-settable runtime choices are one plain `config.Runtime`: booleans,
theme index, owned bounded shell/font strings, requested/effective font facts,
one panel-transition enum, and scene-effect booleans
(`config.Runtime`). A compile-time `settings` array
(`config.Runtime.settings`) generates each setting builtin and the rows of the
single `Config` query. It has no callbacks and no parallel query registry to
drift from it.

Requested and effective are separate fields on purpose. Resolving a shell name
to an executable, or a font name to a face, belongs to the native host; the core
retains what the host actually chose and marks a changed request `pending` until
the next spawn or the next atlas acknowledges it. A rejected request therefore
stays queryable without claiming it is on screen.

= Modes and selections

== Three modes and a boolean

`Pane.mode` is `enum { normal, insert, tty }` (`pardes.Mode`).

*normal* is the helix motion model. *insert* is click-and-type: terminals get
splice runs (shift right, never overwrite, anchored to an absolute row), files
get real edits. *tty* is raw pty forwarding — a Ctrl-key toggle, default Ctrl-b,
`--tty-toggle` — where the mouse is still usable, entry does a
`promptClickMove`, and prompts become visible again (they are hidden in the
other two modes via OSC 133).

Select is not a fourth mode. `v` sets `Pane.select`, a bool on top of normal,
which makes motions extend from a fixed anchor instead of replacing the range;
`pane.mode` stays `.normal`, and insert/tty transitions drop it
(`Pane.select`). It is *displayed* as a fourth mode name, "select",
because that is what the user is in — but nothing in the dispatch branches on a
fourth mode.

Since the helix motion model landed, a traversal motion SELECTS the range it
crossed. That is why there is no verb+noun grammar and why `i` after `w` types
at the selection's start.

== One primary range and up to 63 more

`MAX_SELS` is 64 (`pardes.MAX_SELS`). helix's `Selection` is a list of
ranges plus a primary index; pardes keeps the PRIMARY exactly where it has
always been — `cur_row`/`cur_col` plus `vsel` — and the other ranges in
`sels: [MAX_SELS - 1]SelRange`, document-ordered and disjoint
(`Pane.sels`, `Pane.nsel`).

That split is the whole design. Every motion, operator, renderer and mouse path
still reads one selection, so with `nsel == 0` not a byte of behaviour moves, and
the differential and snapshot suites keep proving it. The extra ranges are driven
by replaying the single-selection key handler once per range (`replaySels`).

`s`/`S` — select and split by regex — snapshot the selection they were armed on
in `sel_snap` and re-derive the preview FROM that snapshot on every keystroke,
rather than from the previous preview. This is what helix's `regex_prompt` does,
it is what makes typing a pattern one character at a time land on the same answer
as pasting it whole, and it is what makes Esc a plain restore with nothing else
to undo. The snapshot also records whether the range was a *user-intent*
selection, so restoring cannot silently promote motion residue into something
the acme chords will act on (`Pane.sel_snap`).

== Three buttons, three meanings

The mouse selections are `[3]Sel`, one per button, block-shaped, in text-area
coordinates where `r` counts from the tag row (`pardes.Sel`). Each
has three states, `none`, `dragging` and `done`, which is what lets a kept left
selection stay highlighted after the drag and still be distinguishable from one
in progress.

Left selects and pins the cursor. Middle executes: with no drag it expands to a
file-ish word, then runs a builtin or sends the text to the shell, and it does
not focus. Right looks. A theme owns the SELECTION — `sel_bg`/`sel_fg` are one
pair per theme, and the three per-button tints and the dimmed extra cursors are
mixed off it, so what stays fixed is the distinction between buttons and not the
colours.

`Drag` is a `union(enum)` with six arms (`pardes.Drag`): `none`,
`border_v`, `border_h`, `move`, `tag`, `select`. `border_v` carries an optional
`corner`: when the press lands on a cell that is both a column's vertical border
and one of the two adjoining columns' own horizontal borders, the one drag moves
*both* boundaries — never three, and when both columns happen to be split at the
grabbed row the left one wins, so the gesture that existed before is bit-for-bit
unchanged.

== The tag is a command line

A tag is one line: a live prefix (mode indicator, cwd or path) plus an editable
tail, and the tail gets the full modal editor. The coordinate space is UTF-8 byte
offsets into the *whole* rendered tag, prefix ++ tail, always on grapheme
boundaries (`Pane.tag_col`). The prefix is live chrome, so it is
selectable, yankable and executable but READ-ONLY: every edit op measures from
`edit0` — the first editable byte — and does nothing left of it.

`tag_tail` is a fixed `[max_tag_tail]u8`, and the bound IS the storage
(`limits.max_tag_tail`): every writer — `appendTag`, `tagInsert`,
`restoreDumpTail`, the 9P `tag` file — refuses input that does not fit
rather than truncating it. The schema limit and the buffer therefore can never
disagree, which is what lets a dump reader reject data before copying it into a
pane.

A tag edit is always insert mode, so it hijacks the body's mode; `tag_mode`
remembers what it hijacked and terminals restore it on exit, which is why
clicking a tag never changes a pane's mode.

= Diffing and presenting a frame

== Eleven transitions

`layout.zig` is backend-neutral data and math: the transition
vocabulary, easing, exact endpoint progress, stable per-cell noise, and a POD
track. `Transition` has twelve members counting `off`
(`layout.Transition`), with explicit numeric values because they
cross both GUI shader ABIs — GLSL receives the enum in an instance `uvec4` and
the Core Image kernel receives it as a float, so spelling the numbers keeps a
source reorder from changing pixels.

#table(
  columns: (auto, auto, auto, 1fr),
  align: (left, left, left, left),
  table.header([*id*], [*name*], [*frames*], [*composed by*]),
  [1], [`slide`], [12], [backend shader / tty grid],
  [2], [`zoom`], [14], [backend shader / tty grid],
  [3], [`dissolve`], [10], [backend shader / tty grid],
  [4], [`ascii`], [13], [core],
  [5], [`vertical`], [12], [backend, lifecycle only],
  [6], [`edges`], [12], [core],
  [7], [`fall`], [14], [core],
  [8], [`wave`], [14], [core],
  [9], [`curtain`], [12], [core],
  [10], [`scramble`], [12], [core],
  [11], [`typewriter`], [14], [core],
)

The split in the last column is the design. `composedByCore` is true for every
*character* effect: the core writes the finished glyphs into the published
`Surface`, so no backend owns a byte walk, a stagger, or a noise threshold, and
every renderer presents the same byte at a given frame. The geometry effects
(`slide`, `zoom`, `vertical`) and `dissolve` are the ones a backend evaluates,
because they are transforms over rectangles rather than choices about characters.

Easing follows from that too. A sweep and a typewriter are constant-rate by
definition, so `curtain` and `typewriter` are `linear`: easing their head would
make the pass visibly hesitate mid-pane. Character walks and per-cell locks read
best with a slow start, a fast middle and a slow settle, so `ascii`, `fall` and
`scramble` are `smoother`.

The core detects opening and moving rectangles when it commits layout and
publishes only active tracks. A separate dense closing-track list is
presentation-only state for a pane whose functional lifetime has already ended,
which is why it needs no pane owner and no serial guard. Pointer input inverts
the presented slide/zoom/vertical rectangle back to the canonical grid, lets
unchanged dissolve and ASCII cells through immediately, and rejects closing
pixels, so pixels and gestures cannot disagree during a transition.

== The semantic cell diff <diff>

```zig
pub const PanelCellDiff = union(enum) {
    unchanged,
    visual,
    ascii: AsciiDiff,

    pub fn between(
        old: *const Cell,
        new: *const Cell,
    ) PanelCellDiff {
        if (old.visuallyEqual(new)) return .unchanged;
        if (AsciiDiff.between(old, new)) |diff|
            return .{ .ascii = diff };
        return .visual;
    }

    // ...
};
```

`pardes.PanelCellDiff`; the elided member is `changed`, which is
`diff != .unchanged` and is what `Surface.panelCellChanged` calls. The core
retains the last successfully presented canonical grid and this typed old/new
classification, and it is the *semantics* that matter: unused grapheme bytes do
not manufacture a change, and a style-only or multi-byte change is `.visual` and
passes straight through.

An `.ascii` diff is a `{ from: u8, to: u8 }` pair, and the core composes it by
incrementing or decrementing the printable byte. Short walks move one value per
frame; a longer walk is crossed by eased character skips and finishes within
`ascii_max_movement_frames`, which is 12
(`layout.ascii_max_movement_frames`). Frame
zero is the exact old byte and the endpoint is exact, so an intermediate frame is
always valid UTF-8. `Track.frame_count` carries the core-computed duration for
these data-dependent effects — zero selects the effect preset, and the ASCII
composer fills it from the longest eased byte walk in the pane's diff.

== What each backend does with it

TTY copies that `Surface` grid into a compositor scratch grid, clears slide and
zoom destinations, then paints moving, opening, and closing panels in order.
Cleared geometry uses the theme page colour when it is explicit and the host
terminal default only for transparent themes, so a light theme cannot flash a
dark gap. Slide and zoom change the copied rectangle; dissolve changes only diff
cells from their old value to their new value; vertical raises only an opening or
frozen closing pane inside its own clip.

Every effect's last active sample is its exact canonical endpoint, so the
compositor returns the source surface unchanged when no track is
non-canonical — keeping the real cursor and attachments in that sample rather
than suppressing them for one frame that is otherwise pixel-identical
(`panel_compositor.compose`). Kitty placements cannot be resampled
through the character-grid transform, so a moving pane's attachment is omitted
while its geometry moves and placed again on the last active sample.

SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect
parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII
because they have no character byte. macOS passes the same records across its
plain C ABI and composites old/new panel images in Metal and Core Image. Scene
`Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI.

DOM web is a separate platform, not a shader GUI: retaining selectable HTML and
CSS is more important than duplicating the renderer in canvas, so it exposes
neither effect family.

`EffectCode <effect>` lists the current backend's build-embedded source paths
under `/virtual`. Look opens each full file without a source checkout.
Shared implementations share paths.

== The ASCII fast paths

Three loops in this codebase run once per character over text that is almost
always ASCII, and each asks two or three general functions for what arithmetic
already knows. The guard that makes elision *correct* rather than merely fast is
the same in all three, and it is a statement about Unicode: an ASCII base joins a
following combining mark, ZWJ or spacing mark into ONE cluster, and every scalar
that can do that is non-ASCII. So a printable ASCII byte followed by another
ASCII byte, or by nothing, is a complete grapheme cluster one column wide.

```zig
// ASCII FAST PATH. Printable ASCII is one byte,
// one cell, one column, and the general path
// below reaches that answer through a UTF-8
// length, a decode, a freshly constructed
// grapheme iterator, a slice validation and a
// width lookup - per character.
{
    const b = text[i];
    if (b >= 0x20 and b < 0x7f and
        (i + 1 == text.len or text[i + 1] < 0x80))
    {
        s.set(col, y, text[i .. i + 1], style);
        i += 1;
        col += 1;
        continue;
    }
}
```

`Surface.print`. Its own comment records the reason
it exists: this function was 26% of a keystroke when profiled in the ESP32-P4's
configuration — 40×12, no tree-sitter — which was the largest single item there.
`\t`, `\r`, the C0 controls and DEL are excluded by the range test and keep their
existing handling.

The second is `panes.File.fitEnd` (`panes.File.fitEnd`), which decides
where a soft-wrapped row breaks. It asks `modal.nextGrapheme` and
`graphemeDisplayWidth` once per character, and a 640-column line asks 640 times.

The third is `modal.nextGrapheme` itself
(`modal.nextGrapheme`), and it is where the argument above needs its one
correction. GB3 is the single UAX #29 rule that joins two ASCII scalars: a CR
takes a following LF into the same cluster. The two range-tested loops never see
it, because `0x20..0x7e` excludes CR — but `nextGrapheme`'s fast path admits
every byte below `0x80`, so it excludes CRLF by name. It did not always, and
`graphemeStart` did: the two then disagreed about a CRLF file by exactly one
byte, a head stepped onto the offset between CR and LF, and `graphemeStart`
repaired it back onto the CR. Everything else ASCII is still O(1).

Because `fitEnd` decides where text lands on screen, a fast path that is off by
one column *moves text*. Both of its tests therefore pin it to the general walk
it replaces rather than to a transcribed expectation: one sweeps every byte
below 0x80 against a set of neighbours at every width and start offset
(*the ASCII run in fitEnd survives an exhaustive byte sweep*), and the other runs a hand-written case list —
including `"abc\u{00e9}def"` for a hand-over mid-run, a wide glyph a width
boundary can land inside, `"a\u{0301}bc"` for a cluster the fast path must not
split, and `"e\u{0301}x"` for an ASCII byte followed by a continuation byte —
through both routes (*the ASCII run in fitEnd cuts where the grapheme walk
would*). `Surface.print` has the same
arrangement: its reference implementation is `print` with the fast-path block
deleted and nothing else changed (the test *the ASCII fast path in surface
print paints what the general arm paints*).

= Detached sessions <detached>

#wide(caption: [A detached session. The daemon owns the core and every
machine-local resource; a frontend owns a screen, a keyboard and a clipboard.
Counts and tag numbers from `wire.ClientTag` and `wire.ServerTag`; the routing rules
from `src/detached/server.zig:34-59`.])[
  #diagram[
    #cetz.canvas(length: 1cm, {
      import cetz.draw: *
      set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))

      // ---- the daemon ----
      rect((0, 2.0), (6.4, 5.5), name: "d")
      content((3.2, 5.24), text(7.4pt)[*the daemon* --- `pardes --detach[=name]`])
      line((0, 5.02), (6.4, 5.02))
      content((3.2, 4.74), text(6.0pt)[one `Pardes`; `update` is called here])
      content((3.2, 4.38), text(6.0pt)[every pty master (`host_io.forkShell`)])
      content((3.2, 4.02), text(6.0pt)[every file (`host_io.writeFileBytes`)])
      content((3.2, 3.66), text(6.0pt)[the inotify fd (`file_watch.zig`)])
      content((3.2, 3.30), text(6.0pt)[the theme dir and the acme mount])
      content((3.2, 2.94), text(6.0pt)[16 of 21 `VTable` methods implemented])
      content((3.2, 2.58), text(6.0pt)[ONE `poll(2)` --- the only sleep])
      content((3.2, 2.26), text(5.6pt, style: "italic")[pane shells outlive every frontend])

      // ---- the socket, drawn in segments so the labels sit in the gaps ----
      line((9.3, 2.0), (9.3, 3.02), stroke: (dash: "dashed"))
      line((9.3, 3.46), (9.3, 4.72), stroke: (dash: "dashed"))
      line((9.3, 5.16), (9.3, 5.6), stroke: (dash: "dashed"))
      content((9.3, 5.78), text(6.2pt)[`AF_UNIX`])

      // ---- the two directions ----
      line((12.2, 4.7), (6.4, 4.7), mark: (end: "stealth"))
      content((9.3, 4.94), text(6.2pt)[`ClientTag` --- 11 tags])
      line((6.4, 3.0), (12.2, 3.0), mark: (end: "stealth"))
      content((9.3, 3.24), text(6.2pt)[`ServerTag` --- 8 tags])
      content((9.3, 2.48), text(5.6pt, style: "italic")[a frame per pump, per client;])
      content((9.3, 2.18), text(5.6pt, style: "italic")[never queued, only skipped])

      // ---- frontends ----
      rect((12.2, 4.35), (18.0, 5.5))
      content((15.1, 5.24), text(6.8pt)[*frontend* --- a terminal])
      content((15.1, 4.86), text(5.8pt)[`--attach`; vaxis paints `grid`/`cursor`])
      content((15.1, 4.52), text(5.8pt)[screen + keyboard + clipboard + a link])

      rect((12.2, 3.1), (18.0, 4.15))
      content((15.1, 3.90), text(6.8pt)[*frontend* --- an SDL window])
      content((15.1, 3.52), text(5.8pt)[same protocol, same session, same screen])
      content((15.1, 3.22), text(5.8pt)[`screen -x`, not N sessions])

      rect((12.2, 1.85), (18.0, 2.9))
      content((15.1, 2.68), text(6.8pt)[#sym.dots.v up to `max_clients` = 32])
      content((15.1, 2.32), text(5.8pt)[the 33rd gets a `refuse .full`, not a backlog])
      content((15.1, 2.02), text(5.6pt, style: "italic")[NO FORK HAPPENS IN A FRONTEND])

      // ---- the message tables, full width ----
      rect((0, -2.8), (18.0, 1.5))
      content((9.0, 1.24), text(7.0pt)[*every message, and which way it crosses*])
      line((0, 1.02), (18.0, 1.02))
      let row(y, body) = content((0.3, y), body, anchor: "west")
      row(0.72, text(5.6pt)[frontend #sym.arrow.r core (11): `hello` `bye` `key` `mouse` `resize` `paste` `command` `pdf_scroll` `pinch` `touch_scroll` `pointer_leave`])
      row(0.32, text(5.6pt)[core #sym.arrow.r frontend (8): `welcome` `refuse` `frame` `quit` `detach` | `set_clipboard` `read_clipboard` `open_link`])
      row(-0.16, text(5.6pt)[BROADCAST --- every screen must show the same thing: `frame`, `set_clipboard`])
      row(-0.54, text(5.6pt)[ORIGIN ELSE PRIMARY --- the answer belongs to the human who acted: `read_clipboard`, `open_link`, `detach`])
      row(-0.92, text(5.6pt)[`read_clipboard`'s answer is not a reply message: it comes back as an ordinary `Event.paste`])
      row(-1.40, text(5.6pt)[the gaps `0x13`..`0x17`, `0x1e` were `output` `eof` `lsp_resp` `pipe_resp` `file_changed` `tick`: deleted, not renumbered,])
      row(-1.78, text(5.6pt)[when the daemon took the disk --- a decodable `output` let an attached peer forge a pane's text])
      row(-2.16, text(5.6pt)[never on the wire: `wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s])
      row(-2.56, text(5.6pt)[that answer their own caller, and `fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for])
    })
  ]
]

== The daemon owns the machine

The `Pardes` instance lives in the detached process. A frontend owns a terminal
(or a window, or a serial panel) and a socket, and nothing else: it sends the
input it collects and draws the frames it is sent. One core per session, N
frontends attached to it, all looking at the same screen — `screen -x`, not N
sessions.

The argument for putting every machine-local effect in the daemon is one
sentence: a unix socket means the core and its frontends are on the SAME machine,
so there is no question of whose disk or whose process table is meant, and given
that, the process that must hold them is the long-lived one. A shell forked by a
frontend dies with that frontend, and a session whose whole promise is outliving
the frontend attached to it cannot keep its panes that way. So the daemon forks
the pane shells, writes the files, marks the directories, and drains the pty
masters in its own `poll(2)`. The pane shells outlive every frontend: attach,
detach, kill the terminal, attach from another one, and the build that was
running in pane 3 is still running and has been scrolling into the core the whole
time.

The detached core also serves the default 9P socket. Its event loop drains
filesystem transactions alongside frontend and terminal input.

Nothing blocks indefinitely, and that property is what a detached session is
*for*. Every descriptor is non-blocking; the single `poll(2)` is the only place
the process sleeps; and every queue that could grow without bound has a ceiling
with a stated answer for reaching it. Frames in particular are NOT queued: a
client with bytes still owed to the kernel is skipped for this frame and its
mirror is left alone, so the next frame it does get is a diff against what it
actually has. A slow frontend therefore sees fewer, larger frames instead of a
growing queue, and coalescing costs no byte surgery. What is left in a client's
out-queue is control messages, capped by `out_backlog` and checked *before* an
append, so a single oversized message still goes out whole and what gets refused
is a client that has stopped draining: it is closed, and its peers are untouched.

== The wire

```zig
pub const ClientTag = enum(u8) {
    hello = 0x01,
    bye = 0x02,

    key = 0x10,
    mouse = 0x11,
    resize = 0x12,
    paste = 0x18,
    command = 0x19,
    pdf_scroll = 0x1a,
    pinch = 0x1b,
    touch_scroll = 0x1c,
    pointer_leave = 0x1d,
};

pub const ServerTag = enum(u8) {
    welcome = 0x01,
    refuse = 0x02,
    frame = 0x03,
    quit = 0x04,
    detach = 0x05,

    set_clipboard = 0x10,
    read_clipboard = 0x11,
    open_link = 0x12,
};
```

`wire.ClientTag` and `wire.ServerTag`. Eleven tags in, eight out, and the shape of both
lists is the argument.

Every `ClientTag` is something a human did: a handshake, a goodbye, and what a
keyboard, a mouse, a trackpad or a window manager produces. Six numbers are
missing from the input run — `0x13`..`0x17` and `0x1e` — and the gaps are left
rather than tidied away, because renumbering is a change every deployed frontend
feels. They were `output`, `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and
`tick`: the machine-local host's own reports, which stopped being a frontend's
business when the daemon took the disk and the process table. Leaving them
decodable was not merely dead weight. `server.zig`'s `apply` routes any decoded
non-resize event straight into `core.update`, so an attached peer could forge a
pane's output, forge an `eof` for a shell that was still running — and unlike the
daemon's own `paneEof` the wire path never called `closePty`, so the master
stayed open and the shell was orphaned for the life of the session — or replace a
pane's text with bytes the next `Save` would write to disk.

On the way out there are only three effects left, and which three is the whole
design: what a process nobody is looking at genuinely cannot do is put something
on THIS human's clipboard, read it back, and open a link in front of the person
who clicked it. The eight machine-local pushes — `spawn`, `pty_write`,
`pty_resize`, `write_file`, `write_dump`, `watch_file`, `watch_theme`,
`dump_themes` — were routed to ONE frontend precisely because each has one real
resource behind it, and every one of them is now performed in the daemon. Two
frontends can no longer fork two shells for pane 3 or race each other writing one
path, because neither of them writes anything.

`detach` sits in the session range beside `quit` rather than among the three
effects, because it is not an effect the session performs on the world: it is one
frontend being told it is done.

Host polling, presentation, process queries and worker dispatch stay local to
the session owner. They are not frontend wire messages. The owner also serves
9P: each filesystem reply returns to its requesting connection, not to attached
frontends. `Event.fs_req` therefore has no `ClientTag`.

=== The codec is architecture- and build-neutral

The frontend on the other end may be riscv32-freestanding while the core is
`x86_64` linux, so: every integer is an explicit width, little
endian, and no `usize` reaches the wire; no native struct is ever blitted, because
`@bitCast` of a Zig struct puts this compiler's field order and padding on a
socket; every union and every enum gets a tag chosen in this file and never
`@intFromEnum` of a core type, with exhaustive mapping switches, so adding a
variant to `Event` is a compile error here rather than a silent protocol
redefinition; every variable-length payload carries an explicit length prefix and
`max_payload` bounds the lot; a bool is one byte, 0 or 1, and any other value is a
decode error rather than "nonzero is true"; floats travel as their IEEE-754
binary32 bit pattern inside an explicit `u32`.

The codec is build-neutral for the same reason: `Event.resize.cell_pixels` exists only when
native PDF placement is compiled in, and a frontend must not have to have been
built with the core's options, so it is ALWAYS on the wire and dropped on arrival
by a build with nowhere to put it.

`version` is a `u16` checked on connect and refused loudly, because two builds of
pardes are routinely on one machine — `zig build` replaces the binary under a
running session — and a frontend decoding another version's frame layout would
paint garbage and blame the terminal. `ClientTag` is exhaustive: both ends are
pardes, and an unknown tag is not a valid message in this protocol version.

`max_payload` is 16 MiB, derived rather than chosen: a full frame of the largest
grid this protocol admits (512×128) at a worst case of one run per cell is
512×128×(6+20) = 1.6 MiB, and one paste is already capped at 4 MiB by the tty
frontend (`wire.max_payload`).

== Connect first, swap second <swap>

```zig
if (core.takeAttach()) |req| {
    var attempt = detached_client.attempt(
        gpa, req.name, core.screen_w, core.screen_h);
    switch (attempt) {
        .greeted => |client| {
            attached.* = client;
            break :frames;
        },
        else => {
            var mbuf: [256]u8 = undefined;
            core.setMessage(
                req.pane,
                attemptEnd(&attempt, req.name)
                    .row(&mbuf),
            );
        },
    }
}
```

`tty.localSession`, and the `takeAttach` block in `src/gui/gui.zig` is the same
shape.
CONNECTING IS NOT BEING ATTACHED, which is why this asks for a *greeted* client
and not for a socket: `Client.open` writes a hello and returns, and every way a
session says no — `refuse .version` for a session built from other bytes,
`.full`, `.quitting`, or a plain `quit` from one that ended in the same round —
arrives *after* a successful `connect(2)`. A swap that trusted the connect would
already have SIGKILLed every pane shell, unmounted the filesystem and freed every
undo history by the time it decoded the refusal.

So `attached` is set only with the welcome in hand, and until it is, nothing has
been touched: a failed `Attach` costs one message row and leaves every pane,
every shell and every undo history where it was. The teardown that follows is
the function's own defers, reached by leaving its scope.

The ordering is enforced in three places at once. The builtin only asks
(`builtins.Attach`). `Effect.attach` is unpacked into `attach_req` and
`attach_buf` by `perform`, and what the frontend acts on is what `takeAttach`
hands back AFTER the drain, not the effect value, which dies in the loop that
read it (`drainForAttach`). And `takeAttach` is consumed from the shell's
OUTER loop, beside `takeRestore` and for the same reason: both END this core, and
nothing running inside `pump` may destroy the core it is running in
(`Pardes.attach_req`). The unit test *Attach asks for a session and tears
nothing down* asserts
exactly that — after `Attach` and a full drain, `p.quit` is false and `p.panes[0]`
is still there.

Because a failed connect is cheap, `Attach` is the one word in the session group
that is safe to press by accident, and it is the one that gets a leader chord:
`SPC s a`. `Detach` takes `SPC s D`, a capital because `sd` has been `Dump`'s
since before there was anything to detach from. Both entries sit behind
`if (pardes.can_attach)` in `src/config.zig`, and that gate is not decoration:
the leader table may only name a builtin that EXISTS, so on macOS — hosted, but
no poller — the two words are compiled out and naming them would be a compile
error. Which is the good outcome, and the reason the predicate exists.

`Detach` is not `Attach` backwards, and the asymmetry is deliberate. Turning a
live local session into a daemon needs `setsid` and a fork; a word that pretended
to would hand you a session that dies with the window it was typed in. Run
locally, `Detach` therefore reports rather than acts.

== `host_io.zig`: the machine-local half

`forkShell`, `writeFileBytes` and `writeFd`, and it is now the only copy of them:
`tty.zig`, `detached/server.zig`, `gui/gui.zig` and `macos.zig` all fork and
write through it (`src/host_io.zig`, module header).

They did not always, and what the four copies had in common is the better
argument for the file existing than "it is shared" is: ALL FOUR were missing
`FD_CLOEXEC` on the pty master. `/dev/ptmx` is opened by `forkpty` with no
`O_CLOEXEC` and there is no flag argument to ask for one, so in every shell
pardes has ever shipped, a program in one pane could read and write another
pane's terminal. The silent half is worse and compounds: closing a master is the
only thing that hangs its shell up, and a master a later shell still holds open is
not closed, so a pane delete or a respawn left an orphaned shell that never
exited — never reaped, eventually blocked writing into a pty nobody reads — and
each orphan pinned every earlier pane's master in turn. The startup drain forks
pane 0 and then pane 1, so the arrangement existed from boot.

`nested.setCloexec(master)` after the fork fixes it for all four callers at once,
and the file states the window that leaves rather than papering over it: `fcntl`
after `fork` is not atomic, so a thread that forks and execs between the two
syscalls inherits the master anyway. In the detached daemon there is no such
thread — it is single-threaded by construction, which is what putting the pty
masters in its own `poll(2)` bought. Closing the window in the threaded shells
means replacing `forkpty` with `posix_openpt(O_CLOEXEC)` / `grantpt` /
`unlockpt` / fork / `setsid`.

`forkShell` takes the core it is forking on behalf of and nothing about
terminals: no vaxis, no `Loop`, no reader thread. Who drains the master is the
caller's business, and the callers answer differently on purpose — the tty, gui
and macOS shells hand it to a worker that posts into their event loop; the daemon
adds it to the one `poll(2)` it already runs and makes its own copy non-blocking
in order to.

= The board

== An object, not a module

`-Dplatform=esp32p4` emits ONE freestanding riscv32 object exporting the C ABI in
`src/esp32p4.zig`; the sibling `05-zig-p4` toolchain links it beside its own `_start`, its
generated linker script, and its UART driver (the `pardes-esp32p4` object
`build.zig` emits). Not an
executable, because the entry point is over there. Not a library, because
`addLibrary` bundles a `compiler_rt` the firmware already has.

Historically, it was a module exposed through `build.zig.zon`. A dependency in the OTHER direction was built and
reverted: nesting this package's roughly 30-package graph under `zig-p4`'s broke
every build in that repo, not just the firmware one. `std/Build.zig:2091`
exceeded its
1000-branch comptime quota through ghostty's `SharedDeps.zig:874` `lazyImport`,
seven cached tree-sitter versions failed to compile because their `build.zig`
uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files
into a repo whose entire claim is that Zig is its only dependency. This direction
was possible because `zig_p4` declared no dependencies of its own. The current
editor build no longer imports that package; the firmware toolchain is separate.

The C ABI carries terminal bytes. After building the object here, `zig build
-Dpardes` in `../05-zig-p4` links `src/esp32p4/app.zig` into the firmware image.
That toolchain needs ESP-IDF register headers; the local object build does not.
The standalone GPIO 9P image instead uses `zig build
-Dapp=../02-pardes-code/src/esp32p4_9p.zig` there. It does not link the editor:
`src/esp32p4_gpio.zig` holds its fixed namespace and `src/esp32p4_9p.zig` drives
the UART protocol loop.

The object is also the compile probe. Rooted at `src/esp32p4.zig` it drags the
whole core through the riscv32 backend by actually calling it, so `llvm-size` on
the result is a real number to hold against the board's 1.5 MiB factory
partition.

Where the terminal is: on the host. The board writes ANSI and reads ANSI, and the
emulator at the far end of the serial line does the font rendering and answers
this program's own capability queries. That is why vaxis works here unmodified —
`Vaxis.render`, `queryTerminalSend` and `enableDetectedFeatures` all take a bare
`*std.Io.Writer`, so the transport is a parameter, while `vaxis.Tty` and
`vaxis.Loop` are termios/ioctl/SIGWINCH bound and are not used. Window size
arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like
any other input, because firmware has no `TIOCGWINSZ`.

== The memory budget is one table

`src/memory.zig`'s `limits` contains the board-shaped capacities. These numbers used
to be nine `platform == .esp32p4` tests scattered across nine files, each one a
separate place to forget — and they are not nine decisions. They are ONE
decision, how much memory this build is allowed to spend, taken nine times where
no reader could see the total.

The original budget table below is historical; `memory.limits` is authoritative.

```zig
/// `board` is the ESP32-P4 firmware's budget:
/// a 384 KiB heap and a 240 KiB chunk of L2MEM
/// shared between `.bss`, `.data` and the stack.
const board = config.platform == .esp32p4;
/// No OS means no address space to reserve
/// megabytes out of, whatever the platform is
/// called.
const reduced_target =
    builtin.os.tag == .freestanding;

pub const board_heap_bytes = 384 * KiB;
pub const effect_cap = if (board) 128 else 4096;
pub const wrap_rows = if (board) 128 else 256;
pub const cwd_buf_cap = if (board) 0 else 1024;
pub const undo_max = if (board) 16 else 256;
pub const max_tag_tail: usize =
    if (board) 512 else 4096;
pub const host_path_cap: usize =
    if (board) 0 else 4095;
pub const embedded_sources = !board;
pub const hexdump_row_bytes: u32 =
    if (board) 8 else 16;
// ... `arena` below; see the wide listing
```

Two booleans derive all of it, and nothing outside this file tests the platform
for a capacity again. Every cap says what it is measured against, and
`board_heap_bytes` is the number the others are measured against: the 384 KiB
chunk of L2MEM at `0x4FF40000`. It is unconditional and not profile-derived,
because it is a fact about the silicon rather than a budget this build chose — a
desktop build that wants to know what the board affords is asking exactly that
question, which is what the memory tests in `pardes.zig` do with it.

Each cap is a different *kind* of shrink, which is why they are not one scale
factor.

- `cwd_buf_cap` and `host_path_cap` go to *zero*, and zero is a type: `Text(0)`
  is a zero-sized field whose `set` refuses every non-empty path, so the three
  producers — the resolved shell, the picked font file, the watched theme file —
  report failure instead of storing 12 KiB nothing can fill. This is the
  `PdfSlot` rule applied to a capacity: the board has no filesystem, no processes
  to spawn a shell for and no font picker.
- `undo_max` and `wrap_rows` shrink *gracefully*. `pushHistory` evicts and frees
  the oldest once full, so the smaller ring loses the deepest undo steps and
  nothing else. `wrapWidth` reads the array's own length and refuses to wrap a
  pane taller than it, so a taller pane renders unwrapped rather than getting a
  truncated map.
- `effect_cap` changes *behaviour*, and the file says so: `emit` has always
  refused rather than evicted once full, so on the board a burst larger than 128
  effects now drops its tail where 4096 would have held it. That is reachable
  only through `emitWrite`, i.e. only if a pty ever appears on this platform.
- `embedded_sources` is a capacity spelled as rodata. The allowlist is empty on
  the board, because the table is about 0.95 MiB against a 1.5 MiB partition.
  The API is unchanged — `all` is a zero-length array and `find` answers null — so
  every caller compiles identically and simply finds nothing embedded.
- `hexdump_row_bytes` is the one that is about the *display* rather than memory.
  `hexdump -C`'s sixteen needs 79 columns; the P4 drives 56 of which seven go to
  the line-number gutter, so a sixteen-byte row wraps onto a second display line
  and the columns stop lining up, which is the entire value of the layout. Eight
  fits in 46 and keeps every property that matters.

#wide(caption: [`limits.arena`. Three tiers, because the address space
differs by four orders of magnitude. The board's tier is deliberately ALL
FALLBACK: every buffer here is a `StackFallbackAllocator`'s static, which lands
in `.bss`, and on the P4 `.bss`, `.data` and the stack share one 240 KiB chunk of
L2MEM while the heap is a separate 384 KiB chunk. A megabyte-shaped reservation
would not fit, and every byte that did fit would be taken from the stack's
neighbourhood to duplicate memory the heap already has. Zero is legal and always
spills, which is exactly what an arena for a compiled-out subsystem should do.])[
```zig
pub const arena = struct {
    pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB;
    pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB;
    // ...
    pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB;
    pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB;
    pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
};
```
]

What does NOT belong in this table is capability switches. `terminal_panes`,
`builtins.Board.enabled`, `hosted` and `font_picker` answer "does this build have
the thing at all", which is a question about the platform and not about a budget,
so they stay next to the thing they gate.

That division is also why a build option selecting the board's budget on a
desktop cannot work, and the file records the attempt. `-Dmem-profile=board` was
meant to let a native test runner compile the board's capacities and boot the
core under them. The dominant term in a boot is `@sizeOf(Pane)`, which carries
the ghostty-vt `Terminal` — 1.1 MiB of it — and what removes that is
`pardes.terminal_panes`, a CAPABILITY keyed on the platform rather than a
capacity in this table. So the option shrank the rings and left the boot six
times over budget, producing a configuration nothing was designed for:
`zig build unit-test -Dmem-profile=board` deadlocked in a futex rather than
failing, because a hosted build with the board's effect ring silently drops
effects a hosted test is waiting on. What DOES test the board's memory pressure
natively is in `pardes.zig`: the grid-scaled cost and the allocation-failure
sweep, both platform-independent and both running on the ordinary build.

== What the board does not have

`terminal_panes` is false there and nowhere else (`pardes.terminal_panes`), and it
is a platform gate and deliberately not one derived from the target: `web` is
freestanding too and KEEPS the emulator, because the browser shell renders a
replayed dump. False means ghostty-vt is not in the module graph at all —
`build.zig` never even asks for the dependency — which removes about 400 KiB of
flash, a `PageList` of RAM spent parsing input that cannot arrive, and a pile of
freestanding root hooks (`os.PATH_MAX`, `os.heap.page_allocator`, a cwd handle)
that the core itself does not want.

Tree-sitter is refused outright: `-Dplatform=esp32p4` with anything but
`-Dtree-sitter=disabled` fails the build, because the grammars' parse tables are
megabytes against a 1.5 MiB partition (`build.zig:298`). MuPDF is refused for the
same reason (`build.zig:297`).

The board gains four words nothing else has, all gated on
`builtins.Board.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and
`Gpio`. Each takes an address or a pin, so none can have a leader path — a key
path names a builtin and can never carry an operand. `Gpio` is the one that goes
through the host seam (@seam) rather than reaching the registers directly, and
`gpio_toggle`'s comment says why: driving a pad correctly is not one
register. It is the IO MUX function select, the GPIO matrix output route, the
pad's drive and input-buffer bits, and the output enable, keyed by a per-pin
table. The firmware already owns that code and checks it against ESP-IDF's own
headers on the die; a second copy in the core would be a second copy nobody
tests.

`builtins.Board` makes the target the *witness* rather than the gate: `enabled`
is keyed on the platform, and a `comptime` block then refuses to compile if that
platform is hosted, is not freestanding, or is wasm — because whatever else
`esp32p4` means, it has to still be a machine whose addresses are the bus's
(`builtins.Board.enabled`).

= The control filesystem <fs>

== Namespace and transactions

`src/fs.zig` owns Look resolution and the editor's file interface. An ordinary
Look checks the OS first, then the virtual tree. `/n/os` and `/n/self` select
those mounts explicitly; `/virtual` names the embedded and self-reflecting tree.
Named remote mounts live under `/n/<name>` and retain their identity through Save.

Every native session opens a 9P2000 Unix socket. The wire root exposes `os` and
`self`, without the editor's `/n` prefix. `self/pane/<serial>` contains `body`,
`tag`, `ctl`, `addr`, `data`, `event`, and selection files. Offsets are UTF-8
bytes. `self/screen` freezes rendered cells and styles for the lifetime of an open.
See `docs/fs.md` for the public paths and commands.

`src/9p.zig` implements the protocol without OS dependencies; `src/9p_io.zig`
owns native sockets and the client. Requests enter through `Event.fs_req`, and
`Effect.fs_reply` carries replies. A pending event read returns `Status.again`;
the native listener owns waiting and retries. Filesystem mutation runs on the
same thread as editing.

== Nested Look

Pane shells inherit `PARDES_9P`, `PARDES_PANE` (the pane serial), and
`PARDES_FORWARD_LOOK`. A child launch resolves its OS-relative argument in
the child's working directory, then writes `look <path>` to the parent's
`self/pane/<serial>/ctl`. Explicit `/virtual` and `/n` paths resolve in the
parent. The ordinary filesystem update performs layout and drains host effects.

`--nested` starts a separate editor and disables forwarding from its direct
pane shells. Its 9P socket remains available for control and plugins. There is
no executable-name discovery or separate Look listener.

Detached frontends use `pardes-detached-<name>.sock` in the same runtime
directory. The shared Unix socket conventions live in `src/9p_io.zig`.

= Build

#wide(caption: [The build graph. One `root_mod` per invocation, rooted at
whichever file that platform's host enters through; up to three more compilations
of the same graph beside it; one `pardes_config` per distinct *frontend*, which
is the only thing two of them are allowed to disagree about. Line numbers are
`build.zig`.])[
  #diagram[
    #cetz.canvas(length: 1cm, {
      import cetz.draw: *
      set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))
      let row(y, body) = content((0.3, y), body, anchor: "west")

      // ---- tier 1: inputs, full width ----
      rect((0, 6.76), (18.0, 9.5))
      content((9.0, 9.24), text(7.0pt)[*inputs, generated or read at configure time*])
      line((0, 9.02), (18.0, 9.02))
      row(8.72, text(5.8pt)[`highlights.scm` from 29 grammars #sym.arrow.r one options module])
      row(8.34, text(5.8pt)[`vendor/themes/*.toml` (214 helix) and `*.json` (11 zed) #sym.arrow.r 225 generated theme modules plus a `list.zig`, straight into the build cache])
      row(7.96, text(5.8pt)[working-tree `.zig` sources #sym.arrow.r the web shell's read-only source archive])
      row(7.58, text(5.8pt)[8 GLSL shaders #sym.arrow.r SPIR-V through `glslc` --- or `-Dprebuilt-shaders` embeds the committed `.spv` + `.glsl` pair, so `EffectCode` cannot lie])
      row(7.24, text(5.8pt)[`@import("build.zig.zon")`, UNTYPED (`:8`) #sym.arrow.r `zon.version` (`:1920`) and a `comptime` check that `zls_version` matches the pinned sha (`:36-46`)])
      row(6.90, text(5.8pt)[`gitCommit(b)` (`:1855-1867`) #sym.arrow.r `?[]const u8`, null when there is no repository, no `git`, or nothing to say])

      // ---- tier 2: modules and the options module ----
      rect((0, 3.8), (8.7, 6.6))
      content((4.35, 6.34), text(7.0pt)[*modules that compile* `src/pardes.zig`])
      line((0, 6.12), (8.7, 6.12))
      content((4.35, 5.84), text(5.8pt)[`root_mod` (`:306`) --- its root is the file this])
      content((4.35, 5.50), text(5.8pt)[platform's host enters through:])
      content((4.35, 5.14), text(5.6pt)[`main.zig` (tty, gui) | `web.zig` | `macos.zig` | `esp32p4.zig`])
      content((4.35, 4.74), text(5.8pt)[`hx_core_mod` --- a headless second core for `hxdiff`])
      content((4.35, 4.38), text(5.8pt)[`isolated_mod` --- one comptime bool: no filesystem])
      content((4.35, 4.02), text(5.8pt)[`gui_mod` --- the same `main.zig` at `platform = .gui`])

      rect((9.3, 3.8), (18.0, 6.6))
      content((13.65, 6.34), text(7.0pt)[`pardes_config` --- ONE PER DISTINCT FRONTEND])
      line((9.3, 6.12), (18.0, 6.12))
      content((13.65, 5.84), text(5.8pt)[`platform`, `mupdf`, tree-sitter tier, `zls_version`,])
      content((13.65, 5.50), text(5.8pt)[`esp32p4_cols`/`rows`, `theme_animation`, `zig_lib_dir`,])
      content((13.65, 5.14), text(5.8pt)[`version` (from the manifest), `commit` (from `git`)])
      content((13.65, 4.70), text(5.8pt)[`ShellConfig` (`:1874-1890`) holds every field that is a])
      content((13.65, 4.34), text(5.8pt)[property of the BUILD, so the two modules a default build])
      content((13.65, 4.00), text(5.8pt)[cannot drift apart in any field but `platform`])

      line((4.35, 6.76), (4.35, 6.6), mark: (end: "stealth"))
      line((13.65, 6.76), (13.65, 6.6), mark: (end: "stealth"))
      line((9.3, 5.2), (8.7, 5.2), mark: (end: "stealth"))

      // ---- tier 3: outputs, full width ----
      rect((0, 0), (18.0, 3.3))
      content((9.0, 3.04), text(7.0pt)[*what one invocation emits*])
      line((0, 2.82), (18.0, 2.82))
      let out(y, name, what) = {
        content((0.3, y), text(6.0pt)[#name], anchor: "west")
        content((4.9, y), text(5.8pt)[#what], anchor: "west")
      }
      out(2.52, [`pardes`], [`-Dplatform=tty`, the default --- vaxis over a real terminal])
      out(2.12, [`pardes-gui`], [`-Dplatform=gui` --- SDL3, a FreeType atlas, SPIR-V])
      out(1.72, [`pardes.wasm`], [`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>`, plus a vanilla DOM shell])
      out(1.32, [`libpardes.a`], [`-Dplatform=macos`, then the `macos-app` step #sym.arrow.r `pardes.app` (Darwin host)])
      out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4`; the sibling `05-zig-p4` toolchain builds the firmware image])
      row(0.46, text(5.6pt)[a bare `zig build` emits the FIRST TWO together, because those two are what installing pardes means; every other spelling is one invocation each])
      row(0.18, text(5.6pt)[beside them, from the same graph: `pardes-isolate` (tty only --- the filesystem is not compiled in) and `hxdiff`'s headless core])

      line((4.35, 3.8), (4.35, 3.3), mark: (end: "stealth"))
    })
  ]
]

== One primary shell, sometimes two

`-Dplatform` names ONE shell. Absent, the build makes BOTH native shells — the
tty cli and the SDL gui — because those two together are what installing pardes
means, and asking for them one at a time is two invocations a person has to
remember are two (`also_gui` in `build.zig`). Everything else derives from `platform`,
the PRIMARY shell: the one rooted at `root_mod`, the one `unit-test` runs, and
the one the snapshot and harness suites drive. `also_gui` adds the second beside
it and changes nothing about the first.

A bare `zig build` with an untouched prefix also redirects the install prefix, on
two conditions and the second is not the obvious one: no shell was NAMED
(`-Dplatform=web` keeps writing `zig-out/web`, which its docs name), and nothing
else has already said where to install — no `DESTDIR`, no `--prefix`, no
`--prefix-*dir`. It cannot depend on which *step* was asked for, because
`build.zig` cannot know that: `build_runner` keeps the step names in a local and
resolves them after `build()` returns. That is why the dev binaries install to
`<prefix>/dev` rather than `<prefix>/bin` — `zig build perf` redirects the prefix
too, and a 200 MB Debug benchmark must not land on a PATH.

The other three platforms are separate invocations:
`-Dplatform=gui` for `pardes-gui`;
`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` for `pardes.wasm`
plus a vanilla DOM shell; `-Dplatform=macos` plus the `macos-app` step for
`pardes.app` wrapped around a static `libpardes.a` on a Darwin host; and
`-Dplatform=esp32p4` for the freestanding object. Any other spelling of the
combination fails on purpose rather than building something silently wrong — the
web target, the web dump, MuPDF on a freestanding platform, and tree-sitter on
the board each have a named refusal (`build.zig:292-298`). The esp32p4 target is
in fact *forced* to `esp32p4_target` rather than taken from `-Dtarget`, so a
`-Dtarget` cannot discard its CPU features, and a wrong one is still an error
rather than silently overridden.

Because `platform` is comptime and `pardes.platform` is read from
`pardes_config`, two frontends in one build need two options modules — the gui
shell `@cImport`s an SDL the cli must not link. `ShellConfig` gathers everything
`pardes_config` carries that is a property of the BUILD rather than of one
frontend into one value, so the two modules a default build makes cannot drift
apart in any field but the one that is supposed to differ
(`ShellConfig`). `hxdiff`'s headless core and `pardes-isolate` share the
primary shell's, being the same frontend.

`pardes-isolate` is a third compilation of the same graph whose only difference
is one comptime bool. It cannot be the same binary with a flag, because the point
is that the isolated build does not CONTAIN the filesystem: `look.zig`'s libc
paths compile away, so no runtime mistake can reach a disk that a `--flag` build
still links. Only the tty platform has one — gui adds a GPU it would still need,
and web and macOS are libraries whose host owns `main()`.

== Generated inputs

Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a
pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the
web and the board, lazy), ZLS, mvzr (the regex engine behind `s`/`S`),
zig-tree-sitter with 29 grammars for 28 languages (markdown takes two, block and
inline) — all pinned through `zig fetch` and wired in `build.zig`. The board's
`05-zig-p4` firmware toolchain is a separate sibling checkout.

Generated during the build: `highlights.scm` into an options module; the vendored
helix and zed theme sources into the generated half of the theme ring;
working-tree `.zig` sources into the web shell's read-only archive; and eight
GLSL shaders into SPIR-V.

The themes go straight into the build cache and are handed over as a module,
rather than written back into `src/`. The output then has no freshness problem to
own — zig re-runs the generator only when a vendored file changes, and a cached
run leaves the compile's inputs byte-identical, so `zig build` with nothing
touched still does nothing — there is nothing to gitignore, and no stale `.zig`
can survive a deleted source. The INPUT list is read from the directory rather
than written out, so adding a theme is dropping a file in, and each file goes in
as a content-hashed argument, which is what makes that new file re-run the step
and nothing else. The generator is compiled in Debug on purpose: it runs for
about 40 ms, and every core module imports what it generates, so its compile is the
first link of every cold build; ReleaseSafe cost 16 s of compile to save 47 ms of
run time, and the files it writes are byte-identical either way
(`theme_gen` in `build.zig`).

The shaders are the only build input wanting a tool a stock machine lacks
(`glslc`), so they are also the only one whose output is committed:
`zig build shaders` refreshes each paired `shaders/prebuilt/*.spv` binary and
`.glsl` source snapshot together. `-Dprebuilt-shaders` embeds that exact pair
rather than shelling out, so `EffectCode` cannot describe different shader text
from the binary on screen; this is also what lets a gui build need nothing but a
C toolchain.

The tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds
are incremental for the seconds-loop; release builds are the product.

== Versioning

There is one version string in the tree and it is in the manifest.

```zig
// build.zig:8
const zon = @import("build.zig.zon");
// ...in shellOptions, build.zig:1915-1923:
o.addOption([]const u8, "version", zon.version);
o.addOption(?[]const u8, "commit", cfg.commit);
```

The `@import` is UNTYPED on purpose, and that is what makes it possible:
annotating its type would demand an exact field match and reject
`.dependencies`, `.paths` and the rest, which is the failure the hand-synced
literal that used to sit there was working around.

```zig
// src/pardes.zig
pub const version = @import("pardes_config").version;
pub const commit: ?[]const u8 =
    @import("pardes_config").commit;

// src/main.zig
const version_text = if (pardes.commit) |c|
    "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n"
else
    "pardes " ++ pardes.version ++ "\n";
```

Both halves are comptime, so `version_text` is one string in rodata and
`pardes --version` is one write (`main.version_text`).

`gitCommit(b)` runs
`git -C <build root> rev-parse --short=12 HEAD` at CONFIGURE
time, so the binary carries a string rather than the ability to shell out. That
is the whole point: a `--version` that runs `git` itself reports the tree it
happens to be standing in rather than the one it was built from, and on the board
there is no `git` to run and no process to run it with. The `-C` is not
decoration either — `zig build` may be run from anywhere, and a `git` resolved
against the cwd would cheerfully answer about a different repository.

Absence is not an error and must not be. A release tarball has no `.git`, a
container may have no `git` binary, and a source drop is not a repository; every
way of having no answer lands on the same `null` and the frontends print the
version alone. It is deliberately NOT `--dirty`: marking a dirty tree would cost
a worktree stat on every configure and — the real cost — would change
`pardes_config` on every file edit. Every module in the build imports that
options module, so a dirty marker means editing one line rebuilds the world. The
commit alone changes only when a commit does.

The manifest is read at comptime twice, and the second read is what keeps the
one string this build cannot fold into the manifest honest. `zls_version`
(`zls_version`) has to be spelled beside `.dependencies.zls.url`, because the
semver half of it — `0.16.1-dev` — exists nowhere in the manifest, and ZLS's own
`build.zig` needs it: that file otherwise shells out to `git describe`, which
fails on a fetched package with no `.git`. So the duplication is unavoidable and
a `comptime` block beside it makes the two AGREE instead: it takes the
short commit after the `+`, takes the sha after the `#` in
`zon.dependencies.zls.url`, and `@compileError`s unless the pinned sha starts
with it. A `.zon` bump that forgets the line beside it is now a build error
rather than a `SPC l i` naming an analyser nobody linked.

= Testing: the old program is the oracle

Before the rewrite compiled, the prototype got a harness (`test/snapshot.zig`,
the `zig build snap` step): it forks either binary in a pty, feeds it an *event
script* (one line per input: keys, SGR mouse, resizes, sync points), and captures
the rendered grid — text, cursor, and per-cell style runs — through its own
ghostty terminal. Goldens are generated from the old binary
(`zig build snap -- --update`); the new binary must reproduce them byte for byte.
Eighteen scripts covered the checklist in Appendix A at the rewrite; ninety-five
cover it and everything since (`test/snapshots/` holds 95 `.snap`/`.golden`
pairs). All pass.

Determinism pins: fixed workdir paths (they appear in tags), a controlled `$HOME`
with `PS1='$ '`, `LC_ALL=C`, and a grid-stability sync primitive instead of
timing guesses.

Two of those pins were doing damage rather than work.

== Deltas, not screens

A capture used to restate the whole screen, so 82% of golden lines were a copy of
the line above (`test/snapshot.zig:59`), every golden carried the topbar and a tag
row, and adding one builtin word to a tagline rewrote 78 of them — 3,758 lines of
diff for a change no test was about. A capture is now a *delta* against the
previous capture of the same kind in the same script: the first is the whole
screen, the rest are only the rows that changed. The corpus went from 16,700 lines
to 5,722 and the same one-word edit now moves 337 (`src/CHANGELOG.md:9-13`). A
capture whose only change is the cursor is the empty delta its script always
meant.

== A click names a word

A click used to name a screen column, which is a coordinate into that same
chrome. When `Newtty`, `Joincol` and `Changelog` were added, seven scripts began
clicking the word next door. `snapshot.zig`'s own note enumerates six of them:
`tutor.snap` clicked `Grep` where it meant `Tutor`, `exec.snap`, `respawn.snap`
and `tagalign.snap` clicked `Newtty` where they meant `Del`, `find.snap` clicked
`Joincol` for `Find`, and `windowops.snap` clicked `Tutor` for `Debug` — and
`--update` blessed all of it, so 256 golden lines were green while asserting the
opposite of their script's first line (`test/snapshot.zig:817-825`). The seventh
is the changelog's: `tagbottomimage.snap` clicked blank space 176 columns from
the `Del` whose effect it asserted (`src/CHANGELOG.md:9-11`). A click may now
name the word (`press middle @Del 2`, `@Del#2` for the second pane on a row,
`@Save-2` for a column beside one), so the word is either there to be clicked or
the script fails.

== Regeneration verifies itself

Regeneration was the other half of that failure: `--update` captured once,
serially, and wrote whatever it saw, which is how six wrong clicks became
goldens. It now captures in parallel at the widest probe settings the harness
has and then runs the ordinary verify pass over what it wrote, so a capture that
does not reproduce is reported instead of committed — 77 s to 41 s, and the retry
machinery serial update never had (`src/CHANGELOG.md:14-16`).

== Deviations kept after review

Three deliberate deviations surfaced by the oracle and kept.

The greeting `ls` waits for the exact OSC 133 B input mark after the real resize;
the prototype raced bash's startup and won only by allocator luck. Shells without
prompt integration omit that cosmetic greeting rather than guessing. Commands
which create a fresh shell are owned by its terminal pane until the host reports
the actual prompt capability, then wait for the same mark when it exists.

Dump files compare with base64 pty history elided: it encodes prompt-redraw
micro-timing, not state, and the cleaned text fields are the contract.

Typed insert runs do not survive a dump replay. They are an overlay, not pty
bytes, and the prototype's replay viewer had the same semantics.

== The shell suites

Shell correctness is no longer out of scope, and that is the biggest change to
this section since the rewrite. `web-snap` and `web-e2e` drive headless Chromium
over CDP with real DOM pointer and touch events and diff `test/web-snapshots/`;
`image-harness` and `pdf-harness` snapshot native PIXEL output through kitty
graphics and SDL; `macos-e2e` is an offscreen AppKit snapshot suite over its own
seven scripts (`test/macos-snapshots/`: boot, cwd, drop, font, keys, rotate,
trackpad). In the sibling `05-zig-p4` toolchain, `zig build selftest` flashes and
runs `src/esp32p4/selftest.zig` on real hardware. The local freestanding object
and the standalone GPIO 9P image can both be compiled without a board attached.

Two suites are differential rather than golden: `hxdiff` compares the core's
motion and operator results against helix case by case, and `hxparity` compares
editing a file against editing the same text in a pty — which is what the
per-pane ghostty-vt `Terminal` buys.

What remains untested by a snapshot is the last hop — that vaxis diffs correctly
onto a real terminal — plus each shell's inline unit tests (the FreeType atlas
raster, the trackpad and rotation maths, the gamepad replay).

= Style

TigerStyle, plus house rules proven in the prototype: imperative and flat; one
big `update` dispatch, not handler objects; no one-line helpers — inline the
four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
allocation at init, arenas per frame.

The rewrite used source size as a pressure toward direct code, and that remains
useful when a refactor deletes duplicate policy or state. A checked-in line-count
inventory does not: it goes stale whenever a pane kind, backend, or generated
asset moves. Measure the current tree when making that comparison; keep this
document about ownership and invariants that should survive the next edit.

Two things that number is not. It is not one program's worth of growth — the
macOS, web and firmware shells and the PDF and language work are several products
sharing a core. And it is not licence: the rule that survives is the local one,
that a change should leave the file it touches no longer than it found it.

= What is deliberately absent

No render abstraction over the shells: the surface *is* the abstraction. No
plugin system, because the control filesystem is the extension point
(@fs) and needs no API of its own. No async runtime in the core — the
shells may thread, the core is single-threaded by construction.

"No config files" held until the startup file (`docs/config.md`) arrived, and that
is the narrowest thing the phrase could still cover: a list of builtin COMMANDS
run before the first frame — no schema, no new vocabulary, and no key remapping.
The keymap is `src/config.zig`, compiled in, where a wrong binding is a compile
error rather than a silent no-op.

#heading(numbering: none)[Appendix A: feature parity checklist]

From the prototype survey; every line is covered by at least one of the event
scripts in `test/snapshots/` — the original eighteen (boot, tty, edit, scroll,
modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load,
ttyonly, syntax, fileedit, images), and seventy-seven more added since for
everything below that the prototype never had.

*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not
per column); global topbar;
per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only
the source (cursor row kept visible); `Newcol` takes width only from the source
column and cannot resize any unrelated column; dying pane's weight absorbed by one
sibling; emptied column hands width to a neighbor; border-drag resize on a
pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints;
corner grab moves exactly two boundaries;
move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move
pane to new column; Ctrl-w h/j/k/l directional focus; body-normal Esc hops to
the pane you were in before this one, alternating between two (the same
builtin as SPC j j);
`--tty` single-pane mode.

*Mouse.* Left: select (block, stays highlighted after drag, pins cursor,
enters normal), click clears, tag-row click enters tag edit, scrollbar
click scrolls up-to-row (right button: down), border/move drags. Middle:
execute — no-drag expands to file-ish word (alnum `.-+/:@_~`); builtin or send
to shell; does not focus. Right: look — peel `:NNN`, resolve against the
clicked pane's dir (`/proc/pid/cwd`, or a file's dirname) and, if that fails,
against every other live pane's, most-recently-focused first (the jump stack
backwards, duplicate dirs skipped; absolute words try one dir and stop) so a
relative name is openable from any window that can see it; dir → shell + `ls`
in source column (dedup by cwd), file → file pane at line (dedup by path,
first doc opens left column and may evict a lone pristine shell; later docs
split below the existing doc — an output pane, `+Search`/`+Help`, is not a
doc for either half of that rule: it never claims a column, it splits below
whatever pane asked for it, and nothing splits from it), image ext → image
pane, `.pdf` → PDF pane. Ctrl+left is goto-definition, the one chord borrowed
from every editor with a language backend. Two spellings the word expansion
has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather
than opening as a file, and `@p7:10:5` addresses a live pane by number for the
things — terminals, output buffers — that have no path to name.
Middle+left chord: kept
left selection appended as trailing CLI argument. A stationary pointer gets a
delayed, theme-derived highlight of the exact side-effect-free selection that
Look would expand; it neither focuses nor installs that selection, and pointer
leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched.

*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and
`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth
mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS
up to 64 (`C`/`Alt-C` copy, `s`/`S` select and split by regex with a live
preview, `Alt-s` split on newline, `,`/`Alt-,` keep and remove the primary,
`)`/`(` rotate, `Alt--`/`Alt-_` merge), operators (`d c y p P R u U`, `Alt-d`
delete-noyank, `J`, `>`/`<`, `~`/`` ` ``/`` Alt-` ``, `Ctrl-a`/`Ctrl-x`,
`Ctrl-c` comment-toggle), textobjects and surrounds under `m`
(`mm mi ma ms mr md`), the `]`/`[` pairs (`]p ]d ]D ]<space>`), `/` with `n`/`N`,
`|` to filter the selection through a command, `:` for the tag as a command
line, and Enter=look Tab=execute at cursor. Since the helix motion model
landed, a traversal motion SELECTS the range it crossed — which is why there is
no verb+noun grammar and why `i` after `w` types at the selection's start. insert:
click-and-type; terminals get splice runs (shift right, never overwrite;
absolute-row anchored), files get real edits. tty: raw pty forwarding
(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable,
promptClickMove on entry, prompts visible (hidden in the other modes via
OSC 133). `y` fills the yank register and `p` pastes it; neither touches the
system clipboard, which is helix's five words — `SPC y/Y/p/P/R`, out via
`set_clipboard` and back via `read_clipboard` — and nothing else, so a delete
cannot clobber what the desktop was holding. A paste from an outer terminal
arrives bracketed, as one `paste` event.

*Tag.* Compact name/status prefix + editable command tail. File names support
staged edits: Enter commits a new buffer save target, Escape cancels, and no
disk rename or write happens until explicit Save. Terminal cwd and image/PDF
status stay generated. Save leads the tail of every pane holding text of its own:
`Save Tty Del Collapse` for a file or an output buffer,
`Save Tty Del Togglettymode Filter Collapse` for a terminal, and `Tty Del Collapse`
for an image. PDFs use `Tty Del PdfSections PdfTint Collapse`, without tint status text.
`Collapse` toggles a pane between its tagline alone and its expanded height;
hidden body contents and running terminals are retained.
An unsaved file has `*` after its name; an image tag
reports
`img petscii:<on|off> palette:<commodore|terminal> ascii:<on|off> <path>`
before the ordinary tail (its renderer toggles are builtins under `SPC t
p/l/a`). Topbar:
`Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill` —
editable by left click, with keyboard command navigation and Exec/Look gestures.
An additional editable tag in each column supplies local New, Tty, Find,
Grep and Joincol commands. ColumnTags hides that row when space is tight;
Colors and Crt left it for their leader paths.

*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics,
OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the
nushell/helix regression), a default-on pane-local Filter which uses ghostty-vt's
theme-derived 256-colour generation and keys rendered cell foreground/background
truecolour and OSC overrides through that palette without mutating emulator state,
greeting `ls`, auto-follow output unless
navigating. File: line-number gutter (fixed width), tree-sitter highlights
(c/cpp/zig minimal tier; 26 grammars full tier; re-highlight on edit,
visible-range first), Save, open-at-line, undo/redo. Image: zstbi decode,
kitty graphics when available, petscii matcher fallback (C64/terminal
palettes, ascii glyph set toggle). PDF (`-Dmupdf`, native default on): MuPDF
rendering as one continuous page strip, real text search, mouse text
selection, the document outline into `+PdfSections`, fit-width/fit-height and
a themed duotone tint — the last three named in the pane's own live tag.
Embedded PDF links use the ordinary right-click Look gesture. Hovering one
shows an accent highlight and a pointing hand in graphical frontends; dragging
still selects text. Internal links reveal their page and anchor. When the
annotation's visible label resolves to a different Look location, a `+Links`
output lists the visible destination and the embedded destination for you to
choose with Look. Internal destinations in that list use `document.pdf:page`.
Labels that are not locations follow the embedded link directly; identical
destinations open once. Links use the same supported URL and file-location
rules as Look.
Tutor: embedded text as file pane.

*Language.* The native host snapshots each request and runs it outside the UI
loop. Zig uses the in-process ZLS backend; configured external language servers
use the JSON-RPC client. Results become output rows or edits, applied only while
the request's pane and revision still match. Web and board builds have no
language backend. See `docs/lsp.md` for configuration and commands.

*Chrome.* One theme per `.zig` file, folded into the ring at comptime: ours
first — helix (default; near-black page, chrome one grey-ramp step off it,
colors lifted from the helix editor's own theme), dark, and the acme-light one
named simply `acme` — then
everything `tools/gen_themes.zig` exports at build time out of the vendored
helix `.toml` and zed `.json` sources in `vendor/themes`, which is all 214 helix
ships plus zed's 11, sorted by name. Names are unique by construction: helix's
own acme is vendored as `acme_helix.toml` so it cannot collide with ours, and
every zed theme takes a `_zed` suffix for the same reason. helix and dark leave
a child's ANSI palette native; acme and the zed exports resolve it onto
the page so shell output stays readable on a light one. A theme owns the page,
the tag bar, the gutter, the move box and the SELECTION: `sel_bg`/`sel_fg` are
one pair per theme, and the three per-button tints and the dimmed extra cursors
are mixed off it, so what stays fixed is the distinction between buttons and not
the colours. `NextColor` browses the ring one
step at a time — at 228 it is no longer how you REACH one —
`Theme <name>` jumps to one and `ThemeSel` (`SPC t t`) lists them all into an
output buffer whose rows are those very commands — execute a row (Tab, middle
click) and the theme goes on; n/N select such a row WHOLE, since a command
line holds no place to pick out of it — the third grain of that motion, the
other two being one stop per ROW in a results list (the location at its head,
never the matched text after it) and every look-able word in free text. Native
shells also accept `ThemeFile <path>`: one complete ZON `Theme`, loaded at
runtime and, where document watches are available, watched with the same
parent-directory/rename-over semantics. `DumpThemes` materializes the compiled
ring under `<config>/themes/builtin/`, providing the schema and a copyable
starting point without adding inheritance or a second theme vocabulary.
Colors toggles
all recolor passes; Debug stats overlay; Dump writes state ZON. Eleven panel
transitions and three scene bits are settings, one builtin each, generated from
`config.Runtime.settings`.

*Session.* `--detach[=name]` runs a core with no terminal; `--attach[=name]`
makes a thin frontend over a unix socket; `Attach [name]` (`SPC s a`) hands a
running frontend's screen to a detached core, connecting before it swaps;
`Detach` (`SPC s D`) leaves a session that carries on. Up to 32 frontends on one
session, all showing the same screen; the pane shells belong to the daemon and
outlive every frontend. The default 9P socket serves the control tree; a nested
`pardes <file>` hands its argument to the outer session over the per-pid socket.
`pardes --version` prints the manifest version and the commit it was configured
from.

*Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel,
scrollbar, Colors/NextColor, and link-LOOK opening a new
tab (new — the prototype has no web link handling). One-finger touch is
deliberately complete by itself: tap = LOOK; drag past a small slop = natural
scroll, with no LOOK on release. A build-generated read-only archive lets LOOK
open the current contents of tracked or new/nonignored Pardes `.zig` files despite the web
shell having no host filesystem. The published launcher dump is captured from
a running Pardes TTY after
`git ls-files --cached --others --exclude-standard -- '*.zig' | sort`, so its
complete terminal listing is the same tracked-plus-new/nonignored set the user
can open. The freestanding module carries no host
libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into
the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains
tap-LOOK/drag-scroll, but finger-down on
a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture
for its lifetime, keeping layout drags out of the scroll heuristic. Touch
input translation and scroll/tap state live entirely in the JavaScript shell.
The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V).
The browser exposes each cell as selectable, inspectable text and applies the
surface styles with CSS. The browser `.snap` harness drives real Chromium touch
input and reads both text and per-cell styles from the DOM renderer's packed
surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships
no gamepad input.

*Board (ESP32-P4).* A 56×14 grid by default (`-Desp32p4-cols`/`-rows`), 384 KiB
of heap, no ptys, no tree-sitter, no MuPDF, no filesystem of its own, and no
embedded source table. vaxis unmodified over a byte sink the firmware supplies;
DEC mode 2048 for resizes. `Peek`, `Poke`, `Hexdump` (eight bytes per row) and
`Gpio` exist only here, and `Gpio` goes through the host vtable because the pad
sequence belongs to the firmware that already tests it against ESP-IDF's headers
on the die.