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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/classic/layout.d.ts +57 -0
- package/dist/classic/layout.d.ts.map +1 -0
- package/dist/classic/layout.js +1558 -0
- package/dist/classic/layout.js.map +1 -0
- package/dist/classic/node.d.ts +648 -0
- package/dist/classic/node.d.ts.map +1 -0
- package/dist/classic/node.js +1002 -0
- package/dist/classic/node.js.map +1 -0
- package/dist/constants.d.ts +58 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +70 -0
- package/dist/constants.js.map +1 -0
- package/dist/index-classic.d.ts +30 -0
- package/dist/index-classic.d.ts.map +1 -0
- package/dist/index-classic.js +57 -0
- package/dist/index-classic.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-flex-lines.d.ts +77 -0
- package/dist/layout-flex-lines.d.ts.map +1 -0
- package/dist/layout-flex-lines.js +317 -0
- package/dist/layout-flex-lines.js.map +1 -0
- package/dist/layout-helpers.d.ts +48 -0
- package/dist/layout-helpers.d.ts.map +1 -0
- package/dist/layout-helpers.js +108 -0
- package/dist/layout-helpers.js.map +1 -0
- package/dist/layout-measure.d.ts +25 -0
- package/dist/layout-measure.d.ts.map +1 -0
- package/dist/layout-measure.js +231 -0
- package/dist/layout-measure.js.map +1 -0
- package/dist/layout-stats.d.ts +19 -0
- package/dist/layout-stats.d.ts.map +1 -0
- package/dist/layout-stats.js +37 -0
- package/dist/layout-stats.js.map +1 -0
- package/dist/layout-traversal.d.ts +28 -0
- package/dist/layout-traversal.d.ts.map +1 -0
- package/dist/layout-traversal.js +65 -0
- package/dist/layout-traversal.js.map +1 -0
- package/dist/layout-zero.d.ts +26 -0
- package/dist/layout-zero.d.ts.map +1 -0
- package/dist/layout-zero.js +1601 -0
- package/dist/layout-zero.js.map +1 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +61 -0
- package/dist/logger.js.map +1 -0
- package/dist/node-zero.d.ts +702 -0
- package/dist/node-zero.d.ts.map +1 -0
- package/dist/node-zero.js +1268 -0
- package/dist/node-zero.js.map +1 -0
- package/dist/testing.d.ts +69 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +179 -0
- package/dist/testing.js.map +1 -0
- package/dist/trace.d.ts +74 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +191 -0
- package/dist/trace.js.map +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +43 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +41 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +197 -0
- package/dist/utils.js.map +1 -0
- package/package.json +58 -3
- package/src/CLAUDE.md +512 -0
- package/src/beorn-logger.d.ts +10 -0
- package/src/classic/layout.ts +1783 -0
- package/src/classic/node.ts +1121 -0
- package/src/constants.ts +81 -0
- package/src/index-classic.ts +110 -0
- package/src/index.ts +110 -0
- package/src/layout-flex-lines.ts +346 -0
- package/src/layout-helpers.ts +140 -0
- package/src/layout-measure.ts +259 -0
- package/src/layout-stats.ts +43 -0
- package/src/layout-traversal.ts +70 -0
- package/src/layout-zero.ts +1792 -0
- package/src/logger.ts +67 -0
- package/src/node-zero.ts +1412 -0
- package/src/testing.ts +209 -0
- package/src/trace.ts +252 -0
- package/src/types.ts +229 -0
- 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
|
+
}
|