File size: 184,864 Bytes
6912fd3
b60973d
 
6912fd3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6912fd3
 
 
 
 
 
 
93ef16f
6912fd3
 
b60973d
6912fd3
b60973d
 
 
6912fd3
 
 
f35d3f6
6912fd3
f35d3f6
 
 
 
 
 
 
 
505a1e7
 
 
 
 
 
6912fd3
d3283b8
 
 
6912fd3
 
f35d3f6
6912fd3
ec6b346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8012b6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b60973d
 
6912fd3
 
 
53148eb
 
 
 
784680a
 
 
 
6246de6
 
 
b60973d
784680a
53148eb
 
 
 
 
 
 
 
 
 
6912fd3
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f361450
 
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f361450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
085e5b4
 
f361450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735a30f
 
 
 
 
 
ec6b346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6912fd3
735a30f
6912fd3
 
 
 
11af3e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6912fd3
784680a
ec3a520
11af3e7
 
ec3a520
 
784680a
 
 
 
 
6912fd3
 
b60973d
 
93ef16f
6912fd3
a54b6e7
 
 
 
 
 
 
11af3e7
a54b6e7
b60973d
735a30f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
085e5b4
735a30f
085e5b4
735a30f
 
 
784680a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6246de6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b60973d
 
 
 
6912fd3
8ae88e7
 
 
 
d3283b8
 
6912fd3
d3283b8
6912fd3
8ae88e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b60973d
8ae88e7
6912fd3
ec3a520
 
735a30f
 
 
 
 
 
 
 
 
 
fcffddc
 
 
 
ec3a520
b60973d
735a30f
b60973d
 
 
 
 
 
 
fcffddc
 
 
 
 
 
 
 
ec3a520
 
6912fd3
 
63bc919
 
085e5b4
b60973d
 
 
 
 
085e5b4
b60973d
2679e4e
 
 
 
 
 
 
fcffddc
 
2679e4e
 
 
ed7ee81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3283b8
 
 
ed7ee81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6912fd3
 
 
 
 
 
 
 
735a30f
 
6912fd3
 
 
 
 
b60973d
 
 
735a30f
b60973d
 
6912fd3
 
ec3a520
735a30f
ec3a520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcffddc
735a30f
fcffddc
 
 
 
 
 
 
 
 
 
ec3a520
 
 
 
 
 
fcffddc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124d540
 
 
 
 
 
 
 
 
 
 
735a30f
124d540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c42947
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21092d1
735a30f
21092d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45ebf29
 
 
 
 
 
 
 
779f7df
 
 
 
 
 
 
 
 
 
 
 
46a2576
 
 
 
319c03c
 
 
 
1596003
 
779f7df
675fbd2
779f7df
 
 
 
 
 
 
 
 
 
 
46a2576
 
 
1596003
 
 
779f7df
 
46a2576
 
 
 
 
 
319c03c
 
 
 
 
 
1596003
 
 
 
319c03c
 
779f7df
46a2576
779f7df
1596003
 
779f7df
 
319c03c
 
779f7df
 
 
 
 
46a2576
779f7df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c066e
 
 
 
 
 
 
 
 
 
675fbd2
 
 
 
 
 
 
 
29c066e
675fbd2
a404034
 
675fbd2
 
 
 
 
 
 
 
 
29c066e
 
 
 
 
 
 
 
 
 
 
675fbd2
29c066e
675fbd2
29c066e
a404034
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c066e
a404034
29c066e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a404034
 
675fbd2
29c066e
 
 
 
 
 
 
 
 
a404034
 
 
 
29c066e
 
 
 
 
 
 
 
 
 
 
 
 
a404034
 
 
 
29c066e
a404034
29c066e
 
 
a404034
 
 
 
 
 
675fbd2
a404034
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29c066e
675fbd2
2460317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6cf611
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f64215e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45ebf29
735a30f
45ebf29
 
 
 
 
 
 
 
 
 
b60973d
 
7efcba7
 
735a30f
7efcba7
 
 
 
 
 
80e566e
 
 
4d9b5d1
80e566e
 
 
 
 
 
 
 
 
 
4d9b5d1
 
 
 
 
 
 
 
80e566e
 
 
 
 
e5e233a
f35d3f6
 
e5e233a
 
 
 
 
 
 
 
 
 
94f8eb1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ad2aff
 
 
4d9b5d1
 
2ad2aff
7e842fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f35d3f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d479ea
9b49194
9d479ea
 
 
 
 
 
 
9b49194
 
9d479ea
 
aeb6b2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b2fb14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63bc919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b2fb14
9d479ea
 
 
9b49194
 
 
9d479ea
8c82c03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
064739c
 
 
 
 
 
 
 
8c82c03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
064739c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c82c03
 
 
 
 
 
 
 
 
 
 
 
 
 
064739c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c82c03
 
 
 
 
 
 
 
 
 
 
 
e5e233a
 
 
 
 
 
aedebe4
 
e5e233a
 
 
 
26dfc7b
 
 
 
 
 
 
 
a54b6e7
 
 
 
 
 
 
 
 
 
 
 
 
26dfc7b
 
 
7cdc17b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26dfc7b
 
 
e5e233a
085e5b4
 
 
 
 
e5e233a
 
735a30f
e5e233a
 
 
 
 
 
 
 
 
 
 
735a30f
e5e233a
 
 
 
 
d3283b8
 
 
 
 
 
 
 
 
5c89c6a
 
735a30f
5c89c6a
 
 
 
 
 
 
fbcd415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63bc919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9ca812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5d08da
 
 
 
 
 
 
 
 
 
 
 
f9ca812
e5d08da
 
f9ca812
 
e5d08da
 
 
 
f9ca812
 
 
 
 
e5d08da
f9ca812
e5d08da
 
 
f9ca812
 
 
e5d08da
 
 
f9ca812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5d08da
 
 
 
 
f9ca812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5d08da
 
 
 
 
 
 
f9ca812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5d08da
 
f9ca812
 
 
 
 
 
 
e5d08da
f9ca812
 
 
 
 
e5d08da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c89c6a
 
 
 
 
 
 
 
 
 
 
d3283b8
e5e233a
d3283b8
 
e5e233a
 
 
 
93ef16f
 
 
 
 
6912fd3
 
 
 
 
 
 
 
 
 
93ef16f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9ca812
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93ef16f
 
 
 
 
 
 
 
 
 
 
 
 
29c066e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93ef16f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8012b6f
93ef16f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeb6b2b
93ef16f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6912fd3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
// Real-browser UI test (chromium via playwright-core). Drives the actual editor:
// login, typing, selection -> comment/suggest, margin cards, images, discussion.
//   node test/browser.js
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'

const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const PORT = 3299
const BASE = `http://localhost:${PORT}`
const DATA = path.join(root, '.browser-data')
const SHOT = process.env.SHOT_DIR || '/tmp/claude-1000/ui-shots'

fs.rmSync(DATA, { recursive: true, force: true })
fs.mkdirSync(SHOT, { recursive: true })

const server = spawn('node', ['server/index.js'], {
  cwd: root,
  env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '' },
  stdio: ['ignore', 'pipe', 'pipe'],
})
server.stderr.on('data', d => process.stderr.write('[server] ' + d))

// Put the caret at the end of the document without depending on where things
// happen to sit: Playwright clicks an element's centre, and if an atom node (a
// formula, an image) is there, the click opens THAT instead of placing a caret.
async function focusDocEnd(page) {
  // Put the caret at the end of the document, reliably. Two traps here:
  //  - typing before the document has synced loses those keystrokes, because
  //    the remote state then replaces them (the skeleton lifting is the signal);
  //  - editor.commands.focus() sets the selection but does NOT take DOM focus
  //    in headless Chromium, so the keystrokes go to the body instead.
  // Hence a real click, at a point that is always text: the title line. Clicking
  // the element's centre would be a gamble — an atom (a formula, an image)
  // sitting there swallows the click and every keystroke after it.
  await page.waitForFunction(() => document.getElementById('doc-skeleton')?.classList.contains('hidden'), null, { timeout: 20000 })
  await page.waitForFunction(() => window.__editor && !window.__editor.isDestroyed)
  await page.click('.tiptap', { position: { x: 24, y: 10 } })
  await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 })
  await page.keyboard.press('Control+End')
}

const consoleErrors = []

async function main() {
  await waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server')

  const browser = await chromium.launch({
    headless: true,
    executablePath: process.env.PW_EXECUTABLE || undefined,
    args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'],
  })
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
  page.on('console', msg => {
    if (msg.type() === 'error' && !msg.text().includes('fonts.googleapis') && !msg.text().includes('fonts.gstatic')) {
      consoleErrors.push(msg.text())
    }
  })
  page.on('pageerror', err => consoleErrors.push('PAGEERROR: ' + err.message))

  // --- login + create doc, from the switcher rather than an index page ---
  await page.goto(`${BASE}/auth/dev?u=alice&next=/`)
  // With no documents yet, / has nowhere to send us and serves the bare shell.
  // The switcher's loading state has to be in the markup the server sends, not
  // built by JS — that is what makes it visible on the first frame.
  const shellHtml = await (await page.request.get(`${BASE}/`)).text()
  assert.ok((shellHtml.match(/sk-card/g) || []).length >= 3, 'shell ships skeleton cards for the switcher')
  assert.ok(shellHtml.includes('id="docs-list" class="loading"'), 'switcher list starts in its loading state')
  assert.ok(shellHtml.includes('window.__BOOT'), 'shell inlines the session')
  await page.waitForSelector('#docs-pop:not(.hidden)', { timeout: 10000 })
  // Nothing will ever sync here, so the skeleton the shell ships has to come
  // down explicitly — left up, a first visit reads as a document stuck loading.
  await waitFor(async () => await page.evaluate(
    () => document.getElementById('doc-skeleton')?.classList.contains('hidden')),
    'the skeleton comes down on a first visit with no documents')
  assert.ok(await page.$('.empty-doc'), 'the empty sheet says how to make the first document')
  await page.click('#new-doc')
  await page.waitForSelector('.ui-modal input')
  await page.fill('.ui-modal input', 'Browser Test Doc')
  await page.keyboard.press('Enter')
  await page.waitForURL('**/d/**')
  await page.waitForSelector('.tiptap', { timeout: 10000 })
  console.log('✓ login + doc creation from the switcher + editor mounted')

  // --- the shell paints a loading state, and carries the session inline ---
  const bootInline = await page.evaluate(() => !!window.__BOOT?.user)
  assert.ok(bootInline, 'the session is inlined in the shell (no /api/me round trip)')
  const meCalls = await page.evaluate(() =>
    performance.getEntriesByType('resource').filter(r => r.name.endsWith('/api/me')).length
  )
  assert.equal(meCalls, 0, 'no /api/me request was needed')
  const assets = await page.evaluate(() =>
    performance.getEntriesByType('resource')
      .filter(r => /\.(js|css)\?v=/.test(r.name))
      .map(r => ({ name: r.name.split('/').pop(), enc: r.encodedBodySize, dec: r.decodedBodySize }))
  )
  assert.ok(assets.length >= 2, 'versioned assets were fetched: ' + JSON.stringify(assets))
  for (const a of assets) {
    assert.ok(a.enc > 0 && a.enc < a.dec * 0.6, `${a.name} arrived compressed (${a.enc} of ${a.dec} bytes)`)
  }
  console.log('✓ shell: session inlined, bundles served compressed')

  // --- theme: light by default, toggle sticks across a reload ---
  const themeState = () =>
    page.evaluate(() => {
      const sum = c => (c.match(/\d+/g) || []).slice(0, 3).reduce((t, n) => t + +n, 0)
      return {
        theme: document.documentElement.dataset.theme,
        stored: localStorage.getItem('cw-theme'),
        bg: sum(getComputedStyle(document.body).backgroundColor),
        ink: sum(getComputedStyle(document.querySelector('.tiptap')).color),
        page: sum(getComputedStyle(document.getElementById('editor')).backgroundColor),
      }
    })
  const lightState = await themeState()
  assert.equal(lightState.theme, 'light', 'starts light when the OS is light: ' + JSON.stringify(lightState))
  assert.ok(lightState.bg > 700 && lightState.ink < 60, 'light: pale canvas, black ink: ' + JSON.stringify(lightState))
  await page.click('#theme-btn')
  await waitFor(async () => (await themeState()).theme === 'dark', 'toggle switches to dark')
  const darkState = await themeState()
  assert.ok(darkState.bg < 160 && darkState.ink > 600, 'dark: dark canvas, light ink: ' + JSON.stringify(darkState))
  assert.ok(darkState.page < 200 && darkState.page > darkState.bg, 'dark: the sheet sits above the canvas: ' + JSON.stringify(darkState))
  assert.equal(darkState.stored, 'dark', 'choice is stored')
  await page.reload()
  await page.waitForSelector('.tiptap')
  const afterReload = await themeState()
  assert.equal(afterReload.theme, 'dark', 'dark survives a reload (no light flash path)')
  await page.click('#theme-btn')
  await waitFor(async () => (await themeState()).theme === 'light', 'toggle switches back to light')
  console.log('✓ theme: OS default, toggle to dark, persists, toggles back')

  // register an agent handle (agents live in a header popover now)
  await page.click('#agents-btn')
  await page.fill('#agent-handle', 'ui-agent')
  await page.click('#agent-form button')
  await page.waitForSelector('.agent-row')
  // registration issues a one-time agent key
  await page.waitForSelector('.key-box code', { timeout: 5000 })
  const shownKey = await page.textContent('.key-box code')
  assert.ok(shownKey.startsWith('ak_'), 'agent key shown once: ' + shownKey.slice(0, 6))
  // a second handle, so the @ menu has a list to arrow through
  await page.fill('#agent-handle', 'ui-agent-two')
  await page.click('#agent-form button')
  await waitFor(async () => (await page.textContent('#agents-list')).includes('ui-agent-two'), 'second agent registered')
  await waitFor(async () => (await page.$$('.key-box code')).length > 1, 'second agent key issued')
  const shownKeyTwo = (await page.$$eval('.key-box code', els => els.map(e => e.textContent)))[1]
  assert.ok(shownKeyTwo.startsWith('ak_') && shownKeyTwo !== shownKey, 'second agent has its own key')
  await page.click('#agents-btn') // close
  console.log('✓ agents registered via header panel, key issued')

  // --- sharing UI ---
  await page.click('#share-btn')
  await page.waitForSelector('#share-pop:not(.hidden)')
  await waitFor(async () => (await page.textContent('#share-list')).includes('owner'), 'share list shows owner')
  await page.fill('#share-user', 'bob')
  await page.click('#share-form button')
  await waitFor(async () => (await page.textContent('#share-list')).includes('bob'), 'bob added to share list')
  await page.click('#share-btn') // close
  console.log('✓ share panel: add collaborator')

  // --- LaTeX math: typed, typeset by KaTeX, source editable ---
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('Einstein said $E = mc^2$ and meant it.')
  await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 })
  // KaTeX is lazy: it must NOT be in the main bundle, and must arrive on demand
  await waitFor(async () => await page.evaluate(() => !!window.katex), 'katex loads on demand')
  const katexFetches = await page.evaluate(() =>
    performance.getEntriesByType('resource').filter(r => /katex\.(js|css)/.test(r.name)).map(r => r.name.split('/').pop())
  )
  assert.equal(katexFetches.length, 2, 'katex js + css fetched separately from the main bundle: ' + katexFetches)
  await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 5000 })
  const rendered = await page.textContent('.tiptap .math-inline .katex')
  assert.ok(rendered.includes('E') && rendered.includes('mc'), 'formula is typeset, not raw source: ' + rendered)
  assert.ok(!(await page.textContent('.tiptap')).includes('$E = mc^2$'), 'the $ delimiters are gone from the text')

  // double-clicking a formula opens its LaTeX source; Enter commits the edit
  await page.dblclick('.tiptap .math-inline .math-view')
  await page.waitForSelector('.tiptap .math-inline.editing .math-src', { timeout: 5000 })
  assert.equal(await page.inputValue('.math-inline.editing .math-src'), 'E = mc^2', 'the source is what you edit')
  await page.fill('.math-inline.editing .math-src', 'E = mc^3')
  await page.keyboard.press('Enter')
  await waitFor(async () => (await page.textContent('.tiptap .math-inline .katex')).includes('mc'), 'edited formula re-renders')
  assert.ok(!(await page.$('.math-inline.editing')), 'Enter closes the source editor')

  // a display formula from $$ on its own line
  await focusDocEnd(page) // the caret was in the previous formula's source input
  await page.keyboard.press('Enter')
  await page.keyboard.type('$$')
  await page.waitForSelector('.tiptap .math-block', { timeout: 5000 })
  await page.waitForSelector('.tiptap .math-block.editing .math-src', { timeout: 5000 })
  await page.fill('.math-block.editing .math-src', '\\int_0^1 x^2 dx')
  await page.keyboard.press('Enter')
  await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 5000 })
  console.log('✓ math: $x$ typesets via lazily-loaded KaTeX, source click-to-edit, $$ display block')

  // --- a formula can be selected and commented on ---
  // Single click selects it (that is how you comment on one) and must NOT open
  // the source editor; focusing the comment box must not dismiss the selection.
  await page.click('.tiptap .math-inline .math-view')
  const picked = await page.evaluate(() => ({
    editing: document.querySelectorAll('.math-inline.editing').length,
    selNode: window.__editor.state.selection.node?.type?.name || null,
  }))
  assert.equal(picked.editing, 0, 'a single click does not open the source editor')
  assert.equal(picked.selNode, 'mathInline', 'a single click selects the formula: ' + JSON.stringify(picked))
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
  await page.click('#composer-text')
  await page.waitForTimeout(300)
  assert.ok(await page.isVisible('#composer'), 'focusing the comment box does not dismiss the formula selection')
  // the quote shows the formula source, not an empty string
  const mathQuote = await page.textContent('#composer-quote')
  assert.ok(mathQuote.includes('mc'), 'the comment quotes the formula source: ' + JSON.stringify(mathQuote))
  await page.fill('#composer-text', '@ui-agent is this the right constant?')
  await page.click('#composer-send')
  await waitFor(async () => (await page.textContent('#margin-items')).includes('right constant'), 'comment on a formula posts')
  // close it: later assertions count the open threads on this document
  await page.click('.card.thread.expanded .close-btn')
  await waitFor(async () => !(await page.$('.card.thread')), 'this test cleans up its thread')
  console.log('✓ math: single click selects a formula, comment box keeps the selection and quotes the source')

  // dragging a text selection through a formula must work, not open the editor
  const dragBox = await page.evaluate(() => {
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.querySelector('.math-inline'))
    const r = p.getBoundingClientRect()
    return { x1: r.left + 4, x2: r.right - 4, y: r.top + r.height / 2 }
  })
  await page.mouse.move(dragBox.x1, dragBox.y)
  await page.mouse.down()
  await page.mouse.move(dragBox.x2, dragBox.y, { steps: 20 })
  await page.mouse.up()
  const dragged = await page.evaluate(() => {
    const sel = window.__editor.state.selection
    let hasMath = false
    window.__editor.state.doc.nodesBetween(sel.from, sel.to, n => {
      if (n.type.name === 'mathInline') hasMath = true
    })
    return { empty: sel.empty, hasMath, editing: document.querySelectorAll('.editing').length }
  })
  assert.ok(!dragged.empty && dragged.hasMath, 'a drag selects through the formula: ' + JSON.stringify(dragged))
  assert.equal(dragged.editing, 0, 'dragging over a formula does not open its editor')
  console.log('✓ math: text selection drags straight through a rendered formula')

  // double click is what opens the source
  await page.dblclick('.tiptap .math-inline .math-view')
  await page.waitForSelector('.math-inline.editing .math-src', { timeout: 5000 })
  await page.keyboard.press('Escape')
  await waitFor(async () => !(await page.$('.math-inline.editing')), 'Escape closes the source editor')
  console.log('✓ math: double click opens the source, Escape closes it')

  // it must survive a reload — i.e. it is really in the shared document
  await page.reload()
  await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 15000 })
  await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 15000 })
  console.log('✓ math persists across a reload (stored in the collaborative doc)')

  // --- loading skeleton ---
  const docHtml = await (await page.request.get(page.url())).text()
  assert.ok(docHtml.includes('id="doc-skeleton"') && (docHtml.match(/sk-line/g) || []).length > 3,
    'the doc shell ships the skeleton sheet in its markup')
  assert.ok(docHtml.includes('<span id="doc-title">Browser Test Doc</span>'), 'the shell carries the real title, not a placeholder')
  // plain fetch: no browser cookie jar, so this is a stranger asking
  const anonHtml = await (await fetch(page.url())).text()
  assert.ok(!anonHtml.includes('Browser Test Doc'), 'a shell for someone without access carries no title')
  const sk = await page.evaluate(() => {
    const el = document.getElementById('doc-skeleton')
    return el ? { exists: true, hidden: el.classList.contains('hidden'), lines: el.querySelectorAll('.sk-line').length } : { exists: false }
  })
  assert.ok(sk.exists && sk.lines > 3, 'the doc shell ships a skeleton sheet: ' + JSON.stringify(sk))
  assert.ok(sk.hidden, 'the skeleton is lifted once the document has synced')
  console.log('✓ loading: skeleton sheet ships in the shell and lifts after sync')

  // --- type into the editor ---
  await focusDocEnd(page)
  await page.keyboard.type('The quick brown fox jumps over the lazy dog.')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('quick brown fox'), 'typed text')
  console.log('✓ typing works')

  // --- double-click a word: the comment box opens without moving the document ---
  // Put the word at the bottom of a short viewport, where scrollIntoView() on
  // the margin composer used to drag the whole page down to reveal the box.
  await page.setViewportSize({ width: 1440, height: 500 })
  const wordPoint = await page.evaluate(() => {
    const needle = 'quick'
    const walker = document.createTreeWalker(document.querySelector('.tiptap'), NodeFilter.SHOW_TEXT)
    let node
    while ((node = walker.nextNode())) {
      const index = node.textContent.indexOf(needle)
      if (index < 0) continue
      const range = document.createRange()
      range.setStart(node, index)
      range.setEnd(node, index + needle.length)
      const rect = range.getBoundingClientRect()
      window.scrollBy(0, rect.bottom - (window.innerHeight - 20))
      const placed = range.getBoundingClientRect()
      return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 }
    }
    throw new Error('quick not found')
  })
  await page.mouse.dblclick(wordPoint.x, wordPoint.y)
  await page.waitForFunction(() => !window.__editor.state.selection.empty)
  const scrollBeforeComposer = await page.evaluate(() => window.scrollY)
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
  assert.ok(await page.isHidden('#selection-menu'), 'no floating selection widget on desktop')
  await page.waitForTimeout(300)
  const scrollAfterComposer = await page.evaluate(() => window.scrollY)
  assert.equal(scrollAfterComposer, scrollBeforeComposer, 'opening the comment box preserves document scroll')
  const composerTop = parseFloat(await page.evaluate(() => document.getElementById('composer').style.top))
  assert.ok(composerTop > 30, `composer aligned to anchored text, not at column top (top=${composerTop})`)
  // it comes pre-mentioning my first agent, and must NOT have taken the keyboard
  const prefilled = await page.inputValue('#composer-text')
  assert.equal(prefilled, '@ui-agent ', 'comment box pre-mentions my first agent: ' + JSON.stringify(prefilled))
  const focusStillInDoc = await page.evaluate(() => !!document.activeElement?.closest('.tiptap'))
  assert.ok(focusStillInDoc, 'auto-opened box does not steal focus from the document')
  await page.fill('#composer-text', '@ui-agent please review this phrase')
  await page.click('#composer-send')
  await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
  await waitFor(async () => (await page.textContent('#margin-items')).includes('please review'), 'thread text')
  await page.waitForSelector('.card.thread .chip', { timeout: 15000 })
  await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 })
  await page.waitForSelector('.tiptap .comment-range-active', { timeout: 5000 })
  const commentLocator = await page.evaluate(() => {
    const block = document.querySelector('.tiptap .comment-range-active')
    const bar = getComputedStyle(block, '::after')
    return { width: parseFloat(bar.width), height: parseFloat(bar.height), color: bar.backgroundColor }
  })
  assert.ok(commentLocator.width >= 3 && commentLocator.height > 10 && commentLocator.color !== 'rgba(0, 0, 0, 0)', 'active comment has a visible block-height gutter marker: ' + JSON.stringify(commentLocator))
  await page.setViewportSize({ width: 1440, height: 900 })
  console.log('✓ comment card created: chip + text underline + active gutter marker visible')

  // --- Enter sends a comment, Shift+Enter adds a line ---
  await selectText(page, 'brown fox jumps')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
  await page.click('#composer-text')
  await page.fill('#composer-text', '')
  await page.keyboard.type('first line')
  await page.keyboard.down('Shift')
  await page.keyboard.press('Enter')
  await page.keyboard.up('Shift')
  await page.keyboard.type('second line')
  const twoLines = await page.inputValue('#composer-text')
  assert.ok(twoLines.includes('\n'), 'Shift+Enter adds a newline: ' + JSON.stringify(twoLines))
  assert.ok(await page.isVisible('#composer'), 'Shift+Enter does not send')
  await page.keyboard.press('Enter')
  await waitFor(async () => await page.isHidden('#composer'), 'Enter sends the comment')
  await waitFor(async () => (await page.textContent('#margin-items')).includes('second line'), 'both lines posted')
  console.log('✓ comment composer: Enter sends, Shift+Enter adds a line')

  // the reply box behaves the same way
  await page.click('.card.thread')
  await page.waitForSelector('.card.thread.expanded .reply-row textarea', { timeout: 5000 })
  const replyBox = '.card.thread.expanded .reply-row textarea'
  await page.click(replyBox)
  await page.keyboard.type('reply one')
  await page.keyboard.down('Shift')
  await page.keyboard.press('Enter')
  await page.keyboard.up('Shift')
  await page.keyboard.type('reply two')
  assert.ok((await page.inputValue(replyBox)).includes('\n'), 'Shift+Enter adds a line in a reply')
  await page.keyboard.press('Enter')
  await waitFor(async () => (await page.textContent('.card.thread')).includes('reply two'), 'Enter sends the reply')
  assert.equal(await page.inputValue(replyBox), '', 'the reply box clears after sending')
  // close it again: later assertions count the open threads, and this test's
  // own thread would otherwise look like a second discussion
  await page.click('.card.thread.expanded .close-btn')
  await waitFor(async () => (await page.$$('.card.thread')).length === 1, 'this test cleans up its thread')
  console.log('✓ reply box: Enter sends, Shift+Enter adds a line')

  // --- @ menu: arrows pick an agent, no mouse needed ---
  await selectText(page, 'over the lazy')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
  await page.click('#composer-text')
  await page.fill('#composer-text', '')
  await page.keyboard.type('@ui')
  await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 })
  const menuOrder = await page.evaluate(() =>
    [...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim())
  )
  assert.deepEqual(menuOrder, ['@ui-agent', '@ui-agent-two'], 'both handles offered: ' + menuOrder.join(','))
  const firstSelected = await page.evaluate(
    () => document.querySelector('#composer-mentions .mention-item')?.classList.contains('selected')
  )
  assert.ok(firstSelected, 'first agent is preselected')
  await page.keyboard.press('ArrowDown')
  const secondSelected = await page.evaluate(
    () => [...document.querySelectorAll('#composer-mentions .mention-item')][1]?.classList.contains('selected')
  )
  assert.ok(secondSelected, 'ArrowDown moves the selection')
  await page.keyboard.press('Enter')
  const afterEnter = await page.inputValue('#composer-text')
  assert.equal(afterEnter, '@ui-agent-two ', 'Enter inserts the arrowed-to handle: ' + JSON.stringify(afterEnter))
  assert.ok(await page.isHidden('#composer-mentions'), '@ menu closes after picking')
  await page.click('#composer-cancel')
  console.log('✓ @ menu: own agents first, preselected, arrow keys + Enter pick')

  // --- a connected agent is the one the box hands work to ---
  // ui-agent-two starts polling; presence is derived from that long-poll, so it
  // is now the only agent that would pick a mention up right away.
  const pollAbort = new AbortController()
  const polling = fetch(`${BASE}/api/mentions/stream?wait=20`, {
    headers: { authorization: `Bearer ${shownKeyTwo}` },
    signal: pollAbort.signal,
  }).catch(() => {})
  await waitFor(async () => {
    const agents = await page.evaluate(async () => {
      const r = await fetch(`/api/agents?doc=${location.pathname.split('/').pop()}`)
      return (await r.json()).agents
    })
    return agents.find(a => a.handle === 'ui-agent-two')?.online === true
  }, 'ui-agent-two shows as online')

  await selectText(page, 'quick brown fox')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
  // the box refreshes presence when it opens, so the mention corrects itself
  await waitFor(
    async () => (await page.inputValue('#composer-text')) === '@ui-agent-two ',
    'comment box pre-mentions the CONNECTED agent, not merely the first one'
  )
  // and the @ menu puts it at the top, where Enter lands
  await page.fill('#composer-text', '')
  await page.type('#composer-text', '@ui-')
  await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 })
  const onlineFirst = await page.evaluate(() =>
    [...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim())
  )
  assert.deepEqual(onlineFirst, ['@ui-agent-two', '@ui-agent'], 'connected agent ranks first: ' + onlineFirst.join(','))
  await page.keyboard.press('Escape')
  await page.click('#composer-cancel')
  pollAbort.abort()
  await polling
  console.log('✓ connected agent is preferred: pre-mention + top of the @ menu')

  // collapse on outside click
  await page.mouse.click(40, 500)
  await waitFor(async () => !(await page.$('.card.thread.expanded')), 'card collapses')
  console.log('✓ cards collapse when clicking away')

  // --- the human suggest UI is hidden until proposing means editing inline ---
  assert.ok(await page.isHidden('#mode-switch'), 'Editing/Suggesting switch is hidden')
  assert.ok(await page.isHidden('#suggest-btn'), 'Suggest button is hidden')
  assert.ok(await page.evaluate(() => window.__editor.isEditable), 'the document stays directly editable')
  // The Space runs in an iframe where native dialogs are suppressed — the app
  // must never open one (everything goes through the in-app ui* helpers now).
  page.on('dialog', d => {
    throw new Error('native dialog opened: ' + d.message())
  })
  // suggestions themselves are unchanged — post one the way an agent does, then
  // check the whole reviewing flow on top of it
  const suggested = await page.evaluate(async () => {
    const id = location.pathname.split('/d/')[1].split('/')[0]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('lazy dog'))
    const res = await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        block_index: idx,
        replacement_markdown: 'The quick brown fox jumps over the **energetic** dog.',
        rationale: 'more positive energy',
      }),
    })
    return { idx, ...(await res.json()) }
  })
  assert.ok(suggested.idx >= 0 && suggested.suggestion_id, 'suggestion posted on the typed block: ' + JSON.stringify(suggested))
  await page.waitForSelector('.card.suggestion', { timeout: 5000 })
  await page.click('.card.suggestion')
  await page.waitForSelector('.card.suggestion.expanded', { timeout: 5000 })
  console.log('✓ suggest UI hidden; document editable; suggestion arrives from the API')

  // inline track-changes rendering in the document
  await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 })
  // wait for the decoration set to settle: the widgets appear as it is rebuilt,
  // so reading the first paint was always a race
  await waitFor(
    async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('energetic'),
    'inserted words shown inline'
  )
  await waitFor(
    async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes('lazy'),
    'deleted words struck through inline'
  )
  // slim card: accept/reject in the header, no diff repetition
  assert.ok(await page.$('.card.suggestion .accept-btn'), 'accept button in card header')
  assert.ok(!(await page.$('.card.suggestion .udiff')), 'card does not repeat the diff')
  console.log('✓ suggestion rendered inline (strikethrough + insertion); card is slim')

  // --- discuss the suggestion (guide the agent) ---
  await page.fill('.card.suggestion .reply-row textarea', '@ui-agent could you keep the dog lazy though?')
  await page.click('.card.suggestion .reply-row button')
  await waitFor(async () => (await page.textContent('.card.suggestion')).includes('keep the dog lazy'), 'discussion message')
  // the discussion thread must NOT appear as a separate card
  const threadCards = await page.$$('.card.thread')
  assert.equal(threadCards.length, 1, 'suggestion discussion is embedded, not a separate card')
  console.log('✓ discussion on suggestion (embedded thread)')

  // --- collapse, then accept straight from the collapsed card ---
  await page.mouse.click(40, 500)
  await waitFor(async () => !(await page.$('.card.suggestion.expanded')), 'suggestion card collapsed')
  assert.ok(await page.locator('.card.suggestion .accept-btn').isVisible(), 'accept visible while collapsed')
  // active-highlight: click the card, inline marks gain .active
  await page.click('.card.suggestion .head .who')
  await waitFor(async () => (await page.$('.tiptap .sugg-ins.active')) !== null, 'active suggestion emphasized inline')
  await page.click('.card.suggestion .accept-btn')
  // (the inline insertion widget also contains the word — wait for the real applied mark)
  await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'suggestion applied with marks')
  console.log('✓ suggestion accepted -> text replaced with marks')



  // closed items are hidden by default, shown with the toggle
  await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden')
  await page.check('#show-resolved')
  await page.waitForSelector('.card.suggestion', { timeout: 5000 })
  assert.ok((await page.textContent('.card.suggestion')).includes('accepted'), 'status visible when shown')
  await page.uncheck('#show-resolved')
  console.log('✓ closed items hidden by default, toggle shows them')

  // --- undo the accept: text reverts AND the suggestion reopens ---
  await page.click('#undo-btn')
  await waitFor(async () => !(await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'undo reverts accepted text')
  await page.waitForSelector('.card.suggestion', { timeout: 5000 }) // visible again => open
  await waitFor(async () => (await page.$('.tiptap .sugg-ins')) !== null, 'inline diff back after reopen')
  console.log('✓ undo reverts the accept and reopens the suggestion')

  // accept again to continue (straight from the collapsed card)
  await page.click('.card.suggestion .accept-btn')
  await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 're-accept applies')
  console.log('✓ re-accept after undo works')

  // --- accepting while the collaboration socket is down ---
  // The accept endpoint is plain HTTP: it still answers when the websocket is
  // gone. Trusting a dead tab's "I applied it locally" marked suggestions
  // accepted while their content was silently dropped, which is how six
  // accepted entries vanished from a real document.
  const offlineSugg = await page.evaluate(async () => {
    const id = location.pathname.split('/').filter(Boolean)[1]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const bi = snap.blocks.findIndex(b => b.markdown.includes('paragraph'))
    return await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: bi < 0 ? 1 : bi, replacement_markdown: 'Written while the socket was down.' }),
    })).json()
  })
  assert.ok(offlineSugg.suggestion_id, 'offline-case suggestion posted')
  await page.waitForSelector('.card.suggestion .accept-btn', { timeout: 8000 })
  await page.evaluate(() => window.__provider.disconnect())
  await waitFor(async () => !(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'connection indicator goes off')
  await page.click('.card.suggestion .accept-btn')
  await page.waitForSelector('.ui-modal-scrim', { timeout: 8000 })
  assert.match(await page.textContent('.ui-modal-msg'), /not connected/i, 'accepting offline warns instead of silently failing')
  await page.keyboard.press('Escape') // dismiss, or the modal blocks the clicks below
  const stillOpen = await page.evaluate(async sid => {
    const id = location.pathname.split('/').filter(Boolean)[1]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    return snap.suggestions.find(x => x.id === sid)?.status
  }, offlineSugg.suggestion_id)
  assert.equal(stillOpen, 'open', 'a suggestion is not marked accepted while the tab cannot save')
  assert.ok(await page.$('#offline-notice'), 'a disconnected tab says so')

  // reconnect: the same accept now works end to end
  await page.evaluate(() => window.__provider.connect())
  await waitFor(async () => !!(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'reconnected')
  await waitFor(async () => !(await page.$('#offline-notice')), 'offline notice clears on reconnect')
  await page.click('.card.suggestion .accept-btn')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('Written while the socket was down.'), 'accept applies after reconnect')
  console.log('✓ accepts are refused (not silently lost) while the socket is down, and work after reconnect')

  // --- image upload ---
  const png = Buffer.from(
    'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAF0lEQVR4nGP8z8Dwn4EIwESMolGFtFEIAK5+AxGmizXcAAAAAElFTkSuQmCC',
    'base64'
  )
  fs.writeFileSync(`${SHOT}/test.png`, png)
  const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.click('#image-btn')])
  await chooser.setFiles(`${SHOT}/test.png`)
  await page.waitForSelector('.tiptap img:not(.ProseMirror-separator)', { timeout: 10000 })
  const src = await page.getAttribute('.tiptap img:not(.ProseMirror-separator)', 'src')
  assert.ok(src.startsWith('/files/'), 'image served from /files: ' + src)
  const imgRes = await page.evaluate(async u => (await fetch(u)).status, src)
  assert.equal(imgRes, 200, 'image fetchable')
  console.log('✓ image upload + render')

  // --- reply + resolve on the standalone thread ---
  await page.click('.card.thread')
  await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
  await page.fill('.card.thread .reply-row textarea', 'looks good, thanks!')
  await page.click('.card.thread .reply-row button')
  await waitFor(async () => (await page.textContent('.card.thread')).includes('looks good'), 'reply visible')
  console.log('✓ reply works')

  // --- list scenario: agent adds one item, only that item shows as inserted ---
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('- alpha')
  await page.keyboard.press('Enter')
  await page.keyboard.type('beta')
  await page.keyboard.press('Enter')
  await page.keyboard.type('gamma')
  await page.waitForTimeout(400)
  const listSugg = await page.evaluate(async () => {
    const id = location.pathname.split('/').pop()
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const li = snap.blocks.findIndex(b => b.markdown.includes('alpha'))
    return (await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: li, replacement_markdown: '- alpha\n- beta\n- gamma\n- delta' }),
    })).json())
  })
  assert.ok(listSugg.ok, 'list suggestion: ' + JSON.stringify(listSugg))
  await waitFor(async () => {
    const spans = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent))
    return spans.some(t => t.includes('delta'))
  }, 'list insertion inline')
  const delSpans = await page.$$eval('.tiptap .sugg-del', els => els.map(e => e.textContent).filter(t => ['alpha', 'beta', 'gamma'].some(w => t.includes(w))))
  assert.equal(delSpans.length, 0, 'unchanged list items are NOT struck through: ' + JSON.stringify(delSpans))
  // typing a new block right after the suggested range must not get highlighted
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.press('Enter') // second Enter exits the list into a fresh paragraph
  await page.keyboard.type('untouched trailing line')
  await page.waitForTimeout(400)
  const trailingCls = await page.evaluate(() => {
    const blocks = [...document.querySelectorAll('.tiptap > *')]
    const t = blocks.find(b => b.textContent.includes('untouched trailing line'))
    return t ? t.className || '' : 'MISSING'
  })
  assert.ok(!trailingCls.includes('suggestion-hl') && !trailingCls.includes('sugg-del') && trailingCls !== 'MISSING', 'new trailing block not highlighted: ' + trailingCls)
  await page.evaluate(async sid => {
    const id = location.pathname.split('/').pop()
    await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
  }, listSugg.suggestion_id)
  console.log('✓ list suggestion: only the added item renders as inserted')

  // --- new blocks (heading + list) render FORMATTED in the insertion panel ---
  const blockSugg = await page.evaluate(async () => {
    const id = location.pathname.split('/').pop()
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const li = snap.blocks.findIndex(b => b.markdown.includes('alpha'))
    return (await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        block_index: li,
        replacement_markdown: snap.blocks[li].markdown + '\n\n## Sources\n\n- one **bold** source',
      }),
    })).json())
  })
  assert.ok(blockSugg.ok, 'block suggestion: ' + JSON.stringify(blockSugg))
  await page.waitForSelector('.tiptap .sugg-ins-block', { timeout: 5000 })
  assert.ok(await page.$('.tiptap .sugg-ins-block h2'), 'inserted heading renders as a heading')
  assert.ok(await page.$('.tiptap .sugg-ins-block ul li strong'), 'inserted list renders as bullets with marks')
  await page.evaluate(async sid => {
    const id = location.pathname.split('/').pop()
    await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
  }, blockSugg.suggestion_id)
  console.log('✓ new blocks render formatted (heading, bullets, marks) in the insertion panel')

  // --- formatting-only changes are visible, and inserted runs keep their marks ---
  // The inline diff used to compare mark-stripped text: a suggestion that only
  // linked (or bolded) an existing word looked completely identical, so the
  // document showed no diff at all and the card seemed to propose nothing.
  const markCases = [
    { label: 'link', md: 'A paragraph to [link up](https://example.com/ref) here.', sel: '.tiptap .sugg-ins a[href="https://example.com/ref"]' },
    { label: 'bold', md: 'A paragraph to **link up** here.', sel: '.tiptap .sugg-ins strong' },
    { label: 'code', md: 'A paragraph to `link up` here.', sel: '.tiptap .sugg-ins code' },
  ]
  // its own paragraph, verified to be a standalone block: the point of these
  // cases is that ONLY the formatting differs from the replacement markdown
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('A paragraph to link up here.')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('A paragraph to link up here.'), 'mark-case paragraph typed')
  await waitFor(async () => {
    const md = await page.evaluate(async () => {
      const id = location.pathname.split('/').filter(Boolean)[1]
      const snap = await (await fetch(`/api/docs/${id}`)).json()
      const b = snap.blocks.find(x => x.markdown.includes('A paragraph to link up here.'))
      return b?.markdown
    })
    return md === 'A paragraph to link up here.'
  }, 'the paragraph is its own block (formatting-only cases need an exact match)')
  for (const c of markCases) {
    const sugg = await page.evaluate(async md => {
      const id = location.pathname.split('/').filter(Boolean)[1]
      const snap = await (await fetch(`/api/docs/${id}`)).json()
      const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.'))
      return await (await fetch(`/api/docs/${id}/suggestions`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ block_index: bi, replacement_markdown: md, rationale: 'formatting only' }),
      })).json()
    }, c.md)
    assert.ok(sugg.suggestion_id, `${c.label} suggestion posted: ` + JSON.stringify(sugg))
    // the old run is struck AND the new run is inserted with its formatting
    await page.waitForSelector(c.sel, { timeout: 8000 })
    const marked = await page.evaluate(sel => {
      const ins = document.querySelector(sel)
      return { text: ins.textContent, deleted: [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent) }
    }, c.sel)
    assert.equal(marked.text, 'link up', `${c.label}: inserted run carries the text`)
    assert.ok(marked.deleted.some(t => t.includes('link up')), `${c.label}: the unformatted run is struck through: ${JSON.stringify(marked.deleted)}`)
    await page.evaluate(async sid => {
      const id = location.pathname.split('/').filter(Boolean)[1]
      await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
    }, sugg.suggestion_id)
    await waitFor(async () => !(await page.$(c.sel)), `${c.label} suggestion cleared`)
  }
  console.log('✓ formatting-only suggestions (link / bold / code) render as visible diffs')

  // a link inside a *proposed* change must not navigate away on a plain click
  const linkSugg = await page.evaluate(async () => {
    const id = location.pathname.split('/').filter(Boolean)[1]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.'))
    return await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: bi, replacement_markdown: 'A paragraph to [link up](https://example.com/ref) here.' }),
    })).json()
  })
  assert.ok(linkSugg.suggestion_id, 'preview-link suggestion posted')
  await page.waitForSelector('.tiptap .sugg-ins a[href]', { timeout: 8000 })
  const urlBefore = page.url()
  await page.click('.tiptap .sugg-ins a[href]')
  await page.waitForTimeout(500)
  assert.equal(page.url(), urlBefore, 'clicking a suggested link stays on the page')
  assert.ok(await page.$('.card.suggestion.expanded'), 'clicking a suggested link opens its card instead')
  // accepting turns it into a real link in the document
  await page.click('.card.suggestion.expanded .accept-btn')
  await waitFor(async () => !!(await page.$('.tiptap a[href="https://example.com/ref"]')), 'accepted link is a real link')
  await waitFor(async () => {
    const md = await page.evaluate(async () => {
      const id = location.pathname.split('/').filter(Boolean)[1]
      return (await (await fetch(`/api/docs/${id}`)).json()).markdown
    })
    return md.includes('[link up](https://example.com/ref)')
  }, 'accepted link round-trips to markdown')
  console.log('✓ suggested links preview safely, then accept into real links')

  // --- dissimilar rewrite: struck old block + formatted panel, never a flat text blob ---
  const dis = await page.evaluate(async () => {
    const id = location.pathname.split('/').pop()
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const li = snap.blocks.findIndex(b => b.markdown.includes('untouched trailing line'))
    return (await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: li, replacement_markdown: '## Appendix\n\nCompletely different content here.' }),
    })).json())
  })
  assert.ok(dis.ok, 'dissimilar suggestion: ' + JSON.stringify(dis))
  await waitFor(async () => (await page.$$('.tiptap .sugg-ins-block')).length >= 1, 'panel for dissimilar rewrite')
  assert.ok(await page.$('.tiptap .sugg-del-block'), 'unrelated old block struck wholesale')
  const flatIns = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent).filter(t => t.includes('Appendix')))
  assert.equal(flatIns.length, 0, 'heading is not flat inline text')
  assert.ok(await page.$$eval('.tiptap .sugg-ins-block', els => els.some(e => e.querySelector('h2'))), 'panel contains a real heading')
  await page.evaluate(async sid => {
    const id = location.pathname.split('/').pop()
    await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
  }, dis.suggestion_id)
  console.log('✓ dissimilar rewrites: whole-block strike + formatted panel (no raw blob)')

  // --- typing at a suggestion boundary pushes the insertion panel down ---
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('ordering base line')
  await page.waitForTimeout(400)
  const push = await page.evaluate(async () => {
    const id = location.pathname.split('/').pop()
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const li = snap.blocks.findIndex(b => b.markdown.includes('ordering base line'))
    return (await (await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: li, replacement_markdown: 'ordering base line\n\n## Pushed Panel' }),
    })).json())
  })
  assert.ok(push.ok, JSON.stringify(push))
  await waitFor(async () => (await page.textContent('.tiptap')).includes('Pushed Panel'), 'panel appears')
  await page.evaluate(() => {
    const e = window.__editor
    let pos = null
    e.state.doc.descendants((node, p) => {
      if (node.isText && node.text.includes('ordering base line')) pos = p + node.text.length
    })
    e.chain().focus().setTextSelection(pos).run()
  })
  await page.keyboard.press('Enter')
  await page.keyboard.type('pushes the panel')
  await page.waitForTimeout(500)
  const domOrder = await page.evaluate(() => {
    const kids = [...document.querySelector('.tiptap').children].map(el =>
      el.className.includes('sugg-ins-block') ? 'PANEL' : el.textContent.slice(0, 25)
    )
    return kids.slice(kids.findIndex(t => t.includes('ordering base')))
  })
  assert.ok(
    domOrder.indexOf('PANEL') > domOrder.findIndex(t => t.includes('pushes the panel')),
    'typed line lands ABOVE the panel (pushes it down): ' + JSON.stringify(domOrder)
  )
  await page.evaluate(async sid => {
    const id = location.pathname.split('/').pop()
    await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
  }, push.suggestion_id)
  console.log('✓ typing at the boundary pushes the insertion panel down')

  // --- zoom ---
  const baseSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize))
  await page.click('#zoom-in')
  const zoomedSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize))
  assert.ok(zoomedSize > baseSize, `zoom-in grows text (${baseSize} -> ${zoomedSize})`)
  await page.click('#zoom-out')
  console.log('✓ zoom controls')

  // --- the sheet is a page: fixed width, panels fold for it, zoom magnifies it ---
  // Regression guards for three things that used to be wrong: the sheet shrank
  // with the window (so the measure changed as you resized), zoom moved the type
  // but not the page, and nothing gave way before the document did.
  const geom = () =>
    page.evaluate(() => {
      const col = document.getElementById('editor-col')
      return {
        sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
        font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize),
        pages: !document.getElementById('sidebar').classList.contains('hidden'),
        comments: !document.getElementById('margin-col').classList.contains('hidden'),
        pageArrow: document.getElementById('sidebar-toggle').getBoundingClientRect().width > 0,
        sidebarWidth: Math.round(document.getElementById('sidebar').getBoundingClientRect().width),
        sidebarRight: Math.round(document.getElementById('sidebar').getBoundingClientRect().right),
        sheetLeft: Math.round(document.getElementById('editor').getBoundingClientRect().left),
        sheetRight: Math.round(document.getElementById('editor').getBoundingClientRect().right),
        textRight: Math.round(document.getElementById('editor').getBoundingClientRect().right -
          parseFloat(getComputedStyle(document.getElementById('editor')).paddingRight)),
        commentsLeft: Math.round(document.getElementById('margin-col').getBoundingClientRect().left),
        appliedOverlap: Math.round(parseFloat(getComputedStyle(document.documentElement)
          .getPropertyValue('--margin-applied-overlap')) || 0),
        colScroll: col.scrollWidth - col.clientWidth,
        colWidth: col.clientWidth,
        bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
      }
    })
  const resizeTo = async width => {
    await page.setViewportSize({ width, height: 900 })
    await page.waitForTimeout(250)
    return geom()
  }

  const wide = await resizeTo(1440)
  assert.ok(wide.pages && wide.comments, 'both side panels fit at 1440')
  assert.ok(!wide.pageArrow, 'the Pages arrow is absent while the sidebar fits')
  assert.ok(Math.abs(wide.sheetLeft - wide.sidebarRight - 20) <= 2,
    `the sidebar fills the full gutter up to the page (${wide.sidebarRight} -> ${wide.sheetLeft})`)
  assert.ok(Math.abs(wide.commentsLeft - wide.sheetRight - 20) <= 2 && wide.appliedOverlap === 0,
    `comments stay outside the page while there is room (${wide.sheetRight} -> ${wide.commentsLeft})`)

  const sheetW = wide.sheet

  // The page stays fixed while every additional wide-screen pixel belongs to
  // Pages — there is no dead strip between a fixed nav and the document.
  const extraWide = await resizeTo(1600)
  assert.ok(extraWide.sidebarWidth >= wide.sidebarWidth + 150,
    `the sidebar grows with the gutter (${wide.sidebarWidth} -> ${extraWide.sidebarWidth})`)

  // Borrowing the page's right margin lets Pages survive at 1280, where it
  // previously disappeared even though the comments could safely move inward.
  const resilient = await resizeTo(1280)
  assert.strictEqual(resilient.sheet, sheetW, `the sheet keeps its width at 1280 (${resilient.sheet} vs ${sheetW})`)
  assert.ok(resilient.pages && resilient.comments, 'Pages survives at 1280 by bringing comments inward')
  assert.ok(!resilient.pageArrow, 'no Pages arrow while the overlapped three-column layout fits')
  assert.ok(resilient.appliedOverlap > 0, 'overlap appears only when the full-width columns run short')
  assert.ok(resilient.textRight - resilient.commentsLeft >= 18 && resilient.textRight - resilient.commentsLeft <= 30,
    `the maximum overlap touches only a little text (${resilient.textRight - resilient.commentsLeft}px)`)

  // Below that, Pages is still the first column to yield.
  const mid = await resizeTo(1240)
  assert.ok(!mid.pages, 'the Pages sidebar folds away first')
  assert.ok(mid.pageArrow, 'the Pages arrow appears only after the sidebar auto-collapses')
  assert.ok(mid.comments, 'the comments margin is still there when only the sidebar had to go')
  assert.equal(mid.appliedOverlap, 0, 'comments stop overlapping as soon as Pages collapses')
  assert.ok(mid.commentsLeft >= mid.sheetRight, 'comments return outside the page after Pages collapses')

  // narrower still: the comments follow, and the page is STILL the same width
  const tight = await resizeTo(1000)
  assert.strictEqual(tight.sheet, sheetW, `the sheet keeps its width at 1000 (${tight.sheet} vs ${sheetW})`)
  assert.ok(!tight.pages && !tight.comments, 'both columns fold before the page is touched')

  // growing back restores them, in reverse
  const back = await resizeTo(1440)
  assert.ok(back.pages && back.comments, 'panels come back when the room does')
  assert.ok(!back.pageArrow, 'the Pages arrow disappears when the sidebar returns')

  // zoom magnifies the page itself: sheet and type take the same factor
  await page.click('#zoom-in')
  await page.click('#zoom-in')
  await page.waitForTimeout(250)
  const zoomed = await geom()
  const sheetRatio = zoomed.sheet / wide.sheet
  const fontRatio = zoomed.font / wide.font
  assert.ok(zoomed.sheet > wide.sheet, `zoom widens the sheet (${wide.sheet} -> ${zoomed.sheet})`)
  assert.ok(
    Math.abs(sheetRatio - fontRatio) < 0.02,
    `sheet and type scale together (sheet x${sheetRatio.toFixed(3)}, type x${fontRatio.toFixed(3)})`
  )

  // a page too wide for the window scrolls sideways INSIDE its column — the
  // window itself must never gain a horizontal scrollbar (that unsticks the header)
  const overflowing = await resizeTo(1000)
  assert.ok(overflowing.colScroll > 0, 'an over-wide page scrolls inside the editor column')
  assert.ok(overflowing.bodyOverflow <= 0, `the window itself does not scroll sideways (${overflowing.bodyOverflow}px)`)

  await page.click('#zoom-out')
  await page.click('#zoom-out')
  await resizeTo(1440)
  console.log('✓ fixed-width page: folds the side panels, zooms whole, scrolls in place')

  // --- the same document at every width: reflow only as late as it must ---
  // The document used to keep its page geometry at every width, on the grounds
  // that reflowing it moved the line breaks. It did — and it also made a 390px
  // phone show 68% of each line off-screen with no zoom that both fitted the
  // page and left legible type. So the sheet now goes fluid below 840px, in two
  // stages, and this walks the stages:
  //   >840  nothing changes at all — the page fits, so it stays a page
  //   ≤840  the sheet is fluid but the text column is still capped at the page's
  //         own 624px, so the SAME words break on the SAME lines with no scroll
  //   ≤700  a handset cannot hold 624px, so the measure and the type both give
  const shape = () =>
    page.evaluate(() => {
      const t = document.querySelector('.tiptap')
      const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 60)
      const col = document.getElementById('editor-col')
      return {
        sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
        measure: Math.round(t.getBoundingClientRect().width),
        padLeft: Math.round(parseFloat(getComputedStyle(document.getElementById('editor')).paddingLeft)),
        font: getComputedStyle(t).fontSize,
        // a dragged table carries inline geometry that ignores the measure
        table: Math.round(document.querySelector('.tiptap table')?.getBoundingClientRect().width || 0),
        // if the lines broke differently, the paragraph and the document change height
        paraHeight: para ? Math.round(para.getBoundingClientRect().height) : 0,
        docHeight: Math.round(t.getBoundingClientRect().height),
        colScroll: col.scrollWidth - col.clientWidth,
        colWidth: col.clientWidth,
        windowOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
      }
    })

  // A resize lands in the renderer asynchronously and the header's own
  // ResizeObserver can move --hdr-h a frame later, so a fixed sleep is a
  // coin toss at these boundaries: read the shape until two reads agree.
  const shapeAfterResize = async (width, height) => {
    await page.setViewportSize({ width, height })
    let prev = null
    for (let i = 0; i < 25; i++) {
      await page.waitForTimeout(100)
      const now = await shape()
      if (prev && now.sheet === prev.sheet && now.measure === prev.measure && now.docHeight === prev.docHeight) return now
      prev = now
    }
    return prev
  }

  // Put a table with a DRAGGED column in the document first. A pristine table is
  // width:100% and divides the measure, so it can never fail the no-scroll checks
  // below — the assertions were sound but pointed at content that could not fail
  // them. prosemirror-tables records a drag as an inline min-width on the table
  // and an inline width on a <col>, and that is what follows the reader onto a
  // phone and puts the sideways pan back. Reviewed and reproduced: 562px of table
  // inside a 354px measure, 190px of scroll on #editor-col.
  await page.click('.tiptap p')
  await page.keyboard.press('End')
  await page.click('button[title="Insert table"]')
  await page.waitForSelector('.tiptap table th')
  await page.waitForTimeout(400)
  const tblBorder = await page.evaluate(() => {
    const c = document.querySelector('.tiptap table:first-of-type tr:first-child > *')
    const r = c.getBoundingClientRect()
    return { x: r.right, y: r.top + r.height / 2 }
  })
  await page.mouse.move(tblBorder.x - 1, tblBorder.y)
  await page.waitForSelector('.tiptap .column-resize-handle')
  await page.mouse.down()
  await page.mouse.move(tblBorder.x + 200, tblBorder.y, { steps: 10 })
  await page.mouse.up()
  await waitFor(
    async () => await page.evaluate(() => /min-width/.test(document.querySelector('.tiptap table').getAttribute('style') || '')),
    'the drag writes an inline min-width onto the table (the thing that overflows)'
  )
  await page.waitForTimeout(400)

  const onDesktop = await shapeAfterResize(1440, 900)
  assert.ok(onDesktop.table > 0, 'the resized table is in the document under test')

  // above the reflow width the page is untouched, down to the pixel
  for (const width of [1100, 900, 860]) {
    const wideEnough = await shapeAfterResize(width, 844)
    assert.strictEqual(wideEnough.sheet, onDesktop.sheet, `at ${width}px the page keeps its width`)
    assert.strictEqual(wideEnough.measure, onDesktop.measure, `at ${width}px the text column is unchanged`)
    assert.strictEqual(wideEnough.font, onDesktop.font, `at ${width}px the document is set at the same size`)
    assert.strictEqual(wideEnough.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`)
    assert.strictEqual(wideEnough.docHeight, onDesktop.docHeight, `at ${width}px nothing above or below moved`)
    assert.strictEqual(wideEnough.colScroll, 0, `at ${width}px the page fits, so nothing scrolls sideways`)
  }

  // 701-840: the sheet gives up being a page, and NOTHING else gives. This is
  // the assertion that keeps the reflow honest — a tablet is not allowed to
  // re-break a single line just because the canvas around the page went away.
  for (const width of [840, 768, 720]) {
    const fluid = await shapeAfterResize(width, 1024)
    assert.equal(fluid.sheet, fluid.colWidth, `at ${width}px the sheet is fluid, not a page (${JSON.stringify(fluid)})`)
    assert.ok(fluid.sheet <= width, `at ${width}px the sheet fits the window (${fluid.sheet})`)
    assert.strictEqual(fluid.measure, onDesktop.measure,
      `at ${width}px the text column is still the page's 624px (${fluid.measure})`)
    assert.strictEqual(fluid.font, onDesktop.font, `at ${width}px the type is untouched`)
    assert.strictEqual(fluid.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`)
    assert.strictEqual(fluid.colScroll, 0, `at ${width}px there is nothing left to scroll to (${fluid.colScroll})`)
    assert.ok(fluid.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`)
    assert.ok(fluid.table <= fluid.measure + 1,
      `at ${width}px the dragged table is inside the measure (${fluid.table} in ${fluid.measure})`)
  }

  // ≤700: a handset. Now the measure and the type both move — the point of the
  // whole change — and the one thing that must not move is the fit.
  const desktopFont = parseFloat(onDesktop.font)
  for (const width of [700, 390, 360, 320]) {
    const phone = await shapeAfterResize(width, 844)
    assert.strictEqual(phone.sheet, phone.colWidth, `at ${width}px the sheet is exactly the column (${phone.sheet})`)
    assert.ok(phone.measure <= width - 32, `at ${width}px the text column fits with margins (${phone.measure})`)
    assert.strictEqual(phone.colScroll, 0, `at ${width}px nothing scrolls sideways (${phone.colScroll})`)
    // the inline geometry from a column drag is the one thing that reaches this
    // far and ignores the measure entirely
    assert.ok(phone.table <= phone.measure + 1,
      `at ${width}px the dragged table is inside the measure (${phone.table} in ${phone.measure})`)
    assert.ok(phone.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`)
    // the legibility floor: 16px is what iOS and Android set body text at, and
    // what the old "use zoom to fit it" answer arrived at 7px of
    assert.ok(parseFloat(phone.font) >= 16, `at ${width}px body text is at least 16px (${phone.font})`)
    assert.ok(parseFloat(phone.font) > desktopFont,
      `at ${width}px the type is larger than the page's 11pt, not smaller (${phone.font})`)
    // margins are a proportion of a small screen now, not a printed inch
    assert.ok(phone.padLeft >= 16 && phone.padLeft <= width * 0.09,
      `at ${width}px the page margin is 16px-9% of the screen, not 24.6% (${phone.padLeft})`)
  }

  // and back: the page returns exactly as it was, so this is a view of the
  // document and not an edit to it
  // named for this block specifically: #14 (agent/reader-text-size) declares its
  // own `restored` in this same function scope, and git merges the two files
  // without a conflict into something Node then refuses to parse
  const restoredPage = await shapeAfterResize(1440, 900)
  assert.deepStrictEqual(
    [restoredPage.sheet, restoredPage.measure, restoredPage.font, restoredPage.paraHeight, restoredPage.docHeight],
    [onDesktop.sheet, onDesktop.measure, onDesktop.font, onDesktop.paraHeight, onDesktop.docHeight],
    'the page comes back identical after a round trip through phone widths'
  )
  // A rotation is how a handset crosses 840, and the zoom floor is different
  // either side of it. Read at apply time only, a 40% zoom set while the sheet
  // was a page stayed 40% after the rotation — 6.4px of body text, held until
  // the next reload.
  await shapeAfterResize(1000, 900)
  for (let i = 0; i < 8; i++) await page.click('#zoom-out')
  await page.waitForTimeout(300)
  assert.equal(await page.textContent('#zoom-label'), '40%', 'a fixed-width page still zooms to 40%')
  await shapeAfterResize(390, 844)
  const afterRotate = await page.evaluate(() => ({
    zoom: getComputedStyle(document.documentElement).getPropertyValue('--doc-zoom').trim(),
    font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize),
  }))
  assert.equal(afterRotate.zoom, '0.8', `rotating into the fluid column re-clamps the floor (${afterRotate.zoom})`)
  assert.ok(afterRotate.font >= 12, `and the type is readable rather than 6.4px (${afterRotate.font}px)`)
  // back to a width where the zoom buttons are in the toolbar rather than folded
  // into the ⋯ sheet, then put the setting back for the checks that follow
  await shapeAfterResize(1440, 900)
  for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in')
  await page.waitForTimeout(300)
  assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom restored to 100%')
  console.log('✓ rotating across 840 re-clamps the zoom floor (40% -> 80%, not 6.4px type)')

  console.log('✓ reflow arrives in two stages: geometry at 840, type at 700, nothing above')

  // --- document style: Docs (Arial 11pt/1.15) <-> Reading (serif 22px/1.45) ---
  const typeset = () =>
    page.evaluate(() => {
      const t = getComputedStyle(document.querySelector('.tiptap'))
      const p = [...document.querySelectorAll('.tiptap p')].find(e => e.textContent.trim().length > 40)
      return {
        style: document.documentElement.dataset.docStyle,
        family: t.fontFamily.split(',')[0].replace(/"/g, ''),
        size: parseFloat(t.fontSize),
        line: parseFloat(t.lineHeight),
        paraGap: p ? parseFloat(getComputedStyle(p).marginBottom) : null,
        measure: Math.round(document.querySelector('.tiptap').getBoundingClientRect().width),
      }
    })

  const docsSet = await typeset()
  assert.strictEqual(docsSet.style, 'docs', 'default document style is docs')
  assert.strictEqual(docsSet.family, 'Arial', 'docs style sets the document in Arial')
  assert.strictEqual(docsSet.paraGap, 0, 'docs style has no space between paragraphs')

  await page.click('#docstyle-btn')
  await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif')
  const readingSet = await typeset()
  // The settled numbers, pinned: 16px on 28.8px with 17.6px between paragraphs.
  // Chosen by eye against real text rather than derived, which is exactly why
  // they are worth pinning — nothing else records why these and not others.
  assert.strictEqual(readingSet.size, 16, `body is set at 16px (got ${readingSet.size})`)
  assert.strictEqual(Math.round(readingSet.line * 10) / 10, 28.8, `leading is 28.8px (got ${readingSet.line})`)
  assert.strictEqual(Math.round(readingSet.paraGap * 10) / 10, 17.6, `paragraph gap is 17.6px (got ${readingSet.paraGap})`)
  const head = await page.evaluate(() => {
    const h = document.querySelector('.tiptap h2')
    if (!h) return null
    const s = getComputedStyle(h)
    return { size: parseFloat(s.fontSize), line: parseFloat(s.lineHeight), caps: s.fontVariantCaps,
             ls: parseFloat(s.letterSpacing), mt: parseFloat(s.marginTop) }
  })
  if (head) {
    assert.strictEqual(Math.round(head.size * 10) / 10, 25.6, `section heads are 25.6px (got ${head.size})`)
    assert.strictEqual(head.caps, 'all-small-caps', 'section heads are small caps')
    assert.ok(Math.abs(head.ls / head.size - 0.075) < 0.005, `section heads keep the article's 0.075em tracking (got ${(head.ls / head.size).toFixed(4)}em)`)
    assert.ok(Math.abs(head.mt / head.size - 2) < 0.02, `section heads take 2em of air above (got ${(head.mt / head.size).toFixed(2)}em)`)
  }
  // the switch is typography only — the sheet keeps Docs' letter geometry
  assert.strictEqual(readingSet.measure, docsSet.measure, 'the text column is untouched by the document style')
  // (no words-per-line guard against Docs: this face at this size fits 10.69
  // to Docs' 13.54, so the two deliberately differ and switching reflows)
  // and it survives a reload, like the theme
  await page.reload({ waitUntil: 'domcontentloaded' })
  await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads')
  assert.strictEqual((await typeset()).family, 'Libre Baskerville', 'document style persists across a reload')
  await page.click('#docstyle-btn')
  await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs')
  console.log('✓ document style switch (Docs <-> Reading), persisted, page width untouched')

  // --- reader text size: the half of the pair that DOES reflow ---
  // Zoom above is guarded on "the sheet and the type take the same factor, so
  // the lines never move". This is the exact complement and needs its own guard,
  // because the failure mode is the two controls quietly becoming one: a text
  // size that also widened the page would leave the words-per-line alone, which
  // is the one thing a reader reaches for it to change.
  //
  // The document this suite has built up by now is all short lines, and a line
  // that never wrapped cannot re-break — so the measurement needs a paragraph
  // long enough to wrap. It is appended here and taken out again at the end of
  // the block, with the document's text asserted back to what it was.
  const LONG =
    'A reader who asks for larger type is not asking for a larger page: the sheet should ' +
    'stay exactly where it is on the screen while the words inside it find new lines, ' +
    'which is the one thing zoom can never do and the whole reason this control exists ' +
    'next to it.'
  const beforeText = await page.textContent('.tiptap')
  const anchor = await page.evaluate(text => {
    const e = window.__editor
    const at = e.state.doc.content.size
    e.chain().focus().insertContentAt(at, { type: 'paragraph', content: [{ type: 'text', text }] }).run()
    return at
  }, LONG)
  await page.waitForTimeout(300)

  const sized = () =>
    page.evaluate(marker => {
      const t = document.querySelector('.tiptap')
      const p = [...t.querySelectorAll('p')].find(e => e.textContent.startsWith(marker))
      const lh = p ? parseFloat(getComputedStyle(p).lineHeight) : 1
      return {
        scale: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-text-scale')),
        stored: localStorage.getItem('doc-text-scale'),
        label: document.getElementById('docsize-label').textContent,
        size: parseFloat(getComputedStyle(t).fontSize),
        sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
        measure: Math.round(t.getBoundingClientRect().width),
        lines: p ? Math.round(p.getBoundingClientRect().height / lh) : 0,
      }
    }, LONG.slice(0, 30))

  const base = await sized()
  assert.strictEqual(base.scale, 1, 'text size starts at 1x')
  assert.ok(base.lines >= 3, `the measured paragraph wraps to begin with (${base.lines} lines)`)
  assert.strictEqual(base.label, '15px', `the readout is the document's own size, in px (got ${base.label})`)

  for (let i = 0; i < 3; i++) await page.click('#text-larger')
  await page.waitForTimeout(250)
  const bigger = await sized()
  assert.ok(Math.abs(bigger.size / base.size - 1.3) < 0.01,
    `three steps up is 1.3x the type (${base.size} -> ${bigger.size})`)
  assert.strictEqual(bigger.sheet, base.sheet, `the page keeps its width (${base.sheet} -> ${bigger.sheet})`)
  assert.strictEqual(bigger.measure, base.measure, `and so does the text column (${base.measure} -> ${bigger.measure})`)
  assert.ok(bigger.lines > base.lines, `so the paragraph re-breaks onto more lines (${base.lines} -> ${bigger.lines})`)
  assert.strictEqual(bigger.label, '19px', `the readout follows (got ${bigger.label})`)
  assert.strictEqual(bigger.stored, '1.3', 'the choice is stored')

  // it is a factor, not a size: a style switch changes what 100% means and the
  // reader's own step up rides along on top of whatever the style asked for
  await page.click('#docstyle-btn')
  await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif')
  await page.waitForTimeout(250)
  const readingBig = await sized()
  assert.strictEqual(readingBig.scale, 1.3, 'the reader\'s step up survives a document style switch')
  assert.ok(Math.abs(readingBig.size - 16 * 1.3) < 0.05,
    `Reading's own 16px takes the same 1.3x (got ${readingBig.size})`)
  assert.strictEqual(readingBig.label, '21px', `the px readout re-reads the new style's base (got ${readingBig.label})`)
  assert.strictEqual(readingBig.sheet, base.sheet, 'the page is still the same width in the other style')
  await page.click('#docstyle-btn')
  await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs')
  await page.waitForTimeout(250)

  // …and it survives a reload, like zoom and the style itself
  await page.reload({ waitUntil: 'domcontentloaded' })
  await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads')
  await page.waitForTimeout(400)
  const reloaded = await sized()
  assert.strictEqual(reloaded.scale, 1.3, 'text size persists across a reload')
  assert.strictEqual(reloaded.label, '19px', 'and so does the readout')

  // the two knobs do not touch: zoom on top of a text size still freezes the
  // line breaks it was given and magnifies the page whole
  await page.click('#zoom-in')
  await page.click('#zoom-in')
  await page.waitForTimeout(250)
  const zoomedToo = await sized()
  assert.ok(zoomedToo.sheet > reloaded.sheet, `zoom still widens the page (${reloaded.sheet} -> ${zoomedToo.sheet})`)
  assert.strictEqual(zoomedToo.lines, reloaded.lines, 'zoom on top of a text size does not re-break the lines')
  assert.strictEqual(zoomedToo.scale, 1.3, 'and does not disturb the text size')
  await page.click('#zoom-out')
  await page.click('#zoom-out')
  await page.waitForTimeout(250)

  for (let i = 0; i < 3; i++) await page.click('#text-smaller')
  await page.waitForTimeout(250)
  const restored = await sized()
  assert.strictEqual(restored.scale, 1, 'three steps back down returns to 1x')
  assert.strictEqual(restored.size, base.size, `and to the size it started at (${restored.size})`)
  await page.evaluate(from => {
    const e = window.__editor
    e.chain().focus().deleteRange({ from, to: e.state.doc.content.size }).run()
  }, anchor)
  await page.waitForTimeout(300)
  assert.strictEqual(await page.textContent('.tiptap'), beforeText,
    'the measuring paragraph is taken back out, leaving the document as it was')
  console.log('✓ reader text size: page width holds, lines re-break, rides on top of the style, persists')

  // --- the toolbar always fits its row ---
  // Added because it did not. #header-tools scrolls with its scrollbar hidden
  // (app.css: scrollbar-width: none), so a toolbar too wide for its track does
  // not announce itself — it just ends, and the buttons past the edge are gone
  // with nothing on screen saying they exist. Adding the text-size pair took the
  // toolbar from 721px to 818px and pushed the image button off a 1280px window.
  //
  // The width the toolbar needs is not a function of the window width, which is
  // the whole reason this is measured rather than fixed at a breakpoint: the
  // title is up to 260px of the row (#doc-title max-width) and a full-length one
  // moves the point where it stops fitting by ~180px. So the sweep runs at both
  // title lengths, and asserts the header grew a row instead of clipping.
  const fits = () =>
    page.evaluate(() => {
      const bar = document.getElementById('header-tools')
      const track = bar.getBoundingClientRect()
      const within = id => {
        const e = document.getElementById(id)
        if (!e) return null
        const b = e.getBoundingClientRect()
        return b.width > 0 && b.left >= track.left - 1 && b.right <= track.right + 1
      }
      return {
        clipped: Math.max(0, bar.scrollWidth - bar.clientWidth),
        ownRow: document.body.classList.contains('tools-own-row'),
        image: within('image-btn'),
        smaller: within('text-smaller'),
        larger: within('text-larger'),
        label: document.getElementById('docsize-label')?.textContent,
      }
    })
  const LONG_TITLE = 'Quarterly Planning and Roadmap Review Document 2026'
  const shortTitle = await page.textContent('#doc-title')
  for (const title of [shortTitle, LONG_TITLE]) {
    // the title is layout here, not content — set it directly rather than
    // renaming the document out from under every later test
    await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, title)
    for (const width of [1600, 1440, 1366, 1280, 1200, 1100, 1024]) {
      await page.setViewportSize({ width, height: 800 })
      await page.waitForTimeout(200)
      const f = await fits()
      const at = `${width}px, title "${title.slice(0, 18)}"`
      assert.strictEqual(f.clipped, 0, `the toolbar is not cut off at ${at} (${f.clipped}px lost)`)
      assert.ok(f.image, `the image button is reachable at ${at}`)
      assert.ok(f.smaller && f.larger, `both text-size buttons are reachable at ${at}`)
      assert.ok(/^\d+px$/.test(f.label), `the readout is whole, not truncated, at ${at} (got "${f.label}")`)
    }
  }
  // and the way it wins is by taking a row, not by shrinking anything
  await page.setViewportSize({ width: 1280, height: 800 })
  await page.waitForTimeout(250)
  const narrowFit = await fits()
  assert.ok(narrowFit.ownRow, 'at 1280 with a long title the toolbar takes a row of its own')
  const headerGrew = await page.evaluate(() => ({
    topbar: Math.round(document.getElementById('topbar').getBoundingClientRect().height),
    hdrVar: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--hdr-h')),
  }))
  assert.ok(headerGrew.topbar > 52, `the header grew a row rather than clipping (${headerGrew.topbar}px)`)
  assert.ok(Math.abs(headerGrew.hdrVar - headerGrew.topbar) <= 1,
    `--hdr-h follows it, so the drawer and popovers stay under the header (${headerGrew.hdrVar} vs ${headerGrew.topbar})`)
  // a wide window puts it back on one row
  await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, shortTitle)
  await page.setViewportSize({ width: 1440, height: 900 })
  await page.waitForTimeout(250)
  const roomy = await fits()
  assert.ok(!roomy.ownRow, 'and gives the row back when the window can hold it')
  assert.strictEqual(roomy.clipped, 0, 'still nothing clipped at 1440')
  console.log('✓ the toolbar fits its row at every desktop width, or takes a row of its own')

  // --- undo / redo ---
  await focusDocEnd(page)
  await page.waitForTimeout(600)
  await page.keyboard.type(' UNDOME')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'typed marker')
  await page.click('#undo-btn')
  await waitFor(async () => !(await page.textContent('.tiptap')).includes('UNDOME'), 'undo removes marker')
  await page.click('#redo-btn')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'redo restores marker')
  await page.click('#undo-btn')
  console.log('✓ undo / redo buttons')

  await page.screenshot({ path: `${SHOT}/redesign-doc.png`, fullPage: false })

  // "- [ ] " markdown shortcut converts to a real task list (done last so it
  // doesn't perturb the earlier suggestion tests on this doc)
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('- [ ] shortcut task')
  await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), '"- [ ] " creates a task list')
  assert.ok(!(await page.textContent('.tiptap')).includes('[ ] shortcut'), 'no literal bracket text left')
  console.log('✓ "- [ ] " shortcut makes a task list')

  // live HTML embed: insert via toolbar, render in a sandboxed iframe, persist
  await page.click('button[title="Embed live HTML (sandboxed)"]')
  await waitFor(async () => !!(await page.$('.embed-overlay-ta')), 'embed overlay opens')
  await page.fill('.embed-overlay-ta', '<div id="embed-probe" style="height:220px">hello <b>embed</b></div>')
  await page.click('.embed-overlay .btn.primary')
  await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed iframe renders')
  const sandbox = await page.getAttribute('.tiptap iframe.html-embed', 'sandbox')
  assert.ok(/\ballow-scripts\b/.test(sandbox), 'iframe allows scripts')
  assert.ok(!/allow-same-origin/.test(sandbox), 'iframe is NOT same-origin (sandbox intact)')
  // content actually rendered inside the frame
  await waitFor(async () => {
    const fr = page.frames().find(f => f.parentFrame())
    try { return !!(fr && (await fr.$('#embed-probe'))) } catch { return false }
  }, 'embed content mounted inside frame')
  // iframe auto-sizes to content — no stray inner scrollbar (frame not scrollable)
  await waitFor(async () => {
    const fr = page.frames().find(f => f.parentFrame())
    try {
      const m = await fr.evaluate(() => ({ s: document.documentElement.scrollHeight, c: document.documentElement.clientHeight }))
      return m.c >= 200 && m.s <= m.c + 1
    } catch { return false }
  }, 'embed frame fits its content (no vertical scrollbar)')
  // survives a reload (round-trips through Yjs + markdown)
  await page.reload({ waitUntil: 'networkidle' })
  await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed persists after reload')
  console.log('✓ sandboxed HTML embed renders + persists')

  // --- playground: sidebar, strips, chains, image bubble, reset ---
  await page.click('#docs-btn')
  await page.waitForSelector('#docs-pop:not(.hidden)')
  await page.click('#playground-btn')
  await page.waitForURL('**/d/**')
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.$$('.card')).length >= 8, 'playground cards render')
  await page.waitForTimeout(800)

  const treeLinks = await page.$$eval('#page-tree a', as => as.map(a => a.textContent))
  assert.ok(treeLinks.length >= 2, 'sidebar tree has pages: ' + JSON.stringify(treeLinks))
  console.log('✓ playground: sidebar tree')

  // --- REGRESSION: suggestions and comments must work on a SUBPAGE too ---
  // Anchors are relative positions into the page's OWN fragment. The client
  // resolved them against 'default' — the home page's fragment — so on every
  // other page a suggestion's range came back null: the card appeared in the
  // margin, the inline preview never did, and there was nothing to review.
  {
    const target = await page.$$eval('#page-tree a', as => {
      const link = as.find(a => /\/d\/[^/]+\/.+/.test(a.getAttribute('href') || ''))
      return link ? link.getAttribute('href') : null
    })
    assert.ok(target, 'playground has a subpage to open')
    await page.click(`#page-tree a[href="${target}"]`)
    await page.waitForURL(`**${target}`)
    await page.waitForSelector('.tiptap')
    await page.waitForTimeout(600)
    // baseline on THIS page: the home page has highlights of its own
    const before = (await page.$$('.tiptap .comment-hl')).length

    const posted = await page.evaluate(async () => {
      const [id, slug] = location.pathname.split('/d/')[1].split('/')
      const snap = await (await fetch(`/api/docs/${id}?page=${slug}`)).json()
      const idx = (snap.blocks || []).findIndex(b => !/^#/.test(b.markdown || '') && (b.markdown || '').split(/\s+/).length > 3)
      // keep the replacement CLOSE to the original: an unrelated rewrite renders
      // as an insertion panel by design, and it is the in-place word diff that
      // the anchor range is needed for
      const original = snap.blocks[idx].markdown
      const words = original.split(/\s+/)
      const swapped = words.map(w => (w === words[2] ? 'zwitschernd' : w)).join(' ')
      const sugg = await (
        await fetch(`/api/docs/${id}/suggestions`, {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ page: slug, block_index: idx, replacement_markdown: swapped, rationale: 'subpage preview check' }),
        })
      ).json()
      const loose = await (
        await fetch(`/api/docs/${id}/threads`, {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ page: slug, text: 'Page-level note: this belongs to the page, not to one sentence.' }),
        })
      ).json()
      return { slug, idx, sugg, loose, replaced: words[2] }
    })
    assert.ok(posted.idx >= 0 && posted.sugg.suggestion_id, 'suggestion posted on the subpage: ' + JSON.stringify(posted))
    await waitFor(
      async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('zwitschernd'),
      'a subpage suggestion previews inline, not only as a card'
    )
    await waitFor(
      async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes(posted.replaced),
      'the replaced word is struck through on a subpage'
    )

    // an unanchored comment: its own card, marked as page-level, marking no text
    assert.equal(posted.loose.anchored, false, 'thread posted without an anchor: ' + JSON.stringify(posted.loose))
    await waitFor(async () => (await page.$$('.card.thread')).length > 0, 'page-level comment gets a card')
    await page.click('.card.thread')
    await waitFor(async () => !!(await page.$('.card.thread.expanded .page-level')), 'page-level comment says it is about the page')
    assert.equal((await page.$$('.tiptap .comment-hl')).length, before, 'a page-level comment highlights no text')
    console.log('✓ subpages: suggestions preview inline, page-level comments show as their own card')

    // back to the playground home: everything below expects the seeded page
    await page.click(`#page-tree a[href$="/d/${target.split('/d/')[1].split('/')[0]}"]`)
    await page.waitForSelector('.tiptap')
    await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'back on the seeded home page')
  }

  // task lists + tables render natively in the seeded home page
  await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'task list renders with checkboxes')
  await waitFor(async () => !!(await page.$('.tiptap table th')), 'table renders with a header row')
  await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'seeded HTML embed renders in the editor')
  console.log('✓ task lists + tables + HTML embed render in the editor')

  // --- table columns resize by dragging a cell border, and the width sticks ---
  const colWidths = () => page.evaluate(() =>
    [...document.querySelectorAll('.tiptap table:first-of-type tr:first-child > *')]
      .map(c => ({ px: Math.round(c.getBoundingClientRect().width), attr: c.getAttribute('colwidth') })))
  const tableWidth = () => page.evaluate(() =>
    Math.round(document.querySelector('.tiptap table:first-of-type').getBoundingClientRect().width))
  const startCols = await colWidths()
  const startTable = await tableWidth()
  assert.ok(startCols.length >= 2, 'seeded table has columns to resize')
  const border = await page.evaluate(() => {
    const c = document.querySelector('.tiptap table:first-of-type tr:first-child > *')
    const r = c.getBoundingClientRect()
    return { x: r.right, y: r.top + r.height / 2 }
  })
  await page.mouse.move(border.x - 1, border.y)
  await waitFor(async () => !!(await page.$('.tiptap .column-resize-handle')), 'resize handle appears on the border')
  assert.ok(await page.evaluate(() => document.querySelector('.tiptap').classList.contains('resize-cursor')),
    'the editor shows a col-resize cursor while the border is under the pointer')
  await page.mouse.down()
  await page.mouse.move(border.x - 90, border.y, { steps: 10 })
  await page.mouse.up()
  await waitFor(async () => (await colWidths())[0].attr !== null, 'drag writes a colwidth onto the column')
  const narrowedCols = await colWidths()
  assert.ok(narrowedCols[0].px < startCols[0].px - 40, `first column narrowed (${startCols[0].px} -> ${narrowedCols[0].px})`)
  // the last column is deliberately elastic, so the table itself must not grow
  assert.ok(Math.abs(await tableWidth() - startTable) <= 2,
    `table stays pinned to the page width (${startTable} -> ${await tableWidth()})`)
  // and the width is an edit like any other: it survives a reload through the doc
  await page.reload({ waitUntil: 'domcontentloaded' })
  await waitFor(async () => !!(await page.$('.tiptap table')), 'doc reloads with the table')
  await waitFor(async () => (await colWidths())[0].attr !== null, 'column width persisted across the reload')
  const colsAfterReload = await colWidths()
  assert.strictEqual(colsAfterReload[0].attr, narrowedCols[0].attr,
    `same colwidth after reload (${narrowedCols[0].attr} vs ${colsAfterReload[0].attr})`)
  console.log('✓ table columns resize by drag, table width pinned, widths persist')

  // --- the switcher is the whole of navigation now: list, switch, and / resolves ---
  const firstDocUrl = page.url()
  await page.click('#docs-btn')
  await page.waitForSelector('#docs-pop:not(.hidden)')
  await waitFor(async () => (await page.$$('#docs-pop .doc-row')).length >= 2, 'switcher lists the documents')
  const rows = await page.$$eval('#docs-pop .doc-row', rs => rs.map(r => ({
    title: r.querySelector('.title')?.textContent, current: r.classList.contains('current') })))
  assert.strictEqual(rows.filter(r => r.current).length, 1, 'exactly one row is marked as the one you are in: ' + JSON.stringify(rows))
  // switching is a navigation, so the URL is the document — a link worth sharing
  const other = rows.find(r => !r.current)
  await page.click(`#docs-pop .doc-row:not(.current) .doc-row-main`)
  await page.waitForURL(u => u.toString() !== firstDocUrl, { timeout: 15000 })
  await page.waitForSelector('.tiptap', { timeout: 20000 })
  await waitFor(async () => (await page.textContent('#doc-title')).trim() === other.title,
    `header shows the document switched to (${other.title})`)
  assert.ok(await page.isHidden('#docs-pop'), 'the switcher closes behind the navigation')
  console.log('✓ switcher lists documents, marks the current one, switches by navigation')

  // / is no longer a page of its own: it resolves to the most recent document
  const landed = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' })
  assert.ok(/\/d\//.test(page.url()), 'a signed-in visitor at / lands in a document: ' + page.url())
  assert.strictEqual(landed.request().redirectedFrom() ? 302 : landed.status(), landed.request().redirectedFrom() ? 302 : 200)
  await page.waitForSelector('.tiptap', { timeout: 20000 })
  console.log('✓ / resolves to the most recent document, no index page')

  // new-page suggestion: the seeded proposal shows as pending; preview + accept creates it
  await page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } })
  await waitFor(async () => !!(await page.$('#page-tree a.pending-page')), 'proposed page shows in sidebar')
  await page.click('#page-tree a.pending-page')
  await page.waitForSelector('#proposal-view:not(.hidden)')
  await waitFor(async () => !!(await page.$('#proposal-view table')), 'proposal preview renders content')
  await page.click('#proposal-view .proposal-actions .primary')
  await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Proposed Page') && !t.includes('proposed')), 'accepted page joins the tree')
  await waitFor(async () => !(await page.$('#page-tree a.pending-page')), 'no longer pending after accept')
  assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc),
    'opening a page keeps the project collaboration connection')
  console.log('✓ new-page suggestion: pending → preview → accept creates the page')

  // --- a proposed page shows where it would land ---
  // Proposals used to be listed under a "proposed" heading below everything, so
  // an agent's placement was invisible until someone accepted it. They are drawn
  // in the tree at their {parent, index} instead.
  const placed = await page.evaluate(async () => {
    const id = location.pathname.split('/')[2]
    const post = (u, b) => fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json())
    await post(`/api/docs/${id}/structure/groups`, { label: 'Handbook' })
    await post(`/api/docs/${id}/page-suggestions`, { title: 'Filed Proposal', content_markdown: '# Filed\n\nbody', parent: ['"Handbook"'], index: 0 })
    return id
  })
  await page.reload({ waitUntil: 'domcontentloaded' }) // the sidebar polls every 15s; a reload is deterministic
  await page.waitForSelector('#page-tree a', { timeout: 20000 })
  await page.waitForFunction(() => !!window.__provider, { timeout: 20000 })
  // the reload replaced the provider this run compares against later
  await page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } })
  await waitFor(async () => (await page.$$eval('#page-tree .proposal-row a', as => as.map(a => a.textContent))).some(t => t.includes('Filed Proposal')), 'the proposal appears in the tree')
  const proposalShape = await page.evaluate(() => {
    const rows = [...document.querySelectorAll('#page-tree .tree-row')]
    const groupIdx = rows.findIndex(r => r.querySelector('.group-label')?.textContent.trim() === 'Handbook')
    const propIdx = rows.findIndex(r => r.classList.contains('proposal-row') && r.textContent.includes('Filed Proposal'))
    return {
      rightAfterItsGroup: propIdx === groupIdx + 1,
      indentedDeeper: rows[propIdx].getBoundingClientRect().left > rows[groupIdx].getBoundingClientRect().left,
      sectionHeadings: [...document.querySelectorAll('#page-tree .unfiled-label')].map(n => n.textContent.trim()),
      stillClickable: !!rows[propIdx].querySelector('a.pending-page'),
    }
  })
  assert.ok(proposalShape.rightAfterItsGroup, 'the proposal sits directly under the group it was proposed for: ' + JSON.stringify(proposalShape))
  assert.ok(proposalShape.indentedDeeper, 'and is indented as that group\'s child')
  assert.ok(!proposalShape.sectionHeadings.includes('proposed'), 'no separate "proposed" section remains: ' + JSON.stringify(proposalShape.sectionHeadings))
  assert.ok(proposalShape.stillClickable, 'and it still opens its preview')
  console.log('✓ proposed pages render in place in the tree, not in a list below it')

  // --- the open page is marked by its highlight alone ---
  const currentRow = await page.evaluate(() => {
    const row = document.querySelector('#page-tree .page-row:has(a.current)')
    if (!row) return null
    const cs = getComputedStyle(row)
    const alpha = c => Number((c.match(/[\d.]+\)$/) || ['1)'])[0].slice(0, -1))
    return {
      leftRuleVisible: cs.borderLeftStyle !== 'none' && parseFloat(cs.borderLeftWidth) > 0 && alpha(cs.borderLeftColor) > 0,
      hasTint: cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent',
    }
  })
  assert.ok(currentRow, 'the current page has a row in the tree')
  assert.ok(!currentRow.leftRuleVisible, 'no accent rule down the left of the current row: ' + JSON.stringify(currentRow))
  assert.ok(currentRow.hasTint, 'the highlight still marks it: ' + JSON.stringify(currentRow))
  console.log('✓ current page: highlight only, no left accent rule')

  // --- download: page vs project, markdown vs pdf ---
  await page.click('#download-btn')
  await page.waitForSelector('#download-pop:not(.hidden)')
  const dlPanel = await page.evaluate(() => ({
    scope: [...document.querySelectorAll('#dl-scope .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })),
    format: [...document.querySelectorAll('#dl-format .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })),
    action: document.getElementById('dl-go').textContent,
  }))
  assert.deepEqual(dlPanel.scope.map(o => o.label), ['This page', 'Whole project'], 'scope choices: ' + JSON.stringify(dlPanel))
  assert.deepEqual(dlPanel.format.map(o => o.label), ['Markdown', 'PDF'], 'format choices: ' + JSON.stringify(dlPanel))
  assert.ok(dlPanel.scope[0].on && dlPanel.format[0].on, 'defaults to this page as markdown: ' + JSON.stringify(dlPanel))

  // markdown, this page: a real download with the server's filename
  const [pageZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')])
  assert.match(pageZip.suggestedFilename(), /\.zip$/, 'page download is a zip: ' + pageZip.suggestedFilename())
  // markdown, whole project
  await page.click('#download-btn')
  await page.click('#dl-scope .dl-opt:nth-child(2)')
  const [projZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')])
  assert.match(projZip.suggestedFilename(), /-project\.zip$/, 'project download is named for the project: ' + projZip.suggestedFilename())

  // pdf: goes through the browser's print dialog, which the test must stand in for
  await page.evaluate(() => {
    window.__print = { calls: 0, seen: null }
    window.print = () => {
      window.__print.calls++
      window.__print.seen = {
        sheets: document.querySelectorAll('#print-view .print-page').length,
        printingClass: document.body.classList.contains('printing'),
        headings: [...document.querySelectorAll('#print-view h1, #print-view h2')].map(h => h.textContent.trim()).slice(0, 6),
        emptyMath: document.querySelectorAll('#print-view .math-view:empty').length,
      }
    }
  })
  await page.click('#download-btn')
  await page.click('#dl-format .dl-opt:nth-child(2)')
  assert.match(await page.textContent('#dl-go'), /print/i, 'the action says what will happen for PDF')
  await page.click('#dl-go')
  await page.waitForFunction(() => window.__print?.calls > 0, { timeout: 30000 })
  const printed = await page.evaluate(() => window.__print.seen)
  const pageCount = await page.evaluate(() => document.querySelectorAll('#page-tree .page-row:not(.proposal-row) a:not(.ghost-page)').length)
  assert.ok(printed.sheets > 1 && printed.sheets <= pageCount + 1, `every project page gets a sheet (${printed.sheets} sheets, ${pageCount} pages in the tree)`)
  assert.ok(printed.printingClass, 'the print-only body class is on while printing')
  assert.equal(printed.emptyMath, 0, 'formulas are typeset, not left as empty placeholders')

  // and on paper the app itself must not appear
  await page.emulateMedia({ media: 'print' })
  const onPaper = await page.evaluate(() => {
    const painted = id => { const n = document.getElementById(id); if (!n) return false; const r = n.getBoundingClientRect(); return r.width > 0 && r.height > 0 }
    return { topbar: painted('topbar'), sidebar: painted('sidebar'), editor: painted('editor'), printView: painted('print-view') }
  })
  await page.emulateMedia({ media: null })
  assert.ok(onPaper.printView, 'the print rendition is what gets printed: ' + JSON.stringify(onPaper))
  assert.ok(!onPaper.topbar && !onPaper.sidebar && !onPaper.editor, 'no app chrome on the page: ' + JSON.stringify(onPaper))

  // the print view is scaffolding: it must not outlive the dialog
  await page.evaluate(() => window.dispatchEvent(new Event('afterprint')))
  await waitFor(async () => page.evaluate(() => !document.getElementById('print-view') && !document.body.classList.contains('printing')), 'print view is torn down afterwards')
  console.log('✓ download: page/project × markdown/pdf, print shows the document alone')
  // --- the home star must not indent home's title ---
  // top-level real pages only: a nested page or a proposal is indented on purpose
  const titleXs = await page.evaluate(() =>
    [...document.querySelectorAll('#page-tree .page-row[data-depth="0"]:not(.proposal-row)')].map(r => ({
      title: r.querySelector('a')?.textContent.trim(),
      left: Math.round(r.querySelector('a')?.getBoundingClientRect().left),
      star: !!r.querySelector('.home-star'),
    }))
  )
  const starred = titleXs.find(t => t.star)
  assert.ok(starred, 'the front page still carries its star: ' + JSON.stringify(titleXs))
  // and it must not land on the caret when home has children of its own
  const starClash = await page.evaluate(() => {
    const row = document.querySelector('#page-tree .page-row:has(.home-star)')
    const star = row?.querySelector('.home-star')
    const caret = row?.querySelector('.tree-caret')
    if (!star || !caret) return { bothPresent: false }
    const l = n => n.getBoundingClientRect().left
    return { bothPresent: true, apart: Math.abs(l(star) - l(caret)) >= 12 }
  })
  if (starClash.bothPresent) assert.ok(starClash.apart, 'star and caret do not sit on top of each other: ' + JSON.stringify(starClash))
  const others = titleXs.filter(t => !t.star)
  assert.ok(others.length > 0, 'there are sibling pages to compare against: ' + JSON.stringify(titleXs))
  for (const other of others) {
    assert.strictEqual(starred.left, other.left, `"${starred.title}" starts where "${other.title}" does: ` + JSON.stringify(titleXs))
  }
  console.log('✓ the star sits in the gutter; every sidebar title starts at the same x')

  // return to the home page for the remaining playground sub-tests
  await page.evaluate(() => [...document.querySelectorAll('#page-tree a')].find(a => a.textContent.trim() === 'Playground')?.click())
  await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on home with suggestions')
  assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc),
    'returning home keeps the same project collaboration connection')
  console.log('✓ page switches reuse one synced project document')

  // --- search: page text, not just titles -------------------------------------
  // The sidebar box indexes every page of the project out of the one synced Yjs
  // document, so "suggestion" (which is in the prose, not in any title) has to
  // find home, list each occurrence with the heading it sits under, and mark
  // them in the open document.
  const playgroundHome = page.url()
  // hits under one page's row — by default the open one, whose marks we can also
  // count in the document; '*' for every page, since more than one of them
  // mentions "suggestion"
  const hitRows = (scope = 'li:has(> .page-row > a.current)') =>
    page.$$eval(`#page-tree ${scope} > .tree-hits .tree-hit:not(.more)`, rs => rs.map(r => ({
      heading: r.querySelector('.tree-hit-sec')?.textContent || '',
      snippet: r.querySelector('.tree-hit-text')?.textContent || '',
      href: r.getAttribute('href'),
    })))
  await page.fill('#sidebar-search', 'suggestion')
  await waitFor(async () => (await hitRows()).length > 0, 'typing text finds hits inside a page body')
  const suggestionHits = await hitRows()
  const marked = await page.$$eval('#editor mark.search-hl', ms => ms.map(m => m.textContent))
  assert.ok(marked.length >= 5, `every occurrence is marked in the document (${marked.length}): ` + JSON.stringify(marked))
  assert.ok(marked.every(t => t.toLowerCase() === 'suggestion'), 'marks cover the query and nothing else: ' + JSON.stringify(marked))
  // the pill is the page's TRUE count, which is more than the snippets listed
  const trueCount = Number(await page.$eval('#page-tree .page-row:has(a.current) .tree-hit-count', n => n.textContent))
  assert.strictEqual(trueCount, marked.length, `the count on the row is the real number of hits (${trueCount} vs ${marked.length} marks)`)
  assert.ok(suggestionHits.every(h => /suggestion/i.test(h.snippet)), 'each snippet quotes the match in context: ' + JSON.stringify(suggestionHits))
  assert.ok(suggestionHits.some(h => h.heading === 'Checklist') && suggestionHits.some(h => h.heading === 'Table'),
    'each hit is tagged with the heading it sits under: ' + JSON.stringify(suggestionHits.map(h => h.heading)))
  // hits are links: /d/<id>/<page>?q=<term>&n=<hit>, shareable and openable in a tab
  assert.match(suggestionHits[0].href, /\?q=suggestion$/, 'the first hit links to the plain query: ' + suggestionHits[0].href)
  assert.match(suggestionHits[1].href, /\?q=suggestion&n=1$/, 'later hits carry their ordinal: ' + suggestionHits[1].href)
  // Clicking a hit is a place you can come back from, on the open page as much as
  // on another one — typing the query is what must not litter the history, not
  // deciding to go and look at something.
  await page.click('#page-tree .tree-hits .tree-hit:nth-child(2)')
  await page.waitForURL(u => /\?q=suggestion&n=1$/.test(u.toString()), { timeout: 15000 })
  await page.evaluate(() => history.back())
  await page.waitForURL(u => /\?q=suggestion$/.test(u.toString()), { timeout: 15000 })
  assert.ok(await page.$('#editor mark.search-hl'), 'going back from a hit keeps the search running')
  // a common word overruns the per-page snippet list, and the ones it did not
  // print are still counted — the pill and the "+n more" row have to add up
  await page.fill('#sidebar-search', 'the')
  await waitFor(async () => !!(await page.$('#page-tree .tree-hit.more')), 'a common word overruns the snippet list')
  const capped = await page.evaluate(() => {
    const li = document.querySelector('#page-tree li:has(> .page-row > a.current)')
    return {
      count: Number(li.querySelector('.tree-hit-count').textContent),
      listed: li.querySelectorAll(':scope > .tree-hits .tree-hit:not(.more)').length,
      more: li.querySelector(':scope > .tree-hits .tree-hit.more').textContent,
      marks: document.querySelectorAll('#editor mark.search-hl').length,
    }
  })
  assert.ok(capped.listed < capped.count, `the snippet list is capped (${capped.listed}) where the count is not (${capped.count})`)
  assert.strictEqual(capped.count, capped.marks, 'the count still matches what is marked in the document: ' + JSON.stringify(capped))
  assert.strictEqual(capped.more, `+${capped.count - capped.listed} more matches`, 'the unlisted hits are accounted for: ' + JSON.stringify(capped))
  // Two occurrences in one sentence produce the same excerpt, and each must bold
  // its OWN one — re-finding the query inside the excerpt would bold the first
  // occurrence twice and send the second hit to the first hit's words.
  const bolded = await page.$$eval('#page-tree .tree-hit-text', ts => ts.map(t => ({
    text: t.textContent,
    at: t.firstChild.textContent.length, // the excerpt is [before, <b>, after]
  })))
  const twins = bolded.filter(a => bolded.some(b => b !== a && b.text === a.text))
  assert.ok(twins.length >= 2, 'the fixture has two hits sharing one excerpt: ' + JSON.stringify(bolded.map(b => b.text)))
  assert.ok(new Set(twins.map(t => t.at)).size >= 2, 'they bold their own occurrence, not the same one twice: ' + JSON.stringify(twins))
  console.log('✓ search: body hits with per-hit headings, true counts, in-document marks')

  // a title match bolds the matched part of the title and stays quiet otherwise:
  // there is no body hit to list for it
  await page.fill('#sidebar-search', 'note')
  await waitFor(async () => !!(await page.$('#page-tree .page-row a b')), 'a title query bolds what it matched')
  const titleMatches = await page.$$eval('#page-tree .page-row a:has(b)', as => as.map(a => ({
    title: a.querySelector('.tree-title')?.textContent,
    bold: a.querySelector('b')?.textContent,
  })))
  assert.ok(titleMatches.some(m => m.title === 'Notes' && m.bold === 'Note'),
    'the matched part of the title is bolded: ' + JSON.stringify(titleMatches))

  // a query that only exists on another page pulls that page up, and clicking the
  // hit navigates there and scrolls to it
  await page.fill('#sidebar-search', 'defines the tree')
  await waitFor(async () => (await hitRows('*')).length === 1, 'a phrase only on the Notes page finds only that page')
  assert.deepStrictEqual(
    await page.$$eval('#page-tree .page-row a .tree-title', ts => ts.map(t => t.textContent.trim())),
    ['Notes'],
    'the tree is filtered down to the page that contains the phrase'
  )
  await page.click('#page-tree .tree-hit')
  await page.waitForURL(u => /\/notes\?q=/.test(u.toString()), { timeout: 15000 })
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'the hit is marked on the page it navigated to')
  const onScreen = await page.evaluate(() => {
    const m = document.querySelector('#editor mark.search-hl')
    const r = m.getBoundingClientRect()
    return r.top > 0 && r.bottom < window.innerHeight
  })
  assert.ok(onScreen, 'the clicked hit is scrolled into view')
  console.log('✓ search: clicking a hit on another page navigates and lands on it')

  // ?q= is a deep link: it starts the search, marks the document and jumps to the
  // named hit — this is what the hrefs above resolve to for someone you send one to
  await page.goto(`${playgroundHome}?q=greet`, { waitUntil: 'domcontentloaded' })
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.inputValue('#sidebar-search')) === 'greet', 'a ?q= link arrives with the search box filled in')
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a ?q= link marks the document once it has synced')
  // the hit list can only fill in once the socket has synced the text, which on a
  // cold load lands after the shell has already painted the tree
  await waitFor(async () => JSON.stringify((await hitRows()).map(h => h.heading)) === '["Code"]',
    'the deep-linked hit is listed under its heading')
  await waitFor(
    async () => page.evaluate(() => {
      const r = document.querySelector('#editor mark.search-hl').getBoundingClientRect()
      return r.top > 0 && r.bottom < window.innerHeight
    }),
    'a ?q= link scrolls its first hit into view'
  )

  // `n=` is whatever the URL said, so it can name a hit that does not exist. That
  // must not leave the reader on an unscrolled page while the frame budget drains
  // (~10s of it) — once the marks stop arriving, the last hit is the answer.
  await page.goto(`${playgroundHome}?q=greet&n=99999`, { waitUntil: 'domcontentloaded' })
  await page.waitForSelector('.tiptap')
  await waitFor(
    async () => page.evaluate(() => {
      const ms = document.querySelectorAll('#editor mark.search-hl')
      if (!ms.length) return false
      const r = ms[ms.length - 1].getBoundingClientRect()
      return r.top > 0 && r.bottom < window.innerHeight
    }),
    'an out-of-range n= settles on the last hit instead of spinning',
    6000
  )

  // The index is the live CRDT, not a snapshot the server took: text typed now is
  // searchable now. Done on Notes, which carries none of home's seeded
  // suggestions — typing into a block one of those is anchored to is a different
  // test, and not this one's business.
  await page.goto(`${playgroundHome}/notes`, { waitUntil: 'domcontentloaded' })
  await page.waitForSelector('.tiptap')
  await page.fill('#sidebar-search', 'quokka')
  await waitFor(async () => !!(await page.$('#page-tree .tree-empty')), 'a query that matches nothing says so instead of emptying the sidebar')
  await page.evaluate(() => window.__editor.chain().focus('end').insertContent(' quokka ').run())
  await waitFor(async () => (await hitRows()).length === 1, 'text typed while the search is running becomes a hit')
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'and is marked as it is typed')
  await page.evaluate(() => window.__editor.commands.undo())
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'undoing the text takes its hit away again')

  // The invariant both walks rest on: a non-text inline node counts as exactly
  // one character, because that is what it costs in a ProseMirror position. Get
  // that wrong and every mark after an atom in the same block slides left by one
  // per atom — so this paragraph puts a mark AND a formula ahead of the hit.
  // Nothing in the seeded fixture does (its images are their own blocks), which
  // is why this is built here rather than searched for. Built as explicit JSON
  // because the markdown seeder does not parse inline `$…$` — it survives as
  // literal text, and literal text would not test anything: a marked text node
  // still costs one position per character, so only a real atom bites.
  await page.evaluate(() =>
    window.__editor
      .chain()
      .focus('end')
      .insertContent({
        type: 'paragraph',
        content: [
          { type: 'text', marks: [{ type: 'bold' }], text: 'Bold' },
          { type: 'text', text: ' and ' },
          { type: 'mathInline', attrs: { latex: 'x^2' } },
          { type: 'text', text: ' then wombat here, but wom' },
          { type: 'mathInline', attrs: { latex: 'y' } },
          { type: 'text', text: 'bat is two words with a formula in it.' },
        ],
      })
      .run()
  )
  await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 })
  await page.fill('#sidebar-search', 'wombat')
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a hit sitting after an atom is still marked')
  // Two things at once, and each fails on a different slip. The count: a formula
  // interrupts a word, so `wom`+formula+`bat` is NOT the query — count the atom
  // as zero characters and it becomes a spurious hit the sidebar never counted.
  // The text: the mark has to land ON the query, which is what the position
  // arithmetic after two atoms is for.
  const searchAtomHit = await page.evaluate(() => ({
    marks: [...document.querySelectorAll('#editor mark.search-hl')].map(m => m.textContent),
    pill: Number(document.querySelector('#page-tree .page-row:has(a.current) .tree-hit-count')?.textContent || 1),
  }))
  assert.deepStrictEqual(searchAtomHit.marks, ['wombat'],
    'an atom is one unmatchable character: the query is marked once, exactly, and does not span a formula: ' + JSON.stringify(searchAtomHit))
  assert.strictEqual(searchAtomHit.pill, searchAtomHit.marks.length,
    'and the sidebar counted the same hits the document marked: ' + JSON.stringify(searchAtomHit))
  // put the page back for the sub-tests after this one (seeded text predates this
  // session, so it is not in the undo history and cannot be walked back by mistake)
  await page.evaluate(() => { for (let i = 0; i < 60; i++) window.__editor.commands.undo() })
  await waitFor(async () => !(await page.$('.tiptap .math-inline')), 'the probe paragraph is undone again')
  assert.ok(!(await page.textContent('.tiptap')).includes('wombat'), 'and takes its text with it')

  // clearing puts everything back, including drag-and-drop
  await page.click('#sidebar-search-key')
  await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'clearing the box drops the marks')
  assert.strictEqual(await page.inputValue('#sidebar-search'), '', 'the ✕ badge empties the box')
  assert.ok(!/\?q=/.test(page.url()), 'and takes ?q= out of the URL: ' + page.url())
  assert.ok(await page.evaluate(() => document.querySelectorAll('#page-tree [data-td-row]').length > 0), 'rows are draggable again')
  console.log('✓ search: ?q= deep links, live index, empty state, clear restores the tree')

  // back where the rest of the playground sub-tests expect to be
  await page.goto(playgroundHome, { waitUntil: 'domcontentloaded' })
  await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on the playground home page')

  // revision chain: ‹ 2/3 shows generation two inline
  assert.ok(await page.$('.chain-nav'), 'chain nav present')
  assert.equal(await page.textContent('.chain-pos'), '3/3', 'chain defaults to latest')
  await page.click('.chain-nav .iconbtn:first-child')
  await page.waitForTimeout(400)
  assert.equal(await page.textContent('.chain-pos'), '2/3', 'nav moves to generation 2')
  const gen2Text = await page.textContent('.tiptap')
  assert.ok(gen2Text.includes('generation') && gen2Text.includes('sharper'), 'inline diff follows the displayed generation')
  await page.click('.chain-nav .iconbtn:last-child')
  await page.waitForTimeout(300)
  console.log('✓ revision chain navigation ‹ n/N ›')

  // Feedback derived from one request stays as standalone cards. Focusing the
  // source comment highlights every related card and its inline change.
  const family = await page.evaluate(() => {
    const origin = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('Can we end on something stronger?'))
    const related = origin && document.querySelector(`.card.suggestion[data-origin="${origin.dataset.item}"]`)
    return { origin: origin?.dataset.item || null, related: related?.dataset.item || null }
  })
  assert.ok(family.origin && family.related, 'origin and related suggestion are separate cards: ' + JSON.stringify(family))
  const relatedCardLayout = await page.evaluate(id => {
    const card = document.querySelector(`.card.suggestion[data-item="${id}"]`)
    const head = card.querySelector('.head')
    const cardRect = card.getBoundingClientRect()
    const actionsRect = card.querySelector('.head-actions').getBoundingClientRect()
    return {
      head: [head.clientWidth, head.scrollWidth],
      cardRight: Math.round(cardRect.right),
      actionsRight: Math.round(actionsRect.right),
    }
  }, family.related)
  assert.ok(relatedCardLayout.head[1] <= relatedCardLayout.head[0] + 1, 'suggestion header does not overflow: ' + JSON.stringify(relatedCardLayout))
  assert.ok(relatedCardLayout.actionsRight <= relatedCardLayout.cardRight, 'suggestion actions remain inside the card: ' + JSON.stringify(relatedCardLayout))
  await page.click(`.card.thread[data-item="${family.origin}"]`)
  await waitFor(async () => !!(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`)), 'related suggestion highlighted from origin')
  assert.ok(await page.$(`.tiptap .suggestion-hl[data-sugg="${family.related}"].active`), 'related inline change highlighted from origin')

  // Closing the source must not sever the trail. The derived card names the
  // closed source and can reveal/focus it again through the existing resolved
  // items view.
  await page.click(`.card.thread[data-item="${family.origin}"] .close-btn`)
  await waitFor(async () => await page.evaluate(id => {
    const card = document.querySelector(`.card.thread[data-item="${id}"]`)
    return !card || card.textContent.includes('closed')
  }, family.origin), 'origin closes')
  await waitFor(async () => (await page.textContent(`.card.suggestion[data-item="${family.related}"] .origin-link`)).includes('closed'), 'derived card identifies its closed origin')
  await page.click(`.card.suggestion[data-item="${family.related}"] .origin-link`)
  await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"].expanded`)), 'closed origin revealed and focused')
  assert.ok(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`), 'related card remains highlighted for a closed origin')
  // Restore the seeded source so the independent close-button regression below
  // still has an open comment to exercise.
  await page.evaluate(id => window.__ydoc.getMap('threads').get(id).set('resolved', false), family.origin)
  await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"] .close-btn`)), 'origin restored for remaining playground checks')

  await page.click(`.card.suggestion[data-item="${family.related}"] .accept-btn`)
  await waitFor(async () => (await page.textContent('.tiptap')).includes('go break things'), 'standalone accept applies')
  console.log('✓ standalone feedback cards retain and highlight their common origin')

  // comment closable without opening the card
  assert.equal(await page.textContent('.card.thread .close-btn'), 'Close', 'collapsed thread has a Close button')
  await page.click('.card.thread .close-btn')
  await waitFor(async () => !(await page.$('.card.thread:not(.expanded) .close-btn')), 'thread closed from collapsed head')
  console.log('✓ comment closes without opening')

  // image bubble: width + alignment
  const img = await page.$('.tiptap img:not(.ProseMirror-separator)')
  await img.scrollIntoViewIfNeeded()
  await img.click()
  await page.waitForTimeout(400)
  assert.ok(await page.evaluate(() => !document.getElementById('image-bubble').classList.contains('hidden')), 'bubble appears on image selection')
  for (const title of ['Width 50%', 'Align center']) {
    await page.evaluate(t => {
      ;[...document.querySelectorAll('#image-bubble .iconbtn')].find(b => b.title === t).dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
    }, title)
    await page.waitForTimeout(300)
  }
  const imgAttrs = await page.evaluate(() => {
    const i = document.querySelector('.tiptap img:not(.ProseMirror-separator)')
    return { style: i.getAttribute('style') || '', align: i.getAttribute('data-align') }
  })
  assert.ok(imgAttrs.style.includes('50%') && imgAttrs.align === 'center', 'image resized + centered: ' + JSON.stringify(imgAttrs))
  console.log('✓ image layout bubble (resize + align)')

  // the structure page left the sidebar but stays reachable by URL
  assert.ok(!(await page.$('#structure-link')), 'no structure link in the sidebar')
  {
    const docPath = await page.evaluate(() => location.pathname.split('/').slice(0, 3).join('/'))
    await page.goto(BASE + docPath + '/_structure')
    await page.waitForSelector('#structure-hint', { timeout: 8000 })
    await page.goBack()
    await page.waitForSelector('.tiptap', { timeout: 8000 })
  }

  // [[ cross-reference: autocomplete inserts a page link
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('See also [[not')
  await waitFor(async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))), 'pagelink menu appears')
  await page.keyboard.press('Enter')
  await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
  console.log('✓ [[ page cross-references')

  // --- a link you can actually click ---
  // In an editor a plain click has to place the caret, so following a link
  // needed ctrl/cmd+click — with nothing on screen saying so, a cross-reference
  // just looked broken. A plain click now raises a bubble naming the target,
  // with an Open button for a mouse to press.
  const linkSel = '.tiptap a[href$="/notes"]'
  await page.click(linkSel)
  await waitFor(async () => !(await page.evaluate(() => document.getElementById('link-bubble').classList.contains('hidden'))), 'a plain click raises the link bubble')
  const bubble = await page.evaluate(() => {
    const b = document.getElementById('link-bubble')
    const link = document.querySelector('.tiptap a[href$="/notes"]').getBoundingClientRect()
    const box = b.getBoundingClientRect()
    return {
      target: b.querySelector('.lb-target').textContent,
      actions: [...b.querySelectorAll('button')].map(x => x.textContent.trim() || x.dataset.tip),
      nearTheLink: Math.abs(box.left - link.left) < 80 && box.top > link.top,
      onScreen: box.left >= 0 && box.right <= innerWidth && box.bottom <= innerHeight,
    }
  })
  assert.strictEqual(bubble.target, 'Notes', 'the bubble names the page it goes to, not a raw URL')
  assert.ok(bubble.actions.includes('Open'), 'and offers Open: ' + JSON.stringify(bubble.actions))
  assert.ok(bubble.nearTheLink && bubble.onScreen, 'anchored to the link, inside the window: ' + JSON.stringify(bubble))
  const urlBeforeOpen = page.url()
  await page.click('#link-bubble .btn')
  await waitFor(async () => page.url().endsWith('/notes'), 'Open follows the link')
  assert.notStrictEqual(page.url(), urlBeforeOpen, 'and it actually moved')

  // ctrl+click must keep working for anyone who already knows it
  await page.goBack()
  await page.waitForSelector('.tiptap', { timeout: 10000 })
  await page.waitForTimeout(800)
  await page.click(linkSel, { modifiers: ['Control'] })
  await waitFor(async () => page.url().endsWith('/notes'), 'ctrl+click still goes straight there')
  await page.goBack()
  await page.waitForSelector('.tiptap', { timeout: 10000 })
  await page.waitForTimeout(800)
  console.log('✓ link bubble: a plain click offers Open, ctrl+click still follows')

  // --- what you are commenting on stays marked while you type about it ---
  // Focusing the composer moves focus out of the editor, which drops the
  // browser's own selection highlight: the text you picked went unmarked while
  // the box asked you about it.
  await focusDocEnd(page)
  await page.keyboard.press('Enter')
  await page.keyboard.type('A line worth discussing at some length.')
  await page.waitForTimeout(400)
  const pickLine = () => page.evaluate(() => {
    const target = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('A line worth discussing'))
    const pos = window.__editor.view.posAtDOM(target, 0)
    window.__editor.commands.setTextSelection({ from: pos, to: pos + target.textContent.length })
  })
  await pickLine()
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  await page.click('#composer-text')
  await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 1), 'the pending range is marked once the composer has focus')
  const pending = await page.evaluate(() => {
    const n = document.querySelector('.tiptap .pending-hl')
    const cs = getComputedStyle(n)
    return { text: n.textContent, bg: cs.backgroundColor, style: cs.borderBottomStyle, nativeSelectionGone: getSelection().isCollapsed }
  })
  assert.ok(pending.text.startsWith('A line worth discussing'), 'it marks the text the composer is about: ' + JSON.stringify(pending))
  assert.notStrictEqual(pending.bg, 'rgba(0, 0, 0, 0)', 'and it is actually visible: ' + JSON.stringify(pending))
  assert.strictEqual(pending.style, 'dashed', 'dashed, to read as not-yet-committed: ' + JSON.stringify(pending))

  // and it is scaffolding: cancelling must not leave it behind
  await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
  await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 0), 'cancelling clears the mark')
  console.log('✓ composer marks the text it is about, and clears it on cancel')

  // --- a comment and a suggestion on the same words ---
  // Two filled highlights over one range cancelled each other out: the
  // suggestion's insert/delete spans were invisible under the comment tint.
  // the box opens when the selection CHANGES, so move the caret before picking
  // the same line again
  await focusDocEnd(page)
  await page.waitForTimeout(200)
  await pickLine()
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  await page.fill('#composer-text', 'can this be tightened?')
  await page.click('#composer .primary, #composer button:has-text("Comment")')
  await page.waitForSelector('.card.thread', { timeout: 10000 })
  await page.click('.card.thread') // active, as it is right after commenting
  await page.waitForTimeout(300)
  await page.evaluate(async () => {
    const id = location.pathname.split('/d/')[1].split('/')[0]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('A line worth discussing'))
    await fetch(`/api/docs/${id}/suggestions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ block_index: idx, replacement_markdown: 'A line worth debating at some length.', rationale: 'sharper verb' }),
    })
  })
  await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .sugg-ins').length > 0), 'the suggestion reaches the document')
  const overlap = await page.evaluate(() => {
    const opaque = c => c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent'
    const commentHl = [...document.querySelectorAll('.tiptap .comment-hl')]
    const ins = [...document.querySelectorAll('.tiptap .sugg-ins')]
    return {
      commentMarked: commentHl.every(n => n.classList.contains('with-sugg')),
      commentUnderlined: commentHl.every(n => ['dotted', 'dashed', 'solid'].includes(getComputedStyle(n).borderBottomStyle)),
      commentFilled: commentHl.some(n => opaque(getComputedStyle(n).backgroundColor)),
      insFilled: ins.length > 0 && ins.every(n => opaque(getComputedStyle(n).backgroundColor)),
      insText: ins.map(n => n.textContent.trim()),
    }
  })
  assert.ok(overlap.commentMarked, 'the comment knows it is sharing text with a suggestion: ' + JSON.stringify(overlap))
  assert.ok(!overlap.commentFilled, 'so it gives up its fill: ' + JSON.stringify(overlap))
  assert.ok(overlap.commentUnderlined, 'but stays marked by its underline: ' + JSON.stringify(overlap))
  assert.ok(overlap.insFilled, 'and the inserted words keep a visible fill of their own: ' + JSON.stringify(overlap))
  console.log('✓ a suggestion stays visible on text that already carries a comment')

  // --- hover the right of the page: a comment anchored to a place, not a span ---
  // The Google-Docs affordance. Everything here is about it staying out of the
  // way: no gutter, nothing visible until the pointer is in the right third,
  // and nothing at all while a selection owns the comment flow.
  // Clear the margin first. Cards are stacked, never overlapped: a card whose
  // anchor is already occupied gets pushed down the panel, and the alignment this
  // feature promises could not be measured through the pile the tests above left.
  // (The playground reset further down puts the seeded cards back.)
  await waitFor(
    async () =>
      (await page.evaluate(() => {
        document.querySelectorAll('#margin-items .close-btn, #margin-items .reject-btn').forEach(b => b.click())
        return document.querySelectorAll('#margin-items .card').length
      })) === 0,
    'margin cleared for the hover checks',
    20000
  )
  // appended through the editor rather than typed, so it is definitely the LAST
  // block on the page — which is what makes the deletion case below deterministic
  await page.evaluate(() => {
    const e = window.__editor
    e.commands.insertContentAt(e.state.doc.content.size, {
      type: 'paragraph',
      content: [{ type: 'text', text: 'Bees navigate by polarised light, which is the part nobody expects.' }],
    })
  })
  await page.waitForTimeout(400)
  const hlBefore = (await page.$$('.tiptap .comment-hl')).length

  // measure fresh each time — typing at the end of the document scrolls
  const beeGeom = () =>
    page.evaluate(() => {
      const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
      p.scrollIntoView({ block: 'center' })
      const r = p.getBoundingClientRect()
      const sheet = document.getElementById('editor').getBoundingClientRect()
      const text = document.querySelector('.tiptap').getBoundingClientRect()
      return { top: r.top, mid: r.top + r.height / 2, sheet, textRight: text.right }
    })
  let g = await beeGeom()
  const hoverBtn = () =>
    page.evaluate(() => {
      const b = document.getElementById('hover-comment')
      const r = b.getBoundingClientRect()
      return { hidden: b.classList.contains('hidden'), left: r.left, right: r.right, mid: r.top + r.height / 2 }
    })

  // arming the zone must not move the page: the button lives in the page's own
  // right margin, absolutely positioned, so nothing reflows
  const widthBefore = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width)
  await page.mouse.move(g.sheet.left + g.sheet.width * 0.2, g.mid)
  await page.waitForTimeout(120)
  assert.ok((await hoverBtn()).hidden, 'no button while the pointer is in the left two thirds')

  await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'the right third reveals the comment button')
  const btn = await hoverBtn()
  const widthAfter = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width)
  assert.equal(widthAfter, widthBefore, 'revealing it does not add a gutter')
  assert.ok(btn.left > g.textRight, `it sits out in the page margin, clear of the text (${btn.left} > ${g.textRight})`)
  assert.ok(btn.right < g.sheet.right, 'and still on the paper, not off its edge')
  assert.ok(Math.abs(btn.mid - (g.top + 9)) < 14, `level with the paragraph it is beside (btn ${btn.mid}, para top ${g.top})`)

  // A live selection owns the comment flow, and TWO separate rules enforce that.
  // They have to be checked separately: the button is armed by a real pointer move
  // above, and then nothing but the selection changes, so the first assertion can
  // only be satisfied by the selectionUpdate drop in bindSelectionMenu. Dispatching
  // a mousemove first — which is what this test used to do — re-enters
  // updateHoverComment and is caught by its own empty-selection bail instead, so
  // the keyboard rule went uncovered and deleting it left the suite green.
  // Both run inside the 220ms settle, before the selection composer opens, so
  // neither is really testing "a box is open".
  const hoverSelRules = await page.evaluate(async () => {
    const btnHidden = () => document.getElementById('hover-comment').classList.contains('hidden')
    const frame = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
    const armed = !btnHidden() // the pointer move above really did arm it
    // rule 1: a keyboard selection, with no pointer event anywhere near it
    const pos = window.__editor.view.posAtDOM(p, 0)
    window.__editor.commands.setTextSelection({ from: pos, to: pos + 12 })
    await frame()
    const afterKeyboardSelection = btnHidden()
    // rule 2: a pointer event arriving while that selection still stands must not
    // re-arm it — the drag that ends in the right third
    const sheet = document.getElementById('editor').getBoundingClientRect()
    const r = p.getBoundingClientRect()
    document.getElementById('editor-col').dispatchEvent(
      new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: r.top + r.height / 2, bubbles: true })
    )
    await frame()
    return {
      armed,
      afterKeyboardSelection,
      afterPointerDuringSelection: btnHidden(),
      composerOpen: !document.getElementById('composer').classList.contains('hidden'),
    }
  })
  assert.ok(hoverSelRules.armed, 'the pointer had really armed the button first: ' + JSON.stringify(hoverSelRules))
  assert.ok(hoverSelRules.afterKeyboardSelection, 'a keyboard selection drops it with no pointer event to notice: ' + JSON.stringify(hoverSelRules))
  assert.ok(hoverSelRules.afterPointerDuringSelection, 'and a pointer move cannot re-arm it while the selection stands: ' + JSON.stringify(hoverSelRules))
  // and it stays away over the box that selection opens
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  g = await beeGeom()
  await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
  await page.waitForTimeout(150)
  assert.ok((await hoverBtn()).hidden, 'and hidden while the selection composer is open')
  await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
  await focusDocEnd(page)
  await page.waitForTimeout(250)

  // click it: a composer with a locator instead of a quote, and the paragraph
  // marked as where the comment is about to land
  g = await beeGeom()
  await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'button back after the selection is gone')
  await page.click('#hover-comment')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  const opened = await page.evaluate(() => {
    const mark = document.querySelector('.tiptap .comment-anchor.pending')
    return {
      quoteHidden: document.getElementById('composer-quote').classList.contains('hidden'),
      loc: document.getElementById('composer-loc').textContent,
      locShown: !document.getElementById('composer-loc').classList.contains('hidden'),
      markText: mark?.textContent || null,
      pendingSpans: document.querySelectorAll('.tiptap .pending-hl').length,
      composerTop: document.getElementById('composer').getBoundingClientRect().top,
    }
  })
  assert.ok(opened.quoteHidden, 'no blockquote: there is nothing quoted: ' + JSON.stringify(opened))
  assert.ok(opened.locShown && opened.loc.includes('Bees navigate'), 'the locator says where instead: ' + JSON.stringify(opened))
  assert.equal(opened.pendingSpans, 0, 'and no span is underlined as if the words were the subject')
  assert.ok(opened.markText?.startsWith('Bees navigate'), 'the paragraph it will land beside is marked: ' + JSON.stringify(opened))
  assert.ok(Math.abs(opened.composerTop - g.top) < 40, `the box opens level with that paragraph (${opened.composerTop} vs ${g.top})`)

  await page.fill('#composer-text', 'Worth a footnote here?')
  await page.click('#composer .primary, #composer button:has-text("Comment")')
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'a position-anchored card lands in the margin')
  const posted = await page.evaluate(async () => {
    const card = document.querySelector('.card.thread .anchor-loc').closest('.card')
    const id = location.pathname.split('/d/')[1].split('/')[0]
    const snap = await (await fetch(`/api/docs/${id}`)).json()
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
    return {
      loc: card.querySelector('.anchor-loc').textContent,
      quotes: card.querySelectorAll('blockquote').length,
      orphaned: card.querySelector('.anchor-loc').classList.contains('orphaned'),
      cardTop: card.getBoundingClientRect().top,
      paraTop: p.getBoundingClientRect().top,
      activeMark: document.querySelector('.tiptap .comment-anchor.active')?.textContent || null,
      hls: document.querySelectorAll('.tiptap .comment-hl').length,
      thread: (snap.threads || []).find(t => t.anchor_kind === 'position') || null,
    }
  })
  assert.ok(posted.loc.includes('Bees navigate'), 'the card shows the paragraph it sits beside: ' + JSON.stringify(posted))
  assert.equal(posted.quotes, 0, 'and quotes nothing')
  assert.ok(!posted.orphaned, 'the paragraph is still there, so it is not orphaned')
  assert.ok(Math.abs(posted.cardTop - posted.paraTop) < 40, `the card sits next to that paragraph (${posted.cardTop} vs ${posted.paraTop})`)
  assert.ok(posted.activeMark?.startsWith('Bees navigate'), 'the open card marks its paragraph: ' + JSON.stringify(posted))
  assert.equal(posted.hls, hlBefore, 'and adds no comment underline — nothing here is about those words')
  assert.ok(posted.thread, 'the API reports the anchor kind: ' + JSON.stringify(posted.thread))
  assert.equal(posted.thread.excerpt, null, 'excerpt stays null — no words are quoted')
  assert.ok(posted.thread.anchor_context.startsWith('Bees navigate'), 'anchor_context carries the place: ' + JSON.stringify(posted.thread))
  console.log('✓ hover comment: right third only, anchored to a place, quotes nothing')

  // --- the anchored paragraph is deleted ---
  // Kept and never auto-closed: deleting text must not silently end a conversation
  // about it. The anchor resolves to the gap the paragraph left, which is not
  // inside any block, so the card reads as orphaned — and because that gap is
  // still where the paragraph was, it holds its row in the panel rather than
  // dropping to the bottom.
  const paraTopBefore = posted.paraTop
  await page.evaluate(() => {
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
    const pos = window.__editor.view.posAtDOM(p, 0)
    const $p = window.__editor.state.doc.resolve(pos)
    window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth)))
  })
  // the editor, not the body: the card's locator still carries those words, which
  // is the whole point of the assertions below
  await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Bees navigate by polarised')), 'the paragraph is gone')
  await waitFor(
    async () => page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.classList.contains('orphaned') === true),
    'the card notices its paragraph was deleted'
  )
  const afterDelete = await page.evaluate(() => {
    const loc = document.querySelector('.card.thread .anchor-loc')
    return {
      words: loc.textContent,
      struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine,
      title: loc.title,
      closed: loc.closest('.card').textContent.includes('closed'),
      cardTop: loc.closest('.card').getBoundingClientRect().top,
      marks: document.querySelectorAll('.tiptap .comment-anchor').length,
    }
  })
  assert.ok(afterDelete.words.includes('Bees navigate'), 'the words are all that is left of the place, so they stay: ' + JSON.stringify(afterDelete))
  assert.equal(afterDelete.struck, 'line-through', 'struck through to say the paragraph is gone')
  assert.ok(/deleted/.test(afterDelete.title), 'and it says so: ' + JSON.stringify(afterDelete))
  assert.ok(!afterDelete.closed, 'the thread is NOT auto-closed')
  assert.equal(afterDelete.marks, 0, 'nothing in the document is marked any more: ' + JSON.stringify(afterDelete))
  assert.ok(Math.abs(afterDelete.cardTop - paraTopBefore) < 90, `the card stays where the paragraph was (${afterDelete.cardTop} vs ${paraTopBefore})`)
  // close only this one, so the margin the later checks see is the one they left
  await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'an orphaned thread still closes normally')
  console.log('✓ a deleted paragraph orphans its position comment, keeps it, and keeps its place')

  // Same again with content AFTER the anchored paragraph, which is the case that
  // could plausibly differ (y-prosemirror's diff is free to reuse a surviving
  // sibling element instead of deleting one, and then the anchor would still be
  // inside a live block). Measured, it does not differ here — but rather than
  // assert a branch of someone else's diff, the assertion below is the invariant
  // that has to hold whichever branch runs: the card reads as orphaned exactly
  // when the words really are gone from the page.
  await page.evaluate(() => {
    const e = window.__editor
    e.commands.insertContentAt(e.state.doc.content.size, {
      type: 'doc',
      content: [
        { type: 'paragraph', content: [{ type: 'text', text: 'Wasps, by contrast, remember faces for weeks on end.' }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'Trailing paragraph so the one above is not the last.' }] },
      ],
    })
  })
  await page.waitForTimeout(400)
  const waspGeom = () =>
    page.evaluate(() => {
      const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast'))
      p.scrollIntoView({ block: 'center' })
      const r = p.getBoundingClientRect()
      const sheet = document.getElementById('editor').getBoundingClientRect()
      return { top: r.top, mid: r.top + r.height / 2, sheet }
    })
  const wg = await waspGeom()
  await page.mouse.move(wg.sheet.left + wg.sheet.width * 0.85, wg.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the middle paragraph')
  await page.click('#hover-comment')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  await page.fill('#composer-text', 'Source for this?')
  await page.click('#composer .primary, #composer button:has-text("Comment")')
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'second position comment posted')
  const waspTopBefore = (await waspGeom()).top
  await page.evaluate(() => {
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast'))
    const pos = window.__editor.view.posAtDOM(p, 0)
    const $p = window.__editor.state.doc.resolve(pos)
    window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth)))
  })
  await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Wasps, by contrast')), 'the middle paragraph is gone')
  await page.waitForTimeout(400)
  const midDelete = await page.evaluate(() => {
    const loc = document.querySelector('.card.thread .anchor-loc')
    if (!loc) return null
    return {
      words: loc.textContent,
      orphaned: loc.classList.contains('orphaned'),
      docHasWords: document.querySelector('.tiptap').textContent.includes('Wasps, by contrast'),
      closed: loc.closest('.card').textContent.includes('closed'),
      top: loc.closest('.card').getBoundingClientRect().top,
    }
  })
  assert.ok(midDelete, 'the comment survives its paragraph being deleted')
  assert.ok(midDelete.words.includes('Wasps, by contrast'), 'and still names the paragraph it was left beside: ' + JSON.stringify(midDelete))
  assert.ok(!midDelete.closed, 'deleting text never closes the conversation about it')
  assert.equal(midDelete.orphaned, !midDelete.docHasWords, 'orphaned exactly when the words really are gone: ' + JSON.stringify(midDelete))
  assert.ok(Math.abs(midDelete.top - waspTopBefore) < 90, `and it stays where that paragraph was (${midDelete.top} vs ${waspTopBefore})`)
  await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and it closes normally')
  console.log('✓ deleting a paragraph mid-page keeps the comment, its label and its place')

  // --- a MERGE is not a deletion ---
  // Backspace at the start of the anchored paragraph joins it into the one above:
  // the most ordinary way a paragraph stops existing. The block boundary goes, so
  // the anchor lands outside any text block exactly as it does for a real
  // deletion — but every word survives, one line up. The first cut of this feature
  // read "not inside a block" as "deleted" and struck the card through while
  // pointing straight at the text it claimed was gone. Same anchor, same resolve,
  // opposite verdict, decided by whether the remembered words are still beside the
  // boundary.
  const mergeHost = 'Hoverfly larvae eat aphids, which is why gardeners like them.'
  const mergeAnchored = 'Their wing beat is close to two hundred hertz in level flight.'
  await page.evaluate(texts => {
    const e = window.__editor
    e.commands.insertContentAt(e.state.doc.content.size, {
      type: 'doc',
      content: texts.map(t => ({ type: 'paragraph', content: [{ type: 'text', text: t }] })),
    })
  }, [mergeHost, mergeAnchored])
  await page.waitForTimeout(400)
  const mergeGeom = () =>
    page.evaluate(t => {
      const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
      p.scrollIntoView({ block: 'center' })
      const r = p.getBoundingClientRect()
      const sheet = document.getElementById('editor').getBoundingClientRect()
      return { top: r.top, mid: r.top + r.height / 2, sheet }
    }, mergeAnchored)
  const mg = await mergeGeom()
  await page.mouse.move(mg.sheet.left + mg.sheet.width * 0.85, mg.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph about to be merged')
  await page.click('#hover-comment')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  await page.fill('#composer-text', 'Is two hundred right?')
  await page.click('#composer .primary, #composer button:has-text("Comment")')
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it')
  const mergeProbe = () =>
    page.evaluate(t => {
      const loc = document.querySelector('.card.thread .anchor-loc')
      if (!loc) return { collapsed: true, paraCount: document.querySelectorAll('.tiptap p').length }
      return {
        orphaned: loc.classList.contains('orphaned'),
        title: loc.title,
        struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine,
        words: loc.textContent,
        docHasWords: document.querySelector('.tiptap').textContent.includes(t),
        paraCount: document.querySelectorAll('.tiptap p').length,
      }
    }, mergeAnchored)
  const beforeMerge = await mergeProbe()
  assert.ok(!beforeMerge.orphaned, 'live before the merge: ' + JSON.stringify(beforeMerge))
  // Caret to the very start of the anchored paragraph, then Backspace, as a person
  // would. The click is what takes DOM focus — commands.focus() sets the selection
  // but leaves the keystrokes going to the body in headless Chromium (see
  // focusDocEnd) — and setTextSelection then makes the position exact.
  const mergeParaBox = await page.evaluate(t => {
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
    p.scrollIntoView({ block: 'center' })
    const r = p.getBoundingClientRect()
    return { x: r.left + 3, y: r.top + r.height / 2 }
  }, mergeAnchored)
  await page.mouse.click(mergeParaBox.x, mergeParaBox.y)
  await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 })
  await page.evaluate(t => {
    const e = window.__editor
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
    e.commands.setTextSelection(e.view.posAtDOM(p, 0))
  }, mergeAnchored)
  await page.keyboard.press('Backspace')
  await waitFor(
    async () => page.evaluate(n => document.querySelectorAll('.tiptap p').length === n - 1, beforeMerge.paraCount),
    'the two paragraphs really did join into one'
  )
  // Clicking into the document to place the caret deselected the card, and a
  // collapsed card has no locator to read — reopen it.
  await page.click('.card.thread .head')
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'the card is open again after the merge')
  await page.waitForTimeout(400)
  const afterMerge = await mergeProbe()
  assert.ok(afterMerge.docHasWords, 'the words are still on the page after the merge: ' + JSON.stringify(afterMerge))
  assert.ok(!afterMerge.orphaned, 'so the card must NOT claim the paragraph was deleted: ' + JSON.stringify(afterMerge))
  assert.equal(afterMerge.struck, 'none', 'and must not strike the words through')
  assert.ok(!/deleted/.test(afterMerge.title), 'and the tooltip still offers to jump to it: ' + JSON.stringify(afterMerge))
  assert.ok(afterMerge.words.includes('Their wing beat'), 'still naming the place it was left: ' + JSON.stringify(afterMerge))
  // and it still resolves to somewhere sane — the locator jumps without throwing
  const mergeJump = await page.evaluate(() => {
    document.querySelector('.card.thread .anchor-loc').click()
    const e = window.__editor
    return { pos: e.state.selection.from, size: e.state.doc.content.size }
  })
  assert.ok(mergeJump.pos > 0 && mergeJump.pos <= mergeJump.size, 'the locator still resolves: ' + JSON.stringify(mergeJump))
  await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally')
  console.log('✓ merging the anchored paragraph away is not reported as a deletion')

  // --- the reason the anchor is a relative position and not an offset ---
  // Insert whole blocks ABOVE a position comment. An offset would slide the
  // comment down the document; the relative position must keep naming the same
  // paragraph and travel with it.
  const driftAnchored = 'Bumblebees can fly in colder air than honeybees manage.'
  await page.evaluate(t => {
    const e = window.__editor
    e.commands.insertContentAt(e.state.doc.content.size, {
      type: 'doc',
      content: [
        { type: 'paragraph', content: [{ type: 'text', text: t }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'A trailing paragraph, so the one above is not the last.' }] },
      ],
    })
  }, driftAnchored)
  await page.waitForTimeout(400)
  const driftGeom = () =>
    page.evaluate(t => {
      const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
      p.scrollIntoView({ block: 'center' })
      const r = p.getBoundingClientRect()
      const sheet = document.getElementById('editor').getBoundingClientRect()
      return { top: r.top, mid: r.top + r.height / 2, sheet }
    }, driftAnchored)
  const dg = await driftGeom()
  await page.mouse.move(dg.sheet.left + dg.sheet.width * 0.85, dg.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph that must not drift')
  await page.click('#hover-comment')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  await page.fill('#composer-text', 'Worth a citation.')
  await page.click('#composer .primary, #composer button:has-text("Comment")')
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it')
  await page.waitForTimeout(300)
  // where the anchor resolves BEFORE anything is inserted above it
  const driftPosBefore = await page.evaluate(() => {
    document.querySelector('.card.thread .anchor-loc').click()
    return window.__editor.state.selection.from
  })
  // three whole paragraphs, inserted above it
  await page.evaluate(t => {
    const e = window.__editor
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
    const $p = e.state.doc.resolve(e.view.posAtDOM(p, 0))
    e.commands.insertContentAt($p.before($p.depth), {
      type: 'doc',
      content: [0, 1, 2].map(i => ({ type: 'paragraph', content: [{ type: 'text', text: `An inserted paragraph, number ${i}.` }] })),
    })
  }, driftAnchored)
  await page.waitForTimeout(600)
  const afterInsertAbove = await page.evaluate(t => {
    const loc = document.querySelector('.card.thread .anchor-loc')
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
    // where the anchor actually points now, via the app's own resolve
    loc.click()
    const $sel = window.__editor.state.doc.resolve(window.__editor.state.selection.from)
    return {
      orphaned: loc.classList.contains('orphaned'),
      words: loc.textContent,
      cardTop: loc.closest('.card').getBoundingClientRect().top,
      paraTop: p.getBoundingClientRect().top,
      landedIn: $sel.parent.isTextblock ? $sel.parent.textContent.slice(0, 30) : null,
      pos: window.__editor.state.selection.from,
    }
  }, driftAnchored)
  assert.ok(!afterInsertAbove.orphaned, 'inserting blocks above does not orphan it: ' + JSON.stringify(afterInsertAbove))
  assert.ok(afterInsertAbove.words.includes('Bumblebees can fly'), 'and it still names the same paragraph: ' + JSON.stringify(afterInsertAbove))
  assert.ok(
    afterInsertAbove.landedIn?.startsWith('Bumblebees can fly'),
    'the anchor still points INSIDE that paragraph: ' + JSON.stringify(afterInsertAbove)
  )
  // The pair of assertions is the whole point, and neither alone would do it: the
  // resolved position MOVED (an offset would have stayed put and landed in one of
  // the inserted paragraphs) and it moved to the same paragraph as before.
  assert.ok(
    afterInsertAbove.pos > driftPosBefore,
    `the anchor moved down by what was inserted above it (${driftPosBefore} -> ${afterInsertAbove.pos})`
  )
  assert.ok(
    Math.abs(afterInsertAbove.cardTop - afterInsertAbove.paraTop) < 90,
    `and the card travelled down with it (${afterInsertAbove.cardTop} vs ${afterInsertAbove.paraTop})`
  )
  await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
  await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally')
  console.log('✓ blocks inserted above a position comment do not move it off its paragraph')

  // --- scrolling drops the button ---
  // It is positioned inside #editor-col, so it travels with the text and keeps
  // pointing at its own paragraph — but the pointer that armed it has not moved,
  // so it ends up offering to comment on something nowhere near the cursor.
  const scrollGeom = await page.evaluate(() => {
    const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.length > 40)
    p.scrollIntoView({ block: 'center' })
    const r = p.getBoundingClientRect()
    const sheet = document.getElementById('editor').getBoundingClientRect()
    return { mid: r.top + r.height / 2, sheet }
  })
  await page.mouse.move(scrollGeom.sheet.left + scrollGeom.sheet.width * 0.85, scrollGeom.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'armed before scrolling')
  await page.mouse.wheel(0, 350)
  await waitFor(async () => (await hoverBtn()).hidden, 'a scroll drops the button rather than leaving it beside a paragraph the pointer has left')
  console.log('✓ scrolling without moving the pointer drops the hover button')

  // --- a table row anchors to its leftmost cell ---
  // Every cell in a row shares one Y band, so document order picks the first.
  // Documented at textblockAtHeight; asserted here so it stays a decision.
  await page.evaluate(() => {
    const e = window.__editor
    e.commands.insertContentAt(e.state.doc.content.size, {
      type: 'doc',
      content: [{
        type: 'table',
        content: [{
          type: 'tableRow',
          content: ['Leftmost cell of the row', 'Middle cell', 'Rightmost cell'].map(t => ({
            type: 'tableCell',
            content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }],
          })),
        }],
      }],
    })
  })
  await page.waitForTimeout(500)
  const cellGeom = await page.evaluate(() => {
    const c = [...document.querySelectorAll('.tiptap td, .tiptap th')].find(n => n.textContent.startsWith('Rightmost cell'))
    c.scrollIntoView({ block: 'center' })
    const r = c.getBoundingClientRect()
    const sheet = document.getElementById('editor').getBoundingClientRect()
    return { mid: r.top + r.height / 2, sheet }
  })
  await page.mouse.move(cellGeom.sheet.left + cellGeom.sheet.width * 0.85, cellGeom.mid)
  await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside a table row')
  await page.click('#hover-comment')
  await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
  const rowLoc = await page.evaluate(() => document.getElementById('composer-loc').textContent)
  assert.ok(rowLoc.includes('Leftmost cell of the row'), 'level with the last cell, it anchors to the row\'s first: ' + JSON.stringify(rowLoc))
  await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
  await page.waitForTimeout(200)
  console.log('✓ hovering beside a table row anchors to the row\'s leftmost cell')

  // ghost page: visiting a slug that does not exist creates it, titled by slug
  const projId = page.url().split('/d/')[1].split('/')[0]
  await page.goto(`${BASE}/d/${projId}/road-map`)
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('Road map'), 'ghost page seeded with slug title')
  await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Road map')), 'ghost page in sidebar')
  console.log('✓ ghost pages auto-created from the structure')
  await page.goto(`${BASE}/d/${projId}`)
  await page.waitForSelector('.tiptap')
  await page.waitForTimeout(1000)

  // reset restores the seeded state (through the in-app confirm modal)
  await page.click('#reset-btn')
  await page.waitForSelector('.ui-modal')
  await page.click('.ui-modal .btn.primary, .ui-modal .ui-danger')
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.textContent('.tiptap')).includes('happy testing'), 'reset restores content')
  console.log('✓ playground reset')

  // --- phone layout (same doc, iPhone-sized touch viewport) ---
  // its own browser: chromium runs --single-process here and tears down when
  // its last page closes, so contexts are not swapped mid-run
  await mobileChecks(page.url())

  assert.deepEqual(consoleErrors, [], 'no console errors: ' + JSON.stringify(consoleErrors))
  console.log('✓ zero console errors')

  await browser.close()
  server.kill()
  fs.rmSync(DATA, { recursive: true, force: true })
  console.log('\nBROWSER TEST PASSED — screenshots in ' + SHOT)
  process.exit(0)
}

// The phone shell is a different layout, not just narrower: compact header with
// a ⋯ menu and a toggleable formatting row, the page tree as a drawer, the
// comments as a bottom sheet. Everything below runs at 390x844 with touch.
async function mobileChecks(docUrl) {
  const browser = await chromium.launch({
    headless: true,
    executablePath: process.env.PW_EXECUTABLE || undefined,
    args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'],
  })
  const ctx = await browser.newContext({
    viewport: { width: 390, height: 844 },
    isMobile: true,
    hasTouch: true,
    deviceScaleFactor: 2,
  })
  const page = await ctx.newPage()
  page.on('console', msg => {
    if (msg.type() === 'error' && !msg.text().includes('fonts.g')) consoleErrors.push('mobile: ' + msg.text())
  })
  page.on('pageerror', err => consoleErrors.push('mobile PAGEERROR: ' + err.message))
  // fresh browser => fresh cookie jar: sign in, landing on the same document
  const docPath = new URL(docUrl).pathname
  await page.goto(`${BASE}/auth/dev?u=alice&next=${encodeURIComponent(docPath)}`)
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.$$('.card')).length >= 1, 'mobile cards render')
  await page.waitForTimeout(800)
  const shot = name => page.screenshot({ path: path.join(SHOT, `mobile-${name}.png`) })

  // nothing may widen the page: a phone browser would zoom out to fit
  const width = await page.evaluate(() => ({ inner: window.innerWidth, scroll: document.documentElement.scrollWidth }))
  assert.equal(width.scroll, width.inner, `no horizontal overflow (${JSON.stringify(width)})`)

  // The hover comment affordance does not exist on touch, on purpose: there is
  // no hover to reveal it with, and a button parked in the margin is exactly the
  // gutter the desktop design avoids. Selecting text and tapping Comment is the
  // whole flow here, and it is exercised further down.
  const hoverOnTouch = await page.evaluate(async () => {
    const sheet = document.getElementById('editor').getBoundingClientRect()
    const p = document.querySelector('.tiptap p').getBoundingClientRect()
    document.getElementById('editor-col').dispatchEvent(
      new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: p.top + p.height / 2, bubbles: true })
    )
    await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
    const b = document.getElementById('hover-comment')
    return {
      noHover: matchMedia('(hover: none)').matches,
      classHidden: b.classList.contains('hidden'),
      display: getComputedStyle(b).display,
    }
  })
  assert.ok(hoverOnTouch.noHover, 'the phone viewport really is a no-hover one: ' + JSON.stringify(hoverOnTouch))
  assert.ok(hoverOnTouch.classHidden, 'a synthetic hover cannot arm it: ' + JSON.stringify(hoverOnTouch))
  assert.equal(hoverOnTouch.display, 'none', 'and CSS keeps it gone even if something did')

  // the header must stay put at any scroll depth (regression: body{height:100%}
  // made the sticky containing block one viewport tall)
  for (const y of [900, 2500]) {
    await page.evaluate(scrollY => window.scrollTo(0, scrollY), y)
    await page.waitForTimeout(250)
    const top = await page.evaluate(() => Math.round(document.getElementById('topbar').getBoundingClientRect().top))
    assert.equal(top, 0, `header pinned at scrollY=${y} (top=${top})`)
  }
  await page.evaluate(() => window.scrollTo(0, 0))
  await page.waitForTimeout(200)
  await shot('01-doc')
  console.log('✓ mobile: no horizontal overflow, header stays pinned while scrolling')

  // --- the document surface itself, at 390px ---
  // The two symptoms this replaced: an 816px sheet in a 390px window put 68% of
  // every line off screen, and a 1in page margin spent 24.6% of the viewport
  // before the first letter. Both are measured here, not asserted by eye.
  const surface = () =>
    page.evaluate(() => {
      const ed = document.getElementById('editor')
      const t = document.querySelector('.tiptap')
      const col = document.getElementById('editor-col')
      const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 120)
      const lh = para ? parseFloat(getComputedStyle(para).lineHeight) : 1
      const lines = para ? Math.max(1, Math.round(para.getBoundingClientRect().height / lh)) : 0
      return {
        sheet: Math.round(ed.getBoundingClientRect().width),
        pad: Math.round(parseFloat(getComputedStyle(ed).paddingLeft)),
        measure: Math.round(t.getBoundingClientRect().width),
        font: parseFloat(getComputedStyle(t).fontSize),
        line: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-line')),
        titleFont: Math.round(parseFloat(getComputedStyle(t.querySelector('h1')).fontSize)),
        colScroll: col.scrollWidth - col.clientWidth,
        // the whole point: how much of a line a reader can actually see
        offScreen: Math.max(0, Math.round(ed.getBoundingClientRect().right - window.innerWidth)),
        charsPerLine: lines ? Math.round(para.textContent.trim().length / lines) : 0,
      }
    })

  const phone = await surface()
  assert.equal(phone.sheet, 390, `the sheet is the screen, edge to edge (${phone.sheet})`)
  assert.equal(phone.offScreen, 0, `no part of the page is off screen (${phone.offScreen}px)`)
  assert.equal(phone.colScroll, 0, `and there is nothing to scroll sideways to (${phone.colScroll}px)`)
  assert.ok(phone.pad >= 16 && phone.pad <= 34,
    `the margin is a phone margin, not a printed inch (${phone.pad}px = ${Math.round((phone.pad / 390) * 100)}% a side)`)
  assert.ok(phone.font >= 16, `body text is at least 16px, iOS and Android's own body size (${phone.font})`)
  assert.ok(phone.charsPerLine >= 32 && phone.charsPerLine <= 52,
    `a phone line, not a page line (${phone.charsPerLine} characters)`)
  // the Docs Title is 2.364em against a 624px column; at 354px that was 37.8px
  assert.ok(phone.titleFont < 34, `the display title is re-struck for the column (${phone.titleFont}px)`)
  console.log(`✓ mobile: the page reflows into the screen — ${phone.measure}px column, ${phone.font}px type, ${phone.charsPerLine} chars/line`)

  // No reflow jitter: the drawer and the comment sheet are position:fixed, so
  // revealing either one must not resize the column and re-break a line. This
  // was the real risk in making the sheet fluid — a sheet sized off a container
  // that panels can shrink would re-set the text every time one opened.
  const stable = []
  for (const [what, sel] of [['pages drawer', '#m-pages'], ['comment sheet', '#m-comments']]) {
    await page.click(sel)
    await page.waitForTimeout(400)
    stable.push([what, await surface()])
    await page.click(sel)
    await page.waitForTimeout(400)
  }
  for (const [what, open] of stable) {
    assert.equal(open.measure, phone.measure, `the ${what} does not re-break a line (${open.measure} vs ${phone.measure})`)
  }
  // typing must not move the column either
  await page.click('.tiptap', { position: { x: 40, y: 12 } })
  await page.keyboard.type('x')
  await page.waitForTimeout(300)
  const typed = await surface()
  assert.equal(typed.measure, phone.measure, `typing does not re-break the column (${typed.measure})`)
  await page.keyboard.press('Backspace')
  console.log('✓ mobile: opening the drawer, the sheet, or the keyboard reflows nothing')

  // zoom is now a type control here, not a fit control: the sheet is already the
  // screen, so ± only reaches the words — and its floor lifts, because 40% of a
  // fluid column is 6.4px of text and buys nothing
  await page.evaluate(() => { document.body.classList.add('fmt-open') })
  await page.waitForTimeout(200)
  for (let i = 0; i < 6; i++) await page.click('#zoom-out')
  await page.waitForTimeout(350)
  const zoomedOut = await surface()
  assert.equal(zoomedOut.sheet, phone.sheet, `zooming out does not narrow the sheet (${zoomedOut.sheet})`)
  assert.ok(zoomedOut.font >= 12.8,
    `the zoom floor is 80% here, so the type stays readable (${zoomedOut.font}px at ${await page.textContent('#zoom-label')})`)
  // back to 100% by the readout, not by counting clicks: the floor swallows the
  // extra ones, so six out and six in would land at 140%
  for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in')
  await page.waitForTimeout(350)
  assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom back to 100% for the rest of the checks')
  await page.evaluate(() => { document.body.classList.remove('fmt-open') })
  console.log('✓ mobile: zoom sets the type instead of trying to fit a page that already fits')

  // the Reading style is measured against a 624px column (10.69 words a line);
  // on a phone it gets 354px and only its leading is allowed to give
  // the ⋯ sheet is a toggle, and the style button lives inside it — so open it
  // by its state rather than by clicking and hoping it was closed
  const moreOpen = () => page.evaluate(() => !document.getElementById('more-pop').classList.contains('hidden'))
  const openMore = async () => {
    if (!(await moreOpen())) await page.click('#m-more')
    await page.waitForSelector('#more-pop:not(.hidden)')
    await page.waitForTimeout(150)
  }
  // and put it back down afterwards: the header-chrome checks measure whether
  // the desktop buttons are visible, and on a phone they live inside this sheet
  const closeMore = async () => {
    if (await moreOpen()) await page.click('#m-more')
    await waitFor(async () => !(await moreOpen()), 'the ⋯ sheet is back down')
  }
  await openMore()
  await page.click('#docstyle-btn')
  await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'reading'), 'reading style on')
  await page.waitForTimeout(700)
  const reading = await surface()
  assert.equal(reading.measure, phone.measure, 'Reading gets the same phone column as Docs')
  assert.equal(reading.colScroll, 0, 'Reading does not overflow it either')
  assert.ok(reading.font >= 16, `Reading keeps the 16px base it was measured at (${reading.font})`)
  assert.ok(reading.line < 1.8 && reading.line >= 1.5,
    `only the leading gives: 1.8 was struck for a long line (${reading.line})`)
  assert.ok(reading.titleFont < 40, `the display serif headline is re-struck too (${reading.titleFont}px)`)
  await shot('02-reading')
  await openMore()
  await page.click('#docstyle-btn')
  await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'docs'), 'docs style back')
  await closeMore()
  console.log(`✓ mobile: Reading holds its 16px base and gives its leading instead (${reading.line})`)

  // desktop-only chrome is gone; phone controls are real 40px tap targets
  const targets = await page.evaluate(() => {
    const out = {}
    for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
      const r = document.querySelector(sel).getBoundingClientRect()
      out[sel] = [Math.round(r.width), Math.round(r.height)]
    }
    out.shareVisible = document.getElementById('share-btn').getBoundingClientRect().height > 0
    out.edgeTabs = document.getElementById('sidebar-toggle').getBoundingClientRect().height > 0
    return out
  })
  assert.ok(!targets.shareVisible, 'desktop Share button hidden on phones (moved into ⋯)')
  assert.ok(!targets.edgeTabs, 'desktop edge tabs hidden on phones')
  for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
    const [w, h] = targets[sel]
    assert.ok(w >= 40 && h >= 40, `${sel} is a 40px+ tap target (got ${w}x${h})`)
  }

  // ⋯ menu holds Share / Agents / identity and still works from there
  await page.click('#m-more')
  await page.waitForSelector('#more-pop:not(.hidden)')
  const inMenu = await page.evaluate(() => [...document.getElementById('more-pop').children].map(c => c.id))
  assert.ok(inMenu.includes('share-btn') && inMenu.includes('agents-btn') && inMenu.includes('whoami'), 'more menu: ' + inMenu)
  assert.ok(inMenu.includes('theme-btn'), 'the theme toggle rides the ⋯ sheet on a phone: ' + inMenu)
  await shot('02-more-menu')
  await page.click('#more-pop #share-btn')
  await page.waitForSelector('#share-pop:not(.hidden)')
  const pop = await page.evaluate(() => {
    const r = document.getElementById('share-pop').getBoundingClientRect()
    return { left: Math.round(r.left), right: Math.round(r.right), width: Math.round(r.width) }
  })
  assert.ok(pop.left >= 0 && pop.right <= 390 && pop.width > 300, 'share sheet fits the screen: ' + JSON.stringify(pop))
  await page.click('#m-more') // dismiss
  await page.waitForTimeout(200)
  console.log('✓ mobile: ⋯ menu carries Share/Agents/identity, popovers fit the screen')

  // formatting toolbar: hidden by default, revealed as a scrollable second row
  assert.ok(await page.evaluate(() => document.getElementById('header-tools').getBoundingClientRect().height === 0), 'toolbar collapsed by default')
  await page.click('#m-fmt')
  await page.waitForTimeout(300)
  const tools = await page.evaluate(() => {
    const t = document.getElementById('header-tools')
    const r = t.getBoundingClientRect()
    return { h: Math.round(r.height), top: Math.round(r.top), scrollable: t.scrollWidth > t.clientWidth + 10, hdr: document.getElementById('topbar').offsetHeight }
  })
  assert.ok(tools.h > 25 && tools.top < tools.hdr, 'formatting row shows inside the header: ' + JSON.stringify(tools))
  assert.ok(tools.scrollable, 'formatting row scrolls horizontally instead of squeezing')
  await shot('03-fmt-row')
  // and it actually formats
  await selectText(page, 'quick brown fox')
  await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
  await waitFor(async () => await page.evaluate(() => window.__editor.isActive('bold')), 'bold applied from the phone toolbar')
  await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
  await page.click('#m-fmt')
  await page.waitForTimeout(200)
  console.log('✓ mobile: formatting row toggles, scrolls and applies marks')

  // pages drawer: scrim, dismiss, and closing itself when a page is chosen
  await page.click('#m-pages')
  await page.waitForTimeout(350)
  const drawer = await page.evaluate(() => {
    const s = document.getElementById('sidebar').getBoundingClientRect()
    return {
      onScreen: s.left >= 0 && s.width > 200,
      belowHeader: Math.round(s.top) >= document.getElementById('topbar').offsetHeight,
      scrim: !document.getElementById('scrim').classList.contains('hidden'),
    }
  })
  assert.ok(drawer.onScreen && drawer.belowHeader && drawer.scrim, 'drawer slides over with a scrim: ' + JSON.stringify(drawer))
  await shot('04-drawer')
  // tap the exposed part of the scrim, to the right of the 300px drawer
  await page.mouse.click(360, 620)
  await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'scrim dismisses the drawer')
  await page.click('#m-pages')
  await page.waitForTimeout(300)
  await page.click('#page-tree a:not(.current):not(.pending-page)') // a real page, not a proposal
  await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'drawer closes after picking a page')
  await page.waitForSelector('.tiptap')
  await page.waitForTimeout(600)
  console.log('✓ mobile: pages drawer — scrim dismiss + closes on navigation')

  // comments bottom sheet: closed by default, badge shows the count, cards land
  // on screen (they used to stack below the whole document, out of sight)
  await page.goto(`${BASE}${docPath}`)
  await page.waitForSelector('.tiptap')
  await waitFor(async () => (await page.$$('.card')).length >= 1, 'cards back on the main page')
  await page.waitForTimeout(700)
  assert.ok(await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet starts closed')
  const badge = await page.evaluate(() => {
    const b = document.querySelector('#m-comments .count')
    return { text: b.textContent, shown: !b.classList.contains('hidden') }
  })
  assert.ok(badge.shown && Number(badge.text) > 0, 'comment count badge: ' + JSON.stringify(badge))
  await page.click('#m-comments')
  await page.waitForTimeout(500)
  const sheet = await page.evaluate(() => {
    const r = document.getElementById('margin-col').getBoundingClientRect()
    const cards = [...document.querySelectorAll('.card')]
    return {
      box: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.bottom)],
      vh: window.innerHeight,
      visibleCards: cards.filter(c => { const b = c.getBoundingClientRect(); return b.top < window.innerHeight && b.bottom > 0 }).length,
      minHeightGap: document.getElementById('margin-items').style.minHeight,
    }
  })
  assert.ok(sheet.box[3] >= sheet.vh - 1 && sheet.box[1] > 0, 'sheet is anchored to the bottom edge: ' + JSON.stringify(sheet))
  assert.ok(sheet.box[2] === 390, 'sheet spans the width')
  assert.ok(sheet.visibleCards >= 1, 'cards are visible inside the sheet')
  assert.ok(!sheet.minHeightGap, 'no leftover desktop min-height gap')
  await shot('05-comments-sheet')
  console.log('✓ mobile: comments open as a bottom sheet with the cards on screen')

  // tapping a comment highlight in the text opens the sheet on that card
  await page.click('#margin-close')
  await waitFor(async () => await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet closes')
  // a real tap: the reveal runs through ProseMirror's click handling
  await page.evaluate(() => document.querySelector('.tiptap .comment-hl, .tiptap .sugg-ins')?.scrollIntoView({ block: 'center' }))
  await page.waitForTimeout(400)
  const hl = await page.locator('.tiptap .comment-hl, .tiptap .sugg-ins').first().boundingBox()
  await page.mouse.click(hl.x + hl.width / 2, hl.y + hl.height / 2)
  await page.waitForTimeout(600)
  const revealed = await page.evaluate(() => ({
    open: !document.getElementById('margin-col').classList.contains('hidden'),
    expanded: !!document.querySelector('.card.expanded'),
  }))
  assert.ok(revealed.open, 'tapping highlighted text opens the sheet')
  console.log('✓ mobile: tapping highlighted text opens the sheet' + (revealed.expanded ? ' on that card' : ''))

  // selection -> Comment must open the composer inside the sheet
  await page.click('#margin-close').catch(() => {})
  await page.waitForTimeout(200)
  await selectText(page, 'lazy dog')
  await page.waitForSelector('#selection-menu:not(.hidden)')
  const menuBtn = await page.evaluate(() => {
    const r = document.getElementById('comment-btn').getBoundingClientRect()
    return [Math.round(r.width), Math.round(r.height)]
  })
  assert.ok(menuBtn[1] >= 40, `selection menu buttons are touch-sized (${menuBtn})`)
  await page.click('#comment-btn')
  await page.waitForSelector('#composer:not(.hidden)')
  await page.waitForTimeout(500) // the sheet slides up
  const composer = await page.evaluate(() => {
    const r = document.getElementById('composer').getBoundingClientRect()
    return {
      sheetOpen: !document.getElementById('margin-col').classList.contains('hidden'),
      rect: [Math.round(r.top), Math.round(r.bottom), Math.round(r.width)],
      vh: window.innerHeight,
      scrollTop: document.getElementById('margin-items').scrollTop,
      onScreen: r.top < window.innerHeight && r.bottom > 0 && r.width > 100,
      fontSize: getComputedStyle(document.getElementById('composer-text')).fontSize,
    }
  })
  assert.ok(composer.sheetOpen, 'commenting opens the sheet holding the composer')
  assert.ok(composer.onScreen, 'composer is on screen: ' + JSON.stringify(composer))
  assert.equal(composer.fontSize, '16px', 'inputs are 16px so iOS does not zoom on focus')
  await shot('06-composer')
  await page.fill('#composer-text', 'from a phone')
  await page.click('#composer-send')
  await waitFor(async () => (await page.textContent('#margin-items')).includes('from a phone'), 'phone comment posted')
  console.log('✓ mobile: selection → comment posts from the sheet, inputs are zoom-safe')

  // long agent replies are clamped with a Show more toggle
  await page.evaluate(() => {
    const long = 'A very long agent reply that should not be allowed to run on forever. '.repeat(40)
    const thread = [...window.__ydoc.getMap('threads').values()].find(t => t.get('messages'))
    thread.get('messages').push([{ id: 'long1', author: 'ui-agent', authorType: 'agent', text: long, ts: Date.now() }])
  })
  await page.waitForTimeout(600)
  await page.evaluate(() => {
    const card = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('long agent reply')) || document.querySelector('.card.thread')
    card?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
  })
  await waitFor(async () => !!(await page.$('.card.expanded .more-btn')), 'clamped long comment offers Show more')
  const clamp = await page.evaluate(() => {
    const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
    return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), full: el.scrollHeight }
  })
  assert.ok(clamp.clamped && clamp.shown < clamp.full / 2, 'long comment is clamped: ' + JSON.stringify(clamp))
  await shot('07-long-comment')
  await page.click('.card.expanded .more-btn')
  await page.waitForTimeout(300)
  const expandedText = await page.evaluate(() => {
    const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
    return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), label: document.querySelector('.card.expanded .more-btn').textContent }
  })
  assert.ok(!expandedText.clamped && expandedText.shown > clamp.shown, 'Show more reveals the rest: ' + JSON.stringify(expandedText))
  assert.equal(expandedText.label, 'Show less', 'toggle flips to Show less')
  console.log('✓ mobile: long comments clamp to a max height with Show more / Show less')

  await browser.close()
}

async function selectText(page, needle) {
  await page.evaluate(text => {
    const editor = window.__editor
    let found = null
    editor.state.doc.descendants((node, pos) => {
      if (found || !node.isText) return
      const idx = node.text.indexOf(text)
      if (idx !== -1) found = { from: pos + idx, to: pos + idx + text.length }
    })
    if (!found) throw new Error('text not found: ' + text)
    editor.chain().focus().setTextSelection(found).run()
  }, needle)
  await page.waitForTimeout(250)
}

async function waitFor(fn, what, ms = 8000) {
  const t0 = Date.now()
  while (Date.now() - t0 < ms) {
    try {
      if (await fn()) return
    } catch {}
    await new Promise(r => setTimeout(r, 150))
  }
  throw new Error('timeout: ' + what)
}

main().catch(async err => {
  console.error('\nBROWSER TEST FAILED:', err.message)
  console.error('console errors:', consoleErrors)
  server.kill()
  process.exit(1)
})