@silvery/term 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. package/src/xterm/xterm-provider.ts +204 -0
@@ -0,0 +1,587 @@
1
+ # Pipeline Internals
2
+
3
+ Read this before modifying content-phase.ts, render-text.ts, render-box.ts, or layout-phase.ts. These files implement incremental rendering -- the most complex and bug-prone part of Silvery.
4
+
5
+ ## Pipeline Overview
6
+
7
+ The render pipeline runs on every frame. Phases execute in strict order:
8
+
9
+ ```
10
+ measure -> layout -> scroll -> sticky -> screenRect -> [notify] -> content -> output
11
+ ```
12
+
13
+ | Phase | File | What it does |
14
+ | ----------- | -------------------- | ------------------------------------------------------------------------------------------ |
15
+ | measure | measure-phase.ts | Set Yoga constraints for fit-content nodes |
16
+ | layout | layout-phase.ts | Run `calculateLayout()`, propagate rects, set `prevLayout` and `subtreeDirty` |
17
+ | scroll | layout-phase.ts | Calculate scroll offset, visible children, sticky positions for overflow=scroll containers |
18
+ | sticky | layout-phase.ts | Calculate sticky render offsets for non-scroll parents with sticky children |
19
+ | screenRect | layout-phase.ts | Compute screen-relative positions (content position minus ancestor scroll offsets) |
20
+ | notify | layout-phase.ts | Fire `layoutSubscribers` callbacks (drives `useContentRect`/`useScreenRect`) |
21
+ | **content** | **content-phase.ts** | **Render nodes to a TerminalBuffer (this is the complex part)** |
22
+ | output | output-phase.ts | Diff current buffer against previous, emit minimal ANSI escape sequences |
23
+
24
+ Orchestrated by `executeRender()` in `pipeline/index.ts`. The scheduler (`scheduler.ts`) calls `executeRender()` and passes the previous frame's buffer for incremental rendering.
25
+
26
+ ## Dirty Flags
27
+
28
+ The reconciler sets flags on nodes when props/children change. The content phase reads them to decide what to re-render. All are cleared by the content phase after processing.
29
+
30
+ | Flag | Set by | Meaning |
31
+ | --------------- | ------------------------- | ---------------------------------------------------------------------------------------- |
32
+ | `contentDirty` | Reconciler | Text content or content-affecting props changed |
33
+ | `paintDirty` | Reconciler | Visual props changed (color, bg, border). Survives measure phase clearing `contentDirty` |
34
+ | `bgDirty` | Reconciler | `backgroundColor` specifically changed (added, modified, or removed) |
35
+ | `subtreeDirty` | Layout phase / reconciler | Some descendant has dirty flags. Node's OWN rendering may be skippable |
36
+ | `childrenDirty` | Reconciler | Direct children added, removed, or reordered |
37
+ | `layoutDirty` | Reconciler | Layout-affecting props changed; triggers Yoga recalculation |
38
+
39
+ The layout phase also sets `subtreeDirty` upward when a descendant's `contentRect` changes via `layoutChangedThisFrame`.
40
+
41
+ | Flag | Set by | Meaning |
42
+ | ------------------------ | ------------ | -------------------------------------------------------------------------------- |
43
+ | `layoutChangedThisFrame` | Layout phase | Node's contentRect changed this frame; cleared by content phase after processing |
44
+
45
+ ## Incremental Rendering Model
46
+
47
+ This is the core optimization. Instead of rendering every node every frame, the content phase:
48
+
49
+ 1. **Clones** the previous frame's buffer (the buffer the output phase already diffed)
50
+ 2. **Skips** subtrees where nothing changed (their pixels are already correct in the clone)
51
+ 3. **Re-renders** only dirty nodes and their affected descendants
52
+
53
+ The fast-path skip condition (all must be false to skip):
54
+
55
+ ```typescript
56
+ !node.contentDirty &&
57
+ !node.paintDirty &&
58
+ !layoutChanged && // node.layoutChangedThisFrame
59
+ !node.subtreeDirty &&
60
+ !node.childrenDirty &&
61
+ !childPositionChanged && // any child's x/y differs from prevLayout
62
+ !ancestorLayoutChanged // any ancestor had layoutChangedThisFrame
63
+ ```
64
+
65
+ If `hasPrevBuffer` is false (first render or dimension change), nothing is skipped.
66
+
67
+ ### Key Invariant
68
+
69
+ **Incremental render must produce identical output to a fresh render.** `SILVERY_STRICT=1` verifies this by running both and comparing cell-by-cell. Every content-phase change must be validated against this invariant.
70
+
71
+ ## The hasPrevBuffer / ancestorCleared / ancestorLayoutChanged Cascade
72
+
73
+ These three flags propagate down through `renderNodeToBuffer` calls and control whether children treat the buffer as containing valid previous pixels or stale/cleared pixels.
74
+
75
+ ### hasPrevBuffer
76
+
77
+ Passed to each child. When true, the child can use the fast-path skip (its pixels are intact from the previous frame). When false, the child must render even if its own flags are clean.
78
+
79
+ A parent sets `childHasPrev = false` when:
80
+
81
+ - `childrenDirty` is true (children restructured)
82
+ - `childPositionChanged` is true (sibling sizes shifted positions)
83
+ - `parentRegionChanged` is true (parent's content area was modified)
84
+
85
+ ### ancestorCleared
86
+
87
+ Tells descendants that an ancestor erased the buffer at their position. This is separate from `hasPrevBuffer` because scroll containers may pass `childHasPrev=false` while the buffer still has stale pixels from the clone -- the parent cleared its own region but descendants may need to clear sub-regions.
88
+
89
+ ### ancestorLayoutChanged
90
+
91
+ Tells descendants that an ancestor's layout position/size changed this frame. Propagated as `childAncestorLayoutChanged = node.layoutChangedThisFrame || ancestorLayoutChanged`. When true, the descendant's pixels in the cloned buffer may be at wrong coordinates even if its own dirty flags are clean. This is a safety net in the skip condition -- normally the `hasPrevBuffer=false` cascade handles re-rendering, but `ancestorLayoutChanged` catches edge cases where the cascade doesn't fully propagate (e.g., a parent with `backgroundColor` that breaks the `ancestorCleared` chain without setting `parentRegionChanged`).
92
+
93
+ ### The Critical Formulas
94
+
95
+ These five computed values (plus two intermediates: `textPaintDirty`, `subtreeDirtyWithBg`) in `renderNodeToBuffer` control the entire incremental cascade:
96
+
97
+ ```typescript
98
+ // Did this node's layout position/size change?
99
+ // Uses layoutChangedThisFrame (set by propagateLayout in layout phase)
100
+ // instead of the stale !rectEqual(prevLayout, contentRect).
101
+ layoutChanged = node.layoutChangedThisFrame
102
+
103
+ // Did the CONTENT AREA change? (excludes border-only paint changes for BOX nodes)
104
+ // textPaintDirty: for TEXT nodes, paintDirty IS a content area change (text has no borders).
105
+ // measure phase may clear contentDirty, so paintDirty is the surviving witness.
106
+ // absoluteChildMutated: absolute child had children mount/unmount/reorder, layout change,
107
+ // or child position shift. Forces parent to clear (removes stale overlay pixels in gap areas).
108
+ // descendantOverflowChanged: a descendant's prevLayout extended beyond THIS node's rect
109
+ // and its layout changed. Recursive check (follows subtreeDirty paths).
110
+ textPaintDirty = node.type === "silvery-text" && node.paintDirty
111
+
112
+ contentAreaAffected =
113
+ node.contentDirty ||
114
+ layoutChanged ||
115
+ childPositionChanged ||
116
+ node.childrenDirty ||
117
+ node.bgDirty ||
118
+ textPaintDirty ||
119
+ absoluteChildMutated ||
120
+ descendantOverflowChanged
121
+
122
+ // Should we clear this node's region with inherited bg?
123
+ // Only when: buffer has stale pixels AND content area changed AND no own bg fill
124
+ parentRegionCleared = (hasPrevBuffer || ancestorCleared) && contentAreaAffected && !props.backgroundColor
125
+
126
+ // Can we skip the bg fill? Only when clone has correct bg already
127
+ // subtreeDirtyWithBg: a descendant changed inside a Box with backgroundColor.
128
+ // The bg fill must re-run to clear stale child pixels (e.g., trailing chars from
129
+ // a shrunk Text). Only applies to bg-bearing boxes when contentAreaAffected is false.
130
+ subtreeDirtyWithBg = hasPrevBuffer && !contentAreaAffected && node.subtreeDirty && !!props.backgroundColor
131
+
132
+ skipBgFill = hasPrevBuffer && !ancestorCleared && !contentAreaAffected && !subtreeDirtyWithBg
133
+
134
+ // Must children re-render? (content area was modified OR bg needs refresh on a cloned buffer)
135
+ // subtreeDirtyWithBg triggers this because bg refill overwrites child pixels — children
136
+ // must re-render on top of the fresh fill.
137
+ parentRegionChanged = (hasPrevBuffer || ancestorCleared) && (contentAreaAffected || subtreeDirtyWithBg)
138
+ ```
139
+
140
+ ### How the cascade propagates to children
141
+
142
+ ```typescript
143
+ // Normal containers:
144
+ childHasPrev = childrenDirty || childPositionChanged || parentRegionChanged ? false : hasPrevBuffer
145
+ childAncestorCleared = parentRegionCleared || (ancestorCleared && !props.backgroundColor)
146
+ childAncestorLayoutChanged = node.layoutChangedThisFrame || ancestorLayoutChanged
147
+ ```
148
+
149
+ Key insight: a Box with `backgroundColor` **breaks** the ancestorCleared cascade. Its `renderBox` fill covers stale pixels, so children don't need to know about ancestor clears. Without this, border cells at boundaries get overwritten.
150
+
151
+ Key insight: `ancestorLayoutChanged` does NOT break at `backgroundColor` boundaries. Unlike `ancestorCleared` (which a bg fill can satisfy), an ancestor layout change means descendants' absolute positions in the cloned buffer are wrong regardless of bg fills.
152
+
153
+ ### Why contentAreaAffected is NOT needsOwnRepaint
154
+
155
+ `needsOwnRepaint` includes `paintDirty` (e.g., borderColor change). `contentAreaAffected` excludes pure paint changes because a border-only change doesn't affect the content area -- the clone already has the correct bg. Using `needsOwnRepaint` for `parentRegionChanged` caused border color changes to cascade re-renders through ~200 child nodes per Card.
156
+
157
+ ### Why bgDirty exists
158
+
159
+ When `backgroundColor` changes from `"cyan"` to `undefined`, the current value is falsy but stale cyan pixels remain in the clone. `bgDirty` (set by reconciler specifically for bg changes) ensures `contentAreaAffected` is true so the region gets cleared.
160
+
161
+ ## Scroll Container Three-Tier Strategy
162
+
163
+ Scroll containers (`overflow="scroll"`) have special rendering logic in `renderScrollContainerChildren`:
164
+
165
+ ### Tier 1: Buffer Shift (scrollOnly)
166
+
167
+ When ONLY the scroll offset changed (no child/parent changes):
168
+
169
+ - Shift buffer contents by the scroll delta via `buffer.scrollRegion()`
170
+ - Only re-render newly exposed children at the edges
171
+ - Previously visible children keep their shifted pixels
172
+
173
+ **Unsafe with sticky children** -- sticky headers render in a second pass that overwrites first-pass content. After a shift, those overwritten pixels corrupt items at new positions. Falls back to Tier 2.
174
+
175
+ ### Tier 2: Full Viewport Clear (needsViewportClear)
176
+
177
+ When children restructured, scroll offset changed with sticky children, or parent region changed:
178
+
179
+ - Clear entire viewport with inherited bg
180
+ - Re-render all visible children (childHasPrev=false)
181
+
182
+ `subtreeDirty` alone does NOT trigger viewport clear. Clearing for subtreeDirty caused a 12ms regression (re-rendering ~50 children vs 2 dirty ones).
183
+
184
+ ### Tier 3: Subtree-Dirty Only
185
+
186
+ When only some descendants changed:
187
+
188
+ - Children use `hasPrevBuffer=true` and skip via fast-path if clean
189
+ - Only dirty descendants re-render
190
+
191
+ **Exception with sticky children**: When sticky children exist in Tier 3, all first-pass items are forced to re-render (`stickyForceRefresh`). This is needed because sticky headers overwrite first-pass content in a second pass -- the cloned buffer has stale content from previous frames' sticky positions that must be refreshed before the sticky pass.
192
+
193
+ ## Sticky Children Two-Pass Rendering
194
+
195
+ Scroll containers with `position="sticky"` children render in two passes:
196
+
197
+ 1. **First pass**: Non-sticky items, rendered with scroll offset
198
+ 2. **Second pass**: Sticky headers, rendered at their computed sticky positions (hasPrevBuffer=false, ancestorCleared=false)
199
+
200
+ Order matters: sticky headers render ON TOP of first-pass content. The second pass uses `hasPrevBuffer=false` because the effective scroll offset for a sticky child can change even when the container's doesn't.
201
+
202
+ Sticky children use `ancestorCleared=false` to match fresh render semantics. On a fresh render, the buffer at sticky positions has first-pass content, not "cleared" space. Using `ancestorCleared=true` would cause transparent spacer Boxes to clear their region, wiping overlapping sticky headers rendered earlier in the second pass.
203
+
204
+ ## Text Background Inheritance (inheritedBg)
205
+
206
+ Text nodes with no explicit background inherit bg from their nearest ancestor Box with `backgroundColor`. This is now done via explicit `inheritedBg` parameter passed through the render tree, computed by `findInheritedBg()` in content-phase.ts.
207
+
208
+ ```typescript
209
+ // content-phase.ts: compute and pass inherited bg
210
+ const textInheritedBg = findInheritedBg(node).color
211
+ const textInheritedFg = findInheritedFg(node)
212
+ renderText(node, buffer, layout, props, nodeState, textInheritedBg, textInheritedFg, ctx)
213
+
214
+ // render-text.ts → renderGraphemes: priority chain for bg
215
+ // 1) Text's own bg 2) inheritedBg from ancestor Box 3) getCellBg buffer read (legacy fallback)
216
+ const existingBg = style.bg !== null ? style.bg : inheritedBg !== undefined ? inheritedBg : buffer.getCellBg(col, y)
217
+ ```
218
+
219
+ **Why inheritedBg instead of getCellBg?** The old approach read bg from the buffer (`getCellBg`), creating a coupling between text rendering and buffer state. On incremental renders, the cloned buffer could have stale bg at positions outside the parent's bg-filled region (e.g., overflow text, moved nodes). Using `inheritedBg` from the render tree is deterministic regardless of buffer state. The `getCellBg` fallback remains only for external callers of `renderTextLine` that don't pass `inheritedBg` (e.g., scroll indicators in render-box.ts).
220
+
221
+ **stickyForceRefresh** exists because sticky headers overwrite first-pass content in a second pass, and the cloned buffer has stale content from previous frames' sticky positions. Tier 3 incremental renders need all first-pass items to re-render (with a pre-clear to null bg) to ensure the buffer matches fresh render state before the sticky pass.
222
+
223
+ Nested Text `backgroundColor` is handled separately via `BgSegment` tracking (not ANSI codes) to prevent bg bleed across wrapped text lines.
224
+
225
+ ## Normal Container Three-Pass Rendering
226
+
227
+ `renderNormalChildren` uses three passes (CSS paint order):
228
+
229
+ 1. **First pass**: Normal-flow children (skip sticky + absolute)
230
+ 2. **Second pass**: `position="sticky"` children at computed `renderOffset` positions (when `node.stickyChildren` is present — set by `stickyPhase` for non-scroll parents)
231
+ 3. **Third pass**: `position="absolute"` children (rendered on top)
232
+
233
+ Without two-pass, an absolute child rendered before a dirty normal-flow sibling would get its bg wiped by the sibling's `clearNodeRegion`.
234
+
235
+ **Second pass always uses `hasPrevBuffer=false, ancestorCleared=false`** for absolute children. The buffer at their position contains first-pass content, not previous-frame content — conceptually a fresh render. This prevents transparent overlays from clearing first-pass content via `parentRegionCleared`.
236
+
237
+ **Stale overlay pixel cleanup**: When an absolute child's structure changes (children mount/unmount, layout shifts, child positions change), `absoluteChildMutated` triggers the PARENT's `contentAreaAffected=true`. This clears the parent's entire region and forces all normal-flow children to re-render, removing stale overlay pixels from gap areas (positions not covered by any current child). This makes the incremental render match a fresh render. The cost is one full re-render per frame when overlays change — acceptable since overlay changes are user-triggered (dialog open/close) and infrequent.
238
+
239
+ ## Region Clearing
240
+
241
+ When a node's content area changed but it has no `backgroundColor`, stale pixels from the clone remain visible. `clearNodeRegion` fills the node's rect with inherited bg (found by walking up ancestors via `findInheritedBg`).
242
+
243
+ When a node shrinks, the excess area (old bounds minus new bounds) is also cleared via `clearExcessArea()`. This excess clearing clips to the colored ancestor's bounds to prevent inherited bg from bleeding into sibling areas.
244
+
245
+ **Important:** Excess area clearing runs independently of `parentRegionCleared`. Even when `parentRegionCleared=false` (e.g., absolute children with `forceRepaint=true` where `hasPrevBuffer=false` + `ancestorCleared=false`), the cloned buffer still has stale pixels in the old-but-not-new area that must be cleared. This also applies to nodes WITH `backgroundColor` — `renderBox` fills only the new (smaller) region.
246
+
247
+ ### Descendant Overflow Clearing
248
+
249
+ When a child overflows its parent (e.g., text content extending beyond the parent's rect with `overflow:visible`), `clearExcessArea` on the child clips to the immediate parent's content area (inside border/padding). This leaves stale pixels in ancestor border/padding areas and beyond ancestor rects.
250
+
251
+ `hasDescendantOverflowChanged()` recursively checks if any descendant's `prevLayout` extended beyond THIS node's rect and had `layoutChangedThisFrame`. When detected, `contentAreaAffected=true` triggers the node to clear its own region (restoring borders) and `clearDescendantOverflowRegions()` clears overflow beyond the node's rect.
252
+
253
+ **Why recursive?** A grandchild overflowing a child AND the grandparent must be detected at the grandparent level. If only the child detected it, clearing at the child level would overwrite the grandparent's border (parent-first rendering order: grandparent draws border → child clears overflow → border gone). By detecting at the grandparent, the grandparent clears its region, redraws its border, and the child gets `hasPrevBuffer=false` (renders fresh, no overflow clearing needed).
254
+
255
+ **Performance:** Only runs when `hasPrevBuffer && subtreeDirty`. Follows only `subtreeDirty` paths. Returns early on first match. Overflow is rare, so typically returns false after checking a few direct children.
256
+
257
+ ## prevLayout and layoutChangedThisFrame
258
+
259
+ `layoutChanged` is now driven by the `layoutChangedThisFrame` flag (set by `propagateLayout` in layout phase, cleared by content phase after processing). This replaces the old `!rectEqual(prevLayout, contentRect)` which was permanently stale when layout phase skipped (no dirty nodes), causing O(N) content phase every frame.
260
+
261
+ **How it works:**
262
+
263
+ 1. Layout phase: `propagateLayout` saves `node.prevLayout = node.contentRect`, recomputes rect, sets `node.layoutChangedThisFrame = !rectEqual(old, new)`
264
+ 2. Content phase: reads `node.layoutChangedThisFrame` for skip decisions, clears it after processing
265
+ 3. End of content phase: `syncPrevLayout` sets `prevLayout = contentRect` for all nodes, ensuring `clearExcessArea` and `hasChildPositionChanged` use correct coordinates on multi-pass doRender iterations
266
+
267
+ `prevLayout` is still used by `clearExcessArea` (old bounds for excess clearing) and `hasChildPositionChanged` (sibling position shift detection), but NOT for the primary `layoutChanged` decision.
268
+
269
+ ## clearExcessArea Guards
270
+
271
+ `clearExcessArea` fills old-minus-new bounds when a node shrinks. Two guards prevent border corruption:
272
+
273
+ 1. **Position-change guard**: When a node MOVED (prev.x ≠ layout.x or prev.y ≠ layout.y), clearExcessArea is skipped entirely. The right/bottom excess formulas mix new-x with old-y coordinates, creating phantom rectangles at wrong positions. The parent handles old-pixel cleanup instead.
274
+
275
+ 2. **Parent border inset**: Excess clearing always clips to the immediate parent's content area (inside border/padding), even when the inherited bg comes from a colored ancestor. Without this, a child's bottom excess extends into the parent's border row and overwrites border characters with spaces.
276
+
277
+ ## Common Pitfalls
278
+
279
+ 1. **Transparent Boxes cascade clears.** A Box without `backgroundColor` propagates `ancestorCleared` to all descendants. A Box WITH `backgroundColor` breaks the cascade because its fill covers stale pixels. This is intentional -- don't remove the `!props.backgroundColor` check from `childAncestorCleared`.
280
+
281
+ 2. **Border-only changes must not cascade.** `paintDirty` without `bgDirty` means only the border changed. This must NOT trigger `contentAreaAffected` or `parentRegionChanged`, otherwise every borderColor change cascades through the entire subtree.
282
+
283
+ 3. **Buffer shift + sticky = corruption.** Never use Tier 1 (scrollRegion shift) when sticky children exist. The sticky second pass overwrites pixels that the shift assumed were final.
284
+
285
+ 4. **Scroll Tier 3 + sticky = stale content.** The cloned buffer has stale content from previous frames' sticky positions. Tier 3 (no viewport clear) must force all items to re-render (`stickyForceRefresh`) and pre-clear to null bg to match fresh render state.
286
+
287
+ 5. **Absolute children need ancestorCleared=false in second pass.** After the first pass, the buffer at absolute positions has correct normal-flow content. Setting ancestorCleared=true causes transparent absolute overlays to clear that content.
288
+
289
+ 6. **skipBgFill is critical for subtreeDirty.** When only a descendant changed, the parent's bg fill must be skipped. Re-filling destroys child pixels that won't be repainted (they're clean and will be fast-path skipped).
290
+
291
+ 7. **getCellBg coupling (mostly resolved).** Text bg inheritance now uses explicit `inheritedBg` from `findInheritedBg()` instead of reading the buffer via `getCellBg()`. This decouples text rendering from buffer state, fixing mismatches where overflow text read stale bg from the cloned buffer. The `getCellBg` fallback is still used by external callers of `renderTextLine` that don't pass `inheritedBg` (e.g., scroll indicators in render-box.ts).
292
+
293
+ 8. **Descendant overflow must be detected recursively.** When a child overflows its parent and shrinks, `clearExcessArea` clips to the immediate parent's content area. If the overflow extends into a grandparent's border/padding, the grandparent must detect and handle it — otherwise a child-level clear overwrites the grandparent's border (parent-first render order). Use `hasDescendantOverflowChanged()` which follows `subtreeDirty` paths.
294
+
295
+ ## Debugging
296
+
297
+ ```bash
298
+ # Verify incremental vs fresh render equivalence
299
+ SILVERY_STRICT=1 bun km view /path
300
+
301
+ # Write pipeline debug output
302
+ DEBUG=silvery:* DEBUG_LOG=/tmp/silvery.log bun km view /path
303
+
304
+ # Enable instrumentation counters (exposed on globalThis.__silvery_content_detail)
305
+ SILVERY_INSTRUMENT=1 bun km view /path
306
+
307
+ # Trace which nodes cover a specific cell during incremental rendering
308
+ SILVERY_CELL_DEBUG=77,85 bun km view /path
309
+ ```
310
+
311
+ The content phase has extensive instrumentation gated on `_instrumentEnabled` -- node visit/skip/render counts, cascade diagnostics, scroll container tier decisions, and per-node trace entries.
312
+
313
+ **Enriched STRICT errors**: When `SILVERY_STRICT` detects a mismatch, the `IncrementalRenderMismatchError` automatically captures content-phase stats and mismatch debug context (cell attribution, dirty flags, scroll state, fast-path analysis). The scheduler auto-enables instrumentation for the STRICT comparison render and attaches the results to the error. This eliminates the need for separate `SILVERY_INSTRUMENT` or `SILVERY_CELL_DEBUG` runs when diagnosing STRICT failures.
314
+
315
+ ## Inline Incremental Rendering
316
+
317
+ In fullscreen mode, the output phase diffs prev/next buffers and emits only changed cells (~21 bytes/keystroke). In inline mode, `inlineFullRender()` regenerated the ENTIRE ANSI output from scratch every frame (~5,848 bytes at 50 items) — 280x more data per keystroke.
318
+
319
+ `inlineIncrementalRender()` brings inline mode to parity with fullscreen by diffing buffers and emitting only changed cells using relative cursor positioning.
320
+
321
+ ### When incremental runs (all conditions must be met)
322
+
323
+ - `scrollbackOffset === 0` (no external stdout writes between frames)
324
+ - Buffer dimensions unchanged (`prev.width === next.width && prev.height === next.height`)
325
+ - Visible window unchanged (`startLine` is the same — when content exceeds `termRows`, the visible window shifts)
326
+ - Cursor tracking initialized (`state.prevCursorRow >= 0` — set after first render)
327
+
328
+ Content height changes (grow/shrink) are handled incrementally:
329
+
330
+ - **Growth**: `changesToAnsi` writes new content cells. Cursor extends to new bottom row using `\r\n` (which creates terminal lines). CUD (`\x1b[nB`) is NOT used past the old bottom — it's clamped and won't scroll.
331
+ - **Shrinkage**: `changesToAnsi` clears old content cells (writes spaces). Orphan lines below new content are erased with `\x1b[K`.
332
+
333
+ Falls back to `inlineFullRender()` when: scrollback offset > 0, buffer dimensions changed, visible window shifted, or cursor tracking uninitialized.
334
+
335
+ ### Instance-scoped cursor tracking
336
+
337
+ Inter-frame cursor state (`InlineCursorState`) is captured in the `createOutputPhase()` closure — no module-level globals. Each `createOutputPhase()` call gets its own state. Bare `outputPhase()` calls use fresh state each time (always fall back to full render — safe default for tests).
338
+
339
+ ```typescript
340
+ const render = createOutputPhase({ underlineStyles: true })
341
+ render(null, buf1, "inline") // first render → inits cursor tracking
342
+ render(buf1, buf2, "inline") // incremental (state persists in closure)
343
+
344
+ outputPhase(buf1, buf2, "inline") // bare → always full render (no shared state)
345
+ ```
346
+
347
+ ### Relative cursor positioning
348
+
349
+ `changesToAnsi()` accepts `mode: "inline"` to use relative cursor movement instead of absolute row positioning. Inline mode:
350
+
351
+ - Filters changes to visible range (`[startLine, startLine + maxOutputLines)`)
352
+ - Uses `renderY = y - startLine` for render-region-relative coordinates
353
+ - Uses `\x1b[NA` (cursor up), `\x1b[NB` (cursor down), `\r` (carriage return), `\x1b[NC` (cursor forward) instead of `\x1b[row;colH` (absolute)
354
+ - Resets style before cursor jumps to prevent bg bleed across gaps
355
+
356
+ Returns `ChangesResult { output: string, finalY: number }` — the final cursor position is used by `inlineIncrementalRender` to move the cursor to the bottom row before appending the cursor suffix.
357
+
358
+ ### Performance
359
+
360
+ | Scenario | Full Render | Incremental | Reduction |
361
+ | ----------------- | ----------- | ----------- | --------- |
362
+ | 10 rows, 1 change | 1,196 bytes | 42 bytes | 28x |
363
+ | 30 rows, 1 change | 3,540 bytes | 33 bytes | 107x |
364
+ | 50 rows, 1 change | 6,324 bytes | 33 bytes | 192x |
365
+
366
+ ### Verification
367
+
368
+ - `SILVERY_STRICT_OUTPUT=1` verifies incremental ANSI output produces the same terminal state as a fresh render
369
+ - Inline incremental tests in `tests/inline-mode.test.ts` (9 tests covering guard conditions, cursor positioning, multi-frame consistency)
370
+ - Vitest benchmarks in `tests/inline-output.bench.ts`
371
+
372
+ ## File Map
373
+
374
+ | File | Responsibility |
375
+ | ----------------- | ------------------------------------------------------------------------------------------------------------ |
376
+ | content-phase.ts | Tree traversal, dirty-flag evaluation, incremental cascade logic, scroll container tiers, region clearing |
377
+ | render-box.ts | Box bg fill (`skipBgFill` aware), border rendering, scroll indicators |
378
+ | render-text.ts | Text content collection, ANSI parsing, bg segment tracking, `inheritedBg` inheritance, bg conflict detection |
379
+ | layout-phase.ts | Layout calculation, scroll state, screen rects, layout subscriber notification |
380
+ | measure-phase.ts | Intrinsic size measurement for fit-content nodes |
381
+ | output-phase.ts | Buffer diff, dirty row tracking, minimal ANSI output generation, inline incremental rendering |
382
+ | render-helpers.ts | Color parsing, text width, border chars, style computation |
383
+ | helpers.ts | Border/padding size calculation |
384
+
385
+ ## Lessons from Past Sessions
386
+
387
+ ### The Big 4 Content-Phase Bugs
388
+
389
+ `SILVERY_STRICT=1` revealed 402 mismatches across the content phase. Reduced to 47 (88%) by fixing four categories:
390
+
391
+ 1. **Dirty flag propagation failures** — Layout-phase changes weren't propagating `subtreeDirty` to ancestors. Added `markLayoutAncestorDirty()` helper. Without it, ~200 nodes would re-render on every border color change due to misusing `needsOwnRepaint` where `contentAreaAffected` was needed.
392
+
393
+ 2. **Incorrect region clearing** — `clearNodeRegion` used wrong bounds when a node shrank. Excess clearing must clip to the colored ancestor's bounds, not the parent's bounds — otherwise inherited bg bleeds into sibling areas.
394
+
395
+ 3. **Absolute position rendering** — Absolute children rendered in the wrong paint order. A dirty normal-flow sibling would wipe the absolute child's bg. Fixed with two-pass rendering (normal flow first, then absolute children on top).
396
+
397
+ 4. **Text background bleed** — Nested Text `backgroundColor` leaked across wrapped lines via ANSI codes embedded in the text stream. Replaced with `BgSegment` tracking that applies bg per-segment rather than embedding ANSI state.
398
+
399
+ ### Sticky Children Incremental Rendering (2026-02-12)
400
+
401
+ 10/10 fuzz failures in `render-fuzz.fuzz.ts` after sticky children support was added. Three complementary fixes were needed:
402
+
403
+ 1. **Tier 2 viewport clear uses inherited bg; Tier 3 stickyForceRefresh uses `bg: null`** — Originally Tier 2 cleared to `null`, but this was later changed: Tier 2 (`needsViewportClear`) now clears to `scrollBg` (the node's own `backgroundColor` or `findInheritedBg()`), which is correct because children render fresh on top. The separate `stickyForceRefresh` clear (Tier 3 with sticky children) still uses `bg: null` because it must match fresh render state before the sticky second pass. Text bg inheritance uses explicit `inheritedBg` parameter (not `getCellBg` buffer reads), so the viewport bg doesn't affect text rendering — it only matters for cells not covered by any child.
404
+
405
+ 2. **`stickyForceRefresh` in Tier 3** — When sticky children exist and only `subtreeDirty` is set (Tier 3), the cloned buffer has stale content from previous frames' sticky positions. All first-pass items must re-render before the sticky second pass overwrites. Without this, stale content from old sticky positions persists.
406
+
407
+ 3. **Sticky `ancestorCleared=false`** — The second pass renders sticky headers ON TOP of first-pass content. Using `ancestorCleared=true` caused transparent spacer Boxes to clear their region, wiping overlapping sticky headers rendered earlier in the same pass. Fresh render has first-pass content at sticky positions, not "cleared" space.
408
+
409
+ **Blind paths in this session:**
410
+
411
+ - Pre-clearing only current sticky positions (missed that OLD positions also had stale content)
412
+ - Setting `hasPrevBuffer=false` without clearing buffer (stale content remains in the cloned buffer regardless of hasPrevBuffer flag)
413
+ - Attempting to fix with `ancestorCleared=true` for sticky children (broke transparent overlays)
414
+
415
+ ### Output Phase: True Color Row Pre-Check Bug (2026-02-24)
416
+
417
+ `diffBuffers` had a row-level pre-check: `rowMetadataEquals + rowCharsEquals → skip`. This only compared packed Uint32Array metadata and chars. When two cells both had the true-color fg/bg flag set but different actual RGB values in the Maps (fgColors/bgColors), the pre-check said "equal" and skipped the row. Result: progressive garble — characters correct but colors stale.
418
+
419
+ Fix: Added `rowExtrasEquals()` to buffer.ts that checks all Map-based data (true colors, underline colors, hyperlinks). Updated `diffBuffers` to call it as third pre-check: `rowMetadataEquals && rowCharsEquals && rowExtrasEquals → skip`.
420
+
421
+ Also fixed latent width-indexing bug: `rowMetadataEquals`/`rowCharsEquals` used `this.width`-based indexing for both buffers, wrong when widths differ (e.g., during resize). Now uses separate `otherStart = y * other.width`.
422
+
423
+ **Key insight**: SILVERY_STRICT only verifies buffer content (content phase). It cannot detect output phase bugs where the buffer is correct but ANSI generation is wrong. Use `SILVERY_STRICT_OUTPUT` or `SILVERY_STRICT_ACCUMULATE` for output phase bugs.
424
+
425
+ ### Output Phase: CJK Wide Char Cursor Drift (2026-02-25)
426
+
427
+ CJK wide characters (e.g., '廈') occupy 2 terminal columns. In the buffer, col X has `wide=true` and col X+1 should have `continuation=true`. `bufferToAnsi` relies on `continuation` to skip X+1 after writing the wide char — without it, both the wide char AND the non-continuation cell are written, causing every subsequent character on the row to shift right by 1 ("cursor drift").
428
+
429
+ Two fixes applied to `output-phase.ts`:
430
+
431
+ 1. **`bufferToAnsi` robustness**: After writing a wide char, unconditionally skip X+1 (`if (cell.wide) x++`) instead of relying on the next cell's `continuation` flag. This makes output correct even if the buffer has a corrupted/missing continuation cell.
432
+
433
+ 2. **`diffBuffers` wide→narrow transition**: When prev buffer has `wide=true` at X and next doesn't, explicitly add X+1 to the change pool. Without this, the terminal retains the second half of the wide char at X+1 (which the buffer shows as "unchanged" since both prev and next are ' ').
434
+
435
+ **Root cause**: Various buffer operations (`clearNodeRegion`, `renderBox` bg fill, scroll viewport clear) use `buffer.fill()` which defaults `continuation=false`. If these operations overlap with a wide char's continuation cell, the continuation flag is erased. SILVERY_STRICT doesn't catch this because both fresh and incremental renders produce the same corrupted buffer — use SILVERY_STRICT_OUTPUT for output-level verification.
436
+
437
+ **SILVERY_STRICT_OUTPUT now enabled in CI** (`vitest/setup.ts`) after this fix — 3382 vendor + 2090 TUI tests pass with it.
438
+
439
+ ### Text Background Bleed (BgSegment)
440
+
441
+ ANSI-embedded backgrounds (`chalk.bgBlack("text")`) inside a Box with `backgroundColor` caused bg to leak across wrapped lines. The ANSI bg state persisted across line boundaries.
442
+
443
+ Fix: `BgSegment` tracking in `render-text.ts` strips ANSI bg from text content and tracks bg ranges separately. Each line's bg is applied independently. The `bgOverride` utility from ansi allows intentional bg override where needed.
444
+
445
+ ### Descendant Overflow Clearing (2026-03-12)
446
+
447
+ `IncrementalRenderMismatchError` in AI chat status bar: a TextInput node's content shrank from width=91 to width=2, where the old layout overflowed its parent (a `flexGrow` box) and its grandparent (a bordered input box). `clearExcessArea` on the TextInput clipped to the immediate parent's content area, leaving stale pixels in the grandparent's border and padding area.
448
+
449
+ **First attempt (failed):** `hasChildOverflowChanged` checking only direct children at each level. The immediate parent detected the overflow and ran `clearChildOverflowRegions`, which cleared beyond its rect — including the grandparent's border column. But the grandparent had already drawn its border in parent-first order, so the border was overwritten.
450
+
451
+ **Fix:** Made overflow detection recursive (`hasDescendantOverflowChanged`). The bordered grandparent now detects the grandchild's overflow directly, clears its own region (restoring borders), and clears overflow beyond its rect. The immediate parent gets `hasPrevBuffer=false` from the grandparent's cascade, so it renders fresh without needing its own overflow clearing.
452
+
453
+ **Key insight:** Overflow clearing must happen at the level of the ancestor whose border/padding is affected, not at the immediate parent. Parent-first render order means clearing at a child level will overwrite borders that were already drawn by ancestors.
454
+
455
+ ### Output Phase: Flag Emoji Cursor Drift (2026-03-12)
456
+
457
+ Flag emoji (🇨🇦) are regional indicator sequences (U+1F1E6..U+1F1FF pairs). Some terminals (xterm.js headless, older terminals) treat them as two width-1 chars instead of one width-2 char. The buffer models them correctly as one wide cell + one continuation cell, but the terminal cursor advances differently.
458
+
459
+ **Symptom**: After j+l navigation at 200+ cols on a board with flag emoji in the title, the first column shows duplicate card content, stale border fragments, and overlapping cards. Only manifests at wide terminals because the title bar (with flag emoji) is on the same row as the garbled content.
460
+
461
+ **Why SILVERY_STRICT didn't catch it**: STRICT compares buffer content (content phase), which is correct. The bug is in the output phase — ANSI generation creates terminal state that diverges from the buffer. SILVERY_STRICT_OUTPUT uses `replayAnsiWithStyles` which has the same width assumption as the buffer (returns 2 for flag emoji), so it agrees with the buffer. Only feeding ANSI through a real xterm.js terminal emulator (`@termless/xtermjs`) reveals the divergence.
462
+
463
+ **Fix**: Two complementary changes to `output-phase.ts`:
464
+
465
+ 1. `wrapTextSizing` simplified to wrap ALL `cell.wide` characters in OSC 66 unconditionally — no more per-category detection (PUA, text-presentation emoji, flag emoji). If the buffer says wide, the terminal is told width 2. Eliminates whack-a-mole as Unicode evolves.
466
+ 2. Cursor re-sync added to `bufferToAnsi` after every wide char — emits explicit CUP to re-sync the terminal cursor, matching the existing re-sync in `changesToAnsi`. After `x++` (skip continuation), CUP targets `x + 2` (1-indexed) = next cell position.
467
+
468
+ **Testing**: `output-phase-wide-char-matrix.test.ts` (43 tests) verifies both measures across 8 wide char categories (flag emoji, CJK, hangul, fullwidth). Tests OSC 66 presence (with text sizing enabled), CUP re-sync presence, xterm.js cell positions, and incremental vs fresh equivalence. CUP re-sync tests are verified to FAIL without the fix.
469
+
470
+ **Key insight**: `bufferToAnsi` (full render) creates the initial terminal state. If that state diverges from the buffer due to width disagreement, subsequent `changesToAnsi` (incremental) renders use CUP for changed cells (correct), but unchanged cells retain the shifted positions from the full render — creating visible garble where old and new content overlap.
471
+
472
+ ## Common Blind Paths
473
+
474
+ | Blind Path | Why It Doesn't Work | What to Do Instead |
475
+ | --------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
476
+ | Broader viewport clearing | Causes 12ms regression (re-renders ~50 children vs 2 dirty ones) | Only clear viewport for Tier 2 triggers (childrenDirty, scroll+sticky, parentRegionChanged) |
477
+ | Using `needsOwnRepaint` for cascade | Includes `paintDirty`; border color changes cascade through ~200 child nodes | Use `contentAreaAffected` — excludes pure paint changes |
478
+ | Pre-clearing only current sticky positions | Old positions also have stale content in the buffer | Clear entire viewport to `null` bg |
479
+ | `hasPrevBuffer=false` without clearing buffer | Stale content remains in the cloned buffer regardless of hasPrevBuffer flag | Clear viewport first, then set `hasPrevBuffer=false` |
480
+ | `ancestorCleared=true` for sticky second pass | Transparent spacer Boxes clear their region, wiping overlapping sticky content | Use `ancestorCleared=false` — matches fresh render semantics |
481
+ | Blaming the terminal emulator | If 3 terminals show the same glitch, it's your code | Use `withDiagnostics` + `SILVERY_STRICT=1` first |
482
+ | Hand-rolling VirtualTerminal tests | Too simple to catch real app complexity | Use `withDiagnostics(createBoardDriver(...))` |
483
+ | Reading code paths without a failing test | Wastes 20+ turns on theorizing | Write failing test first, THEN trace code |
484
+ | Row pre-check: only packed metadata + chars | Misses true-color Map diffs (fgColors/bgColors) when both cells have TC flag | Always include `rowExtrasEquals()` in the row pre-check |
485
+ | Clearing overflow at immediate parent only | Child-level clear overwrites grandparent's border (parent-first render order) | Use recursive `hasDescendantOverflowChanged` so the bordered ancestor detects and handles it |
486
+
487
+ ## Effective Strategies (Priority Order)
488
+
489
+ 1. **`SILVERY_STRICT=1`** — Run the app or tests. Catches any incremental vs fresh render divergence immediately. Always start here.
490
+
491
+ 2. **Write a failing fuzz seed test** — If fuzz found it, extract the seed. If user-reported, construct a `withDiagnostics(createBoardDriver(...))` test with the minimal reproduction steps.
492
+
493
+ 3. **Read the mismatch error output** — The enhanced error includes cell values, node path, dirty flags, scroll context, and fast-path analysis. This tells you exactly which node diverged and why it was skipped.
494
+
495
+ 4. **`SILVERY_INSTRUMENT=1`** — Exposes skip/render counts, cascade depth, scroll tier decisions on `globalThis.__silvery_content_detail`. Useful for understanding whether too many or too few nodes rendered.
496
+
497
+ 5. **Check the five critical formulas** — `layoutChanged`, `contentAreaAffected`, `parentRegionCleared`, `skipBgFill`, `parentRegionChanged` in `renderNodeToBuffer`. If any is wrong, the cascade propagates errors to the entire subtree.
498
+
499
+ 6. **Text bg inheritance awareness** — Text nodes inherit bg via `inheritedBg` (from `findInheritedBg`), not buffer reads. However, viewport clears and region clears still affect buffer state, which matters for the `getCellBg` legacy fallback (used by scroll indicators). If your fix clears a region, verify it clears to the correct bg (usually `null` to match fresh render state).
500
+
501
+ 7. **Parallel hypothesis testing** — When multiple hypotheses exist (dirty flag issue vs scroll tier issue vs bg inheritance issue), launch parallel sub-agents to test each with a targeted test.
502
+
503
+ ## Symptom → Check Cross-Reference
504
+
505
+ | Symptom | Check First |
506
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
507
+ | Stale background color persists | `bgDirty` flag; `inheritedBg` from `findInheritedBg`; is region being cleared? |
508
+ | Border artifacts after color change | `paintDirty` vs `contentAreaAffected` distinction; border-only change should NOT cascade |
509
+ | Scroll glitch (content jumps/disappears) | Scroll tier selection; Tier 1 unsafe with sticky; Tier 3 needs `stickyForceRefresh` |
510
+ | Children blank after parent changes | `parentRegionChanged` → `childHasPrev=false`; is viewport clear setting `childHasPrev` correctly? |
511
+ | Absolute child disappears | Two-pass rendering order; absolute children need `ancestorCleared=false` in second pass |
512
+ | Content correct initially, wrong after navigation | Incremental rendering bug; `SILVERY_STRICT=1` will catch it |
513
+ | Colors wrong but characters correct (garble) | Output phase: `diffBuffers` row pre-check skipping true-color Map diffs; check `rowExtrasEquals` |
514
+ | Text bg different from parent Box bg | `inheritedBg` from `findInheritedBg`; check if ancestor Box has `backgroundColor`; check region clearing |
515
+ | Flickering on every render | Check `layoutChangedThisFrame` flag; verify `syncPrevLayout` runs at end of content phase |
516
+ | Stale overlay pixels after shrink (black area) | `clearExcessArea` not called; check `parentRegionCleared` + `forceRepaint` interaction |
517
+ | CJK/wide char garble, text shifts right | `bufferToAnsi` cursor drift: wide char without continuation at col+1. Run `SILVERY_STRICT_OUTPUT=1` |
518
+ | Flag emoji garble at wide terminals (200+ cols) | `bufferToAnsi`/`changesToAnsi` cursor re-sync after wide chars; `wrapTextSizing` must include flag emoji (`isFlagSequence`) |
519
+ | Stale chars in ancestor border/padding after child shrinks | Descendant overflow: `clearExcessArea` clips to immediate parent. Use `hasDescendantOverflowChanged()` for recursive detection |
520
+
521
+ ## Quick Regression Test Template
522
+
523
+ When a fuzz test or user report identifies a rendering bug, use this template to write a minimal regression test:
524
+
525
+ ```typescript
526
+ import { describe, test, expect } from "vitest"
527
+ import { createRenderer } from "@silvery/test"
528
+ import { Box, Text } from "silvery"
529
+
530
+ describe("regression: <brief description>", () => {
531
+ test("fuzz seed <N> - <what broke>", async () => {
532
+ const render = createRenderer({ cols: 80, rows: 24 })
533
+
534
+ // Minimal component that reproduces the layout structure
535
+ function App({ state }: { state: number }) {
536
+ return (
537
+ <Box flexDirection="column">
538
+ {/* Mirror the component structure from the failing scenario */}
539
+ <Box overflow="scroll" height={10}>
540
+ <Box backgroundColor="blue">
541
+ <Text>Header</Text>
542
+ </Box>
543
+ <Text>Content {state}</Text>
544
+ </Box>
545
+ </Box>
546
+ )
547
+ }
548
+
549
+ const app = render(<App state={0} />)
550
+
551
+ // Step 1: Initial render (establishes buffer for incremental)
552
+ expect(app.text).toContain("Content 0")
553
+
554
+ // Step 2: Trigger the state change that caused the mismatch
555
+ app.rerender(<App state={1} />)
556
+
557
+ // Step 3: Verify the content is correct (SILVERY_STRICT auto-checks buffer)
558
+ expect(app.text).toContain("Content 1")
559
+ })
560
+ })
561
+ ```
562
+
563
+ For `withDiagnostics` driver tests (full app):
564
+
565
+ ```typescript
566
+ import { describe, test } from "vitest"
567
+ import { createBoardDriver } from "@km/tui/driver.ts"
568
+ import { createFakeRepo } from "@km/storage"
569
+ import { withDiagnostics } from "silvery"
570
+ import { item } from "@km/tui/tests/helpers/board-test.ts"
571
+
572
+ describe("regression: <brief description>", () => {
573
+ test("repro from fuzz/user report", async () => {
574
+ const nodes = item.root("board", item("Column 1", item("Task A"), item("Task B")), item("Column 2", item("Task C")))
575
+ const driver = withDiagnostics(createBoardDriver(createFakeRepo({ nodes }), "board"), {
576
+ checkIncremental: true,
577
+ checkReplay: true,
578
+ checkStability: true,
579
+ })
580
+
581
+ // Reproduce the sequence that triggered the bug
582
+ await driver.cmd.down()
583
+ await driver.cmd.down()
584
+ // Diagnostics auto-check after each command — throws on mismatch
585
+ })
586
+ })
587
+ ```