abstracttui 0.6.0

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
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
# render — cells, surfaces, compositor, diff, presenter

Owner: RENDER. Scope: `src/render/**`, `src/text/**`, `src/anim/**`.
Cycle-2 amendments are marked [C2]; the damage contract
(`docs/design/01-damage-contract.md`) is binding and this doc defers to it.

## 1. Research notes (cycle 1)

### 1.1 ratatui: buffer diff

Source studied: `ratatui-core/src/buffer/{buffer,diff}.rs` (v0.30 line) and the
v0.30.1 release notes.

- `Buffer` is a flat `Vec<Cell>`; `Cell` holds its grapheme in a
  `CompactString` (24-byte small-string, heap spill past that), plus
  fg/bg/underline color and a modifier bitfield.
- Diffing is a zero-allocation iterator (`BufferDiff`) yielding
  `(x, y, &Cell)` for each changed cell. Two counters drive wide-char
  correctness:
  - `to_skip = width(next_symbol) - 1`: after emitting a wide leader, the
    covered trailing cells are not emitted.
  - `invalidated = max(width(next), width(prev)) - 1`: when the *previous*
    frame had a wide glyph here, the following cells must be treated as
    changed even if they compare equal, because the terminal's ground truth
    was overwritten by the wide glyph.
- Special case: emoji presentation sequences containing VS16 (U+FE0F) get
  their trailing cells *explicitly* re-emitted, because several terminals
  fail to clear the second column of such glyphs on their own.
- There is no damage input: diff always scans the full buffer. There is no
  run structure either; the backend re-positions per contiguous cell and
  diffs style lazily per emitted cell.

### 1.2 notcurses: cells, planes, egcpools

Sources studied: `notcurses_cell(3)`, `notcurses_plane(3)`,
`notcurses_render(3)`, `egcpool.h`, `render.c`, `internal.h`, FOSDEM 2021
slides.

- `nccell` is exactly 16 bytes: `gcluster` (4B), `gcluster_backstop` (1B,
  always 0), `width` (1B, cached column width), `stylemask` (2B),
  `channels` (8B: two 32-bit channels, each 24-bit RGB + alpha/default/
  palette-index flag bits).
- EGC storage: if the cluster fits in 4 UTF-8 bytes (true for every single
  codepoint) it is inlined into `gcluster`; longer clusters spill into a
  per-plane `egcpool` (ring buffer, ≤16 MiB) and the cell stores a 24-bit
  offset, disambiguated by a 0x01 first byte (control bytes never enter
  cells, so 0x01 cannot appear in valid inline UTF-8 content).
- Width is *cached in the cell* so render/raster never re-measure.
- Two-phase pipeline: *render* flattens a z-ordered pile of planes into one
  cell matrix (top-down; the topmost glyph wins, backgrounds keep
  accumulating until opaque); *rasterize* compares that matrix against
  `lastframe` (a damage map) and emits minimal escapes while carrying a
  persistent `rasterstate`: physical cursor position, last fg/bg RGB, current
  attribute set, and "elidable" flags so redundant SGR/motion is skipped
  across frames.
- Why it is fast: POD 16-byte cells (2 per cache line pair), spill is rare,
  damage map bounds emission to changed cells, and the persistent raster
  state elides most escapes.

### 1.3 OpenTUI (Zig core): frame diff

Sources studied: `packages/core/src/zig/renderer.zig`, DeepWiki rendering
pipeline notes, commit 56ff129 ("pack color metadata into RGBA high bytes").

- Double buffer of struct-of-arrays cells (`chars: u32` codepoint/cluster
  id, fg/bg RGBA, attribute+link word), rendered into a pre-allocated
  multi-MB output buffer.
- Diff walks every cell of the frame; unchanged cells (`char`, attrs, fg,
  bg equal) emit zero bytes. Changed cells are grouped into *runs* with
  identical styling: position + SGR once per run, then raw glyph bytes.
- Colors were originally compared with a float epsilon; the project later
  moved to packed integer RGBA compared *exactly*, and to deterministic
  integer source-over blending — float color math in the diff path proved
  to be a correctness and determinism liability. This validates our choice
  of `u8`-channel `Rgba` end to end.
- Wide glyphs occupy leader + placeholder continuation cells, like ours.

### 1.4 What we do differently

| Concern | ratatui | notcurses | OpenTUI | AbstractTUI |
| --- | --- | --- | --- | --- |
| Diff scope | full frame | full frame vs lastframe damage map | full frame | **damage rects from the compositor**; only damaged rows are scanned |
| Cell size | 24B + heap spill (CompactString) | 16B, 4B inline EGC | SoA, u32 cluster id | 24B AoS, **10B inline EGC** + per-surface pool |
| Emission | per-cell, lazy style | persistent rstate, elision | per-run SGR | per-run SGR state machine + cursor motion economy + byte-cost compare (incremental vs reset) |
| Color model | u8 RGB | 24-bit channels + flags | f32 → packed u16 RGBA | u8 RGBA everywhere; alpha=0 means "terminal default" at the presenter |
| Compositing | none (single buffer) | top-down glyph lock-in | none (widgets draw into one buffer) | bottom-up source-over with per-layer opacity, wide-pair repair pass |

The bet: fine-grained reactivity gives us *small damage*, so a diff that
only scans damaged rows beats full-frame scans at scale, and the compositor
gives animations (move/fade a layer) without re-rendering content.

## 2. Decisions

### 2.1 Cell = 28 bytes [C2], `Glyph` inlines ≤ 10 bytes

```text
Cell { glyph: Glyph(12B), fg: Rgba(4B), bg: Rgba(4B), ul: Rgba(4B),
       attrs: Attrs(2B), link: u16 }
Glyph { data: [u8; 10], len_or_tag: u8, width: u8 }
```

- `size_of::<Cell>() == 28` (align 2, no padding; within the sanctioned
  24–32 budget). Statically asserted. [C2] `ul` is the underline color
  (SGR 58/59; alpha 0 = default). It is a VALUE, not an interned id —
  colors need no table, and a third id space would recreate the ambient
  ownership problem RT1-4 kills.
- 10 inline bytes cover: every single codepoint (UTF-8 ≤ 4B), combining
  stacks up to ~3 marks, CJK+VS16 (6B), emoji+VS16 (7B), keycaps (7B),
  flags (8B), skin-tone pairs (8B). Only ZWJ sequences (families, ≥ 11B)
  and exotic mark stacks spill to the per-`Surface` pool.
- Rejected: notcurses' 4-byte inline — it spills for *every* emoji
  presentation sequence and flag, which are common in modern UI content.
  Rejected: `CompactString`-style 24-byte glyph — the cell would grow to
  ~40 bytes and halve cache density for a case the pool covers.
- The `width` byte caches the cluster's display width (notcurses lesson:
  never re-measure in diff/present).
- Continuation cells (`TAG_CONTINUATION`) occupy the trailing column of a
  wide glyph. Invariant: a continuation always sits immediately right of
  its leader and **mirrors the leader's fg/bg/attrs/link**. Every write API
  repairs violated pairs by blanking the orphan half (space, style kept).
- Pool ids are *surface-local*. Cross-surface equality (diff) and adoption
  (blit/flatten) resolve through the owning surface's pool; inline-vs-pooled
  can never be content-equal because spill only happens past 10 bytes.
- [C2, RT1-4] Pool ownership is a type-level fact: every `Surface` owns
  exactly one pool + one link table (the flattened frame is a Surface, so
  it owns its own — flatten adopts foreign ids on write). The raw
  resolution primitives (`Glyph::new`, `Glyph::as_str`, `content_eq`) are
  crate-private; the public path is `Surface::glyph_str(&self, &Cell)`,
  which can only resolve through the owning pool. `Surface::debug_validate()`
  (`#[cfg(any(test, debug_assertions))]`) is the structural oracle for
  REDTEAM property tests: pairs intact, styles mirrored, pool/link ids in
  range.
- [C2, RT1-14] Growth caps with labeled degradation: the glyph pool caps
  at `GLYPH_POOL_CAP` (4096) unique long clusters — past it, new clusters
  render as visible U+FFFD and `GlyphPool::dropped()` counts them; the
  link table caps at 65535 URIs — past it, `register_link` returns 0
  (plain text, never a wrapped id: mislinking is worse than dropping) and
  `Surface::links_dropped()` counts. Compaction hook: `Surface::clear`
  resets the pool (the only moment ids are provably unreferenced); link
  ids are handed out to callers, so the link table deliberately survives
  `clear` — its bound is the cap.

### 2.2 Compositor

- `Layer { surface, origin, z, opacity, visible, blend: Normal }` with
  damage recorded in *frame coordinates at damage time* (so moving a layer
  damages both old and new bounds without coordinate skew).
- Flatten walks layers bottom-up per damaged cell:
  - effective color = channel alpha × layer opacity;
  - opaque bg replaces the accumulator wholesale (fast path);
  - translucent bg composites via `Rgba::over`; a glyph-bearing upper cell
    takes the glyph slot and blends its fg over the new bg; a glyph-less
    upper cell *veils* the lower glyph (`fg = upper_bg over fg`).
  - `Glyph::EMPTY` is see-through (compositing transparency); a space is a
    real glyph that erases. Widgets paint background with EMPTY + bg color.
- Damage rects are expanded ±1 column (wide pairs sliced by rect edges),
  coalesced, and capped: above `max_rects` they collapse to their union;
  a union covering most of the frame degrades to full-frame damage.
- After blending, a repair pass re-establishes pair invariants and
  re-mirrors continuation style from the leader — the diff and any VT model
  can then assume continuation style equals leader style.

### 2.2b Layer effects: blend modes, color transforms, cell shaders [C3]

Per-cell contribution pipeline, order contractual:

```text
src cell -> shader.shade(x, y, shader_t, cell) -> ColorTransform
         -> opacity (alpha scale) -> blend into accumulator
```

- **`Blend::Additive`**: saturating premultiplied add
  (`acc + src*src.a/255` per channel, alpha saturating too). Black adds
  nothing; light accumulates toward white — afterglow, particles,
  scanline highlights. A cell holds ONE glyph, so an additive glyph takes
  the glyph slot and its ink renders as light over the lit ground; a
  glyph-less additive cell brightens both the ground and any kept glyph
  ink. Adding onto an alpha-0 ("terminal default") accumulator treats the
  unknown color as black — the only honest choice without the theme
  ground.
- **`ColorTransform`** (one per layer): `None | Dim(f) | Tint(color, s) |
  Grayscale(s)` — the fixed-function grade for fades/disables. Alpha is
  never edited (coverage is opacity's job); alpha-0 slots pass through
  (no RGB to grade). Composing several grades belongs in a shader, not in
  stacked fixed-function state.
- **`CellShader`** (the post-processing hook):
  `shade(&self, x, y, t, cell) -> Cell`, applied to the layer's damaged
  cells at flatten. `x/y` are frame coordinates; `t` is per-layer
  (`Layer::set_shader_t`), driven by the app clock — replaying the same
  layer state composes the same frame, no hidden compositor time.
  Determinism contract: pure in `(x, y, t, cell)` + construction params;
  no interior mutability; built-ins use a triangle wave and an integer
  position hash, never libm transcendentals (bit-stable across
  platforms, REDTEAM goldens them). **Damage/billing rule**: shaders run
  only where damage exists; an animated shader is an ANIMATION —
  advancing `shader_t` damages the layer bounds and the driver requests
  frames for it; a static shader costs nothing after its first paint.
  Wide pairs: a shader that split-styles a leader/continuation pair is
  overruled by the repair pass — the pair renders with the leader's
  final style.
- **`CellShader::changed_region(t0, t1, bounds) -> Option<Rect>` [C7,
  RT6-3]**: the active-region hint. The returned rect is where output may
  differ between the two clocks; OUTSIDE it the shader guarantees
  bit-stability (`shade(x,y,t0,c) == shade(x,y,t1,c)` for every cell).
  `Layer::set_shader_t` damages only this region (intersected with the
  layer bounds) instead of the whole layer: a `Vignette` (ignores `t`) or
  a settled reveal ticks for FREE; a `Sweep`/`ScanlineFade` band damages
  its slab; `Shimmer`/`Rainbow` honestly return `None` (the wave moves
  every ink cell — only exact phase equality is bit-safe, since integral
  phase deltas round differently per cell). Default `None` = old
  whole-bounds behavior, so third-party shaders are unaffected. The
  contract is deliberately *stability*, not identity-with-source (a
  reveal's hidden cells are transparent at both clocks — stable is what
  damage needs). Implementations must be conservative — too-small rects
  are the one dishonesty the property test hunts
  (`changed_region_hints_are_honest_for_every_builtin`: exhaustive grid
  sweep outside the hint over mid-flight/settled/rewind/wrap clock
  pairs).
- Built-ins (`anim::shaders`, since effects are time-driven and anim sits
  above render): `Shimmer` (diagonal luminance ripple over ink, ground
  untouched), `ScanlineFade` (top-down reveal; ground fades in, glyph
  pops at half coverage), `HueDrift` (channel-rotation pulse for focus
  accents), `Dissolve` (seeded per-cell hash threshold; monotone per
  cell). All identity-off: defaults (no shader, `ColorTransform::None`,
  `Blend::Normal`, opacity 1) are byte-identical to the ungraded
  compositor — test-pinned.

### 2.2e Paint helpers: gradients + drop shadow [C6]

- `render::paint::fill_gradient(surface, rect, &GradientSpec)`: linear
  (visual angle, cell aspect 1:2 corrected) or radial (unit-rect center,
  t=1 at the farthest corner), N sorted color stops, sRGB lerp between
  stops (the engine's ONE lerp), background-only (glyphs/attrs/links
  preserved; both draw orders compose). Banding at cell resolution is
  broken by a 4x4 ordered Bayer dither on the interpolation fraction,
  bounded to one lerp step (`without_dither()` for exact-color asserts).
  One-time paint: damages the rect once, never a per-frame cost.
- `render::paint::drop_shadow(panel, offset, feather, color, z) -> Layer`
  — the elevation recipe: a translucent ramp layer (Chebyshev falloff
  over `feather` cells) placed BELOW the panel's layer; the compositor's
  Normal blend + theme ground make it honest over default-bg cells.
  Shadows are layer CONTENT (move/fade via handles), not a post-effect.

### 2.2f Damage visualizer [C6]

`Compositor::set_debug_damage(true)` outlines every composed frame's
damage rects in a magenta bg tint — the minimal-damage claim made
visible for devs and REDTEAM. The outline is real frame content (diffed
and emitted like anything else), so it is a DIAGNOSTIC mode: bytes and
pixels change while on; never enable in byte-golden tests. Stale
outlines persist until damage covers them (they are pixels); wide pairs
stay consistent (leader-tint mirrors, repair pass runs).

### 2.3 Diff

`FrameDiff::compute(prev, next, damage) -> &[Run]`,
`Run { y, x, len }` = contiguous same-row span of changed cells.

- Damage rects → per-row column intervals (merged, ±1 col expansion), so
  overlapping rects never produce overlapping runs.
- Wide handling: a changed wide leader forces its continuation into the run
  (`force` counter — ratatui's `to_skip`, inverted); a changed continuation
  with an (impossibly) equal leader starts the run one cell left so the
  leader is re-emitted. Because continuation cells are first-class values,
  the prev-wide→next-narrow case falls out naturally: the old continuation
  compares unequal to any narrow cell, so the stale half is always re-emitted
  (ratatui's `invalidated` counter, without the counter).
- Size mismatch between prev/next → full-frame damage (resize path).
- Scratch (`runs`, row intervals) is owned by `FrameDiff` and reused;
  steady-state compute allocates nothing once buffers are warm.

### 2.4 Presenter

Deterministic byte emission; REDTEAM snapshots exact bytes.

- **Cursor motion economy**: same row → nothing / `CR` (to col 0) /
  `CUF n` / `CUB n`; row change or unknown → `CUP`. LF/VPA tricks are
  rejected: they depend on ONLCR/origin-mode state we do not own.
- **Virtual cursor** advances by the emitted glyph's cached width.
  Continuation cells inside a run emit nothing (the leader's advance covers
  them).
- **Last-column strategy (the wrap hazard)**: we *do* write the last column
  (including bottom-right) and rely on deferred autowrap (xterm and every
  modern VT descendant: the wrap is pending until the *next* glyph is
  printed). After any last-column write the virtual cursor is invalidated,
  so the next emission always begins with an absolute `CUP`, which clears
  the pending-wrap state — no glyph is ever printed while a wrap is
  pending, so the screen can never scroll. Wide glyphs never start in the
  last column (surface invariant degrades them to a blank at write time),
  so the pending-wrap analysis only involves width-1 emissions.
- **SGR state machine**: persistent across frames. For each style change it
  builds both candidate encodings — incremental (attr removals via
  22/23/24/25/27/28/29 with bold/dim (22) and underline/undercurl (24)
  shared-reset re-adds, then additions 1,2,3,4,4:3,5,7,8,9, then fg, bg) and
  full reset (`0` + everything) — and emits the shorter (tie → incremental).
  Deterministic parameter order = declaration order of the attr bits.
  Undercurl is SGR `4:3`; when UNDERLINE and UNDERCURL are both set only
  `4:3` is emitted.
- **Color**: fg/bg with alpha 0 map to SGR 39/49 ("terminal default") —
  which is also exactly the state after `SGR 0`, so the reset state is
  representable. Truecolor uses `38;2;r;g;b`/`48;2;r;g;b`. [C2] The pen
  stores colors as *resolved emission representations* (default / RGB /
  palette index): comparisons happen post-downlevel, so two truecolor
  values on one palette slot emit a single SGR, and a background change
  that moves a pair-preserving nudge correctly re-emits an "unchanged"
  foreground. Downlevel:
  - **Palette data** [C2, RT1-7]: `base::palette::{SYSTEM_16, XTERM_256,
    CUBE_LEVELS}` — the ONE table shared with the testing VT model. This
    module owns only policy; a drift test pins the midpoint thresholds to
    the base levels.
  - **xterm-256**: cube levels `[0,95,135,175,215,255]` (midpoint
    thresholds 48/115/155/195/235), index `16 + 36r + 6g + b`; gray ramp
    `232..=255` with value `8 + 10(i-232)`, candidate index from luma
    `(2126·R + 7152·G + 722·B) / 10000`; pick the smaller squared RGB
    distance, tie → cube.
  - **16-color**: nearest `SYSTEM_16` entry by squared distance (tie →
    lower index), emitted as 30–37/90–97 and 40–47/100–107.
  - **Pair contrast preservation** [C2, DESIGN req 3]: when both fg and bg
    are concrete, they quantize JOINTLY (`quantize_pair_256/16`): if the
    per-color nearest indices collide while the originals differed (dark
    theme faint-text pairs), the fg re-picks the nearest *distinct*
    palette entry whose integer luma keeps the original light/dark
    ordering against the bg (falling back to nearest-distinct when the bg
    sits at the palette extreme). Genuinely identical colors still
    collapse.
  - **Set contrast preservation** (`quantize_set_256`): the pair policy
    sees the two colors inside ONE cell, so it cannot protect two GROUNDS
    that meet across a cell boundary — a panel fill collapsing into the
    field behind it, which happens in 15 of the 26 built-in themes
    (`tests/theme_quantisation_grounds.rs`). That pair is resolvable
    upstream instead, once per theme and depth: `quantize_set_256` hands
    each color of a set its own palette entry. Byte-identical colors
    share one (the same `rgb_eq` rule as the pair path); where two want
    the same entry, the one the palette represents most exactly keeps it,
    the other takes the nearest entry that is unclaimed, keeps the
    authored light/dark ordering, and is not the natural entry of a color
    still to be placed. The ground list itself is the theme's
    (`TokenSet::grounds`), not a copy — a ground added there is measured
    and separated without anyone remembering to.

    The result reaches emit as a `PaletteAssignment`: `(color, index)`
    pairs installed on the presenter (`set_palette_assignment`) and
    consulted by `resolve_pen` ahead of the nearest lookup. An empty
    assignment — the default — is byte-for-byte the plain path.
    Precedence where the two policies meet: the assignment is a
    PREFERENCE, the fg/bg pair separation is a GUARANTEE. If an assigned
    ground collides with the foreground drawn on it, the foreground still
    moves — two surfaces reading as one is a defect, text reading as its
    own background is erased. Whoever installs an assignment owns the
    repaint, because the frame diff will not re-emit unchanged cells.
    16-color is not assignment-aware: those registers are user-themable,
    so no build-time decision can know what they render as. Nothing
    installs one yet — the theme/driver wiring is open on
    `claim:tui-audit-does-not-survive-quantisation`.
- **Underline color** [C2, DESIGN req 2]: SGR 58 in ISO-8613 colon form —
  `58:2::r:g:b` truecolor, `58:5:n` on 256-color — and `59` for the reset
  to default; emitted only when `caps.underline_color` AND the cell has an
  underline attr (colorless otherwise = zero bytes). Downlevel is labeled:
  without the cap (or on 16-color, which has no SGR 58 palette form) the
  color drops and the plain underline stays. `SGR 0` also resets 59-state,
  so the reset encoding needs no extra param.
- **Risky-cluster cursor discipline** [C2, RT1-7]: after emitting any
  cluster containing VS16, ZWJ, or an East-Asian-Ambiguous character, the
  presenter forgets its virtual cursor; the next emission (same run
  included) re-anchors with absolute CUP. Terminals genuinely disagree on
  these widths (xterm renders ZWJ families at component width; ambiguous
  chars go wide under CJK configs) — re-anchoring confines the damage to
  the risky cluster itself. Deliberate carve-out: U+2500–U+25FF (box
  drawing, blocks, geometric shapes) are ambiguous by UAX #11 but NOT
  flagged — they are the fabric of TUI chrome (per-border-cell CUPs would
  dominate output exactly where it is densest), they render from native
  monospace fonts rather than the emoji fallback that widens symbols in
  practice, and an ambiguous-wide terminal breaks cell layout globally in
  a way cursor re-anchoring cannot repair anyway.
- **Hyperlinks**: OSC 8 with `id=<n>` param, ST-terminated
  (`ESC ] 8 ; id=N ; uri ESC \` … `ESC ] 8 ; ; ESC \`). Link ids are
  surface-local; the presenter tracks the open link by URI string.
- **DEC 2026**: `CSI ? 2026 h` / `l` wrap the whole frame when the caps say
  the terminal supports it. Zero runs → zero bytes (no empty brackets).
- **Frame trailer**: close any open hyperlink, `SGR 0`, park the cursor at
  the bottom-left cell (via normal motion economy; bottom-*right* would arm
  the wrap hazard). The trailer means no SGR/link state ever crosses a
  frame boundary — cross-frame presenter state is just the virtual cursor
  and "pen is at defaults"; `invalidate()` forgets both after external
  writes (gfx protocols, suspend) **and on resize** (0298): an emulator
  reflow moves the physical cursor unpredictably, so the parked virtual
  cursor is a ghost — the driver pairs the prev-poison (re-emit every
  cell) with a presenter invalidate (re-anchor absolutely), because a
  poisoned frame PLACED by relative motion from a ghost cursor paints
  the whole first run at an offset and leaves a stale band on screen.

### 2.4b External bytes + the flush contract [C2]

- **Presenter custody (damage contract §6)**: pixel-protocol payloads
  (kitty APC, iTerm2 OSC 1337, sixel DCS) reach the terminal ONLY through
  `Presenter::external_write(out, bytes, at)`: close any open hyperlink,
  `SGR 0` (payloads must not be interpreted under text attributes),
  absolute CUP to `at` (never relative — mid-composition the virtual
  cursor may already be stale), payload verbatim, then full `invalidate()`
  (protocol payloads move the real cursor in protocol-specific ways).
  GFX3D never writes to the terminal directly; REDTEAM asserts byte
  custody with `CaptureTerm`.
- **One flush per frame (RT1-16a)**: the presenter EMITS (appends bytes to
  the caller's per-frame buffer — `emit` and any `external_write` brackets
  compose into the same buffer, in phase-P order); the App loop WRITES the
  buffer and calls `Terminal::flush` exactly once at frame end. The
  presenter never flushes, never owns the terminal handle. Zero runs and
  zero external writes ⇒ zero bytes ⇒ the App skips write+flush entirely
  (idle frames stay free).

### 2.4d API freeze pass [C8]

- **Drawing vocabulary, one canonical path**: `Surface::draw_text` is THE
  rich-draw call for direct-surface users; `ui::Canvas::put/print` and
  `ui::StyledCanvas::print_styled/fill_styled` are the WIDGET vocabulary
  (draw closures over `&mut dyn`); on a `Surface` every one of them
  funnels into `draw_text`. Documented on the `Surface` type.
- **`StyledCanvas` resolution**: the render-side duplicate trait
  (`render::bridge::StyledCanvas`, same name/drifted signature/zero
  consumers) is DELETED; `ui::StyledCanvas` is the one styled trait and
  `Surface` now implements it DIRECTLY (full fidelity, grapheme-correct)
  — `&mut Surface` slots into widget paint closures without the
  `SurfaceCanvas` wrapper. Breaking: `render::StyledCanvas` no longer
  exists (nothing consumed it).
- **Style terse builders**: `.bold() .dim() .italic() .underline()
  .strike() .reverse()` — the six high-traffic attributes get one-word
  spellings (`Style::new().fg(ink).bold()`); rare attrs stay behind
  `.attrs(...)`. Equivalence test-pinned.
- **Snapshot helpers**: `render::snapshot(&Surface) -> String` (bordered
  char grid) and `render::snapshot_styles` (grid + per-row style-run
  annotations: fg/bg/attrs/ul/link) — the draw-debugging tools; never on
  frame paths, never in byte goldens.
- **Prunes**: `text::is_risky_cluster` → `pub(crate)` (presenter cursor
  defense, not a width oracle — `width`/`cluster_width` are the public
  truth). Kept deliberately despite zero consumers today:
  `wrap_with`/`WrapOpts` (hanging indent is docs-worthy), paint
  gradients/drop_shadow (DESIGN's C6 ask — flagged for the demo cycle
  to claim or they get pruned), caret helpers
  `next_boundary`/`prev_boundary` (the input-widget pair to `segments`),
  pool/link cap constants (documented degradation contract).
- **Rustdoc**: missing-docs count in render/text/anim: 207 → 0; doc-link
  warnings in my modules: 0. Compiling doctests on the six entry points
  (Surface, Style, Compositor+Layer, the full pipeline in `render`
  module docs, markdown→cells in `md`, Tween/Transition/Timeline in
  `anim` module docs).

### 2.4c Bridges (ui Canvas, gfx mosaic) [C2]

- `Surface` implements `ui::Canvas` (REACT req 2) in `render/bridge.rs`:
  `put`/`print` route through `draw_text` (grapheme-correct widths, pair
  invariants, control stripping — `print` returns real columns), `fill`
  through `fill_rect`. Bridge writes are ATOMIC (attrs cleared, no link,
  default underline color; trait-contract exception: bg alpha 0 keeps the
  underlying background) — inheriting a stale BOLD/link under fresh
  content would be a correctness surprise. Rich styling stays native
  `Surface` API. If widgets need attrs/links through the trait, that is a
  follow-up trait on ui's side (request filed), never a weakening of the
  cell model.
- `Surface::blit_mosaic(patches, origin)` consumes gfx mosaic output as
  plain `(Point, char, Rgba, Rgba)` tuples — `gfx::mosaic::CellPatch`'s
  fields in order, one `map` away; render deliberately does not import
  gfx (siblings). Fully-transparent patches write the see-through
  `Cell::EMPTY` (the gfx contract: "image empty here" clears stale
  content); visible patches replace the cell wholesale. Damage is one
  bounding rect (`set_quiet` + a single `add_damage`), not per-cell.

### 2.5 text

- `cluster_width`: 0 for control clusters; 1 if the cluster carries VS15
  (text presentation); 2 if it carries VS16 or ZWJ (emoji presentation and
  joined sequences render double-wide on terminals that support them);
  otherwise `unicode_width::UnicodeWidthStr` capped at 2. One policy shared
  by `text`, `Surface::draw_text` and the diff — width disagreements between
  measurement and rendering are the classic corruption source.
- `wrap`: word wrap at whitespace (break consumes the whitespace), long
  words broken at grapheme boundaries; `\n` respected; width clamps to ≥ 1.
- `truncate_ellipsis`: grapheme-accurate, `…` (width 1), wide glyphs at the
  cut point drop cleanly.
- [C2] `measure(&str, avail: Size) -> Size` (REACT req 4): wrapping-aware
  layout measurement — wrap into `avail.w` (≤ 0 = unconstrained), width =
  widest wrapped line (may honestly exceed `avail.w` only when one cluster
  is wider than the window), height = line count, never clamped to
  `avail.h` (the solver decides visibility; empty text is 1 line).
  Newline handling: `\n` and `\r\n` split logical lines (one cluster each,
  width 0); other control clusters are stripped by wrap/draw and measure 0.
- [C2] `is_risky_cluster` — the RT1-7 classifier (see §2.4).
- [C3/C4] Cursor math for input fields (RT3-2): `segments(&str)` yields
  `(cluster, byte offset, width)` exhaustively (controls kept at width 0
  so every reachable byte boundary is representable);
  `next_boundary/prev_boundary(s, byte_idx)` step carets by whole
  clusters, snapping mid-cluster inputs outward — backspace deletes
  exactly `prev_boundary(s, i)..i`.

### 2.8 Rich text, markdown-lite, syntax tint [C6]

- **Model** (`render::rich`): `Span { text, style: Style, link:
  Option<String> }` / `RichLine` (coalesces same-ink pushes) /
  `RichText`. Links carry URLs (surface-independent); ids resolve at
  draw via `register_link`. Lives in render, not text: the span currency
  is `Style` and text must stay render-import-free (render → text is the
  one arrow). All measurement routes through `crate::text` (one width
  policy).
- **Wrap** preserves spans across boundaries (a word straddling a bold
  edge keeps per-cluster styles), same contract as `text::wrap`
  (test-pinned parity: whitespace consumed at breaks, long words split
  at clusters, empty lines survive). **Draw** into a rect: per-line
  H-alignment, height clipping, ellipsis truncation for overwide lines
  (ellipsis wears the style at the cut); patch semantics keep panel
  grounds.
- **Markdown-lite** (`render::md`): supported EXACTLY — inline `**bold**`
  `*italic*` `` `code` `` `[text](url)` + backslash escapes
  (`\* \` \[ \] \( \) \\ \#`); blocks `#`..`######`, `-`/`*`/`+` and
  `N.` lists (2-space indent depth), `>` quotes (nesting folds to one
  level), ``` fences (verbatim, EOF-closes), `---`/`***` rules,
  blank-line paragraph separation with soft-join. NOT supported (literal
  text, never an error): nested emphasis, `_`/`__`, setext headings,
  tables, HTML, images, reference links, task lists. Styles are
  patches via `MdStyles` (defaults attribute-only; themes override);
  inline merges ONTO block style. [C7] `MdStyles::with_ink(code_fg,
  code_bg, link_fg)` is the canonical theme mapping (render sits below
  `theme`, so tokens arrive as plain `Rgba` — the markdown widget
  resolves its `TokenSet` and calls this); `base` is documented fg-less
  BY CONTRACT: the inline parser stamps `base` on every plain span, so
  an explicit fg there defeats block recoloring (DESIGN's blockquote-dim
  detour, now a doc'd rule + test). [C7] Wrap emission appends borrowed
  cluster text into the tail span (`push_run`) instead of minting a
  `Span`/`String` per cluster — wrap is one pass over clusters with O(1)
  amortized emission (merge checks the LAST span only, never scans), so
  cost is linear in clusters regardless of input span count; test-pinned
  (`wrap_scales_linearly_in_span_count_structurally`) and measured 3.18
  ms for the 800-para doc on a quiet box (budget 20 ms; filed at 15.9 —
  part churn, part the same loaded-box measurement story as RT6-3).
- **Syntax tint** (`text::highlight`): `trait Highlighter { spans(line)
  -> Vec<(Range<usize>, TokenKind)> }`, kinds =
  keyword/string/number/comment/ident/punct. Built-in `CLikeLexer`
  (rust()/c() presets, `Default` = rust): line-at-a-time, C-family
  surface syntax, HONESTLY approximate (no cross-line state — multi-line
  strings/comments mis-tint from line 2). Bridge:
  `RichLine::from_highlighted(line, lexer, base, map)` with the theme
  mapping as a closure — render stays theme-agnostic.

### 2.6c Timeline seek [C7]

`Timeline::seek(t) -> Seek` binds one clock position for scrubbing (the
boot player's test rig, effects demos): `Seek::progress(track)` /
`is_finished()` sample the board at the bound instant without threading
`t` through every call. Pure — a `Seek` is a Copy view, never a
playhead; the timeline stays stateless (the C3 decision stands).
`seek_reversed(t)` gives reverse playback as a clock mirror (board
position `duration − t`, clamped at zero): one pass runs backward with
each track's own easing evaluated at the mirrored clock — deliberately
NOT easing reversal (rebuilding tracks with flipped curves would change
curve identity, and no consumer asked for it).

### 2.6b anim additions [C3]

- `Transition<T: Lerp>`: retargetable ease toward a moving goal —
  `set_target` samples the LIVE in-flight value as the new origin (no
  jumps), redundant retargets are no-ops (a restarted ease has a visible
  velocity hiccup), `tick(now)` settles exactly on the target. Engine-only
  (no reactive imports); REACT binds it to signals next cycle via
  tick-then-`request_frame`-while-unsettled.
- `Timeline`: a storyboard of eased tracks on one clock — `track` /
  `track_after` (sequence) / `stagger`, `LoopMode::{Once, Loop, PingPong}`
  folding the whole board. Tracks yield eased PROGRESS (0..=1), not
  values: storyboards mix types, so values stay with the consumer
  (`from.lerp(to, progress)`). Pure over `t` (frame drops repeat/jump `t`
  per the splash-source seam); evaluation allocates nothing.

### 2.6 anim (skeleton, cycle-2 adjustments)

`Clock` (monotonic, virtual mode for tests), `Easing` (linear, cubic
in/out/in-out, `CubicBezier(x1,y1,x2,y2)` solved by Newton + bisection like
CSS timing functions), `Tween<T: Lerp>` for `f32/i32/Point/Rgba`.

- [C6] `Easing::{Bounce, Elastic(period), Spring(bounciness)}` — feel
  curves from exact polynomials + a Bhaskara-I polynomial sine (|err| ≤
  ~0.0017, no libm — bit-stable; documented as FEEL approximations, not
  physics). Spring(0) cannot overshoot by construction (raised-cosine
  envelope ∈ [0,1]); overshoot scales with bounciness (~21% at 1).
- [C6] `anim::particles::ParticleField` — seeded, deterministic garnish
  (bursts, gravity/drag, explicit-Euler `step(dt)`, life-faded cell
  plotting; aspect-corrected directions via the same polynomial sine).
  Same seed + same steps = same pixels (goldenable).
- [C6] `text::wrap_with(s, w, WrapOpts { hanging_indent, max_lines })` —
  log-pane/chat knobs: continuations indent (single-pass per-row
  budgets, no re-wrap approximation), caps end with a visible `…`.
- [C2] `FrameRequester` moved to `base` (damage contract §7); `anim`
  re-exports it. The local duplicate is deleted.
- [C2] `Easing::bezier(x1, y1, x2, y2)` — `const` named constructor so
  DESIGN's identity curves live as constants. The evaluator clamps TIME
  only: y control points outside [0,1] produce intermediate outputs beyond
  the range (overshoot/settle, anticipation dips). `Lerp` impls follow:
  f32/i32/Point extrapolate; `Rgba` saturates per channel (documented —
  color channels must not wrap).

### 2.2c App-facing layer handles [C4; RETIRED C5]

Cycle 4 shipped `render::LayerStack` (generational ids) as the frozen
handle store; REACT's `app::overlays` landed the same cycle on the SLICE
flatten path with its own monotonic-u64 registry — which carries the same
stale-handle safety (u64 ids never reuse, lookups return None after
removal, Weak-backed handles no-op after app death) plus the reveal
damage rule (`damage_root_under`). Two compositor entry points for one
job is maintenance drag with no consumer on the stack side, so
`LayerStack`/`flatten_stack` were retired in cycle 5 (decision + rationale
in reviews/cycle5/render-requests.md). The compositor keeps exactly one
flatten; the app's overlay store owns handle identity.

### 2.7 Scroll-region optimization — SHIPPED default-OFF [C4]; measurement [C3]

Workload: 200x60 log viewer, every line shifts up, new content enters at
the bottom (truecolor caps, warmed presenter, measured through the real
diff+present pipeline, 2026-07-20):

| Case | cell-diff bytes | scroll-path bytes (ctl + repaint) | ratio |
| --- | --- | --- | --- |
| 1-row scroll | 5,374 (60 runs) | 279 (20 + 259) | **19.3x** |
| 5-row scroll | 5,421 (60 runs) | 1,199 (20 + 1,179) | **4.5x** |

Scroll-path control cost is `DECSTBM(region) + CUP + SU n + DECSTBM(reset)`
≈ 20 bytes; the repaint of scrolled-in rows dominates it. The win is real
and grows with region height; it caps at the full-frame byte budget
(which DEC 2026 already makes tear-free), so this is a bandwidth
optimization (ssh links), not a correctness or latency one.

**Status [C4]: SHIPPED behind `PresenterOpts { scroll_optimization }`,
default OFF.** The cycle-3 verdict's three blockers, resolved or
contained:

1. **Detection soundness is now referee-independent**: whatever shift
   `FrameDiff::compute_scrolled` chooses, the residual runs are computed
   against the virtually-shifted prev (moved rows read their source row;
   entering rows read as BCE-erased blanks), so a wrong candidate costs
   bytes, never pixels. The decomposition property (shift + runs
   reconstruct `next` cell-wise) is pinned over seeded random
   scroll+mutation frames in `render/scroll_tests.rs`.
2. **Guards**: full-width damage union only (DECSTBM scrolls full rows;
   DECSLRM not assumed), band ≥ 8 rows, ≥ 4 rows must become diff-clean
   (the byte-win floor), candidates from row-fingerprint anchors
   (FNV-1a prune, exact row compare verify — collisions cost time only).
3. **BCE**: the presenter's scroll prelude emits `SGR 0` BEFORE
   DECSTBM/SU/SD, so vacated rows erase to the default ground; entering
   cells equal to `Cell::EMPTY` legitimately need no repaint. Prelude
   bytes: `SGR 0`, `CSI top;bottom r`, `CSI n S|T`, `CSI r`; DECSTBM and
   its reset home the cursor (absolute addressing — origin mode never
   enabled), so the virtual cursor re-syncs at (0,0). Snapshot-pinned.

**[C5] Default flipped ON, referee-verified.** REDTEAM's VtScreen gained
DECSTBM + region-scoped SU/SD (+ IL/DL); the byte-level property (emitted
bytes applied to the model reproduce `next` exactly) holds with the
optimization engaged across randomized scroll+mutation sequences and
REDTEAM's published workloads (in-module: `vtscreen_replays_scrolled_
frames_exactly`, `redteam_workload_bytes_with_optimization_on`;
integration: their adv_scroll suite). The pairing is now TYPE-LEVEL:
`compute_scrolled` returns a `ScrolledRuns` token (private fields) that
only `Presenter::emit_scrolled` can consume — shift-relative runs cannot
reach the plain emitter, so the wrong-pairing hazard is unrepresentable
rather than documented. `PresenterOpts::default()` enables the
optimization; detection's byte-win guard (full-width band ≥ 8 rows, ≥ 4
rows made diff-clean, else plain runs) means enabling can only reduce
bytes.

### 2.2d Theme ground [C5]

`Compositor::set_ground(Option<Rgba>)`: the theme's background stands in
for "terminal default" (alpha-0) accumulator cells AT BLEND TIME —
additive light adds onto the theme ground instead of black, translucent
veils blend against it instead of passing through translucent. Cells
nothing blends against keep alpha 0 and still present as SGR 49; the
ground never leaks into untouched content. Default `None` is
byte-identical to the pre-ground compositor (test-pinned). The app owns
wiring the theme signal and the repaint on switch (damage_all — contract
§5).

### 2.10 The render pipeline for app authors [C8 — the docs-cycle page; C9 handoff filed]

One frame, four moves. You own the buffers; the engine owns the rules.
(The COMPILING version of this walkthrough is the `render` module doc
example — CI-checked; the guide should lift that one. Doc-ready prose
for every guide chapter: reviews/cycle9/render-docs-handoff.md.)

```text
1. DRAW    widgets/you write into layer Surfaces   (surfaces record damage)
2. FLATTEN Compositor::flatten(&mut frame, &mut layers) -> &[Rect]
3. DIFF    FrameDiff::compute(&prev, &frame, damage)    -> &[Run]
4. PRESENT Presenter::emit(runs, &frame, &caps, &mut out); flush ONCE;
           prev.blit(&frame, ..)                    (remember what's on screen)
```

**Damage is automatic.** Every `Surface` write records a surface-local
rect; layer mutations (`set_origin`/`set_opacity`/...) record frame
rects for old∪new geometry. You never call `add_damage` for ordinary
drawing — only after mutating cells through some out-of-band path.
Damage over-approximates honestly: the diff re-checks equality, so a
stale rect costs microseconds, never wrong pixels. An idle frame
(nothing drawn, no layer mutated, no shader clock advanced) flattens to
an empty damage list, diffs to zero runs, emits zero bytes and
allocates nothing — idle apps burn nothing, test-pinned.

**Layers vs the root.** One root layer is the whole story for most
apps: widgets draw, the pipeline runs. Reach for MORE layers only for
content that moves/fades/appears INDEPENDENTLY of what is under it —
toasts, modals, a splash overlay, particle glow. A layer buys
independent geometry (move without repainting the world underneath —
the compositor repaints only old∪new bounds), opacity/blend/grade
(`set_opacity`, `Blend::Additive`, `ColorTransform`), and a per-layer
[`CellShader`]. It costs one Surface of memory and its compose share
per damaged cell. Do NOT use layers as a widget system — that is what
the ui tree is for; a dozen layers is a smell, three is a dashboard
with a toast and a modal.

**Shader billing (the rule that keeps effects honest).** A shader runs
only where damage exists. A STATIC shader (installed, clock never
advanced) is paid once at install and never again. An ANIMATED shader
is an animation: advance `Layer::set_shader_t(t)` each frame it should
move — that damages what the shader declares changeable
(`CellShader::changed_region`, default: the whole layer) and your
frame scheduler requests the next frame, exactly like a tween. Settled
reveals and `t`-independent shaders (Vignette) tick for free. Never
advance the clock of an effect nobody can see.

**Bytes reach the terminal once per frame.** `Presenter::emit` appends
to your buffer; you flush the buffer in ONE write. Anything that
bypasses the presenter (image protocols, bells) goes through
`Presenter::external_write` so cursor/pen custody survives. If any
foreign code touched the terminal without telling you:
`presenter.invalidate()` and the next frame re-syncs from absolute
state.

**Debugging a frame.** `render::snapshot(&surface)` prints the char
grid; `render::snapshot_styles` adds per-run style annotations (the
"why isn't this bold" tool). `Compositor::set_debug_damage(true)`
outlines every repaint region in magenta on screen — the minimal-damage
claim made visible. Both are diagnostics: bytes/pixels change, never
use them in golden tests.

### 2.9 Performance envelope [C7, RT6-3]

Phase-split profile, 200x60 + Shimmer full re-shade every frame,
release, isolated (`render::profile` in-module harness, medians over 31
frames): **flatten (compose+shade) 137 µs, diff 53 µs, present 210 µs —
total ~400 µs**, ~7.5x inside the 3 ms budget. REDTEAM's own harness
shape (which also times its per-frame bookkeeping `prev.blit`) medians
430 µs isolated on a quiet box — matching their CYCLE-4 filing (421 µs)
within noise: **the pipeline never regressed**. The cycle-6 3.57 ms
reading was measurement environment, layered: ambient box load (other
teams' builds) lifts the isolated median to ~1.16 ms, and in-binary
parallel test threads lift it past 3.5 ms (`cargo test` co-schedules the
wall-clock perf tests with parser-soup/VT-model iterations). Evidence:
their reported best 1.16 ms equals the loaded-box isolated median
exactly; a full parallel run here reproduced 3.56 ms median / 1.16 ms
best on the same binary that measures 430 µs alone. Perf binaries must
run `--test-threads=1` — filed with REDTEAM, along with the same-class
repro for the gltf alloc-counter test (fails parallel, 8/8 serial).

Envelope for effect authors: one full 200x60 re-shade (12k cells) costs
~137 µs in the compose path ⇒ ~88 shaded kcells/ms; `changed_region`
(§2.2b) is the lever that keeps real workloads from paying it when the
effect is settled or banded.

SGR economy measurement [C7 ask 2]: a 64x20 dashboard frame of repeated
two-color fg toggles emits 7,515 bytes — 321 SGRs, 6,098 bytes, 19.0
avg, exactly one fg-only `38;2;r;g;b` per toggle (opening transition
aside; test-pinned, `dashboard_fg_toggles_emit_only_the_irreducible_sgr`).
A 1-entry last-pen cache was CONSIDERED AND DECLINED: SGR has no
pen-restore instrument, so a truecolor toggle's 38;2 payload is the
floor — there is nothing shorter for a cache to emit. The remaining
lever for byte-sensitive links is 256-color caps (5-byte `38;5;n`), which
downlevel already provides.

`text` segmentation cost [C7 ask 5]: 1.9 µs per `segments()` walk of a
mixed line, 6.3 µs per wrapped `measure()` (release, measured by
`text::tests::profile_segments_and_measure_per_keystroke_cost`). Caret
math runs once per keystroke — a grapheme LRU (eviction, edit
invalidation, ownership) would cost more than it saves. Declined until a
profile shows segmentation hot.

## 3. Known gaps / deferred

- [C2 resolved: caps + drop counters landed, RT1-14] Pool/link dedup is
  still a linear scan — bounded by the caps now; REDTEAM's churn bench
  decides whether generation-based compaction is warranted.
- `Blend::Normal` only; no per-layer effect hooks yet (DESIGN's additive
  blend for afterglow is a cycle-3 candidate).
- VS16 trailing-cell explicit re-emit (ratatui's workaround for buggy
  terminals) is *not* replicated: our continuation model already re-emits
  trailing cells whenever content changes, and [C2] the risky-cluster CUP
  discipline now bounds the residue class; if REDTEAM's terminal matrix
  still shows artifacts, add the targeted re-emit.
- Presenter assumes deferred autowrap. If KERNEL's ConPTY verification
  (RT1-5a) finds a terminal without it, the fallback is a caps bit to skip
  the last column entirely (documented, not implemented).
- [C2] `blit_mosaic` damages the bounding rect of the patch set — precise
  per-row damage for sparse patch sets is a possible refinement if the 3D
  viewport bench shows over-scanning.
- [C2] Underline-color emission uses colon sub-parameter forms; if
  REDTEAM's terminal matrix finds a supported terminal that only parses
  the legacy semicolon form, that becomes a caps nuance (KERNEL detects,
  presenter switches form).