inkferro-napi 0.1.0

Node-API (napi-rs) bridge exposing the inkferro Rust renderer to the inkferro npm package.
Documentation
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
//! inkferro-napi: napi-rs v3 FFI bridge.
//!
//! # M3-E: the production render path
//!
//! `InkRoot` owns the persistent DOM arena, the per-frame [`FrameWriter`]
//! transport, the JS transform dispatcher, and an internal `cursor_dirty` gate.
//! `commit` mutates the arena (decode → `dom::apply`); `render_frame` reads it,
//! styles it through the core entry, and diffs it through the writer.
//!
//! ## Field ownership
//! - the `Arena` (`inkferro_core::dom`) — persisted across `commit()` calls;
//!   per ADR-3 the layout engine is rebuilt fresh each render from the arena
//!   (`render_styled` → `build_layout_engine`), so there is NO incremental
//!   engine-sync state here — `commit` is `decode_ops -> dom::apply`, full stop;
//! - the `FrameWriter` (`inkferro_rt`) — the live diff baseline, advanced ONLY by
//!   interactive (`diff`) and `non-interactive` renders. `debug` / `screen-reader`
//!   renders deliberately route through a THROWAWAY writer so they never touch it
//!   (see `render_frame`);
//! - the JS transform-dispatch `FunctionRef` — the pattern proven in M3-0;
//! - `root_id` — the arena does not designate a root, so `InkRoot` records which
//!   id is the `Kind::Root`. The JS reconciler allocates id 0 as the root
//!   (matching the core corpus + M3-A seam tests), so it defaults to 0;
//! - `cursor_dirty` + `cursor_position` — ink's `setCursorPosition` state
//!   (`log-update.ts:163-165`). `set_cursor(pos)` stores the position and flips
//!   the dirty gate; `render_frame` resolves the ACTIVE cursor
//!   (`active = cursor_dirty ? cursor_position : None`, ink `getActiveCursor`),
//!   passes it into `FrameParams.cursor` for the rt to compose the cursor escape
//!   bytes, then resets `cursor_dirty = false` after each frame (ink resets
//!   `cursorDirty` atop every render). A stale position thus never persists across
//!   non-dirty frames (#41/M3-K3).
//!
//! ## FunctionRef storage (JS callback that outlives the call that supplied it)
//! The JS transform dispatcher arrives as `Function<'_, FnArgs<(u32, String, u32)>, String>`,
//! a *scope-bound* handle valid only for the duration of `new()`. To call it from
//! a *later* method, immediately `create_ref()` it into a
//! `FunctionRef<FnArgs<(u32, String, u32)>, String>` and store THAT in the struct.
//! `FunctionRef` is `Send + Sync` and holds a real napi reference (refcount 1), so
//! it survives across calls and across turns. Never store `Function<'_>` (its
//! lifetime is the call scope) and never store `Env`.
//!
//! ## borrow_back call shape (per-call, no leak by construction)
//! Inside a method holding `env: Env`, re-materialize the callable with
//! `self.dispatcher.borrow_back(&env)?` — a plain `napi_get_reference_value`, it
//! creates NO new reference, so calling it 10k times cannot leak. Then
//! `f.call(FnArgs((id, line, index)))` — the `FnArgs` tuple is spread as THREE
//! positional JS arguments `(id, line, index)`, NOT one array/tuple. `index` is
//! the per-write local 0-based line index (`usize` → `u32`) the core walk
//! computes (ink `output.ts` `lines.entries()`).
//!
//! ## Transform dispatch + the error channel (`Transformer` returns `String`)
//! The core walk reads a node's own transform through a
//! [`TransformAccessor`](inkferro_core::render::walk::TransformAccessor):
//! `Fn(u32) -> Option<Box<dyn Fn(&str, usize) -> String + 'a>>`. The boxed
//! closure's signature is **infallible** (`-> String`, not `-> Result`), so a JS
//! `throw` inside the dispatcher has no return-type channel to surface through.
//!
//! We bridge it with an out-of-band error cell. The accessor mints, for every
//! node whose `has_transform` flag is set, a closure capturing the node id, a
//! borrow of `self.dispatcher` (lifetime `'a`), `env` (Copy), and a
//! `&RefCell<Option<Error>>`. Each invocation:
//!   1. short-circuits (returns its input untouched) if the cell already holds an
//!      error — once one dispatcher throws we stop firing further JS calls for the
//!      rest of the walk;
//!   2. otherwise `borrow_back`s the dispatcher and calls it; on `Ok(s)` returns
//!      `s`, on `Err(e)` stores `e` in the cell and returns the input untouched
//!      (a sentinel — the half-built string is discarded, never observed).
//!
//! After the walk, `render_frame` inspects the cell: a `Some(err)` becomes the
//! method's `Err` return BEFORE any `FrameWriter` mutation. This is the
//! throw-discard guarantee, realized by *ordering* (the M3-0 template): the whole
//! styled frame is built into LOCALS by `render_styled`, and `FrameWriter` is
//! mutated only on the success path after the cell is confirmed clean — so a
//! thrown render leaves the writer's diff baseline EXACTLY as the prior frame left
//! it, and the next `render_frame` emits the bytes it would have emitted had the
//! failed call never happened.
//!
//! Mutating core's `Transformer` to return `Result` was rejected: it would change
//! `render_styled`/`render_to_string` and risk corpus bytes. The error cell keeps
//! core untouched (zero core edits in this task).
//!
//! ## Reentrancy (an EXPLICIT `rendering` guard is required — observed, not assumed)
//! `render_frame` and `commit` take `&mut self`, and `render_frame` holds that
//! `&mut` across the dispatcher call. The hypothesis going in was that napi-rs v3
//! borrow-checks `&mut self` reentrancy and turns a re-entrant call into a
//! catchable throw. **The probe disproved it.** With no guard, a transform
//! dispatcher that re-enters:
//!   * `render_frame` → the host process **SEGFAULTS** (observed exit 139 on
//!     Node) — napi-rs hands the reentrant call a second, *aliasing* `&mut self`;
//!   * `commit` → returns without throwing, but the inner `&mut self.arena`
//!     aliases the outer render's `&self.arena` — undefined behavior that merely
//!     happened not to crash.
//!
//! Both are memory-unsafety. The fix is the `rendering: bool` field: every
//! `render_frame`/`commit` entry reads it FIRST (before touching any other field)
//! and, if set, returns a catchable typed error (`reentrancy_error`). The outer
//! `render_frame` sets it via an RAII `RenderingGuard` that clears it on every
//! exit (return / `Err` / panic). Post-guard, the SAME probe shows a clean
//! catchable throw on BOTH Node and Bun (exit 0); the smoke suite pins this. The
//! outer render still completes — the dispatcher catches the reentry error and
//! returns normally — so the guard rejects only the reentrant call, not the frame
//! that triggered it.
//!
//! ## Transactional `commit` (decode-then-apply, with an id bound)
//! `commit` decodes the WHOLE op buffer first. `decode_ops` is all-or-nothing: on
//! a truncated buffer / unknown opcode / bad tag it returns a typed `DecodeError`
//! and the arena is never touched, so a rejected commit leaves prior DOM state
//! intact for free. Only a fully-decoded `Vec<Op>` reaches `dom::apply`.
//!
//! Decode validates record *structure* but NOT id *magnitude*. The arena trusts
//! its caller's ids: `Op::Create` is the only op that grows the backing vec
//! (`Arena::insert` -> `Vec::resize_with(id + 1)`), and `Node` is ~700 B, so a
//! structurally-valid `Create{id: 0xFFFFFFFF}` would request ~2.95 TB and abort
//! the host process — an UNCATCHABLE allocation failure, not a JS throw. `commit`
//! is the trust boundary that turns untrusted JS bytes into arena calls, so it
//! enforces an id ceiling (`MAX_NODE_ID`) on every id-bearing op BEFORE `apply`,
//! rejecting the whole buffer (no mutation) with a catchable `InvalidArg` error.
//!
//! ## Lifecycle without a Rust finalizer (Bun-safe subset)
//! No `External`, no custom `finalize`/`Drop`-based cleanup, no threadsafe
//! function. JS drives lifecycle: it allocates u32 ids and calls `free(id)` (the
//! `Free` op) when it detaches a node. This is the napi subset Bun's N-API shim
//! covers.
//!
//! BUILD caveat: `@napi-rs/cli` reads its `napi` config block from a
//! `package.json` in the CWD, not from `--manifest-path`. Build from
//! `__test__/` (it carries the config block + the CLI devDep).

use std::cell::RefCell;

use inkferro_core::dom::{Arena, Op, apply, decode_ops};
use inkferro_core::input::{
    EventType, InputEvent as CoreInputEvent, Key as CoreKey, Parser, parse_keypress,
};
use inkferro_core::layout::LayoutEngine;
use inkferro_core::render::{ColorLevel, build_layout_engine, render_static, render_styled};
use inkferro_rt::{CursorPos as RtCursorPos, FrameParams, FrameWriter};
use napi::Env;
use napi::bindgen_prelude::{Buffer, Error, FnArgs, Function, FunctionRef, Result, Status};
use napi_derive::napi;

/// A node's own boxed output transform, as produced by the render accessor — the
/// `Some` payload of `inkferro_core`'s `TransformAccessor` return. Aliased so the
/// accessor closure's signature stays readable (clippy `type_complexity`).
type BoxedTransform<'a> = Box<dyn Fn(&str, usize) -> String + 'a>;

/// The root DOM id. The JS reconciler allocates id 0 as the `Kind::Root`
/// element (matching the core corpus and the M3-A seam tests), so render entries
/// walk from here.
const ROOT_ID: u32 = 0;

/// Upper bound (inclusive) on any node id a `commit` buffer may reference.
///
/// The arena grows its backing `Vec<Option<Node>>` to `id + 1` on `Op::Create`
/// (`Arena::insert`). `Node` is ~700 B, so an unbounded id is an OOM-abort
/// primitive reachable from arbitrary JS bytes. This ceiling bounds the
/// worst-case single-`Create` allocation to `(MAX_NODE_ID + 1) * ~700 B`
/// ≈ 45 MB (`2^16` slots), which a host can satisfy without aborting, while
/// sitting orders of magnitude above any id a real UI tree uses (the corpus,
/// the M3-A seam tests, and the smoke suite all use single-digit ids). An id
/// past this is treated as a malformed buffer and rejected wholesale.
///
/// Contract this bound assumes: the JS reconciler (M3-E/G) RECYCLES freed ids
/// so this caps *concurrent live* nodes, not cumulative lifetime allocations.
/// If the reconciler instead increments ids monotonically without reuse, a
/// long-lived app could legitimately exceed this and start rejecting valid
/// commits — that author must honor id-recycling or revisit this ceiling.
const MAX_NODE_ID: u32 = (1 << 16) - 1;

/// The largest id referenced by an op, if any (`None` for ops that carry no id,
/// though every current `Op` variant carries at least one). Used to reject a
/// `commit` buffer before any of it reaches the id-trusting arena.
fn max_op_id(op: &Op) -> u32 {
    match op {
        Op::Create { id, .. }
        | Op::SetText { id, .. }
        | Op::SetStyle { id, .. }
        | Op::SetAttribute { id, .. }
        | Op::SetTransform { id, .. }
        | Op::SetTextStyle { id, .. }
        | Op::ClearTextStyle { id }
        | Op::SetStatic { id, .. }
        | Op::Hide { id }
        | Op::Unhide { id }
        | Op::Free { id } => *id,
        Op::AppendChild { parent, child } | Op::RemoveChild { parent, child } => {
            (*parent).max(*child)
        }
        Op::InsertBefore {
            parent,
            child,
            before,
        } => (*parent).max(*child).max(*before),
    }
}

/// The render mode selector (`render_frame`'s `mode` arg).
///
/// Each arm sets ONLY fields that already exist on [`FrameParams`] and calls
/// `FrameWriter::write_frame` unchanged — the M3-E scope rule (no new rt logic;
/// rt is covenant-frozen). The screen-reader / non-interactive *behavioral*
/// branches (SGR stripping, alt-screen, etc.) are M3-K2's JS-side job; M3-E only
/// wires the existing knobs.
#[napi(string_enum)]
#[derive(Clone, Copy)]
pub enum RenderMode {
    /// Interactive incremental render: `debug=false`, `is_tty=true`. Advances the
    /// live `FrameWriter` baseline; emits the line-diff bytes (BSU/ESU-wrapped).
    Diff,
    /// Plain full-frame render (`FrameParams.debug=true`, frame.rs:123): no diff,
    /// clear, or sync. Routed through a THROWAWAY writer so it consumes NO diff
    /// state — a debug render between two live frames cannot corrupt the next
    /// diff's steady-gate or clear decision. `renderToString` is this mode.
    Debug,
    /// Screen-reader: a full plain frame, same no-diff-state-consumed property as
    /// `Debug` (routed through the throwaway writer). The accessibility transform
    /// of the *content* is M3-K2's JS concern; here it is the debug knob.
    ScreenReader,
    /// Non-interactive (`is_tty=false`): write_frame's non-TTY path — no clear, no
    /// sync wrap, output padded with a trailing newline and diffed. A real render
    /// that advances the live baseline.
    NonInteractive,
}

/// Per-render geometry (`render_frame`'s `opts` arg). `cols` is the terminal
/// width handed to the core layout; `rows` is the viewport height fed to
/// write_frame's fullscreen/clear decisions.
#[napi(object)]
pub struct RenderOpts {
    pub cols: u16,
    pub rows: u16,
    /// Detected terminal color level (chalk's `chalk.level`, 0–3). The JS drop-in
    /// reads the GLOBAL `chalk.level` — exactly what ink uses and what the
    /// conformance harness forces to 3 — and threads it here. Core colorize emits
    /// SGR at this level for the core-colorize call sites (borders;
    /// Box backgroundColor fill). `<Text>` color is colorized JS-side by chalk
    /// (already level-aware) and arrives pre-styled. 0 → no SGR (non-color
    /// terminal); 1/2 → downgraded codes; 3 → truecolor (the prior behavior).
    pub color_level: u8,
    /// Whether to include `plain_output` in the [`FrameResult`]. When
    /// explicitly `false`, the result's `plain_output` is an empty string — the
    /// `render_styled` computation still runs (its output is the transport
    /// input), but the string is not marshaled back across the FFI boundary.
    /// `None` (absent from JS) or `true` returns the full string, preserving
    /// backward compatibility for callers that do not set it (e.g. the test
    /// harness).
    pub include_plain_output: Option<bool>,
}

/// The result of one `render_frame`. `bytes` is the exact transport payload the
/// JS `Ink` class (the sole stream writer, M3-K1) writes verbatim; the rest are
/// queryable side outputs.
#[napi(object)]
pub struct FrameResult {
    /// The diffed transport bytes from `FrameWriter::write_frame`. Empty only when
    /// the mode produced an empty write (handled as `None` by `render_frame`).
    pub bytes: Buffer,
    /// The rendered frame height in rows (`render_styled`'s height output).
    pub output_height: u32,
    /// The plain styled frame string (`render_styled`'s string output), before
    /// any transport framing. Mode-independent.
    pub plain_output: String,
    /// The `<Static>` subtree's output for this frame — the text printed once
    /// above the live region (ink's `renderer.ts` static branch, via
    /// `render_static`). `""` when the tree has no static node (the common case);
    /// otherwise the static body plus a trailing newline.
    pub static_output: String,
    /// Wall-clock milliseconds spent in the core render + transport write.
    /// NONDETERMINISTIC — excluded from every byte/equality assertion.
    pub render_time_ms: f64,
    /// How many visible lines this frame actually rewrote
    /// (`FrameWriter::last_changed_lines`). Pure ADDITIVE telemetry that rides
    /// alongside `bytes` and does not alter a single transport byte: 0 for a
    /// no-op timer-fire frame, the visible line count for a full repaint, the
    /// differ's changed-line count for an incremental diff. Lets downstream
    /// pacing (P5.3) tell a real-change frame from a no-op fire. Note a fully
    /// no-op frame is returned as `None` by `render_frame`, so a delivered
    /// `FrameResult` normally carries `changed_lines >= 1`.
    pub changed_lines: u32,
}

/// A cursor position handed to [`InkRoot::set_cursor`]. Mirrors ink's
/// `CursorPosition` (`cursor-helpers.ts:3-6`, `{x, y}`). `None` at the call site
/// mirrors `setCursorPosition(undefined)`.
#[napi(object)]
pub struct CursorPos {
    pub x: u32,
    pub y: u32,
}

/// A node's computed layout, returned by [`InkRoot::measure`]. Field set extends
/// ink's measure result to `left`/`top` for `useBoxMetrics` (M3-J). `left`/`top`
/// are signed (negative margins can place a node outside its parent — matching
/// the core [`Rect`](inkferro_core::layout::Rect)'s signed `x`/`y`); `width`/
/// `height` are unsigned terminal cells.
#[napi(object)]
pub struct Rect {
    pub width: u32,
    pub height: u32,
    pub left: i32,
    pub top: i32,
}

impl Rect {
    /// The zero Rect — the infallible sentinel `measure` returns for an unknown,
    /// freed, out-of-range, or never-laid-out id (ink's `measureElement` on an
    /// unmounted ref yields zeros).
    fn zero() -> Self {
        Rect {
            width: 0,
            height: 0,
            left: 0,
            top: 0,
        }
    }
}

/// The discriminant of an [`InputEvent`] — mirrors core's `InputEvent` enum
/// arms. napi has no native tagged-union for `#[napi(object)]`, so the arm is
/// carried as this string enum plus the optional payload fields.
#[napi(string_enum)]
pub enum InputEventKind {
    /// A decoded key/text segment; the `key` and `input` fields are populated.
    Key,
    /// A bracketed-paste payload; the `paste` field is populated.
    Paste,
}

/// A high-level input event from [`InkRoot::push_input`], mirroring core's
/// [`InputEvent`](inkferro_core::input::InputEvent). The `kind` discriminant
/// selects which payload is present: `Key` ⇒ (`key`, `input`); `Paste` ⇒
/// (`paste`).
#[napi(object)]
pub struct InputEvent {
    pub kind: InputEventKind,
    /// The decoded key (ink `Key` shape), present iff `kind == Key`.
    pub key: Option<Key>,
    /// The resolved input string handed to `useInput`'s handler, derived exactly
    /// as ink's `use-input.ts` `handleData` derives it. Present iff `kind == Key`.
    pub input: Option<String>,
    /// The bracketed-paste payload (decoded to text), present iff `kind == Paste`.
    pub paste: Option<String>,
}

impl InputEvent {
    /// Mirror one core [`CoreInputEvent`] into the napi shape, deriving the
    /// JS-facing [`Key`] + `input` string for a key event exactly as ink's
    /// `useInput` does.
    fn from_core(ev: CoreInputEvent) -> Self {
        match ev {
            CoreInputEvent::Key(k) => {
                let input = derive_input(&k);
                let mut key = Key::from_core(&k);
                // ink sets key.shift for a lone uppercase A-Z input AFTER deriving
                // the input string (use-input.ts:240-242) — apply on the SAME
                // input so the returned Key matches ink for typed capitals.
                apply_uppercase_shift(&mut key, &input);
                InputEvent {
                    kind: InputEventKind::Key,
                    key: Some(key),
                    input: Some(input),
                    paste: None,
                }
            }
            // Paste payloads are raw bytes upstream; decode lossily to a JS string
            // (mirrors ink delivering the paste as a string `input`).
            CoreInputEvent::Paste(bytes) => InputEvent {
                kind: InputEventKind::Paste,
                key: None,
                input: None,
                paste: Some(String::from_utf8_lossy(&bytes).into_owned()),
            },
        }
    }
}

/// The JS-facing key object — a 1:1 mirror of ink's `Key` type
/// (`use-input.ts:9-124`), NOT the lower-level `parseKeypress` output. napi-rs
/// renames each snake_case field to the camelCase ink name automatically
/// (`up_arrow` → `upArrow`, …); `return` is a Rust keyword so it is the raw
/// identifier `r#return`, which napi still emits as `return`. The
/// FIELD-NAME-EXACT covenant (M3-F) is asserted by the spike via `Object.keys`.
#[napi(object)]
pub struct Key {
    pub up_arrow: bool,
    pub down_arrow: bool,
    pub left_arrow: bool,
    pub right_arrow: bool,
    pub page_down: bool,
    pub page_up: bool,
    pub home: bool,
    pub end: bool,
    pub r#return: bool,
    pub escape: bool,
    pub ctrl: bool,
    pub shift: bool,
    pub tab: bool,
    pub backspace: bool,
    pub delete: bool,
    pub meta: bool,
    // Kitty keyboard protocol modifiers (fork fields; ink derives these from
    // `parseKeypress` with `?? false`). `super` is a Rust keyword and CANNOT be a
    // raw identifier (unlike `r#return`), so the field is `super_key` with an
    // explicit `js_name` override emitting the ink-exact JS field `super`.
    #[napi(js_name = "super")]
    pub super_key: bool,
    pub hyper: bool,
    pub caps_lock: bool,
    pub num_lock: bool,
    /// Kitty event type: `"press"`, `"repeat"`, or `"release"`; `None` for legacy
    /// (non-kitty) keys, mirroring ink's optional `eventType`.
    pub event_type: Option<String>,
}

impl Key {
    /// Derive the JS-facing `Key` from a core `parseKeypress` result, EXACTLY as
    /// ink's `useInput` `handleData` does (`use-input.ts:179-202`): name-equality
    /// booleans, raw modifiers, and kitty fields (`?? false`).
    fn from_core(k: &CoreKey) -> Self {
        Key {
            up_arrow: k.name == "up",
            down_arrow: k.name == "down",
            left_arrow: k.name == "left",
            right_arrow: k.name == "right",
            page_down: k.name == "pagedown",
            page_up: k.name == "pageup",
            home: k.name == "home",
            end: k.name == "end",
            r#return: k.name == "return",
            escape: k.name == "escape",
            ctrl: k.ctrl,
            shift: k.shift,
            tab: k.name == "tab",
            backspace: k.name == "backspace",
            delete: k.name == "delete",
            meta: k.meta,
            super_key: k.super_key,
            hyper: k.hyper,
            caps_lock: k.caps_lock,
            num_lock: k.num_lock,
            event_type: k.event_type.map(|t| {
                match t {
                    EventType::Press => "press",
                    EventType::Repeat => "repeat",
                    EventType::Release => "release",
                }
                .to_owned()
            }),
        }
    }
}

/// ink's `nonAlphanumericKeys` (`parse-keypress.ts:101`): the legacy `keyName`
/// table values plus `"backspace"`. `useInput` blanks the `input` string for any
/// non-kitty key whose name is in this set (`use-input.ts:227-232`).
const NON_ALPHANUMERIC_KEYS: &[&str] = &[
    "f1",
    "f2",
    "f3",
    "f4",
    "f5",
    "f6",
    "f7",
    "f8",
    "f9",
    "f10",
    "f11",
    "f12",
    "up",
    "down",
    "right",
    "left",
    "clear",
    "end",
    "home",
    "insert",
    "delete",
    "pageup",
    "pagedown",
    "tab",
    "backspace",
];

/// Resolve the `input` string ink's `useInput` hands its handler, ported 1:1 from
/// `use-input.ts:204-238`. Folded into the FFI so the JS `useInput` (M3-I) drops
/// its in-hook `parseKeypress`.
fn derive_input(k: &CoreKey) -> String {
    let mut input: String = if k.is_kitty_protocol {
        // Kitty: printable keys use the text-as-codepoints field (or name);
        // Ctrl+letter still flows the letter name so `exitOnCtrlC` works; all
        // other kitty keys are suppressed.
        if k.is_printable == Some(true) {
            k.text.clone().unwrap_or_else(|| k.name.clone())
        } else if k.ctrl && k.name.chars().count() == 1 {
            k.name.clone()
        } else {
            String::new()
        }
    } else if k.ctrl {
        k.name.clone()
    } else {
        k.sequence.clone()
    };

    if !k.is_kitty_protocol && NON_ALPHANUMERIC_KEYS.contains(&k.name.as_str()) {
        input = String::new();
    }

    // Strip a leading ESC from broken/incomplete sequences parseKeypress did not
    // fully resolve (e.g. a flushed "[").
    if let Some(stripped) = input.strip_prefix('\u{1b}') {
        input = stripped.to_owned();
    }

    // The caller applies the lone-uppercase-A-Z ⇒ Shift rule on this returned
    // string via `apply_uppercase_shift` (use-input.ts:240-242).
    input
}

/// ink sets `key.shift = true` when the resolved `input` is a single uppercase
/// A-Z (`use-input.ts:240-242`). Applied AFTER `derive_input`, on the same
/// `input`, so the returned `Key.shift` matches ink for typed capitals.
fn apply_uppercase_shift(key: &mut Key, input: &str) {
    let mut chars = input.chars();
    if let (Some(c), None) = (chars.next(), chars.next())
        && c.is_ascii_uppercase()
    {
        key.shift = true;
    }
}

/// The real `InkRoot`: owns the persistent DOM arena, the per-frame transport
/// writer, the JS transform dispatcher, and the cursor-dirty gate.
#[napi]
pub struct InkRoot {
    /// Persistent DOM. Mutated by `commit`; read by render. Survives across
    /// every `commit`/render call (ADR-3: persistence lives here, the layout
    /// engine is rebuilt per frame from this).
    arena: Arena,
    /// Live per-frame line-diff transport. Advanced ONLY by `Diff` and
    /// `NonInteractive` renders; `Debug`/`ScreenReader` use a throwaway writer.
    frame_writer: FrameWriter,
    /// The id of the `Kind::Root` node the reconciler created (conventionally 0).
    root_id: u32,
    /// The stored JS transform dispatcher. Survives across calls because it is a
    /// real napi reference, not a scope-bound `Function`. The third `u32` arg is
    /// the per-write LOCAL 0-based line index (ink's `lines.entries()` index),
    /// threaded from the core walk into the `(line, index) => string` JS callback.
    dispatcher: FunctionRef<FnArgs<(u32, String, u32)>, String>,
    /// The `FrameParams.cursor_dirty` input. Held here so `set_cursor` can flip it
    /// and `render_frame` resets it after EVERY frame (mirroring ink resetting
    /// `cursorDirty = false` atop every render/sync, `log-update.ts:64`/`143`).
    cursor_dirty: bool,
    /// The stored cursor position (`render.setCursorPosition`'s `cursorPosition`,
    /// `log-update.ts:164`). `set_cursor(Some)` stores it; `set_cursor(None)`
    /// clears it. The per-frame ACTIVE cursor is resolved as
    /// `active = cursor_dirty ? cursor_position : None` (ink's `getActiveCursor`,
    /// `log-update.ts:43`) and passed into `FrameParams.cursor`. Because
    /// `render_frame` resets `cursor_dirty = false` after each frame, a STALE
    /// position never persists across non-dirty frames — exactly ink's "only use
    /// cursor if setCursorPosition was called since last render" semantics.
    ///
    /// Stored in rt's [`RtCursorPos`] form (the `usize` arithmetic type), mapped
    /// from the napi `u32` [`CursorPos`] at the `set_cursor` boundary.
    cursor_position: Option<RtCursorPos>,
    /// The terminal width (`cols`) of the most recent render. `measure` (M3-F)
    /// rebuilds the per-frame layout engine at THIS width to read `computed(id)`
    /// — ADR-3's per-frame-rebuild model means there is no stored engine to read,
    /// so measure must rebuild coherently at the last-rendered geometry. `None`
    /// until the first render, which makes `measure` return a zero Rect for a
    /// never-laid-out tree (ink's `measureElement` on an unmounted ref → zeros).
    last_render_width: Option<u16>,
    /// The persistent terminal input parser (`Parser::feed`, M2). Its kitty /
    /// legacy segmenter state machine carries partial-sequence state across
    /// `push_input` calls (a CSI split across two chunks must resume), so it is
    /// owned here and NEVER recreated per call.
    input_parser: Parser,
    /// Reentrancy guard. Set true while a `render_frame`/`commit` body runs; a
    /// re-entrant `commit`/`render_frame` (e.g. a transform dispatcher calling
    /// back into this `InkRoot` mid-walk) reads it FIRST and returns a catchable
    /// typed error, touching no field. Empirically REQUIRED: napi-rs v3 here does
    /// NOT borrow-check `&mut self` reentrancy — without this guard a reentrant
    /// call gets a second aliasing `&mut self` and the process SEGFAULTS (observed
    /// exit 139); the guard converts that UB into a clean JS-catchable throw.
    rendering: bool,
}

#[napi]
impl InkRoot {
    /// `new(transform_dispatcher)` — immediately `create_ref()` the supplied
    /// `Function` and store the resulting `FunctionRef`. The `Function` handle
    /// itself does not outlive this call; the `FunctionRef` does. The arena and
    /// frame writer start empty; the JS reconciler builds the tree via `commit`.
    #[napi(constructor)]
    pub fn new(transform_dispatcher: Function<FnArgs<(u32, String, u32)>, String>) -> Result<Self> {
        Ok(InkRoot {
            arena: Arena::new(),
            frame_writer: FrameWriter::new(),
            root_id: ROOT_ID,
            dispatcher: transform_dispatcher.create_ref()?,
            cursor_dirty: false,
            cursor_position: None,
            last_render_width: None,
            input_parser: Parser::new(),
            rendering: false,
        })
    }

    /// `commit(ops)` — apply a batch of DOM mutations transactionally.
    ///
    /// Decodes the ENTIRE op buffer first (M3-C `decode_ops`). Because the
    /// decoder is all-or-nothing, a malformed buffer returns a typed napi error
    /// with ZERO arena mutation — prior DOM state is preserved. Only a fully
    /// decoded `Vec<Op>` is handed to `dom::apply`. The id-magnitude bound runs
    /// before `apply` to keep the arena's `resize_with` out of OOM-abort range.
    #[napi]
    pub fn commit(&mut self, ops: Buffer) -> Result<()> {
        // Reentrancy guard FIRST — before any field borrow. A transform dispatcher
        // re-entering `commit` mid-render would otherwise take `&mut self.arena`
        // while the render holds `&self.arena` (aliasing UB). Bail on the bare
        // bool read; touch nothing else (see `rendering` field docs).
        if self.rendering {
            return Err(reentrancy_error("commit"));
        }
        let ops = decode_ops(ops.as_ref())
            .map_err(|e| Error::new(Status::InvalidArg, format!("op-buffer decode failed: {e}")))?;
        if let Some(id) = ops.iter().map(max_op_id).find(|&id| id > MAX_NODE_ID) {
            return Err(Error::new(
                Status::InvalidArg,
                format!("op-buffer rejected: node id {id} exceeds MAX_NODE_ID {MAX_NODE_ID}"),
            ));
        }
        apply(&mut self.arena, &ops);
        Ok(())
    }

    /// `render_frame(env, mode, opts) -> Option<FrameResult>` — the production
    /// render path (M3-E keystone).
    ///
    /// Pipeline: style the arena through the core entry (`render_styled`, M3-B),
    /// dispatching each `has_transform` node's transform to the stored JS
    /// dispatcher mid-walk; render the `<Static>` subtree once via `render_static`
    /// (renderer.ts's second pass — `""` when the tree has no static node); then
    /// wire the live `(string, height)` plus the static string into
    /// `FrameWriter::write_frame` and return its diffed bytes.
    ///
    /// Ordering (the M3-0 throw-discard template): `render_styled` builds the
    /// whole styled string into LOCALS, firing every dispatcher call during its
    /// walk — BEFORE any `FrameWriter` mutation. A dispatcher throw is captured in
    /// `err_cell` and surfaces as this method's `Err` *before* `write_frame` runs,
    /// so a failed render leaves the writer's diff baseline untouched.
    ///
    /// Returns `None` when `write_frame` produced no bytes (a no-change frame:
    /// `FrameWriter` returns an empty `Vec` for an unchanged output), mirroring
    /// rt's "willRender" no-op semantics; `Some(FrameResult)` otherwise.
    #[napi]
    pub fn render_frame(
        &mut self,
        env: Env,
        mode: RenderMode,
        opts: RenderOpts,
    ) -> Result<Option<FrameResult>> {
        // Reentrancy guard FIRST — before any field borrow. A reentrant
        // `render_frame` (transform dispatcher calling back mid-walk) would
        // otherwise get a second aliasing `&mut self` and SEGFAULT under napi-rs
        // v3 (no catchable borrow flag here). Bail on the bare bool read.
        if self.rendering {
            return Err(reentrancy_error("render_frame"));
        }
        // Run the body under an RAII guard that sets `rendering` true and clears
        // it on EVERY way out — normal return, `Err`, or an unwinding panic — so
        // the instance can never be left permanently locked. The guard borrows
        // ONLY `&mut self.rendering`; the body borrows the OTHER fields it needs
        // disjointly (`arena`, `frame_writer`, `dispatcher`, plus the copy-fields
        // `root_id`/`cursor_dirty`), so the two coexist without raw pointers or
        // unsafe. `cursor_dirty` is read-only in M3-E (no per-frame mutation here
        // — that is M3-F/M3-K1), so passing it by value loses nothing.
        let InkRoot {
            arena,
            frame_writer,
            root_id,
            dispatcher,
            cursor_dirty,
            cursor_position,
            last_render_width,
            input_parser: _,
            rendering,
        } = self;
        // Record this frame's width so `measure` can rebuild the per-frame layout
        // engine at the SAME geometry (ADR-3 per-frame rebuild: no stored engine
        // to read, so measure rebuilds coherently at the last-rendered width).
        // Set unconditionally — even a no-change frame (`None` result) lays the
        // tree out at this width, so a subsequent `measure` reads valid rects.
        *last_render_width = Some(opts.cols);
        // Resolve the ACTIVE cursor for this frame: `getActiveCursor`
        // (`log-update.ts:43`) = `cursorDirty ? cursorPosition : undefined`. A
        // non-dirty frame sees `None`, so a stale position never re-emits.
        let active_cursor = if *cursor_dirty {
            *cursor_position
        } else {
            None
        };
        // Debug/ScreenReader renders are pure queries (throwaway writer): they must
        // NOT consume the cursor gate meant for the next real frame (ink's
        // `renderToString` uses a separate log-update). So only Diff/NonInteractive
        // reset `cursorDirty`.
        let is_debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
        let _guard = RenderingGuard::engage(rendering);
        let result = render_frame_impl(
            arena,
            frame_writer,
            dispatcher,
            *root_id,
            active_cursor,
            env,
            mode,
            opts,
        );
        // Reset the dirty gate after a real frame: ink resets `cursorDirty = false`
        // atop every render/sync (`log-update.ts:64`/`143`). Done here in the
        // wrapper (which holds `&mut` via the destructure) AFTER the impl ran on a
        // copy of the resolved `active_cursor`. The stored `cursor_position`
        // survives so the next render re-resolves `None` until `set_cursor` is
        // called again. Skip for debug/screen-reader (query-only).
        if !is_debug {
            *cursor_dirty = false;
        }
        result
    }

    /// `render_to_string(width, colorLevel)` — one-shot debug render: a plain full
    /// frame of the committed arena at `width`, with NO diff-state mutation (it
    /// routes through `render_frame`'s `Debug` mode + throwaway writer). This is
    /// the surface the npm `renderToString` (M3-L) wires; it returns the visible
    /// string, so it MUST honor the detected color level just like `render_frame`.
    ///
    /// `color_level` is the JS-detected chalk.level (0–3, the SAME value the npm
    /// `renderToString` threads into `render_frame` for the static-capture pass),
    /// so the returned border/Box-bg SGR matches ink: none at level 0, downgraded
    /// at 1/2, truecolor at 3.
    ///
    /// Returns the plain styled output string (mode-independent: the same string
    /// `render_styled` produces), which is what `renderToString` returns. The
    /// transport bytes are irrelevant to a string query, so they are discarded.
    #[napi]
    pub fn render_to_string(&mut self, env: Env, width: u16, color_level: u8) -> Result<String> {
        let opts = RenderOpts {
            cols: width,
            rows: 0,
            color_level,
            include_plain_output: Some(true),
        };
        let result = self.render_frame(env, RenderMode::Debug, opts)?;
        // Debug mode never returns `None` for a non-empty arena, but an empty
        // arena renders an empty frame → empty write → `None`; map that to "".
        Ok(result.map(|r| r.plain_output).unwrap_or_default())
    }

    /// `free(id)` — JS-driven lifecycle without a Rust finalizer.
    ///
    /// JS owns the u32 id space and calls this on `detachDeletedInstance` (M3-G).
    /// It applies a single `Free` op to the arena (drop one slot, no cascade —
    /// `dom::apply` semantics).
    ///
    /// Reentrancy-guarded like `commit`/`render_frame`: `free` is a third
    /// `&mut self.arena` mutation, so a transform dispatcher re-entering it
    /// mid-walk would take `&mut self.arena` while the render holds `&self.arena`
    /// (aliasing UB) AND drop a node out of the very tree being walked. It reads
    /// `rendering` FIRST and rejects a reentrant call with the same catchable
    /// typed error. Returning `Result<()>` is what surfaces that throw to JS — the
    /// reconciler's `detachDeletedInstance` must handle the (vanishingly rare)
    /// reentry rejection.
    #[napi]
    pub fn free(&mut self, id: u32) -> Result<()> {
        // Reentrancy guard FIRST — before any field borrow (see `commit`).
        if self.rendering {
            return Err(reentrancy_error("free"));
        }
        apply(&mut self.arena, &[Op::Free { id }]);
        Ok(())
    }

    /// `set_cursor(pos)` — store the cursor position and flip the `cursor_dirty`
    /// gate.
    ///
    /// Mirrors ink's `render.setCursorPosition` (`log-update.ts:163-166`)
    /// EXACTLY: `cursorPosition = position; cursorDirty = true`. inkferro now
    /// STORES `pos` in [`cursor_position`](Self::cursor_position) (mapping the
    /// napi `u32` `CursorPos` onto rt's `usize` [`RtCursorPos`]) AND sets the
    /// dirty gate. The next `render_frame` resolves the ACTIVE cursor as
    /// `active = cursor_dirty ? cursor_position : None` (`getActiveCursor`,
    /// `log-update.ts:43`), passes it into `FrameParams.cursor`, and the rt
    /// composes the ink-faithful cursor escape bytes — so a cursor-only change on
    /// unchanged content now produces a frame (the `useCursor`/IME behavior).
    ///
    /// `None` mirrors `setCursorPosition(undefined)`: it CLEARS the stored
    /// position and (still) sets dirty, so the next render's active cursor is
    /// `None` and the rt emits the hide sequence if a cursor was shown.
    ///
    /// Reentrancy-guarded like `commit`: it writes `&mut self.cursor_dirty`/
    /// `&mut self.cursor_position`, which a transform dispatcher re-entering
    /// mid-render would alias against the render's read of the same fields. Reads
    /// `rendering` FIRST and rejects a reentrant call with the same catchable
    /// typed error, touching nothing else.
    #[napi]
    pub fn set_cursor(&mut self, pos: Option<CursorPos>) -> Result<()> {
        // Reentrancy guard FIRST — before any field write (see `commit`).
        if self.rendering {
            return Err(reentrancy_error("set_cursor"));
        }
        // Store the position (mapping napi u32 -> rt usize) and flip the gate.
        // ink sets `cursorPosition = position; cursorDirty = true` unconditionally
        // (`log-update.ts:163-165`), for both a set (`Some`) and a clear (`None`).
        self.cursor_position = pos.map(|p| RtCursorPos {
            x: p.x as usize,
            y: p.y as usize,
        });
        self.cursor_dirty = true;
        Ok(())
    }

    /// `measure(id) -> Rect` — read a node's computed layout (M3-F).
    ///
    /// Mirrors ink's `measureElement` (`dom.ts`): returns a node's computed
    /// `{width, height, left, top}`. inkferro has no stored engine (ADR-3 rebuilds
    /// per frame), so `measure` rebuilds the layout engine from the persistent
    /// arena at the last-rendered WIDTH and reads `computed(id)`.
    ///
    /// SEMANTICS — current-DOM-at-last-width, NOT a frozen last-layout snapshot.
    /// The rebuild reads the arena as it stands NOW, laid out at the last render's
    /// width. In ink's reconciler every `commit` is followed by a render in
    /// `resetAfterCommit`, so the arena and the last layout never diverge at a
    /// `measure` call (the M3-J call pattern is render→measure). Reading the live
    /// arena is also what makes the trust cases fall out for free: a node freed
    /// after the last render rebuilds WITHOUT it, so `computed(id)` is `None` →
    /// zero Rect — exactly the freed-id contract below. A cached last-layout rect
    /// would instead return STALE non-zero geometry for that freed id, which is
    /// why this rebuilds rather than caches. The only divergence from ink is the
    /// pathological commit-without-render-then-measure, which the reconciler flow
    /// does not produce.
    ///
    /// TRUST BOUNDARY: `id` is an arbitrary u32 from JS. An unknown, freed,
    /// out-of-range, or never-laid-out id (including the no-render-yet case, where
    /// `last_render_width` is `None`) returns a ZERO Rect — NEVER a panic, NEVER
    /// an `Err`. This matches ink's `measureElement` on an unmounted ref (yields
    /// zeros). `measure` is therefore infallible by contract.
    ///
    /// Reentrancy: it takes `&self`, but a `&self` read aliased against
    /// `render_frame`'s `&mut self` is STILL UB under napi-rs v3. So it is guarded
    /// too — but, because `measure` must stay infallible for bad input, a reentry
    /// returns the SAME safe zero-Rect sentinel instead of erroring.
    #[napi]
    pub fn measure(&self, id: u32) -> Rect {
        // Reentrancy guard FIRST. A `&self` read concurrent with the outer
        // render's `&mut self` is UB; return the infallible zero sentinel rather
        // than rebuild a layout against an aliased arena.
        if self.rendering {
            return Rect::zero();
        }
        // No render has happened yet → the tree was never laid out → zeros, like
        // ink's `measureElement` on an unmounted ref.
        let Some(width) = self.last_render_width else {
            return Rect::zero();
        };
        // Rebuild the per-frame engine at the last-rendered geometry and read the
        // computed rect. `build_layout_engine` is the same seam `render_frame`
        // uses, so the measurement is coherent with the displayed frame. A `None`
        // (degenerate tree) or an unknown/freed `id` (`computed` → `None`) both
        // collapse to a zero Rect — no panic, no error.
        let rect = build_layout_engine(&self.arena, self.root_id, width)
            .and_then(|(engine, _root_rect)| engine.computed(id));
        match rect {
            Some(r) => Rect {
                width: u32::from(r.width),
                height: u32::from(r.height),
                left: r.x,
                top: r.y,
            },
            None => Rect::zero(),
        }
    }

    /// `measureAbsolute(id) -> Rect` — like [`InkRoot::measure`], but `left`/
    /// `top` are ABSOLUTE (root-relative) coordinates: the sum of the rounded
    /// parent-relative offsets down the ancestor chain, i.e. the cell where the
    /// renderer paints the node (#124). `width`/`height` are identical to
    /// `measure`'s.
    ///
    /// Mirrors the jacob314/ink fork's `getBoundingBox` accumulation
    /// (`measure-element.ts`: summing `getComputedLeft/Top` up `parentNode`),
    /// which compat6's `getBoundingBox` shim is built on. ADDITIVE: `measure`'s
    /// parent-relative contract is untouched (wire/API covenant).
    ///
    /// Same semantics, trust boundary, and reentrancy contract as `measure`:
    /// rebuilds the layout at the last-rendered width; an unknown, freed,
    /// out-of-range, never-laid-out, or no-render-yet id returns the ZERO Rect
    /// — never a panic, never an `Err`; a reentrant call returns the same zero
    /// sentinel.
    #[napi]
    pub fn measure_absolute(&self, id: u32) -> Rect {
        // Reentrancy guard FIRST (see `measure` — `&self` aliased against the
        // outer render's `&mut self` is UB under napi-rs v3).
        if self.rendering {
            return Rect::zero();
        }
        // No render yet → never laid out → zeros.
        let Some(width) = self.last_render_width else {
            return Rect::zero();
        };
        // Same rebuild seam as `measure`; `computed_absolute` reads the
        // absolute rect the same `calculate` post-pass produced.
        let rect = build_layout_engine(&self.arena, self.root_id, width)
            .and_then(|(engine, _root_rect)| engine.computed_absolute(id));
        match rect {
            Some(r) => Rect {
                width: u32::from(r.width),
                height: u32::from(r.height),
                left: r.x,
                top: r.y,
            },
            None => Rect::zero(),
        }
    }

    /// `clear() -> Buffer` — ink's `log.clear()` (M3-K3): erase the live frame and
    /// zero the diff baseline, returning the erase bytes for the JS `Ink` (the sole
    /// stream writer) to emit.
    ///
    /// Thin bridge over [`FrameWriter::clear`]: returns `eraseLines(prevHeight)` and
    /// zeroes the [`LineDiff`](inkferro_rt) baseline so the next repaint is full,
    /// while PRESERVING `last_output*` (so `restore_last_output`/`sync_baseline` can
    /// repaint / re-pin afterwards). The one shared erase-emitting gesture the K3
    /// orchestration composes: interactive `writeToStdout`, `instance.clear()`, and
    /// resize-shrink all start here.
    ///
    /// Reentrancy-guarded like `commit`/`render_frame`: it mutates
    /// `&mut self.frame_writer`, which a transform dispatcher re-entering mid-render
    /// would alias against the render's own `&mut frame_writer` (the napi-v3
    /// segfault-on-reentry path). Reads `rendering` FIRST and rejects a reentrant
    /// call with the same catchable typed error, touching nothing else.
    #[napi]
    pub fn clear(&mut self) -> Result<Buffer> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("clear"));
        }
        Ok(self.frame_writer.clear().into())
    }

    /// `sync_baseline()` — ink's `log.sync(lastOutputToRender || lastOutput + '\n')`
    /// (M3-K3): re-pin the diff baseline to the current on-screen frame WITHOUT
    /// emitting any bytes.
    ///
    /// Thin bridge over [`FrameWriter::sync_baseline`]. Returns nothing (it writes
    /// no bytes — the pure-path `LineDiff::sync` is byte-free): `instance.clear()`
    /// composes `write(clear()); sync_baseline()` so a subsequent unchanged
    /// re-render diffs to a no-op (ink's "unmount's final onRender sees it as
    /// unchanged and log-update skips it").
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn sync_baseline(&mut self) -> Result<()> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("sync_baseline"));
        }
        self.frame_writer.sync_baseline();
        Ok(())
    }

    /// `restore_last_output() -> Buffer` — ink's `restoreLastOutput()` (M3-K3):
    /// repaint the last frame from the cleared baseline, returning the repaint bytes
    /// for the JS `Ink` (the sole stream writer) to emit.
    ///
    /// Thin bridge over [`FrameWriter::restore_last_output`]: after a `clear()`
    /// zeroed the baseline, this diff is a bootstrap that re-emits the FULL last
    /// frame. The interactive `writeToStdout` composes `clear() -> data ->
    /// restore_last_output()` so an app `console.log` is sandwiched between an erase
    /// of the live region and its repaint, BSU/ESU-wrapped by the JS caller.
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn restore_last_output(&mut self) -> Result<Buffer> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("restore_last_output"));
        }
        Ok(self.frame_writer.restore_last_output().into())
    }

    /// `composeConsoleWrite(data, sync) -> Buffer` — the FUSED interactive
    /// `writeToStdout` console-interleave (P1.2 / #1): one buffer carrying
    /// `bsu? + clear() + data + restoreLastOutput() + esu?`, the exact
    /// concatenation of the five writes the JS multi-write path used to make
    /// (ink `ink.tsx:687-698`). `sync` is the JS `shouldSync()` result
    /// (`stdout.isTTY && interactive`) — resolved JS-side because the InkRoot
    /// holds no stream/TTY state. The JS `Ink` class stays the SOLE stream
    /// writer: this returns bytes for ONE `stdout.write`.
    ///
    /// Thin bridge over [`FrameWriter::compose_console_write`], which composes
    /// the SAME `clear`/`restore_last_output` primitives (and their state
    /// transitions) the old path triggered — byte-identical in both the
    /// rendered and the nothing-rendered-yet (empty erase/repaint) states.
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn compose_console_write(&mut self, data: Buffer, sync: bool) -> Result<Buffer> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("compose_console_write"));
        }
        Ok(self.frame_writer.compose_console_write(&data, sync).into())
    }

    /// `composeConsolePrefix(sync) -> Buffer` — the stdout-side OPENING half of
    /// the interactive `writeToStderr` interleave: `bsu? + clear()`. Paired
    /// with [`compose_console_suffix`](Self::compose_console_suffix) so the JS
    /// path is exactly 3 writes: prefix->stdout, data->stderr, suffix->stdout —
    /// concatenating (per stream) to the same bytes as the old 5-write shape
    /// (ink `ink.tsx:719-727`).
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn compose_console_prefix(&mut self, sync: bool) -> Result<Buffer> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("compose_console_prefix"));
        }
        Ok(self.frame_writer.compose_console_prefix(sync).into())
    }

    /// `composeConsoleSuffix(sync) -> Buffer` — the stdout-side CLOSING half of
    /// the interactive `writeToStderr` interleave: `restoreLastOutput() +
    /// esu?`. MUST follow the matching
    /// [`compose_console_prefix`](Self::compose_console_prefix) (whose
    /// `clear()` zeroed the diff baseline) so the restore is the full-frame
    /// bootstrap.
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn compose_console_suffix(&mut self, sync: bool) -> Result<Buffer> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("compose_console_suffix"));
        }
        Ok(self.frame_writer.compose_console_suffix(sync).into())
    }

    /// `forget_last_output()` — ink's `resized()` `lastOutput = '';
    /// lastOutputToRender = '';` (`ink.tsx:466-467`): zero the writer's
    /// `last_output`/`last_output_to_render` so the post-clear re-render of the
    /// reflowed (possibly byte-IDENTICAL) frame is FORCED to repaint.
    ///
    /// Thin bridge over [`FrameWriter::forget_last_output`]. Emits no bytes. The
    /// resize-shrink path composes `write(clear()); forget_last_output();
    /// <re-render>`: `clear()` erases + zeroes the diff baseline, this zeroes
    /// `last_output` so the steady gate (`output != last_output`) re-opens even on
    /// an unchanged reflow. This REPLACES the pre-#41 `setCursor(undefined)` hack
    /// (which opened the gate via `cursor_dirty`) now that the cursor gate keys on
    /// POSITION change, not `cursor_dirty`.
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn forget_last_output(&mut self) -> Result<()> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("forget_last_output"));
        }
        self.frame_writer.forget_last_output();
        Ok(())
    }

    /// `reset_static_output()` — ink's `handleStaticChange` (`ink.tsx:522-525`),
    /// the Rust half (#118): zero ONLY the writer's accumulated
    /// `full_static_output` when the `<Static>` node's IDENTITY changes, so the
    /// clear-branch replay (`clearTerminal + fullStaticOutput + output`,
    /// ink `ink.tsx:1066`) never re-emits a dead `<Static>` instance's items.
    /// The JS `Ink.handleStaticChange` calls this alongside its own debug-side
    /// `fullStaticOutput = ''` reset; everything else in the writer
    /// (`last_output*`, diff baseline, cursor state) survives.
    ///
    /// Thin bridge over [`FrameWriter::reset_static_output`]. Emits no bytes.
    /// ADDITIVE method (wire-format covenant: no existing surface changed).
    ///
    /// Reentrancy-guarded like `clear`: it mutates `&mut self.frame_writer`.
    #[napi]
    pub fn reset_static_output(&mut self) -> Result<()> {
        // Reentrancy guard FIRST — before the `&mut self.frame_writer` borrow.
        if self.rendering {
            return Err(reentrancy_error("reset_static_output"));
        }
        self.frame_writer.reset_static_output();
        Ok(())
    }

    /// `push_input(bytes) -> Vec<InputEvent>` — feed the persistent input parser
    /// (M3-F).
    ///
    /// Wraps core's [`Parser::feed`](inkferro_core::input::Parser::feed) (the
    /// ported `input-parser.ts` + `parse-keypress.ts` + kitty pipeline). The
    /// `Parser` is owned by `InkRoot` (the `input_parser` field) and NEVER
    /// recreated per call: its kitty/legacy segmenter is a state machine that
    /// buffers partial sequences across chunks, so a CSI sequence split across two
    /// `push_input` calls must resume from the carried state.
    ///
    /// Each core [`CoreInputEvent`] is mirrored into a `#[napi(object)]`
    /// [`InputEvent`]. For a key event the JS-facing [`Key`] is derived EXACTLY as
    /// ink's `useInput` `handleData` derives it from `parseKeypress`'s output
    /// (`use-input.ts:179-242`): the camelCase boolean fields (`upArrow`, …,
    /// `return`, `escape`, `tab`, `backspace`, `delete`) plus the raw modifiers
    /// and kitty fields, AND the resolved `input` string. This collapses ink's
    /// in-hook `parseKeypress` into the FFI, so the JS `useInput` (M3-I) becomes a
    /// thin subscriber.
    ///
    /// Reentrancy-guarded: `feed` mutates `&mut self.input_parser`, which a
    /// dispatcher re-entering mid-render would alias. (Inputs are dispatched on
    /// the JS main thread between renders, so a real reentry is pathological, but
    /// the guard keeps the memory-safety invariant total.) Reads `rendering` FIRST
    /// and rejects with the same catchable typed error.
    #[napi]
    pub fn push_input(&mut self, bytes: Buffer) -> Result<Vec<InputEvent>> {
        // Reentrancy guard FIRST — before the `&mut self.input_parser` borrow.
        if self.rendering {
            return Err(reentrancy_error("push_input"));
        }
        Ok(self
            .input_parser
            .feed(bytes.as_ref())
            .into_iter()
            .map(InputEvent::from_core)
            .collect())
    }

    /// `has_pending_escape() -> bool` — whether a bare/partial escape is buffered
    /// in the parser awaiting the host's escape-flush timer (M3-I2 / task #52).
    ///
    /// Thin read over core's
    /// [`Parser::has_pending_escape`](inkferro_core::input::Parser::has_pending_escape)
    /// (`input/mod.rs` → `segmenter.rs`): `true` only while a lone `\x1b` (or a
    /// short incomplete escape) is buffered, and explicitly `false` while
    /// assembling a paste-start marker (`ESC [ 2 0 0`) or any other partial CSI —
    /// so an Esc-PREFIXED sequence (e.g. `\x1b[A` → upArrow) never reads as a bare
    /// Esc. The JS `App` polls this after draining a readable burst and, when set,
    /// arms the 20ms `schedulePendingInputFlush` timer that surfaces the buffered
    /// Esc via `flush_pending_escape`.
    ///
    /// Reentrancy: it takes `&self`, but — exactly like `measure` — a `&self` read
    /// aliased against `render_frame`'s `&mut self` is STILL UB under napi-rs v3.
    /// Because this method must stay infallible (it has no error channel a JS
    /// poll could branch on), a reentry returns the SAME safe `false` sentinel
    /// `measure` mirrors with its zero-Rect: under render there is, by definition,
    /// no host escape-flush timer to arm, so reporting "no pending escape" is the
    /// correct conservative answer.
    #[napi]
    pub fn has_pending_escape(&self) -> bool {
        // Reentrancy guard FIRST. A `&self` read concurrent with the outer
        // render's `&mut self` is UB; return the infallible `false` sentinel
        // rather than read an aliased parser.
        if self.rendering {
            return false;
        }
        self.input_parser.has_pending_escape()
    }

    /// `flush_pending_escape() -> Vec<InputEvent>` — take any buffered escape
    /// bytes as literal input, decoded to the SAME `InputEvent` shape `push_input`
    /// returns (M3-I2 / task #52).
    ///
    /// Wraps core's
    /// [`Parser::flush_pending_escape`](inkferro_core::input::Parser::flush_pending_escape),
    /// which `mem::take`s the buffered bytes. A lone `\x1b` here decodes — via the
    /// SAME `parse_keypress` + [`InputEvent::from_core`] mapping `push_input` uses
    /// — to a normal key event `{kind:'Key', input:'', key:{escape:true, …}}`, so
    /// JS receives a parsed Esc keypress, NOT raw bytes. `None` (nothing pending)
    /// yields an empty `Vec`.
    ///
    /// CRITICAL — the flushed bytes MUST be decoded with `parse_keypress`
    /// directly, NOT re-fed through `self.input_parser.feed`. The segmenter treats
    /// a lone `\x1b` as an INCOMPLETE sequence and would re-buffer it (returning
    /// `[]` and re-arming `has_pending_escape`), so a `feed`-based flush would
    /// never surface the Esc. Decoding the taken bytes directly is what makes the
    /// post-flush `has_pending_escape()` go `false` — the discriminating invariant.
    ///
    /// Reentrancy-guarded like `push_input`: `flush_pending_escape` mutates
    /// `&mut self.input_parser`, which a dispatcher re-entering mid-render would
    /// alias. Reads `rendering` FIRST and rejects with the same catchable typed
    /// error.
    #[napi]
    pub fn flush_pending_escape(&mut self) -> Result<Vec<InputEvent>> {
        // Reentrancy guard FIRST — before the `&mut self.input_parser` borrow.
        if self.rendering {
            return Err(reentrancy_error("flush_pending_escape"));
        }
        // `None` ⇒ nothing pending ⇒ no events. Some(bytes) ⇒ decode the taken
        // bytes through `parse_keypress` (NOT `feed`, which would re-buffer a lone
        // ESC) so the bare Esc surfaces as a normal `{key:{escape:true}}` event.
        Ok(self
            .input_parser
            .flush_pending_escape()
            .map(|bytes| {
                vec![InputEvent::from_core(CoreInputEvent::Key(parse_keypress(
                    &bytes,
                )))]
            })
            .unwrap_or_default())
    }
}

/// RAII guard for the `rendering` reentrancy flag: sets it true on `engage` and
/// clears it on `Drop` — so a normal return, an `Err`, or an unwinding panic all
/// leave `rendering == false` and the instance re-callable. Holds a borrow of
/// ONLY the bool (not the whole `InkRoot`), so the render body can borrow the
/// other fields disjointly with no raw pointers or unsafe.
struct RenderingGuard<'a>(&'a mut bool);

impl<'a> RenderingGuard<'a> {
    fn engage(flag: &'a mut bool) -> Self {
        *flag = true;
        RenderingGuard(flag)
    }
}

impl Drop for RenderingGuard<'_> {
    fn drop(&mut self) {
        *self.0 = false;
    }
}

/// The catchable typed error a reentrant `commit`/`render_frame` returns.
fn reentrancy_error(method: &str) -> Error {
    Error::new(
        Status::GenericFailure,
        format!(
            "InkRoot.{method} re-entered during a render — a transform dispatcher must not call \
             back into commit/render_frame (the receiver is already borrowed; reentry is rejected \
             to avoid memory corruption)"
        ),
    )
}

/// The `render_frame` body, hoisted to a free function taking the `InkRoot`
/// fields it needs by DISJOINT borrow so it can run alongside the
/// `RenderingGuard`'s `&mut self.rendering` borrow. See `InkRoot::render_frame`
/// for the guard wiring and the throw-discard ordering contract.
#[allow(clippy::too_many_arguments)]
fn render_frame_impl(
    arena: &Arena,
    frame_writer: &mut FrameWriter,
    dispatcher: &FunctionRef<FnArgs<(u32, String, u32)>, String>,
    root_id: u32,
    cursor: Option<RtCursorPos>,
    env: Env,
    mode: RenderMode,
    opts: RenderOpts,
) -> Result<Option<FrameResult>> {
    let start = std::time::Instant::now();

    // ── 1. Style pass (accumulate-into-locals; dispatcher throws land here) ──
    // The error cell is the out-of-band channel for the infallible `Transformer`
    // signature. The accessor mints a per-id closure that dispatches to JS; a
    // throw is stored here and short-circuits the rest of the walk. `borrow_back`
    // happens INSIDE the closure so the captured dispatcher borrow stays `'a`
    // across the recursion.
    let err_cell: RefCell<Option<Error>> = RefCell::new(None);
    let err_ref = &err_cell;
    let transform_of = |id: u32| -> Option<BoxedTransform<'_>> {
        let node = arena.get(id)?;
        if !node.has_transform {
            return None;
        }
        Some(Box::new(move |s: &str, index: usize| -> String {
            // Once any dispatcher throws, stop firing JS calls — return the input
            // untouched so the remaining walk is a cheap no-op.
            if err_ref.borrow().is_some() {
                return s.to_owned();
            }
            let f = match dispatcher.borrow_back(&env) {
                Ok(f) => f,
                Err(e) => {
                    *err_ref.borrow_mut() = Some(e);
                    return s.to_owned();
                }
            };
            // `index` is the per-write LOCAL 0-based line index the core walk
            // computes (ink `output.ts` `lines.entries()`); forward it verbatim as
            // the 3rd JS arg (`usize` → `u32`, mirroring `height as u32`).
            match f.call((id, s.to_owned(), index as u32).into()) {
                Ok(out) => out,
                Err(e) => {
                    // First throw wins; later nodes short-circuit above.
                    *err_ref.borrow_mut() = Some(e);
                    s.to_owned()
                }
            }
        }))
    };

    // Convert the JS-detected chalk.level (`opts.color_level`, 0–3) to a
    // ColorLevel and thread it into both render passes, so core colorize (borders;
    // Box bg fill) emits SGR at the SAME level chalk would — no SGR at level 0,
    // downgraded codes at 1/2, truecolor at 3. `<Text>` color is already chalk-
    // colorized JS-side and unaffected.
    let color_level = ColorLevel::from_u8(opts.color_level);

    let (plain_output, height) =
        render_styled(arena, root_id, opts.cols, &transform_of, color_level);

    // Static (second) render pass: the `<Static>` subtree rendered once, above the
    // live region (ink's renderer.ts static branch). `transform_of` is `Copy`
    // (shared refs only) so reusing it here is free, and a dispatcher throw fired
    // during this pass lands in the SAME `err_cell` — which is exactly why this
    // call MUST precede the throw-discard barrier below. No static node → "" (the
    // common case), keeping the no-static path byte-identical to before.
    let static_output = render_static(arena, root_id, opts.cols, &transform_of, color_level);

    // ── 2. Throw-discard barrier: surface any dispatcher throw BEFORE any
    //       FrameWriter mutation. `transform_of` is `Copy` (captures only shared
    //       refs), so NLL ends its borrow of `arena`/`err_cell` at its last use
    //       (`render_static` above) — `err_cell` is then free to move out. Both
    //       render passes feed this one cell, so a throw in EITHER pass aborts the
    //       frame before any transport write.
    if let Some(err) = err_cell.into_inner() {
        return Err(err);
    }

    // ── 3. Transport write. Build the exact FrameParams for the mode, then drive
    //       write_frame. Debug/ScreenReader use a THROWAWAY writer so they consume
    //       no diff state; Diff/NonInteractive advance the live writer.
    let debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
    let is_tty = !matches!(mode, RenderMode::NonInteractive);

    let params = FrameParams {
        is_tty,
        viewport_rows: opts.rows as usize,
        output: &plain_output,
        output_height: height as usize,
        // Static (second-pass) output: the `<Static>` subtree printed once above
        // the live region (renderer.ts static branch). "" when the tree has no
        // static node (the common case) — then `write_frame` sees the same empty
        // static channel it always did, so the no-static path is unchanged.
        static_output: &static_output,
        is_unmounting: false,
        // `cursor_dirty` is retained on the params (the rt no longer keys its
        // cursor-render gate off it — that is the resolved `cursor` vs the
        // writer's previous cursor). Set it to whether an active cursor is present
        // so the field stays semantically meaningful, though the rt ignores it.
        cursor_dirty: cursor.is_some(),
        // The resolved ACTIVE cursor (`cursorDirty ? cursorPosition : None`,
        // computed by the wrapper). The rt composes the ink-faithful cursor escape
        // bytes from this against its `previous_cursor_position`.
        cursor,
        // `interactive`/`is_in_ci` resolve sync wrapping; deferring to the CI flag
        // (`None`) with `is_in_ci=false` keeps the interactive path synchronized
        // (matches ink's default TTY behavior). M3-K1 owns any override.
        interactive: None,
        is_in_ci: false,
        debug,
    };

    let (bytes, changed_lines) = if debug {
        // Throwaway writer: a debug/screen-reader render is a pure query of the
        // arena at this width and must NOT advance the live diff baseline. Its
        // `changed_lines` telemetry is read off the same throwaway writer.
        let mut w = FrameWriter::new();
        let b = w.write_frame(&params);
        (b, w.last_changed_lines())
    } else {
        let b = frame_writer.write_frame(&params);
        (b, frame_writer.last_changed_lines())
    };

    let render_time_ms = start.elapsed().as_secs_f64() * 1000.0;

    // No-change frame: write_frame returns an empty Vec (rt "willRender" no-op).
    // Mirror it as `None`.
    if bytes.is_empty() {
        return Ok(None);
    }

    Ok(Some(FrameResult {
        bytes: bytes.into(),
        output_height: height as u32,
        plain_output: if opts.include_plain_output.unwrap_or(true) {
            plain_output
        } else {
            String::new()
        },
        // Surface the real second-pass string (the same value written into
        // `FrameParams.static_output` above). "" when the tree has no static node.
        static_output,
        render_time_ms,
        // Additive per-frame telemetry: how many visible lines this frame
        // rewrote (read off the writer that produced `bytes`). Byte-inert.
        changed_lines,
    }))
}