flexily 0.2.0 → 0.3.1

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 (85) hide show
  1. package/README.md +16 -16
  2. package/package.json +24 -24
  3. package/src/CLAUDE.md +36 -21
  4. package/src/classic/layout.ts +107 -47
  5. package/src/classic/node.ts +60 -0
  6. package/src/constants.ts +2 -1
  7. package/src/index-classic.ts +1 -1
  8. package/src/index.ts +1 -1
  9. package/src/layout-flex-lines.ts +70 -3
  10. package/src/layout-helpers.ts +29 -9
  11. package/src/layout-stats.ts +0 -2
  12. package/src/layout-zero.ts +587 -160
  13. package/src/node-zero.ts +98 -2
  14. package/src/testing.ts +20 -14
  15. package/src/types.ts +22 -15
  16. package/src/utils.ts +47 -21
  17. package/dist/classic/layout.d.ts +0 -57
  18. package/dist/classic/layout.d.ts.map +0 -1
  19. package/dist/classic/layout.js +0 -1558
  20. package/dist/classic/layout.js.map +0 -1
  21. package/dist/classic/node.d.ts +0 -648
  22. package/dist/classic/node.d.ts.map +0 -1
  23. package/dist/classic/node.js +0 -1002
  24. package/dist/classic/node.js.map +0 -1
  25. package/dist/constants.d.ts +0 -58
  26. package/dist/constants.d.ts.map +0 -1
  27. package/dist/constants.js +0 -70
  28. package/dist/constants.js.map +0 -1
  29. package/dist/index-classic.d.ts +0 -30
  30. package/dist/index-classic.d.ts.map +0 -1
  31. package/dist/index-classic.js +0 -57
  32. package/dist/index-classic.js.map +0 -1
  33. package/dist/index.d.ts +0 -30
  34. package/dist/index.d.ts.map +0 -1
  35. package/dist/index.js +0 -57
  36. package/dist/index.js.map +0 -1
  37. package/dist/layout-flex-lines.d.ts +0 -77
  38. package/dist/layout-flex-lines.d.ts.map +0 -1
  39. package/dist/layout-flex-lines.js +0 -317
  40. package/dist/layout-flex-lines.js.map +0 -1
  41. package/dist/layout-helpers.d.ts +0 -48
  42. package/dist/layout-helpers.d.ts.map +0 -1
  43. package/dist/layout-helpers.js +0 -108
  44. package/dist/layout-helpers.js.map +0 -1
  45. package/dist/layout-measure.d.ts +0 -25
  46. package/dist/layout-measure.d.ts.map +0 -1
  47. package/dist/layout-measure.js +0 -231
  48. package/dist/layout-measure.js.map +0 -1
  49. package/dist/layout-stats.d.ts +0 -19
  50. package/dist/layout-stats.d.ts.map +0 -1
  51. package/dist/layout-stats.js +0 -37
  52. package/dist/layout-stats.js.map +0 -1
  53. package/dist/layout-traversal.d.ts +0 -28
  54. package/dist/layout-traversal.d.ts.map +0 -1
  55. package/dist/layout-traversal.js +0 -65
  56. package/dist/layout-traversal.js.map +0 -1
  57. package/dist/layout-zero.d.ts +0 -26
  58. package/dist/layout-zero.d.ts.map +0 -1
  59. package/dist/layout-zero.js +0 -1601
  60. package/dist/layout-zero.js.map +0 -1
  61. package/dist/logger.d.ts +0 -14
  62. package/dist/logger.d.ts.map +0 -1
  63. package/dist/logger.js +0 -61
  64. package/dist/logger.js.map +0 -1
  65. package/dist/node-zero.d.ts +0 -702
  66. package/dist/node-zero.d.ts.map +0 -1
  67. package/dist/node-zero.js +0 -1268
  68. package/dist/node-zero.js.map +0 -1
  69. package/dist/testing.d.ts +0 -69
  70. package/dist/testing.d.ts.map +0 -1
  71. package/dist/testing.js +0 -179
  72. package/dist/testing.js.map +0 -1
  73. package/dist/trace.d.ts +0 -74
  74. package/dist/trace.d.ts.map +0 -1
  75. package/dist/trace.js +0 -191
  76. package/dist/trace.js.map +0 -1
  77. package/dist/types.d.ts +0 -170
  78. package/dist/types.d.ts.map +0 -1
  79. package/dist/types.js +0 -43
  80. package/dist/types.js.map +0 -1
  81. package/dist/utils.d.ts +0 -41
  82. package/dist/utils.d.ts.map +0 -1
  83. package/dist/utils.js +0 -197
  84. package/dist/utils.js.map +0 -1
  85. package/src/beorn-logger.d.ts +0 -10
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
8
  ```typescript
9
- import { Node, FLEX_DIRECTION_ROW, DIRECTION_LTR } from "@beorn/flexily"
9
+ import { Node, FLEX_DIRECTION_ROW, DIRECTION_LTR } from "flexily"
10
10
 
11
11
  const root = Node.create()
12
12
  root.setWidth(100)
@@ -34,7 +34,7 @@ console.log(child.getComputedWidth()) // 100
34
34
 
35
35
  **No tree-shaking.** The WASM binary is monolithic. You get the entire engine even if you use a fraction of the features.
36
36
 
37
- Facebook's original pure-JS flexbox engine (`css-layout`) was abandoned when they moved to C++. [flexbox.js](https://github.com/Planning-nl/flexbox.js) exists but is unmaintained and missing features. Flexily fills the gap: full CSS flexbox spec, Yoga-compatible API, pure JS, zero WASM.
37
+ Facebook's original pure-JS flexbox engine (`css-layout`) was abandoned when they moved to C++. [flexbox.js](https://github.com/Planning-nl/flexbox.js) exists but is unmaintained and missing features. Flexily fills the gap: comprehensive CSS flexbox support, Yoga-compatible API, pure JS, zero WASM.
38
38
 
39
39
  ## Who Should Use Flexily
40
40
 
@@ -45,11 +45,11 @@ Most developers should use a framework built on Flexily, not Flexily directly. F
45
45
  - **Specialized tools** where you need direct control over layout computation
46
46
  - **Anyone replacing Yoga** who wants a drop-in pure-JS alternative
47
47
 
48
- > **Building a terminal UI?** Use [silvery](https://github.com/beorn/silvery), which uses Flexily by default. You get React components, hooks, and layout feedback without touching the low-level API.
48
+ > **Building a terminal UI?** Use [silvery](https://silvery.dev), which uses Flexily by default. You get React components, hooks, and layout feedback without touching the low-level API.
49
49
 
50
50
  ## Status
51
51
 
52
- 1368 tests passing, including 41/41 Yoga compatibility tests and 1200+ incremental re-layout fuzz tests. Used by [silvery](https://github.com/beorn/silvery) as its default layout engine.
52
+ 1495 tests, including 44 Yoga compatibility tests and 1200+ incremental re-layout fuzz tests. Used by [silvery](https://silvery.dev) as its default layout engine.
53
53
 
54
54
  | Feature | Status |
55
55
  | --------------------------------------------- | -------- |
@@ -68,11 +68,11 @@ Most developers should use a framework built on Flexily, not Flexily directly. F
68
68
  ## Installation
69
69
 
70
70
  ```bash
71
- bun add @beorn/flexily
72
- # or
73
- npm install @beorn/flexily
71
+ npm install flexily
74
72
  ```
75
73
 
74
+ **Runtimes:** Bun >= 1.0, Node.js >= 18. Pure JavaScript — no native or WASM dependencies.
75
+
76
76
  ## Performance
77
77
 
78
78
  Flexily and Yoga each win in different scenarios:
@@ -107,13 +107,13 @@ See [docs/performance.md](docs/performance.md) for detailed benchmarks including
107
107
 
108
108
  Flexily provides two layout implementations that produce identical output and pass identical tests:
109
109
 
110
- **Zero-allocation** (default, `@beorn/flexily`): Mutates `FlexInfo` structs on nodes instead of allocating temporary objects. Faster for flat/wide trees typical of TUI layouts. Not reentrant a single layout pass must complete before another starts.
110
+ **Zero-allocation** (default, `flexily`): Mutates `FlexInfo` structs on nodes instead of allocating temporary objects. Faster for flat/wide trees typical of TUI layouts. Re-entrant via save/restore of scratch arrays (supports nested `calculateLayout()` calls from measure/baseline functions).
111
111
 
112
- **Classic** (`@beorn/flexily/classic`): Allocates temporary objects during layout. Easier to read and debug. Use this when stepping through the algorithm or comparing behavior.
112
+ **Classic** (`flexily/classic`): Allocates temporary objects during layout. Easier to read and debug. Use this when stepping through the algorithm or comparing behavior.
113
113
 
114
114
  ```typescript
115
- import { Node } from "@beorn/flexily" // zero-allocation (default)
116
- import { Node } from "@beorn/flexily/classic" // allocating (debugging)
115
+ import { Node } from "flexily" // zero-allocation (default)
116
+ import { Node } from "flexily/classic" // allocating (debugging)
117
117
  ```
118
118
 
119
119
  Both implement CSS Flexbox spec Section 9.7 with iterative freeze for min/max constraints, Yoga-compatible edge-based rounding, weighted flex-shrink, auto margin absorption, and full RTL support.
@@ -124,7 +124,7 @@ Incremental re-layout (caching unchanged subtrees) is essential for performance
124
124
 
125
125
  | Layer | Tests | What it verifies |
126
126
  | ------------------ | --------- | -------------------------------------------------------------- |
127
- | Yoga compatibility | 41 | Identical output to Yoga for every feature |
127
+ | Yoga compatibility | 44 | Identical output to Yoga for every feature |
128
128
  | Feature tests | ~110 | Each flexbox feature in isolation |
129
129
  | **Re-layout fuzz** | **1200+** | Incremental re-layout matches fresh layout across random trees |
130
130
 
@@ -143,7 +143,7 @@ See [docs/testing.md](docs/testing.md) for methodology and [docs/incremental-lay
143
143
 
144
144
  ## API Compatibility
145
145
 
146
- 100% Yoga API compatibility (41/41 comparison tests passing). Drop-in replacement:
146
+ Yoga-compatible API (44 comparison tests passing). Near drop-in replacement for common use cases:
147
147
 
148
148
  ```typescript
149
149
  // Yoga
@@ -152,7 +152,7 @@ const yoga = await Yoga.init() // Async!
152
152
  const root = yoga.Node.create()
153
153
 
154
154
  // Flexily
155
- import { Node } from "@beorn/flexily"
155
+ import { Node } from "flexily"
156
156
  const root = Node.create() // Sync!
157
157
  ```
158
158
 
@@ -178,7 +178,7 @@ Same constants, same method names, same behavior.
178
178
  | [Taffy](https://github.com/DioxusLabs/taffy) | Rust | High-performance layout library supporting Flexbox and CSS Grid. Used by Dioxus and Bevy. |
179
179
  | [flexbox.js](https://github.com/Planning-nl/flexbox.js) | JavaScript | Pure JS flexbox engine by Planning-nl. Reference implementation that inspired Flexily's algorithm. |
180
180
  | [css-layout](https://www.npmjs.com/package/css-layout) | JavaScript | Facebook's original pure-JS flexbox, predecessor to Yoga. Deprecated. |
181
- | [silvery](https://github.com/beorn/silvery) | TypeScript | React for CLIs with layout feedback. Uses Flexily by default. |
181
+ | [silvery](https://silvery.dev) | TypeScript | React for CLIs with layout feedback. Uses Flexily by default. |
182
182
 
183
183
  ## Code Structure
184
184
 
@@ -186,7 +186,7 @@ Same constants, same method names, same behavior.
186
186
  src/
187
187
  ├── index.ts # Main export
188
188
  ├── node-zero.ts # Node class with FlexInfo
189
- ├── layout-zero.ts # Layout algorithm (~2300 lines)
189
+ ├── layout-zero.ts # Layout algorithm (~2000 lines)
190
190
  ├── constants.ts # Flexbox constants (Yoga-compatible)
191
191
  ├── types.ts # TypeScript interfaces
192
192
  ├── utils.ts # Shared utilities
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "flexily",
3
- "version": "0.2.0",
4
- "description": "Pure JavaScript flexbox layout engine with Yoga-compatible API",
3
+ "version": "0.3.1",
4
+ "description": "Pure JavaScript flexbox layout engine \u2014 Yoga-compatible API, faster initial layout, smaller bundle, no WASM",
5
5
  "keywords": [
6
+ "canvas-ui",
6
7
  "css",
7
8
  "flexbox",
8
9
  "layout",
10
+ "measure",
9
11
  "pure-javascript",
10
12
  "terminal",
11
13
  "tui",
14
+ "ui-layout",
12
15
  "yoga",
16
+ "yoga-alternative",
17
+ "yoga-wasm",
13
18
  "zero-dependency"
14
19
  ],
15
20
  "homepage": "https://beorn.github.io/flexily/",
@@ -23,46 +28,41 @@
23
28
  "url": "https://github.com/beorn/flexily.git"
24
29
  },
25
30
  "files": [
26
- "dist",
27
31
  "src"
28
32
  ],
29
33
  "type": "module",
30
- "main": "./dist/index.js",
31
- "types": "./dist/index.d.ts",
34
+ "main": "./src/index.ts",
35
+ "types": "./src/index.ts",
32
36
  "exports": {
33
37
  ".": {
34
- "types": "./dist/index.d.ts",
35
- "import": "./dist/index.js"
38
+ "types": "./src/index.ts",
39
+ "import": "./src/index.ts"
36
40
  },
37
41
  "./classic": {
38
- "types": "./dist/index-classic.d.ts",
39
- "import": "./dist/index-classic.js"
42
+ "types": "./src/classic/index.ts",
43
+ "import": "./src/classic/index.ts"
40
44
  }
41
45
  },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
42
49
  "scripts": {
43
50
  "build": "tsc",
44
51
  "test": "bun test",
45
52
  "test:watch": "bun test --watch",
46
53
  "bench": "bunx --bun vitest bench",
47
- "bench:standalone": "bun bench/run.ts",
48
- "bench:compare": "bun bench/compare.ts",
49
54
  "typecheck": "tsc --noEmit",
50
- "build:docs": "cd docs/site && bun run docs:build",
51
- "ci": "bun run typecheck && bun test",
52
- "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
53
- },
54
- "dependencies": {
55
- "debug": "^4.4.3"
55
+ "docs:dev": "vitepress dev docs",
56
+ "docs:build": "vitepress build docs",
57
+ "docs:preview": "vitepress preview docs"
56
58
  },
57
59
  "devDependencies": {
58
- "@types/bun": "^1.3.0",
59
- "@types/debug": "^4.1.12",
60
- "loggily": "github:beorn/loggily",
61
- "typescript": "^5.7.3",
60
+ "typescript": "^5.9.3",
61
+ "vitepress": "^1.6.3",
62
+ "vitest": "^3.1.0",
62
63
  "yoga-wasm-web": "^0.3.3"
63
64
  },
64
65
  "engines": {
65
- "bun": ">=1.0",
66
- "node": ">=18"
66
+ "node": ">=23.6.0"
67
67
  }
68
- }
68
+ }
package/src/CLAUDE.md CHANGED
@@ -16,16 +16,16 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
16
16
  │ │
17
17
  ┌──────┴──────┐ ┌──────┴───────┐
18
18
  │ node-zero.ts│ │layout-zero.ts │
19
- │ (1412 LOC) │ │ (1781 LOC) │
19
+ │ (1412 LOC) │ │ (2029 LOC) │
20
20
  │ Node class │ │ layoutNode │
21
21
  └──────┬──────┘ └──────┬────────┘
22
22
  │ │
23
23
  ┌──────┴──────┐ ┌──────────────────┼──────────────────┐
24
24
  │ types.ts │ │ │ │
25
25
  │ Interfaces │ layout-helpers.ts layout-flex-lines.ts │
26
- └─────────────┘ (140 LOC) (346 LOC) │
26
+ └─────────────┘ (160 LOC) (349 LOC) │
27
27
  Edge resolution Pre-alloc arrays layout-measure.ts
28
- Line breaking (257 LOC)
28
+ Line breaking (259 LOC)
29
29
  Flex distribution measureNode
30
30
 
31
31
  layout-traversal.ts (70 LOC) - Tree traversal (markSubtreeLayoutSeen, countNodes)
@@ -42,22 +42,22 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
42
42
  - Factory function API: `Node.create()` (no `new` in user code, though `Node` is a class internally)
43
43
  - Yoga-compatible API surface: same method names, same constants, drop-in replacement
44
44
  - Pure JavaScript: no WASM, no native dependencies, synchronous initialization
45
- - Single-threaded: layout uses module-level pre-allocated arrays (not reentrant)
45
+ - Module-level pre-allocated arrays with save/restore for re-entrancy (measureFunc/baselineFunc may call calculateLayout on other trees)
46
46
 
47
47
  ## Source Files
48
48
 
49
49
  | File | LOC | Role | Hot path? |
50
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 |
51
+ | `layout-zero.ts` | 2029 | Core layout: `computeLayout()`, `layoutNode()` (11 phases) | **Yes** - most critical |
52
+ | `layout-helpers.ts` | 160 | Edge resolution: margins, padding, borders | **Yes** - called per edge |
53
+ | `layout-flex-lines.ts` | 349 | Pre-alloc arrays, `breakIntoLines()`, `distributeFlexSpaceForLine()` | **Yes** - flex distribution |
54
+ | `layout-measure.ts` | 259 | `measureNode()` — intrinsic sizing | **Yes** - sizing pass |
55
55
  | `layout-traversal.ts` | 70 | Tree traversal: `markSubtreeLayoutSeen()`, `countNodes()` | Moderate |
56
- | `layout-stats.ts` | 43 | Debug/benchmark counters | No (counters only) |
56
+ | `layout-stats.ts` | 41 | Debug/benchmark counters | No (counters only) |
57
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 |
58
+ | `types.ts` | 232 | `FlexInfo`, `Style`, `Layout`, `Value` interfaces | No (types only) |
59
+ | `utils.ts` | 240 | `resolveValue`, `applyMinMax`, edge helpers, shared traversal stack | Yes (called frequently) |
60
+ | `constants.ts` | 82 | Yoga-compatible numeric constants | No |
61
61
  | `logger.ts` | 67 | Conditional debug logger (`log.debug?.()`) | No (conditional) |
62
62
  | `testing.ts` | 209 | `getLayout`, `diffLayouts`, `expectRelayoutMatchesFresh` | No (test only) |
63
63
  | `classic/` | ~2900 | Allocating reference algorithm | No (debugging only) |
@@ -125,6 +125,7 @@ Single pass over children:
125
125
  ### Phase 7a: Estimate Line Cross Sizes
126
126
 
127
127
  - Tentative cross-axis sizes from definite child dimensions
128
+ - For measureFunc children: uses `child.flex.mainSize` (from flex distribution), not parent `mainAxisSize`. This ensures text wrapping is measured at the child's actual constrained width.
128
129
  - Auto-sized children use 0 (actual size computed in Phase 8)
129
130
 
130
131
  ### Phase 7b: Apply alignContent
@@ -146,6 +147,14 @@ The most complex phase. For each relative child:
146
147
  8. Apply cross-axis alignment offset (flex-end, center, baseline)
147
148
  9. Advance `mainPos` for next child
148
149
 
150
+ ### Phase 9b: Re-stretch Auto-Sized Cross Axis
151
+
152
+ - When cross axis was auto (NaN) during Phase 8, `ALIGN_STRETCH` children need re-stretching to the now-known cross size
153
+
154
+ ### Phase 9c: Re-align Auto-Sized Cross Axis
155
+
156
+ - When `crossAxisSize` was NaN during Phase 8, alignment offsets for `ALIGN_CENTER`, `ALIGN_FLEX_END`, and cross-axis auto margins computed NaN. Phase 9c recomputes these using the final shrink-wrapped cross size.
157
+
149
158
  ### Phase 9: Shrink-Wrap Auto-Sized Containers
150
159
 
151
160
  - For containers without explicit size, compute actual used space from children
@@ -184,7 +193,7 @@ let _lineItemSpacings = new Float64Array(32) // Per-line item spacing
184
193
 
185
194
  These grow dynamically if >32 lines (rare). Total memory: ~1,344 bytes (4 Float64Arrays × 256 bytes + 1 Uint16Array × 64 bytes + Array overhead).
186
195
 
187
- **Consequence: Not reentrant.** Layout is single-threaded; concurrent `calculateLayout()` calls corrupt shared state. This is safe because layout is synchronous.
196
+ **Re-entrancy**: A `measureFunc` or `baselineFunc` may synchronously call `calculateLayout()` on a separate tree. `enterLayout()`/`exitLayout()` in `layout-flex-lines.ts` bracket nested calls to save and restore the module-level scratch arrays, preventing corruption of the outer pass's state. Only allocates on re-entrant calls (depth > 0).
188
197
 
189
198
  ### Per-Node FlexInfo (`node.flex`)
190
199
 
@@ -218,6 +227,8 @@ interface FlexInfo {
218
227
  lastAvailH
219
228
  lastOffsetX
220
229
  lastOffsetY
230
+ lastAbsX
231
+ lastAbsY
221
232
  lastDir
222
233
  layoutValid
223
234
  }
@@ -275,12 +286,16 @@ flex.lastAvailW = availableWidth
275
286
  flex.lastAvailH = availableHeight
276
287
  flex.lastOffsetX = offsetX
277
288
  flex.lastOffsetY = offsetY
289
+ flex.lastAbsX = absX
290
+ flex.lastAbsY = absY
278
291
  flex.lastDir = direction
279
292
  flex.layoutValid = true
280
293
  ```
281
294
 
282
295
  On next call, if `layoutValid && !isDirty && same constraints`, the entire subtree is skipped. Only position delta is propagated (if offset changed).
283
296
 
297
+ **`absX`/`absY` must be fingerprinted** because edge-based rounding depends on absolute position: `width = round(absX + nodeWidth) - round(absX)`. A fractional shift in absX (e.g., from a sibling's width change) changes the rounded result even when availW/availH/direction are unchanged.
298
+
284
299
  **`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
300
 
286
301
  ### Invalidation Triggers
@@ -316,11 +331,11 @@ This is Yoga's algorithm. Layout positions stored in `layout.left`/`layout.top`
316
331
 
317
332
  ## measureNode vs layoutNode
318
333
 
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.
334
+ `measureNode()` (~260 lines) is a lightweight alternative to `layoutNode()` (~1900 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
335
 
321
- ## Integration: How silvery Uses Flexily
336
+ ## Integration: How Silvery Uses Flexily
322
337
 
323
- silvery uses flexily through an adapter layer:
338
+ Silvery uses Flexily through an adapter layer:
324
339
 
325
340
  1. `silvery/src/layout-engine.ts` defines the `LayoutEngine` / `LayoutNode` interfaces
326
341
  2. `silvery/src/adapters/flexily-zero-adapter.ts` wraps `Node` in `FlexilyZeroNodeAdapter`
@@ -335,7 +350,7 @@ silvery calls `calculateLayout()` on every render. The no-change case (cursor mo
335
350
  | ----------------------------------------- | ---------------------------------------- | ------------------------------------- | ---------------------------------------------- |
336
351
  | `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
352
  | Default `flexShrink` | 0 (Yoga native default) | 0 (matches Yoga) | CSS default is 1 |
338
- | Default `flexDirection` | Column | Column | CSS default is Row |
353
+ | Default `flexDirection` | Column | Row (CSS default) | Row |
339
354
  | Baseline alignment | Full spec (recursive first-child) | Simplified (no recursive propagation) | Recursive first-child |
340
355
 
341
356
  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.
@@ -362,10 +377,10 @@ Edge-based properties use 6-element arrays: `[left, top, right, bottom, start, e
362
377
 
363
378
  Border widths are plain numbers (always points). Logical border slots use `NaN` as "not set" sentinel.
364
379
 
365
- ### Default Style (Yoga-compatible, not CSS)
380
+ ### Default Style
366
381
 
367
382
  ```typescript
368
- flexDirection: COLUMN // CSS default is ROW
383
+ flexDirection: ROW // CSS default (diverges from Yoga's COLUMN)
369
384
  flexShrink: 0 // CSS default is 1
370
385
  alignItems: STRETCH // Same as CSS
371
386
  flexBasis: AUTO // Same as CSS
@@ -451,11 +466,11 @@ cd vendor/flexily && bun run build
451
466
 
452
467
  | Layer | Tests | Command | What it verifies |
453
468
  | ------------------ | --------- | ---------------------------------------------------------------------------- | --------------------------------- |
454
- | Yoga compat | 38 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
469
+ | Yoga compat | 44 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
455
470
  | Feature tests | ~110 | `bun test tests/layout.test.ts` | Each flexbox feature in isolation |
456
471
  | **Re-layout fuzz** | **1200+** | `bun test tests/relayout-consistency.test.ts` | Incremental matches fresh |
457
472
  | Mutation testing | 4+ | `bun scripts/mutation-test.ts` | Fuzz catches cache mutations |
458
- | All tests | 1368 | `bun test` | Everything |
473
+ | All tests | 1495 | `bun test` | Everything |
459
474
 
460
475
  The fuzz tests are the most important layer. They've caught 3 distinct caching bugs that all single-pass tests missed.
461
476
 
@@ -123,9 +123,9 @@ export function resolveEdgeBorderValue(
123
123
 
124
124
  // Logical takes precedence if set (NaN = not set)
125
125
  if (logicalSlot !== undefined && !Number.isNaN(arr[logicalSlot])) {
126
- return arr[logicalSlot]
126
+ return arr[logicalSlot]!
127
127
  }
128
- return arr[physicalIndex]
128
+ return arr[physicalIndex]!
129
129
  }
130
130
 
131
131
  export function markSubtreeLayoutSeen(node: Node): void {
@@ -205,7 +205,10 @@ function breakIntoLines(children: ChildLayout[], mainAxisSize: number, mainGap:
205
205
  let lineMainSize = 0
206
206
 
207
207
  for (const child of children) {
208
- const childMainSize = child.baseSize + child.mainMargin
208
+ // CSS spec 9.3.4: line breaking uses the "hypothetical main size" which is
209
+ // the flex base size clamped to min/max, not the unclamped base size.
210
+ const hypotheticalMainSize = Math.max(child.minMain, Math.min(child.maxMain, child.baseSize))
211
+ const childMainSize = hypotheticalMainSize + child.mainMargin
209
212
  const gapIfNotFirst = currentLine.length > 0 ? mainGap : 0
210
213
 
211
214
  // Check if child fits on current line
@@ -528,7 +531,9 @@ function layoutNode(
528
531
  // This ensures children's absolute positions include parent's position offset
529
532
  let parentPosOffsetX = 0
530
533
  let parentPosOffsetY = 0
531
- if (style.positionType === C.POSITION_TYPE_STATIC || style.positionType === C.POSITION_TYPE_RELATIVE) {
534
+ // CSS spec: position:static ignores insets (top/left/right/bottom).
535
+ // Only position:relative applies insets as offsets from normal flow position.
536
+ if (style.positionType === C.POSITION_TYPE_RELATIVE) {
532
537
  const leftPos = style.position[0]
533
538
  const topPos = style.position[1]
534
539
  const rightPos = style.position[2]
@@ -935,6 +940,11 @@ function layoutNode(
935
940
  childCross = crossDim.value
936
941
  } else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
937
942
  childCross = crossAxisSize * (crossDim.value / 100)
943
+ } else if (childLayout.node.children.length > 0) {
944
+ // Auto-sized container children: Phase 5 already laid them out with
945
+ // layoutNode to compute baseSize. The cross-axis size is available
946
+ // on the child's layout (height for row, width for column).
947
+ childCross = isRow ? childLayout.node.layout.height : childLayout.node.layout.width
938
948
  } else {
939
949
  // Auto - use a default or measure. For now, use 0 and let stretch handle it.
940
950
  childCross = 0
@@ -990,6 +1000,16 @@ function layoutNode(
990
1000
  }
991
1001
  break
992
1002
 
1003
+ case C.ALIGN_SPACE_EVENLY:
1004
+ // Equal spacing between lines and at edges
1005
+ if (numLines > 0) {
1006
+ const spaceEvenlyGap = freeSpace / (numLines + 1)
1007
+ for (let i = 0; i < numLines; i++) {
1008
+ lineCrossOffsets[i]! += spaceEvenlyGap * (i + 1)
1009
+ }
1010
+ }
1011
+ break
1012
+
993
1013
  case C.ALIGN_STRETCH:
994
1014
  // Distribute extra space evenly among lines
995
1015
  if (freeSpace > 0 && numLines > 0) {
@@ -1251,11 +1271,12 @@ function layoutNode(
1251
1271
  const fractionalLeft = innerLeft + childX
1252
1272
  const fractionalTop = innerTop + childY
1253
1273
 
1254
- // Compute position offsets for RELATIVE/STATIC positioned children
1274
+ // Compute position offsets for RELATIVE positioned children
1275
+ // CSS spec: position:static ignores insets; only position:relative applies them.
1255
1276
  // These must be included in the absolute position BEFORE rounding (Yoga-compatible)
1256
1277
  let posOffsetX = 0
1257
1278
  let posOffsetY = 0
1258
- if (childStyle.positionType === C.POSITION_TYPE_RELATIVE || childStyle.positionType === C.POSITION_TYPE_STATIC) {
1279
+ if (childStyle.positionType === C.POSITION_TYPE_RELATIVE) {
1259
1280
  const relLeftPos = childStyle.position[0]
1260
1281
  const relTopPos = childStyle.position[1]
1261
1282
  const relRightPos = childStyle.position[2]
@@ -1469,7 +1490,7 @@ function layoutNode(
1469
1490
  }
1470
1491
  }
1471
1492
 
1472
- if (crossOffset > 0) {
1493
+ if (crossOffset !== 0) {
1473
1494
  if (isRow) {
1474
1495
  child.layout.top += Math.round(crossOffset)
1475
1496
  } else {
@@ -1605,6 +1626,7 @@ function layoutNode(
1605
1626
  // Content box dimensions for percentage resolution of absolute children
1606
1627
  const absContentBoxW = absPaddingBoxW - paddingLeft - paddingRight
1607
1628
  const absContentBoxH = absPaddingBoxH - paddingTop - paddingBottom
1629
+ const isRow = isRowDirection(style.flexDirection)
1608
1630
 
1609
1631
  for (const child of absoluteChildren) {
1610
1632
  const childStyle = child.style
@@ -1626,10 +1648,11 @@ function layoutNode(
1626
1648
  const hasTop = topPos.unit !== C.UNIT_UNDEFINED
1627
1649
  const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
1628
1650
 
1629
- const leftOffset = resolveValue(leftPos, nodeWidth)
1630
- const topOffset = resolveValue(topPos, nodeHeight)
1631
- const rightOffset = resolveValue(rightPos, nodeWidth)
1632
- const bottomOffset = resolveValue(bottomPos, nodeHeight)
1651
+ // Yoga resolves percentage position offsets against the content box dimensions
1652
+ const leftOffset = resolveValue(leftPos, absContentBoxW)
1653
+ const topOffset = resolveValue(topPos, absContentBoxH)
1654
+ const rightOffset = resolveValue(rightPos, absContentBoxW)
1655
+ const bottomOffset = resolveValue(bottomPos, absContentBoxH)
1633
1656
 
1634
1657
  // Calculate available size for absolute child using padding box
1635
1658
  const contentW = absPaddingBoxW
@@ -1702,28 +1725,45 @@ function layoutNode(
1702
1725
  const childHeight = child.layout.height
1703
1726
 
1704
1727
  // Apply alignment when no explicit position set
1705
- // For absolute children, align-items/justify-content apply when no position offsets
1728
+ // For absolute children, align-items applies on cross axis, justify-content on main axis
1729
+ // Row: X = main axis (justifyContent), Y = cross axis (alignItems)
1730
+ // Column: X = cross axis (alignItems), Y = main axis (justifyContent)
1706
1731
  if (!hasLeft && !hasRight) {
1707
- // No horizontal position - use align-items (for row) or justify-content (for column)
1708
- // Default column direction: cross-axis is horizontal, use alignItems
1709
- let alignment = style.alignItems
1710
- if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1711
- alignment = childStyle.alignSelf
1712
- }
1713
- const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1714
- switch (alignment) {
1715
- case C.ALIGN_CENTER:
1716
- childX = childMarginLeft + freeSpaceX / 2
1717
- break
1718
- case C.ALIGN_FLEX_END:
1719
- childX = childMarginLeft + freeSpaceX
1720
- break
1721
- case C.ALIGN_STRETCH:
1722
- // Stretch: already handled by setting width to fill
1723
- break
1724
- default: // FLEX_START
1725
- childX = childMarginLeft
1726
- break
1732
+ if (isRow) {
1733
+ // Row: X is main axis, use justifyContent
1734
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1735
+ switch (style.justifyContent) {
1736
+ case C.JUSTIFY_CENTER:
1737
+ childX = childMarginLeft + freeSpaceX / 2
1738
+ break
1739
+ case C.JUSTIFY_FLEX_END:
1740
+ childX = childMarginLeft + freeSpaceX
1741
+ break
1742
+ default: // FLEX_START
1743
+ childX = childMarginLeft
1744
+ break
1745
+ }
1746
+ } else {
1747
+ // Column: X is cross axis, use alignItems/alignSelf
1748
+ let alignment = style.alignItems
1749
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1750
+ alignment = childStyle.alignSelf
1751
+ }
1752
+ const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
1753
+ switch (alignment) {
1754
+ case C.ALIGN_CENTER:
1755
+ childX = childMarginLeft + freeSpaceX / 2
1756
+ break
1757
+ case C.ALIGN_FLEX_END:
1758
+ childX = childMarginLeft + freeSpaceX
1759
+ break
1760
+ case C.ALIGN_STRETCH:
1761
+ // Stretch: already handled by setting width to fill
1762
+ break
1763
+ default: // FLEX_START
1764
+ childX = childMarginLeft
1765
+ break
1766
+ }
1727
1767
  }
1728
1768
  } else if (!hasLeft && hasRight) {
1729
1769
  // Position from right edge
@@ -1734,19 +1774,41 @@ function layoutNode(
1734
1774
  }
1735
1775
 
1736
1776
  if (!hasTop && !hasBottom) {
1737
- // No vertical position - use justify-content (for row) or align-items (for column)
1738
- // Default column direction: main-axis is vertical, use justifyContent
1739
- const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1740
- switch (style.justifyContent) {
1741
- case C.JUSTIFY_CENTER:
1742
- childY = childMarginTop + freeSpaceY / 2
1743
- break
1744
- case C.JUSTIFY_FLEX_END:
1745
- childY = childMarginTop + freeSpaceY
1746
- break
1747
- default: // FLEX_START
1748
- childY = childMarginTop
1749
- break
1777
+ if (isRow) {
1778
+ // Row: Y is cross axis, use alignItems/alignSelf
1779
+ let alignment = style.alignItems
1780
+ if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1781
+ alignment = childStyle.alignSelf
1782
+ }
1783
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1784
+ switch (alignment) {
1785
+ case C.ALIGN_CENTER:
1786
+ childY = childMarginTop + freeSpaceY / 2
1787
+ break
1788
+ case C.ALIGN_FLEX_END:
1789
+ childY = childMarginTop + freeSpaceY
1790
+ break
1791
+ case C.ALIGN_STRETCH:
1792
+ // Stretch: already handled by setting height to fill
1793
+ break
1794
+ default: // FLEX_START
1795
+ childY = childMarginTop
1796
+ break
1797
+ }
1798
+ } else {
1799
+ // Column: Y is main axis, use justifyContent
1800
+ const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
1801
+ switch (style.justifyContent) {
1802
+ case C.JUSTIFY_CENTER:
1803
+ childY = childMarginTop + freeSpaceY / 2
1804
+ break
1805
+ case C.JUSTIFY_FLEX_END:
1806
+ childY = childMarginTop + freeSpaceY
1807
+ break
1808
+ default: // FLEX_START
1809
+ childY = childMarginTop
1810
+ break
1811
+ }
1750
1812
  }
1751
1813
  } else if (!hasTop && hasBottom) {
1752
1814
  // Position from bottom edge
@@ -1769,14 +1831,12 @@ function layoutNode(
1769
1831
  // so the public API remains consistent between versions.
1770
1832
 
1771
1833
  export let layoutNodeCalls = 0
1772
- export let resolveEdgeCalls = 0
1773
1834
  export let layoutSizingCalls = 0
1774
1835
  export let layoutPositioningCalls = 0
1775
1836
  export let layoutCacheHits = 0
1776
1837
 
1777
1838
  export function resetLayoutStats(): void {
1778
1839
  layoutNodeCalls = 0
1779
- resolveEdgeCalls = 0
1780
1840
  layoutSizingCalls = 0
1781
1841
  layoutPositioningCalls = 0
1782
1842
  layoutCacheHits = 0