flexily 0.0.1 → 0.2.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/classic/layout.d.ts +57 -0
  4. package/dist/classic/layout.d.ts.map +1 -0
  5. package/dist/classic/layout.js +1558 -0
  6. package/dist/classic/layout.js.map +1 -0
  7. package/dist/classic/node.d.ts +648 -0
  8. package/dist/classic/node.d.ts.map +1 -0
  9. package/dist/classic/node.js +1002 -0
  10. package/dist/classic/node.js.map +1 -0
  11. package/dist/constants.d.ts +58 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +70 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/index-classic.d.ts +30 -0
  16. package/dist/index-classic.d.ts.map +1 -0
  17. package/dist/index-classic.js +57 -0
  18. package/dist/index-classic.js.map +1 -0
  19. package/dist/index.d.ts +30 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +57 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/layout-flex-lines.d.ts +77 -0
  24. package/dist/layout-flex-lines.d.ts.map +1 -0
  25. package/dist/layout-flex-lines.js +317 -0
  26. package/dist/layout-flex-lines.js.map +1 -0
  27. package/dist/layout-helpers.d.ts +48 -0
  28. package/dist/layout-helpers.d.ts.map +1 -0
  29. package/dist/layout-helpers.js +108 -0
  30. package/dist/layout-helpers.js.map +1 -0
  31. package/dist/layout-measure.d.ts +25 -0
  32. package/dist/layout-measure.d.ts.map +1 -0
  33. package/dist/layout-measure.js +231 -0
  34. package/dist/layout-measure.js.map +1 -0
  35. package/dist/layout-stats.d.ts +19 -0
  36. package/dist/layout-stats.d.ts.map +1 -0
  37. package/dist/layout-stats.js +37 -0
  38. package/dist/layout-stats.js.map +1 -0
  39. package/dist/layout-traversal.d.ts +28 -0
  40. package/dist/layout-traversal.d.ts.map +1 -0
  41. package/dist/layout-traversal.js +65 -0
  42. package/dist/layout-traversal.js.map +1 -0
  43. package/dist/layout-zero.d.ts +26 -0
  44. package/dist/layout-zero.d.ts.map +1 -0
  45. package/dist/layout-zero.js +1601 -0
  46. package/dist/layout-zero.js.map +1 -0
  47. package/dist/logger.d.ts +14 -0
  48. package/dist/logger.d.ts.map +1 -0
  49. package/dist/logger.js +61 -0
  50. package/dist/logger.js.map +1 -0
  51. package/dist/node-zero.d.ts +702 -0
  52. package/dist/node-zero.d.ts.map +1 -0
  53. package/dist/node-zero.js +1268 -0
  54. package/dist/node-zero.js.map +1 -0
  55. package/dist/testing.d.ts +69 -0
  56. package/dist/testing.d.ts.map +1 -0
  57. package/dist/testing.js +179 -0
  58. package/dist/testing.js.map +1 -0
  59. package/dist/trace.d.ts +74 -0
  60. package/dist/trace.d.ts.map +1 -0
  61. package/dist/trace.js +191 -0
  62. package/dist/trace.js.map +1 -0
  63. package/dist/types.d.ts +170 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/dist/types.js +43 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/utils.d.ts +41 -0
  68. package/dist/utils.d.ts.map +1 -0
  69. package/dist/utils.js +197 -0
  70. package/dist/utils.js.map +1 -0
  71. package/package.json +58 -3
  72. package/src/CLAUDE.md +512 -0
  73. package/src/beorn-logger.d.ts +10 -0
  74. package/src/classic/layout.ts +1783 -0
  75. package/src/classic/node.ts +1121 -0
  76. package/src/constants.ts +81 -0
  77. package/src/index-classic.ts +110 -0
  78. package/src/index.ts +110 -0
  79. package/src/layout-flex-lines.ts +346 -0
  80. package/src/layout-helpers.ts +140 -0
  81. package/src/layout-measure.ts +259 -0
  82. package/src/layout-stats.ts +43 -0
  83. package/src/layout-traversal.ts +70 -0
  84. package/src/layout-zero.ts +1792 -0
  85. package/src/logger.ts +67 -0
  86. package/src/node-zero.ts +1412 -0
  87. package/src/testing.ts +209 -0
  88. package/src/trace.ts +252 -0
  89. package/src/types.ts +229 -0
  90. package/src/utils.ts +217 -0
package/src/CLAUDE.md ADDED
@@ -0,0 +1,512 @@
1
+ # Flexily Internals
2
+
3
+ Read this before modifying any source file. This documents the layout algorithm, zero-allocation design, caching system, and integration points.
4
+
5
+ ## Architecture Overview
6
+
7
+ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. The "zero" in `layout-zero.ts` / `node-zero.ts` means **zero-allocation layout** -- the layout pass reuses pre-allocated structures instead of creating temporary objects on the heap.
8
+
9
+ ```
10
+ Public API
11
+ ┌────────────┐
12
+ │ index.ts │ Re-exports Node + constants
13
+ └─────┬──────┘
14
+
15
+ ┌───────────────┴───────────────┐
16
+ │ │
17
+ ┌──────┴──────┐ ┌──────┴───────┐
18
+ │ node-zero.ts│ │layout-zero.ts │
19
+ │ (1412 LOC) │ │ (1781 LOC) │
20
+ │ Node class │ │ layoutNode │
21
+ └──────┬──────┘ └──────┬────────┘
22
+ │ │
23
+ ┌──────┴──────┐ ┌──────────────────┼──────────────────┐
24
+ │ types.ts │ │ │ │
25
+ │ Interfaces │ layout-helpers.ts layout-flex-lines.ts │
26
+ └─────────────┘ (140 LOC) (346 LOC) │
27
+ Edge resolution Pre-alloc arrays layout-measure.ts
28
+ Line breaking (257 LOC)
29
+ Flex distribution measureNode
30
+
31
+ layout-traversal.ts (70 LOC) - Tree traversal (markSubtreeLayoutSeen, countNodes)
32
+ layout-stats.ts (43 LOC) - Debug/benchmark counters
33
+ utils.ts - resolveValue, applyMinMax, traversal stack
34
+ constants.ts - Yoga-compatible enum values
35
+ logger.ts - Optional debug logging (conditional)
36
+ testing.ts - Layout inspection + differential oracles
37
+ classic/ - Allocating algorithm (debugging reference)
38
+ ```
39
+
40
+ **Key design decisions:**
41
+
42
+ - Factory function API: `Node.create()` (no `new` in user code, though `Node` is a class internally)
43
+ - Yoga-compatible API surface: same method names, same constants, drop-in replacement
44
+ - Pure JavaScript: no WASM, no native dependencies, synchronous initialization
45
+ - Single-threaded: layout uses module-level pre-allocated arrays (not reentrant)
46
+
47
+ ## Source Files
48
+
49
+ | File | LOC | Role | Hot path? |
50
+ | ---------------------- | ----- | -------------------------------------------------------------------- | ------------------------------ |
51
+ | `layout-zero.ts` | 1781 | Core layout: `computeLayout()`, `layoutNode()` (11 phases) | **Yes** - most critical |
52
+ | `layout-helpers.ts` | 140 | Edge resolution: margins, padding, borders | **Yes** - called per edge |
53
+ | `layout-flex-lines.ts` | 346 | Pre-alloc arrays, `breakIntoLines()`, `distributeFlexSpaceForLine()` | **Yes** - flex distribution |
54
+ | `layout-measure.ts` | 257 | `measureNode()` — intrinsic sizing | **Yes** - sizing pass |
55
+ | `layout-traversal.ts` | 70 | Tree traversal: `markSubtreeLayoutSeen()`, `countNodes()` | Moderate |
56
+ | `layout-stats.ts` | 43 | Debug/benchmark counters | No (counters only) |
57
+ | `node-zero.ts` | 1412 | Node class, tree ops, caching | **Yes** - second most critical |
58
+ | `types.ts` | 229 | `FlexInfo`, `Style`, `Layout`, `Value` interfaces | No (types only) |
59
+ | `utils.ts` | 217 | `resolveValue`, `applyMinMax`, edge helpers, shared traversal stack | Yes (called frequently) |
60
+ | `constants.ts` | 81 | Yoga-compatible numeric constants | No |
61
+ | `logger.ts` | 67 | Conditional debug logger (`log.debug?.()`) | No (conditional) |
62
+ | `testing.ts` | 209 | `getLayout`, `diffLayouts`, `expectRelayoutMatchesFresh` | No (test only) |
63
+ | `classic/` | ~2900 | Allocating reference algorithm | No (debugging only) |
64
+
65
+ ## Layout Algorithm Phases
66
+
67
+ `layoutNode()` in `layout-zero.ts` implements CSS Flexbox Section 9.7. It is called recursively for each node. The algorithm has 11 phases:
68
+
69
+ ### Phase 1: Early Exit Checks
70
+
71
+ - `display: none` -> zero-size return
72
+ - **Constraint fingerprinting**: If `layoutValid && !isDirty && same(availW, availH, dir)`, skip layout entirely. Only update position delta if offset changed. This is the core of the 5.5x re-layout speedup.
73
+
74
+ ### Phase 2: Resolve Spacing
75
+
76
+ - Resolve margins, padding, borders from `Value` (point/percent/auto) to absolute numbers
77
+ - CSS spec: percentage margins AND padding resolve against containing block's **width only**
78
+ - Logical edges (START/END) resolve based on `direction` (LTR/RTL)
79
+
80
+ ### Phase 3: Calculate Node Dimensions
81
+
82
+ - Resolve width/height from style (point, percent, or auto/NaN)
83
+ - Apply aspect ratio constraint
84
+ - Apply min/max constraints
85
+ - Compute content area (inside border+padding)
86
+ - Compute position offsets for relative/static children
87
+
88
+ ### Phase 4: Handle Leaf Nodes
89
+
90
+ Two cases:
91
+
92
+ - **With measureFunc**: Call `cachedMeasure()` to get intrinsic size (text nodes)
93
+ - **Without measureFunc**: Intrinsic size = padding + border (empty boxes)
94
+
95
+ ### Phase 5: Collect Children and Compute Base Sizes
96
+
97
+ Single pass over children:
98
+
99
+ - Skip `display:none` and `position:absolute` (set `relativeIndex = -1`)
100
+ - Cache all 4 resolved margins on `child.flex`
101
+ - Compute base size from: `flexBasis` > explicit `width`/`height` > `measureFunc` > recursive `measureNode` > padding+border
102
+ - Track flex factors, min/max, auto margins
103
+ - **CSS 4.5 divergence**: For `overflow:hidden/scroll`, force `flexShrink >= 1` (Yoga doesn't do this)
104
+
105
+ ### Phase 6a: Line Breaking and Space Distribution
106
+
107
+ - `breakIntoLines()` splits children into flex lines (for wrap)
108
+ - `distributeFlexSpaceForLine()` implements CSS 9.7 iterative freeze algorithm:
109
+ - Positive free space -> grow (distribute by `flexGrow` ratio)
110
+ - Negative free space -> shrink (distribute by `flexShrink * baseSize` ratio -- weighted shrink)
111
+ - Items that hit min/max are "frozen"; iteration continues with remaining items
112
+ - Single-child fast path skips iteration
113
+
114
+ ### Phase 6b: Justify-Content and Auto Margins (Per Line)
115
+
116
+ - Auto margins absorb space BEFORE `justifyContent` (CSS spec)
117
+ - Per-line calculation for multi-line (flex-wrap) layouts
118
+ - Space-between/around/evenly only apply with positive remaining space
119
+
120
+ ### Phase 6c: Baseline Alignment
121
+
122
+ - Pre-compute baselines for `align-items: baseline` (row direction only)
123
+ - Uses `baselineFunc` if set, otherwise falls back to bottom of content box
124
+
125
+ ### Phase 7a: Estimate Line Cross Sizes
126
+
127
+ - Tentative cross-axis sizes from definite child dimensions
128
+ - Auto-sized children use 0 (actual size computed in Phase 8)
129
+
130
+ ### Phase 7b: Apply alignContent
131
+
132
+ - Distribute flex lines within the cross-axis (stretch, center, space-between, etc.)
133
+ - Yoga quirk: `ALIGN_STRETCH` applies even to single-line layouts
134
+
135
+ ### Phase 8: Position and Layout Children
136
+
137
+ The most complex phase. For each relative child:
138
+
139
+ 1. Determine cross-axis alignment (alignItems/alignSelf)
140
+ 2. Resolve cross-axis size (explicit, percent, stretch, or shrink-wrap/NaN)
141
+ 3. Handle measure function for intrinsic sizing
142
+ 4. Compute fractional position (main-axis from `mainPos`, cross-axis from `lineCrossOffset`)
143
+ 5. **Edge-based rounding** (Yoga-compatible): Round absolute edges, derive sizes as `round(end) - round(start)`. This prevents pixel gaps between adjacent elements.
144
+ 6. Recursively call `layoutNode()` for grandchildren
145
+ 7. Override sizes based on flex distribution results
146
+ 8. Apply cross-axis alignment offset (flex-end, center, baseline)
147
+ 9. Advance `mainPos` for next child
148
+
149
+ ### Phase 9: Shrink-Wrap Auto-Sized Containers
150
+
151
+ - For containers without explicit size, compute actual used space from children
152
+ - Main axis: sum of child sizes + margins + gaps
153
+ - Cross axis: max child size + margins
154
+
155
+ ### Phase 10: Final Output
156
+
157
+ - Edge-based rounding for the node itself
158
+ - Position stored as relative (to parent), not absolute
159
+
160
+ ### Phase 11: Layout Absolute Children
161
+
162
+ - Absolute children positioned relative to padding box (not content box)
163
+ - Support for left/right/top/bottom offsets
164
+ - Auto margins for centering
165
+ - Alignment when no position is set
166
+
167
+ ## Zero-Allocation Design
168
+
169
+ ### Why It Matters
170
+
171
+ Interactive TUIs re-layout on every keystroke. GC pauses cause visible jank. Flexily avoids heap allocation during layout passes.
172
+
173
+ ### Module-Level Pre-Allocated Arrays
174
+
175
+ ```typescript
176
+ // Used for flex-wrap multi-line layouts (layout-zero.ts)
177
+ let _lineCrossSizes = new Float64Array(32) // Cross-axis size per line
178
+ let _lineCrossOffsets = new Float64Array(32) // Cross-axis offset per line
179
+ let _lineLengths = new Uint16Array(32) // Children per line
180
+ let _lineChildren: Node[][] = Array(32) // Node refs per line
181
+ let _lineJustifyStarts = new Float64Array(32) // Per-line justify start
182
+ let _lineItemSpacings = new Float64Array(32) // Per-line item spacing
183
+ ```
184
+
185
+ These grow dynamically if >32 lines (rare). Total memory: ~1,344 bytes (4 Float64Arrays × 256 bytes + 1 Uint16Array × 64 bytes + Array overhead).
186
+
187
+ **Consequence: Not reentrant.** Layout is single-threaded; concurrent `calculateLayout()` calls corrupt shared state. This is safe because layout is synchronous.
188
+
189
+ ### Per-Node FlexInfo (`node.flex`)
190
+
191
+ Instead of creating `ChildLayout` objects per child per pass, intermediate layout state is stored directly on each node in the `_flex: FlexInfo` field. This struct is mutated in place each pass:
192
+
193
+ ```typescript
194
+ interface FlexInfo {
195
+ // Flex distribution state (mutated during Phase 5-6)
196
+ mainSize
197
+ baseSize
198
+ mainMargin
199
+ flexGrow
200
+ flexShrink
201
+ minMain
202
+ maxMain
203
+ frozen
204
+ mainStartMarginAuto
205
+ mainEndMarginAuto
206
+ mainStartMarginValue
207
+ mainEndMarginValue
208
+ marginL
209
+ marginT
210
+ marginR
211
+ marginB
212
+ lineIndex
213
+ relativeIndex
214
+ baseline
215
+
216
+ // Constraint fingerprinting (mutated at end of layoutNode)
217
+ lastAvailW
218
+ lastAvailH
219
+ lastOffsetX
220
+ lastOffsetY
221
+ lastDir
222
+ layoutValid
223
+ }
224
+ ```
225
+
226
+ ### Shared Traversal Stack
227
+
228
+ ```typescript
229
+ // utils.ts - single pre-allocated stack for all iterative traversals
230
+ export const traversalStack: unknown[] = []
231
+ ```
232
+
233
+ Used by `markSubtreeLayoutSeen`, `countNodes`, `resetLayoutCache`, `propagatePositionDelta`. Avoids recursion (prevents stack overflow on deep trees) and avoids per-traversal allocation.
234
+
235
+ ### Measure Cache (Per-Node)
236
+
237
+ 4-entry numeric cache on each Node (`_m0` through `_m3`), avoiding Map/object allocations:
238
+
239
+ ```typescript
240
+ interface MeasureEntry {
241
+ w
242
+ wm
243
+ h
244
+ hm
245
+ rw
246
+ rh
247
+ }
248
+ ```
249
+
250
+ Returns a stable `_measureResult` object (mutated in place) to avoid allocation on cache hits. Cache is cleared on `markDirty()`.
251
+
252
+ ### Layout Cache (Per-Node)
253
+
254
+ 2-entry cache (`_lc0`, `_lc1`) for sizing passes. Stores `availW, availH -> computedW, computedH`. Cleared at start of each `calculateLayout()` pass. Returns a stable `_layoutResult` object. Uses `-1` as invalidation sentinel (not `NaN`, because `Object.is(NaN, NaN)` is true and would cause false hits).
255
+
256
+ ## Caching and Dirty Tracking
257
+
258
+ ### Dirty Propagation
259
+
260
+ `markDirty()` propagates up to root:
261
+
262
+ 1. Clears measure cache (`_m0-_m3`) and layout cache (`_lc0-_lc1`) on every ancestor
263
+ 2. Sets `_isDirty = true`
264
+ 3. Invalidates `flex.layoutValid`
265
+ 4. Stops early if an ancestor is already dirty (caches still cleared)
266
+
267
+ **Subtlety**: Even if a node is already dirty, child changes may invalidate cached layout results that used the old child size. That's why caches are always cleared, even when `_isDirty` is already true.
268
+
269
+ ### Constraint Fingerprinting
270
+
271
+ The core re-layout optimization. At the end of `layoutNode()`, fingerprint values are stored:
272
+
273
+ ```typescript
274
+ flex.lastAvailW = availableWidth
275
+ flex.lastAvailH = availableHeight
276
+ flex.lastOffsetX = offsetX
277
+ flex.lastOffsetY = offsetY
278
+ flex.lastDir = direction
279
+ flex.layoutValid = true
280
+ ```
281
+
282
+ On next call, if `layoutValid && !isDirty && same constraints`, the entire subtree is skipped. Only position delta is propagated (if offset changed).
283
+
284
+ **`Object.is()` is required** for NaN-safe comparison. `NaN === NaN` is `false`; `Object.is(NaN, NaN)` is `true`. NaN represents "unconstrained" -- a legitimate and common constraint value.
285
+
286
+ ### Invalidation Triggers
287
+
288
+ `layoutValid` is set to `false` when:
289
+
290
+ - `markDirty()` is called (style change, content change)
291
+ - A sibling is inserted/removed (positions change)
292
+
293
+ ### `calculateLayout()` Top-Level Skip
294
+
295
+ The root node also has a constraint-based skip:
296
+
297
+ ```typescript
298
+ if (!this._isDirty && same(_lastCalcW, availW) && same(_lastCalcH, availH) && _lastCalcDir === dir) return // No layout needed at all
299
+ ```
300
+
301
+ This makes the no-change case ~27ns (a simple comparison, not even entering the algorithm).
302
+
303
+ ## Edge-Based Rounding (Yoga-Compatible)
304
+
305
+ **Problem**: Naive `Math.round(width)` creates pixel gaps between adjacent elements. If child1 has `width=10.5` and child2 starts at `x=10.5`, rounding each independently gives `width=11` and `x=11` -- 0.5px gap.
306
+
307
+ **Solution**: Round **absolute edge positions**, then derive sizes as differences:
308
+
309
+ ```typescript
310
+ const absLeft = Math.round(absX + marginLeft + fractionalLeft)
311
+ const absRight = Math.round(absX + marginLeft + fractionalLeft + childWidth)
312
+ child.layout.width = absRight - absLeft // Always gap-free
313
+ ```
314
+
315
+ This is Yoga's algorithm. Layout positions stored in `layout.left`/`layout.top` are **relative** to parent.
316
+
317
+ ## measureNode vs layoutNode
318
+
319
+ `measureNode()` (~240 lines) is a lightweight alternative to `layoutNode()` (~1650 lines). It computes `width` and `height` but NOT `left`/`top`. Used during Phase 5 for intrinsic sizing of auto-sized container children. Save/restore of `layout.width`/`layout.height` is required around `measureNode` calls because it overwrites those fields.
320
+
321
+ ## Integration: How silvery Uses Flexily
322
+
323
+ silvery uses flexily through an adapter layer:
324
+
325
+ 1. `silvery/src/layout-engine.ts` defines the `LayoutEngine` / `LayoutNode` interfaces
326
+ 2. `silvery/src/adapters/flexily-zero-adapter.ts` wraps `Node` in `FlexilyZeroNodeAdapter`
327
+ 3. The adapter is mostly delegation (Flexily already has a Yoga-compatible API)
328
+ 4. Measure modes are translated from numeric constants to strings (`"exactly"`, `"at-most"`, `"undefined"`)
329
+
330
+ silvery calls `calculateLayout()` on every render. The no-change case (cursor movement, selection) is the most common scenario in the km TUI, which is why the 5.5x fingerprint-cache advantage matters.
331
+
332
+ ## Intentional Divergences from Yoga
333
+
334
+ | Behavior | Yoga | Flexily | CSS Spec |
335
+ | ----------------------------------------- | ---------------------------------------- | ------------------------------------- | ---------------------------------------------- |
336
+ | `overflow:hidden/scroll` + `flexShrink:0` | Item expands to content (ignores parent) | Item shrinks to fit parent | 4.5: auto min-size = 0 for overflow containers |
337
+ | Default `flexShrink` | 0 (Yoga native default) | 0 (matches Yoga) | CSS default is 1 |
338
+ | Default `flexDirection` | Column | Column | CSS default is Row |
339
+ | Baseline alignment | Full spec (recursive first-child) | Simplified (no recursive propagation) | Recursive first-child |
340
+
341
+ The `flexShrink` override for overflow containers (line ~1244 in layout-zero.ts) is the most significant divergence. Without it, `overflow:hidden` children inside constrained parents balloon to content size, defeating the purpose of clipping.
342
+
343
+ ## Style and Value System
344
+
345
+ ### Value Type
346
+
347
+ ```typescript
348
+ interface Value {
349
+ value: number
350
+ unit: number
351
+ }
352
+ // unit: UNIT_UNDEFINED(0), UNIT_POINT(1), UNIT_PERCENT(2), UNIT_AUTO(3)
353
+ ```
354
+
355
+ ### Style Storage
356
+
357
+ Edge-based properties use 6-element arrays: `[left, top, right, bottom, start, end]`
358
+
359
+ - Physical edges: indices 0-3
360
+ - Logical edges: indices 4-5 (resolved at layout time based on direction)
361
+ - Logical takes precedence over physical when both are set
362
+
363
+ Border widths are plain numbers (always points). Logical border slots use `NaN` as "not set" sentinel.
364
+
365
+ ### Default Style (Yoga-compatible, not CSS)
366
+
367
+ ```typescript
368
+ flexDirection: COLUMN // CSS default is ROW
369
+ flexShrink: 0 // CSS default is 1
370
+ alignItems: STRETCH // Same as CSS
371
+ flexBasis: AUTO // Same as CSS
372
+ width/height: AUTO // Same as CSS
373
+ positionType: RELATIVE // Same as CSS
374
+ ```
375
+
376
+ ## Performance Characteristics
377
+
378
+ ### Where Flexily Wins
379
+
380
+ - **Node creation**: ~8x cheaper than Yoga (no WASM boundary crossing)
381
+ - **Initial layout**: 1.5-2.5x faster (JS node creation dominates)
382
+ - **No-change re-layout**: 5.5x faster (fingerprint cache, 27ns regardless of tree size)
383
+ - **Bundle size**: 2.5x smaller (3.4x without `debug` dep)
384
+
385
+ ### Where Yoga Wins
386
+
387
+ - **Incremental re-layout** (dirty leaf in existing tree): 2.8-3.4x faster (WASM per-node computation is faster)
388
+ - **Deep nesting** (15+ levels): Yoga's advantage increases with depth
389
+
390
+ ### For TUI Use Cases
391
+
392
+ The no-change case dominates (cursor movement, selection, scrolling). Flexily's fingerprint cache makes this essentially free. This is the key differentiator.
393
+
394
+ ## Common Pitfalls
395
+
396
+ ### Modifying layout-zero.ts
397
+
398
+ 1. **Always benchmark** after any change (see instructions below)
399
+ 2. **Don't allocate in the hot path** -- no `new`, no object literals, no array construction inside `layoutNode()` or `distributeFlexSpaceForLine()`
400
+ 3. **NaN semantics are load-bearing** -- `NaN` means "unconstrained/auto". Use `Number.isNaN()` checks, not `=== NaN`. Use `Object.is()` for equality comparison.
401
+ 4. **Edge-based rounding must use absolute coordinates** -- rounding relative positions creates gaps
402
+ 5. **Save/restore `layout.width`/`layout.height` around `measureNode`** -- it overwrites those fields with intrinsic measurements
403
+
404
+ ### Modifying node-zero.ts
405
+
406
+ 1. **markDirty() always clears caches** -- even if node is already dirty, because child content may have changed
407
+ 2. **Cache invalidation uses `-1` sentinel, not `NaN`** -- because `Object.is(NaN, NaN)` is true, NaN would cause false cache hits
408
+ 3. **Lazy allocation for cache entries** -- `_m0` through `_m3` and `_lc0`/`_lc1` are `undefined` until first use
409
+
410
+ ### Modifying caching/fingerprinting
411
+
412
+ 1. **Run the re-layout fuzz tests**: `bun test tests/relayout-consistency.test.ts` (1200+ tests)
413
+ 2. **Run mutation testing**: `bun scripts/mutation-test.ts` to verify fuzz suite catches deliberate cache mutations
414
+ 3. The differential oracle (`expectRelayoutMatchesFresh`) is the primary correctness tool: build tree -> layout -> dirty -> re-layout -> compare against fresh layout
415
+
416
+ ### Adding features
417
+
418
+ 1. Update `createDefaultStyle()` in `types.ts` with correct default
419
+ 2. Add setter/getter to `Node` class (call `markDirty()` in setters)
420
+ 3. Handle in `layoutNode()` (and `measureNode()` if it affects sizing)
421
+ 4. Add to Yoga compatibility tests if corresponding Yoga behavior exists
422
+ 5. Verify re-layout fuzz tests still pass
423
+
424
+ ## Benchmarking Protocol
425
+
426
+ After ANY change to `layout-zero.ts` or `node-zero.ts`:
427
+
428
+ ```bash
429
+ # 1. Check CPU load -- no heavy processes running
430
+ top -l 1 -n 5 -stats command,cpu | head -10
431
+
432
+ # 2. Run benchmark
433
+ cd vendor/flexily && bun bench bench/yoga-compare-warmup.bench.ts
434
+
435
+ # 3. Compare against baseline:
436
+ # Flexily should be ~2x Yoga for flat trees
437
+ # Flexily should be ~2.3x Yoga for shallow deep trees
438
+ # No-change re-layout should be ~5.5x Yoga
439
+
440
+ # 4. If you changed source, rebuild:
441
+ cd vendor/flexily && bun run build
442
+ ```
443
+
444
+ **Acceptable impact:**
445
+
446
+ - Regressions < 5% for minor features
447
+ - No regressions for refactoring (must be neutral or faster)
448
+ - Document any trade-offs
449
+
450
+ ## Testing Hierarchy
451
+
452
+ | Layer | Tests | Command | What it verifies |
453
+ | ------------------ | --------- | ---------------------------------------------------------------------------- | --------------------------------- |
454
+ | Yoga compat | 38 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
455
+ | Feature tests | ~110 | `bun test tests/layout.test.ts` | Each flexbox feature in isolation |
456
+ | **Re-layout fuzz** | **1200+** | `bun test tests/relayout-consistency.test.ts` | Incremental matches fresh |
457
+ | Mutation testing | 4+ | `bun scripts/mutation-test.ts` | Fuzz catches cache mutations |
458
+ | All tests | 1368 | `bun test` | Everything |
459
+
460
+ The fuzz tests are the most important layer. They've caught 3 distinct caching bugs that all single-pass tests missed.
461
+
462
+ ## Lessons from Past Sessions
463
+
464
+ ### Three Caching Bugs (2026-02-10)
465
+
466
+ All 524 Flexily tests passed. The TUI showed visual corruption (text bleeding past card borders) only during re-layout of partially-dirty trees. Root cause: zero tests exercised `calculateLayout()` twice on the same tree.
467
+
468
+ **Bug 1: measureNode corruption** — `measureNode()` overwrote `layout.width/height` on clean nodes as a side effect. The fingerprint check then skipped the clean node, preserving corrupted values. Fix: save/restore `layout.width/height` around `measureNode` calls.
469
+
470
+ **Bug 2: NaN cache sentinel** — `resetLayoutCache()` used `NaN` to invalidate entries. But `NaN` is a legitimate "unconstrained" query value, and `Object.is(NaN, NaN) === true` caused false cache hits. Fix: use `-1` as the invalidation sentinel (non-negative field, so `-1` is outside the legitimate domain).
471
+
472
+ **Bug 3: Fingerprint mismatch** — Auto-sized children receive `NaN` as availableWidth. When the parent's flex distribution changed between passes (shrinkage at 60px vs no shrinkage at 80px), the `NaN===NaN` fingerprint matched even though the parent would override the child's width differently. Fix: ensure fingerprint captures all inputs that affect output, including parent-side overrides.
473
+
474
+ All three bugs were found by a **differential oracle**: build tree → layout → mark dirty → re-layout → compare against fresh layout. Fresh layout is trivially correct (no caching involved). Any difference is a bug.
475
+
476
+ ### Performance Optimization (2026-02-10)
477
+
478
+ Baseline measurements vs Yoga:
479
+
480
+ - **Flat trees**: Flexily ~2x faster (node creation dominates — no WASM boundary)
481
+ - **Shallow deep trees**: Flexily ~2.3x faster
482
+ - **No-change re-layout**: Flexily ~5.5x faster (fingerprint cache, ~27ns)
483
+ - **FlexWrap**: Yoga 1.77x faster (Flexily's multi-line allocation is the weak spot)
484
+ - **Incremental dirty leaf**: Yoga 2.8-3.4x faster (WASM per-node computation is faster)
485
+
486
+ Key takeaway: For TUI use cases, the no-change case dominates (cursor movement, selection, scrolling). Flexily's fingerprint cache makes this essentially free.
487
+
488
+ ## Common Blind Paths
489
+
490
+ | Blind Path | Why It Doesn't Work | What to Do Instead |
491
+ | -------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
492
+ | Allocating in the hot path | GC pauses cause visible jank in interactive TUIs | Use FlexInfo mutation, pre-allocated arrays, `-1` sentinels |
493
+ | Using `NaN` as invalidation sentinel | `Object.is(NaN, NaN)` is `true` — causes false cache hits | Use `-1` for non-negative fields |
494
+ | Rounding relative positions | Creates pixel gaps between adjacent elements | Round absolute edge positions, derive sizes as differences |
495
+ | Single-pass tests for caching code | Zero coverage of re-layout paths; 524 tests can pass with 3 caching bugs | Use differential oracle: fresh layout vs re-layout |
496
+ | Modifying `measureNode` without save/restore | `measureNode` overwrites `layout.width/height` as a side effect | Always save/restore around `measureNode` calls |
497
+ | Assuming dirty propagation stops early | Even if node is already dirty, child changes may invalidate cached results | Always clear caches when `markDirty()` is called, even on already-dirty ancestors |
498
+ | Testing with simple flat trees | Complex bugs appear with nesting, auto-sizing, flex distribution changes | Use fuzz testing with random tree structures and dirty subsets |
499
+
500
+ ## Effective Strategies (Priority Order)
501
+
502
+ 1. **Run the re-layout fuzz suite** — `bun test tests/relayout-consistency.test.ts` (1200+ tests). If it passes, caching logic is correct for known patterns. If a seed fails, use `-t "seed=N"` to isolate.
503
+
504
+ 2. **Differential oracle testing** — Build tree → layout → dirty → re-layout → compare against fresh layout. Don't hardcode expected values. `expectRelayoutMatchesFresh()` in `testing.ts` does this automatically.
505
+
506
+ 3. **Mutation testing** — `bun scripts/mutation-test.ts` verifies the fuzz suite catches deliberate cache mutations. Run after modifying cache/fingerprint logic to ensure test coverage is sufficient.
507
+
508
+ 4. **Benchmark before and after** — Any change to `layout-zero.ts` or `node-zero.ts` requires benchmark comparison. No regressions for refactoring; <5% for features.
509
+
510
+ 5. **Check NaN semantics** — Whenever you see a comparison, ask: "What if this value is NaN?" Use `Object.is()` for equality, `Number.isNaN()` for checks, never `=== NaN`.
511
+
512
+ 6. **Targeted test mirroring real structure** — If fuzz doesn't find it, create a test that mirrors the exact component structure from the TUI (card structure, scroll containers, nested flex).
@@ -0,0 +1,10 @@
1
+ // Optional dependency - resolved via km monorepo workspace, falls back at runtime
2
+ declare module "loggily" {
3
+ export function createLogger(namespace: string): {
4
+ debug?: (msg: string) => void
5
+ info?: (msg: string) => void
6
+ warn?: (msg: string) => void
7
+ error?: (msg: string | Error) => void
8
+ trace?: (msg: string) => void
9
+ }
10
+ }