nodality 1.0.221 → 1.1.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 (279) hide show
  1. package/API.md +227 -0
  2. package/bin/nodality.js +64 -0
  3. package/dist/animator.cjs.js +1 -1
  4. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  5. package/dist/animator.esm.js +1 -1
  6. package/dist/animator.esm.js.LICENSE.txt +1 -1
  7. package/dist/audionew.cjs.js +1 -1
  8. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  9. package/dist/audionew.esm.js +1 -1
  10. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  11. package/dist/base.cjs.js +1 -1
  12. package/dist/base.cjs.js.LICENSE.txt +1 -1
  13. package/dist/base.esm.js +1 -1
  14. package/dist/base.esm.js.LICENSE.txt +1 -1
  15. package/dist/beta-desktop-bar.cjs.js +1 -1
  16. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  17. package/dist/beta-desktop-bar.esm.js +1 -1
  18. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  19. package/dist/beta-mobile-bar.cjs.js +1 -1
  20. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  21. package/dist/beta-mobile-bar.esm.js +1 -1
  22. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  23. package/dist/bundle.umd.js +1 -1
  24. package/dist/bundle.umd.js.LICENSE.txt +131 -1
  25. package/dist/button.cjs.js +1 -1
  26. package/dist/button.cjs.js.LICENSE.txt +1 -1
  27. package/dist/button.esm.js +1 -1
  28. package/dist/button.esm.js.LICENSE.txt +1 -1
  29. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  30. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  31. package/dist/center.cjs.js +1 -1
  32. package/dist/center.cjs.js.LICENSE.txt +1 -1
  33. package/dist/center.esm.js +1 -1
  34. package/dist/center.esm.js.LICENSE.txt +1 -1
  35. package/dist/checkbox.cjs.js +1 -1
  36. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  37. package/dist/checkbox.esm.js +1 -1
  38. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  39. package/dist/code.cjs.js +1 -1
  40. package/dist/code.cjs.js.LICENSE.txt +1 -1
  41. package/dist/code.esm.js +1 -1
  42. package/dist/code.esm.js.LICENSE.txt +1 -1
  43. package/dist/container.cjs.js +1 -1
  44. package/dist/container.cjs.js.LICENSE.txt +1 -1
  45. package/dist/container.esm.js +1 -1
  46. package/dist/container.esm.js.LICENSE.txt +1 -1
  47. package/dist/data-list.cjs.js +1 -1
  48. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  49. package/dist/data-list.esm.js +1 -1
  50. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  51. package/dist/designer.cjs.js +1 -1
  52. package/dist/designer.cjs.js.LICENSE.txt +97 -1
  53. package/dist/designer.esm.js +1 -1
  54. package/dist/designer.esm.js.LICENSE.txt +97 -1
  55. package/dist/element-mapper.cjs.js +1 -1
  56. package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
  57. package/dist/element-mapper.esm.js +1 -1
  58. package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
  59. package/dist/finalresult.esm.js +1 -1
  60. package/dist/finalresult.esm.js.LICENSE.txt +158 -1
  61. package/dist/flex-card.cjs.js +1 -1
  62. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  63. package/dist/flex-card.esm.js +1 -1
  64. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  65. package/dist/flex-grid.cjs.js +1 -1
  66. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  67. package/dist/flex-grid.esm.js +1 -1
  68. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  69. package/dist/flex-row.cjs.js +1 -1
  70. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  71. package/dist/flex-row.esm.js +1 -1
  72. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  73. package/dist/floating-input.cjs.js +1 -1
  74. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  75. package/dist/floating-input.esm.js +1 -1
  76. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  77. package/dist/free.cjs.js +1 -1
  78. package/dist/free.cjs.js.LICENSE.txt +1 -1
  79. package/dist/free.esm.js +1 -1
  80. package/dist/free.esm.js.LICENSE.txt +1 -1
  81. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  82. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  83. package/dist/image-picker.cjs.js +1 -1
  84. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  85. package/dist/image-picker.esm.js +1 -1
  86. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  87. package/dist/image.cjs.js +1 -1
  88. package/dist/image.cjs.js.LICENSE.txt +1 -1
  89. package/dist/image.esm.js +1 -1
  90. package/dist/image.esm.js.LICENSE.txt +1 -1
  91. package/dist/index.cjs.js +1 -1
  92. package/dist/index.cjs.js.LICENSE.txt +158 -1
  93. package/dist/index.d.ts +962 -0
  94. package/dist/index.esm.js +1 -1
  95. package/dist/index.esm.js.LICENSE.txt +158 -1
  96. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  97. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  98. package/dist/link-getter.cjs.js +1 -1
  99. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  100. package/dist/link-getter.esm.js +1 -1
  101. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  102. package/dist/link.cjs.js +1 -1
  103. package/dist/link.cjs.js.LICENSE.txt +1 -1
  104. package/dist/link.esm.js +1 -1
  105. package/dist/link.esm.js.LICENSE.txt +1 -1
  106. package/dist/meta-adder.cjs.js +1 -1
  107. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  108. package/dist/meta-adder.esm.js +1 -1
  109. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  110. package/dist/modal-2025.cjs.js +1 -1
  111. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  112. package/dist/modal-2025.esm.js +1 -1
  113. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  114. package/dist/multiswitcher.cjs.js +1 -1
  115. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  116. package/dist/multiswitcher.esm.js +1 -1
  117. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  118. package/dist/new-nav-bar.cjs.js +1 -1
  119. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  120. package/dist/new-nav-bar.esm.js +1 -1
  121. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  122. package/dist/picker.cjs.js +1 -1
  123. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  124. package/dist/picker.esm.js +1 -1
  125. package/dist/picker.esm.js.LICENSE.txt +1 -1
  126. package/dist/progress.cjs.js +1 -1
  127. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  128. package/dist/progress.esm.js +1 -1
  129. package/dist/progress.esm.js.LICENSE.txt +1 -1
  130. package/dist/radio.cjs.js +1 -1
  131. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  132. package/dist/radio.esm.js +1 -1
  133. package/dist/radio.esm.js.LICENSE.txt +1 -1
  134. package/dist/range.cjs.js +1 -1
  135. package/dist/range.cjs.js.LICENSE.txt +1 -1
  136. package/dist/range.esm.js +1 -1
  137. package/dist/range.esm.js.LICENSE.txt +1 -1
  138. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  139. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  140. package/dist/side-bar.cjs.js +1 -1
  141. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  142. package/dist/side-bar.esm.js +1 -1
  143. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  144. package/dist/side-nav-bar.cjs.js +1 -1
  145. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  146. package/dist/side-nav-bar.esm.js +1 -1
  147. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  148. package/dist/simple-bar.cjs.js +1 -1
  149. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  150. package/dist/simple-bar.esm.js +1 -1
  151. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  152. package/dist/slider-2025.cjs.js +1 -1
  153. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  154. package/dist/slider-2025.esm.js +1 -1
  155. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  156. package/dist/spacer.cjs.js +1 -1
  157. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  158. package/dist/spacer.esm.js +1 -1
  159. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  160. package/dist/stack.cjs.js +1 -1
  161. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  162. package/dist/stack.esm.js +1 -1
  163. package/dist/stack.esm.js.LICENSE.txt +1 -1
  164. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  165. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  166. package/dist/table.cjs.js +1 -1
  167. package/dist/table.cjs.js.LICENSE.txt +1 -1
  168. package/dist/table.esm.js +1 -1
  169. package/dist/table.esm.js.LICENSE.txt +1 -1
  170. package/dist/text-field.cjs.js +1 -1
  171. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  172. package/dist/text-field.esm.js +1 -1
  173. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  174. package/dist/text.cjs.js +1 -1
  175. package/dist/text.cjs.js.LICENSE.txt +1 -1
  176. package/dist/text.esm.js +1 -1
  177. package/dist/text.esm.js.LICENSE.txt +1 -1
  178. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  179. package/dist/theme.esm.js.LICENSE.txt +1 -1
  180. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  181. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  182. package/dist/ulist.cjs.js +1 -1
  183. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  184. package/dist/ulist.esm.js +1 -1
  185. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  186. package/dist/video.cjs.js +1 -1
  187. package/dist/video.cjs.js.LICENSE.txt +1 -1
  188. package/dist/video.esm.js +1 -1
  189. package/dist/video.esm.js.LICENSE.txt +1 -1
  190. package/dist/wrap.cjs.js +1 -1
  191. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  192. package/dist/wrap.esm.js +1 -1
  193. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  194. package/dist/zoom-card.cjs.js +1 -1
  195. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  196. package/dist/zoom-card.esm.js +1 -1
  197. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  198. package/examples/custom-raster-op.js +174 -0
  199. package/layout/animator.js +30 -2
  200. package/layout/audio.js +1 -1
  201. package/layout/audionew.js +1 -1
  202. package/layout/base.js +1 -1
  203. package/layout/beta-desktop-bar.js +1 -1
  204. package/layout/beta-mobile-bar.js +3 -2
  205. package/layout/button.js +1 -1
  206. package/layout/center.js +1 -1
  207. package/layout/checkbox.js +2 -2
  208. package/layout/circle.js +3 -2
  209. package/layout/code.js +3 -2
  210. package/layout/container.js +3 -4
  211. package/layout/dropdown-2025.js +1 -1
  212. package/layout/flex-card.js +1 -1
  213. package/layout/flex-grid.js +1 -1
  214. package/layout/flex-row.js +3 -2
  215. package/layout/form-components/custom.js +1 -1
  216. package/layout/form-components/data-list.js +2 -2
  217. package/layout/form-components/floating-input.js +2 -2
  218. package/layout/form-components/form-all.js +2 -2
  219. package/layout/form-components/form.js +1 -1
  220. package/layout/form-components/image-picker.js +1 -1
  221. package/layout/form-components/picker.js +2 -2
  222. package/layout/form-components/radio.js +2 -2
  223. package/layout/form-components/radiogroup.js +2 -2
  224. package/layout/form-components/range.js +2 -2
  225. package/layout/free.js +3 -4
  226. package/layout/grid-switcher.js +1 -1
  227. package/layout/grid.js +1 -1
  228. package/layout/horizontal-scroller.js +1 -1
  229. package/layout/image.js +1 -1
  230. package/layout/index.js +1 -1
  231. package/layout/link.js +5 -5
  232. package/layout/list.js +3 -4
  233. package/layout/meta-adder.js +1 -1
  234. package/layout/modal-2025.js +1 -1
  235. package/layout/morph.js +1003 -0
  236. package/layout/multiswitcher.js +11 -3
  237. package/layout/nav-bar.js +2 -3
  238. package/layout/nav-factor/custom-div.js +2 -2
  239. package/layout/new-nav-bar.js +11 -4
  240. package/layout/polygon.js +3 -4
  241. package/layout/prerender-site.js +1 -1
  242. package/layout/prerender.js +1 -1
  243. package/layout/progress.js +1 -1
  244. package/layout/row.js +1 -1
  245. package/layout/scroll-video.js +1 -1
  246. package/layout/side-bar.js +1 -1
  247. package/layout/side-nav-bar.js +1 -1
  248. package/layout/simple-bar.js +1 -1
  249. package/layout/slider-2025.js +1 -1
  250. package/layout/spacer.js +1 -1
  251. package/layout/stack.js +1 -1
  252. package/layout/svg.js +3 -4
  253. package/layout/switcher.js +1 -1
  254. package/layout/table.js +1 -1
  255. package/layout/text-field.js +1 -1
  256. package/layout/text.js +5 -5
  257. package/layout/ulist.js +1 -1
  258. package/layout/video.js +1 -1
  259. package/layout/wrap.js +1 -1
  260. package/layout/zoom-card.js +1 -1
  261. package/lib/card-getter.js +1 -1
  262. package/lib/codegen.js +62 -0
  263. package/lib/data.js +37 -1
  264. package/lib/designer.js +18 -1
  265. package/lib/element-mapper.js +351 -247
  266. package/lib/keyframe-animation.js +1 -1
  267. package/lib/link-getter.js +1 -1
  268. package/lib/morph-node.js +424 -0
  269. package/lib/raster-inspect.js +409 -0
  270. package/lib/raster-ops.js +1980 -81
  271. package/lib/raster-presets.js +329 -0
  272. package/lib/scroll-video.js +1 -1
  273. package/lib/seo.js +28 -1
  274. package/lib/stacker.js +1 -1
  275. package/lib/suggest.js +66 -0
  276. package/lib/theme.js +1 -1
  277. package/lib/transform-anim.js +1 -1
  278. package/lib/transition.js +205 -0
  279. package/package.json +20 -7
@@ -0,0 +1,1003 @@
1
+ /*!
2
+ * nodality v1.1.0
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ // Levenshtein "did you mean" matching, shared with ElementMapper since
8
+ // phase P3. Pure, no DOM — importing it does not weaken the purity gate
9
+ // below, which is asserted by test rather than trusted.
10
+ import { suggest } from "../lib/suggest.js";
11
+
12
+ // morph.js — the pure core of the `morph` op.
13
+ //
14
+ // One spec in, ordinary Nodality out:
15
+ //
16
+ // import { expand } from "nodality/morph";
17
+ // const { elements, nodes, slots, meta } = expand(spec);
18
+ // new Des().nodes(nodes).add(elements).set({ mount: "#mount" });
19
+ //
20
+ // Three properties this file exists to hold, all of them testable:
21
+ //
22
+ // PURE — nothing here touches the DOM at import time and nothing
23
+ // imports layout/index.js (which reads `window` at module
24
+ // scope). `node -e "await import('layout/morph.js')"` must
25
+ // exit 0. That purity IS the agent contract and the SSG
26
+ // story: expand() runs in a subprocess, in jsdom, in an MCP
27
+ // server, with no browser anywhere.
28
+ // DETERMINISTIC — no Date.now(), no Math.random(). Variation comes only
29
+ // from mulberry32(seed), the default seed is the constant 1,
30
+ // and every emission walks AXES / REGISTRY / bones in
31
+ // DECLARATION order. Two expand() calls are deep-equal.
32
+ // SEMANTIC — the expansion emits element OPTIONS, never `keySet` and
33
+ // never a <style> tag. If a style needs an option the
34
+ // element classes do not have, the option gets added to the
35
+ // class (with a `//@` annotation); the generator gets no
36
+ // escape hatch. Users keep `keySet` for themselves.
37
+ //
38
+ // morphController() at the bottom is the one DOM-touching export. It is a
39
+ // function body, so importing this module still executes no DOM code.
40
+
41
+ // ── Design source ────────────────────────────────────────────────────
42
+ // Every mapping is lerp(min, max, t) and MUST remain affine — the closed
43
+ // -form inverse ((v - min) / (max - min)) is load-bearing for phase M7
44
+ // (lift). If you feel the need for a curve, put the curve in a morph
45
+ // PATH, never in the mapping.
46
+ const AXES = {
47
+ split: { css: "--nod-split", map: { unit: "%", min: 14, max: 40 }, default: 0.2 },
48
+ density: { css: "--nod-density", map: { unit: "px", min: 8, max: 48 }, default: 0.5 }, // gap
49
+ radius: { css: "--nod-radius", map: { unit: "px", min: 0, max: 24 }, default: 0.6 },
50
+ motion: { css: "--nod-motion", map: { unit: "", min: 0, max: 1 }, default: 0.4 },
51
+ };
52
+
53
+ // The rest of the design source: the magnitudes the registry interpolates.
54
+ // They live here so no expand() body carries a literal — a px number typed
55
+ // inside an expand() is the smell this block removes.
56
+ const SCALE = {
57
+ inset: "12px", // how far a `floating` nav sits off its edges
58
+ layer: 10, // nav stacking order, above page content
59
+ speed: "0.4s", // the motion axis multiplies this into transitions
60
+ dense: "24px", // a `dense` region's padding ceiling — tighter than the
61
+ // full density ramp, which is how a sidebar earns its width
62
+ // Height cap for the DESKTOP bar only. A mobile bar must be free to
63
+ // grow: it stacks in a column and the open hamburger adds the whole
64
+ // link list below the brand, so a cap there clips the menu instead of
65
+ // sizing it. (Matching the production Gesos nav, which caps DesktopBar
66
+ // at 100px and passes no height to MobileBar at all.)
67
+ bar: "100px",
68
+ navBreak: "1200px", // where the Switcher swaps MobileBar → DesktopBar
69
+ };
70
+
71
+ // Role → fluid type scale step, and role → semantic tag. Two tables, one
72
+ // row per role, so a role's size and its element type are each stated once.
73
+ //
74
+ // The mapping is GLOBAL, so a role means one size everywhere: `side` uses
75
+ // `heading` rather than reusing `title`, which would drag the hero's S1 into
76
+ // a 240px column. M4 should move this onto the kind, where it belongs.
77
+ const TYPE = { brand: "S5", title: "S1", sub: "S4", button: "S6", heading: "S4", body: "S6", label: "S6" };
78
+ const TAG = { brand: "h3", title: "h1", sub: "p", button: "a", heading: "h4", body: "p", label: "a" };
79
+
80
+ // The box the allocator solves against at expand time — the poster values
81
+ // baked into prerendered HTML. M6 replaces the width with the measured
82
+ // container; until then it is a constant, because a time- or environment-
83
+ // derived default would break byte-determinism.
84
+ const CONTAINER = { width: 1200, height: 800 };
85
+
86
+ // Closed set. `required` never yields; the rest yield lowest-first.
87
+ const PRIORITY = { required: Infinity, high: 1, low: 0 };
88
+ // A track that declares a `min` but no priority yields first: an author who
89
+ // did not think about priority should not be the one who blocks the layout.
90
+ const DEFAULT_PRIORITY = "low";
91
+
92
+ const DEFAULT_SEED = 1;
93
+
94
+ const RESERVED = ["op", "target", "seed", "bones", "axes", "paths", "content"];
95
+
96
+ // ── PRNG ─────────────────────────────────────────────────────────────
97
+ function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;
98
+ let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;
99
+ return((t^t>>>14)>>>0)/4294967296}}
100
+
101
+ // ── Axis → CSS ───────────────────────────────────────────────────────
102
+ // The ONLY place an axis becomes CSS. Affine by construction: the emitted
103
+ // expression is lerp(min, max, t) with t = var(--nod-<axis>). `scale`
104
+ // re-bases the same lerp onto a design-source magnitude (a duration, an
105
+ // inset) — still lerp(0, scale, t), so M7 can still invert it.
106
+ function axisCalc(name, scale){
107
+ const a = AXES[name];
108
+ if (scale != null) return `calc(${scale} * var(${a.css}))`;
109
+ const span = a.map.max - a.map.min;
110
+ return `calc(${a.map.min}${a.map.unit} + ${span}${a.map.unit} * var(${a.css}))`;
111
+ }
112
+
113
+ // The numeric value of an axis at t, in its own unit.
114
+ function axisValue(name, t){
115
+ const m = AXES[name].map;
116
+ return m.min + (m.max - m.min) * t;
117
+ }
118
+
119
+ // ── Registry ─────────────────────────────────────────────────────────
120
+ // Data, and the single source of truth: the region vocabulary, the token
121
+ // vocabulary, the slot roles, and — in M4 — the .d.ts unions and the docs
122
+ // token table are all read from here.
123
+ //
124
+ // Every expand(ctx) returns { options?, children? }. ctx is
125
+ // { axes, seed, rng, slotId(role), content }.
126
+ const REGISTRY = {
127
+ nav: {
128
+ region: true,
129
+ // `type: "nav"` is a Switcher over a MobileBar / DesktopBar pair —
130
+ // the shape ElementMapper.protoNav already builds and the shape the
131
+ // production Gesos nav uses. The hamburger lives on MobileBar and
132
+ // the breakpoint swap is the Switcher's whole job, so responsive
133
+ // behaviour is inherited rather than reimplemented. (UINavBar was
134
+ // the wrong target: one bar that tries to be both, with a hardcoded
135
+ // 600px query and a one-way show/hide.)
136
+ element: "nav",
137
+ slots: ["brand"],
138
+ expand: () => ({ options: {
139
+ // The width at which the Switcher swaps mobile → desktop.
140
+ breakpoint: SCALE.navBreak,
141
+ maxHeight: SCALE.bar,
142
+ } }),
143
+ // A region's array holds tokens (strings) AND kinds (objects), the
144
+ // same shape `main` uses for its sections. Three links is three
145
+ // `{ kind: "link" }` entries, and §2.5's repeated-kind indexing
146
+ // gives them ids without a `count` concept having to exist.
147
+ kinds: {
148
+ link: {
149
+ slots: ["label"],
150
+ bare: true,
151
+ expand: (ctx) => ({ children: [linkEl(ctx, "label", SCALE.dense)] }),
152
+ },
153
+ },
154
+ tokens: {
155
+ // Detached from the edges — the bars' own `mar` inset.
156
+ floating: {
157
+ conflictsWith: ["pinned"],
158
+ expand: () => ({ options: { mar: [{ a: SCALE.inset }] } }),
159
+ },
160
+ // Flush: no inset at all.
161
+ pinned: {
162
+ conflictsWith: ["floating"],
163
+ expand: () => ({ options: { mar: [{ a: 0 }] } }),
164
+ },
165
+ // MobileBar and DesktopBar both honour `radius`'s VALUE
166
+ // (`obj.radius && (style.borderRadius = obj.radius)`), so the
167
+ // corner really does ride the axis here.
168
+ rounded: {
169
+ expand: () => ({ options: { radius: axisCalc("radius") } }),
170
+ },
171
+ },
172
+ },
173
+
174
+ // The column `@split` is actually about. MORPH-PLAN's canonical bones are
175
+ // `areas: ["nav nav", "side main"]` with `cols: ["@split", "1fr"]` — the
176
+ // split sizes the SIDE, not the nav. MORPH-MVP dropped this region while
177
+ // keeping `@split`, which is what left that track with no owner.
178
+ side: {
179
+ region: true,
180
+ tag: "aside",
181
+ slots: ["heading", "body"],
182
+ expand: () => ({ options: { pad: [{ a: axisCalc("density") }] } }),
183
+ tokens: {
184
+ // Rides with the scroll rather than leaving a dead column.
185
+ sticky: { expand: () => ({ options: { sticky: true } }) },
186
+ // Tighter than the content well beside it.
187
+ dense: { expand: () => ({ options: { pad: [{ a: axisCalc("density", SCALE.dense) }] } }) },
188
+ rounded: { expand: () => ({ options: { radius: axisCalc("radius") } }) },
189
+ },
190
+ },
191
+
192
+ main: {
193
+ region: true,
194
+ tag: "main",
195
+ sections: true,
196
+ },
197
+
198
+ section: {
199
+ kinds: {
200
+ hero: {
201
+ slots: ["title", "sub"],
202
+ expand: (ctx) => ({
203
+ options: { pad: [{ a: axisCalc("density") }] },
204
+ children: [
205
+ textEl(ctx, "title"),
206
+ textEl(ctx, "sub"),
207
+ ],
208
+ }),
209
+ },
210
+ cta: {
211
+ slots: ["button"],
212
+ expand: (ctx) => ({
213
+ options: { pad: [{ a: axisCalc("density") }], center: true },
214
+ children: [linkEl(ctx, "button")],
215
+ }),
216
+ },
217
+ },
218
+ },
219
+
220
+ effect: {
221
+ // M2 implements the op inside lib/raster-ops.js; the token exists
222
+ // here so a spec can be validated without a GPU anywhere near it.
223
+ tokens: { flow: { drivers: ["mouse", "time"] } },
224
+ },
225
+ };
226
+
227
+ const REGION_NAMES = Object.keys(REGISTRY).filter((k) => REGISTRY[k].region);
228
+
229
+ // ── Slot elements ────────────────────────────────────────────────────
230
+ // Slot ids are `<region>.<kind>.<role>` and are independent of `seed` —
231
+ // M7's diffing keys on them, so they are test-pinned. The DOM id is the
232
+ // same string with dots swapped for dashes, which keeps it usable as a
233
+ // plain CSS selector.
234
+ function slotElementId(slotId){
235
+ return "nod-" + slotId.replace(/\./g, "-");
236
+ }
237
+
238
+ // Missing content renders its own id, visibly — never lorem, which hides
239
+ // the gap instead of showing it.
240
+ function slotText(ctx, role){
241
+ const id = ctx.slotId(role);
242
+ const v = ctx.content[id];
243
+ if (v == null) return `[${id}]`;
244
+ return typeof v === "object" ? String(v.text ?? `[${id}]`) : String(v);
245
+ }
246
+
247
+ function textEl(ctx, role){
248
+ const id = ctx.slotId(role);
249
+ return {
250
+ type: TAG[role],
251
+ id: slotElementId(id),
252
+ size: TYPE[role],
253
+ text: slotText(ctx, role),
254
+ };
255
+ }
256
+
257
+ // `padScale` re-bases the padding onto a smaller design-source magnitude:
258
+ // a nav link wants the tight ramp, a page CTA the full one.
259
+ function linkEl(ctx, role, padScale){
260
+ const id = ctx.slotId(role);
261
+ const v = ctx.content[id];
262
+ return {
263
+ type: TAG[role],
264
+ id: slotElementId(id),
265
+ size: TYPE[role],
266
+ text: slotText(ctx, role),
267
+ url: (v && typeof v === "object" && v.url != null) ? String(v.url) : "#",
268
+ radius: axisCalc("radius"),
269
+ pad: [{ a: padScale ? axisCalc("density", padScale) : axisCalc("density") }],
270
+ };
271
+ }
272
+
273
+ // ── Errors ───────────────────────────────────────────────────────────
274
+ // validate() never throws; expand() throws the first error as prose and
275
+ // carries the whole machine-readable report on err.report — that report is
276
+ // the repair loop M5's MCP server hands to agents.
277
+ const HEADLINE = {
278
+ UNKNOWN_KEY: (e) => `Unknown top-level key "${e.path}".`,
279
+ UNKNOWN_TOKEN: (e) => `Unknown token ${q(e.got)} in region "${regionOf(e.path)}".`,
280
+ UNKNOWN_KIND: (e) => `Unknown section kind ${q(e.got)} in region "${regionOf(e.path)}".`,
281
+ UNKNOWN_SLOT: (e) => `Unknown content slot ${q(e.got)}.`,
282
+ UNKNOWN_DRIVER: (e) => `Unknown effect driver ${q(e.got)} at ${e.path}.`,
283
+ BAD_AXIS: (e) => `Bad axis at ${e.path}: ${describe(e.got)}.`,
284
+ TOKEN_CONFLICT: (e) => `Token ${q(e.got)} conflicts with a token already in region "${regionOf(e.path)}".`,
285
+ BAD_BONES: (e) => `Bad bones at ${e.path || "bones"}: ${describe(e.got)}.`,
286
+ };
287
+
288
+ const VALID_LABEL = {
289
+ UNKNOWN_KEY: "keys", UNKNOWN_TOKEN: "tokens", UNKNOWN_KIND: "kinds",
290
+ UNKNOWN_SLOT: "slots", UNKNOWN_DRIVER: "drivers", BAD_AXIS: "axes",
291
+ TOKEN_CONFLICT: "tokens", BAD_BONES: "values",
292
+ };
293
+
294
+ function regionOf(path){
295
+ return String(path).split(/[.[]/)[0];
296
+ }
297
+
298
+ function describe(v){
299
+ if (v === undefined) return "missing";
300
+ if (typeof v === "string") return `"${v}"`;
301
+ if (v === null || typeof v !== "object") return String(v);
302
+ return Array.isArray(v) ? "an array" : "an object";
303
+ }
304
+
305
+ // `got` is usually the offending string, but a malformed region hands us a
306
+ // whole object — quoting that produces "[object Object]".
307
+ const q = (v) => (typeof v === "string" ? `"${v}"` : describe(v));
308
+
309
+ function errorProse(e){
310
+ let out = HEADLINE[e.code](e);
311
+ if (e.suggestions.length) out += `\n Did you mean "${e.suggestions[0]}"?`;
312
+ if (e.valid.length) out += `\n Valid ${VALID_LABEL[e.code]}: ${e.valid.join(", ")}.`;
313
+ return `${out} (${e.code})`;
314
+ }
315
+
316
+ function layoutSpecError(report){
317
+ const err = new Error(errorProse(report.errors[0]));
318
+ err.name = "LayoutSpecError";
319
+ err.report = report;
320
+ return err;
321
+ }
322
+
323
+ // ── validate ─────────────────────────────────────────────────────────
324
+ function validate(spec){
325
+ const errors = [];
326
+ const push = (code, path, got, suggestions, valid) => {
327
+ errors.push({ code, path, got, suggestions: suggestions || [], valid: valid || [] });
328
+ };
329
+ const s = (spec && typeof spec === "object" && !Array.isArray(spec)) ? spec : {};
330
+
331
+ // --- axes -------------------------------------------------------
332
+ const axisNames = Object.keys(AXES);
333
+ const given = (s.axes && typeof s.axes === "object" && !Array.isArray(s.axes)) ? s.axes : null;
334
+ if (s.axes !== undefined && !given) push("BAD_AXIS", "axes", s.axes, [], axisNames);
335
+ if (given) {
336
+ for (const name in given) {
337
+ if (!(name in AXES)) {
338
+ push("BAD_AXIS", `axes.${name}`, name, suggest(name, axisNames), axisNames);
339
+ continue;
340
+ }
341
+ const v = given[name];
342
+ if (typeof v !== "number" || !isFinite(v) || v < 0 || v > 1) {
343
+ push("BAD_AXIS", `axes.${name}`, v, [], axisNames);
344
+ }
345
+ }
346
+ }
347
+
348
+ // --- bones ------------------------------------------------------
349
+ const regions = regionKeys(s);
350
+ checkBones(s.bones, given, regions, push);
351
+
352
+ // --- regions ----------------------------------------------------
353
+ const vocabulary = declaredRegions(s.bones);
354
+ const keyVocabulary = RESERVED.concat(vocabulary);
355
+ for (const key of Object.keys(s)) {
356
+ if (RESERVED.includes(key)) continue;
357
+ if (!vocabulary.includes(key)) {
358
+ push("UNKNOWN_KEY", key, key, suggest(key, keyVocabulary), keyVocabulary);
359
+ continue;
360
+ }
361
+ checkRegion(key, s[key], push);
362
+ }
363
+
364
+ // --- content ----------------------------------------------------
365
+ if (s.content && typeof s.content === "object" && !Array.isArray(s.content)) {
366
+ const ids = collectSlots(s, regions).map((sl) => sl.id);
367
+ for (const key of Object.keys(s.content)) {
368
+ if (!ids.includes(key)) push("UNKNOWN_SLOT", `content.${key}`, key, suggest(key, ids), ids);
369
+ }
370
+ }
371
+
372
+ return { ok: errors.length === 0, errors };
373
+ }
374
+
375
+ // Top-level keys that are not reserved — the regions this page has, in
376
+ // declaration order.
377
+ function regionKeys(s){
378
+ return Object.keys(s).filter((k) => !RESERVED.includes(k));
379
+ }
380
+
381
+ // The vocabulary a region key must belong to. `bones.areas` names the
382
+ // regions explicitly; without it the registry's region entries are the
383
+ // vocabulary, and a key outside both is the silently-dead-key bug that
384
+ // ground rule 4 exists to stop.
385
+ function declaredRegions(bones){
386
+ const areas = (bones && !Array.isArray(bones) && bones.areas);
387
+ if (!Array.isArray(areas)) return REGION_NAMES;
388
+ const out = [];
389
+ for (const row of areas) {
390
+ for (const cell of String(row).trim().split(/\s+/)) {
391
+ if (cell !== "." && !out.includes(cell)) out.push(cell);
392
+ }
393
+ }
394
+ return out;
395
+ }
396
+
397
+ // The kinds a region may hold: its own vocabulary, or the shared `section`
398
+ // one. `nav` declares `link`; `main` borrows the section kinds.
399
+ function kindsOf(def){
400
+ return def.kinds || (def.sections ? REGISTRY.section.kinds : null);
401
+ }
402
+
403
+ // A region's value is an array whose entries are STRINGS (tokens — how the
404
+ // region behaves) or OBJECTS (kinds — what it contains). One shape for
405
+ // every region, so `nav: ["floating", { kind: "link" }, { kind: "link" }]`
406
+ // needs no syntax that `main` did not already have.
407
+ function checkRegion(name, value, push){
408
+ const def = REGISTRY[name];
409
+ if (!def) return; // not a registry region (areas named it); nothing to check
410
+ const tokens = def.tokens ? Object.keys(def.tokens) : [];
411
+ const kinds = kindsOf(def);
412
+ const kindNames = kinds ? Object.keys(kinds) : [];
413
+ if (!Array.isArray(value)) {
414
+ push(tokens.length ? "UNKNOWN_TOKEN" : "UNKNOWN_KIND", name, value, [],
415
+ tokens.length ? tokens : kindNames);
416
+ return;
417
+ }
418
+
419
+ const seen = [];
420
+ value.forEach((entry, i) => {
421
+ const path = `${name}[${i}]`;
422
+ if (typeof entry === "string") {
423
+ if (!tokens.length) {
424
+ push("UNKNOWN_KIND", path, entry, suggest(entry, kindNames), kindNames);
425
+ return;
426
+ }
427
+ if (!tokens.includes(entry)) {
428
+ push("UNKNOWN_TOKEN", path, entry, suggest(entry, tokens), tokens);
429
+ return;
430
+ }
431
+ const conflicts = def.tokens[entry].conflictsWith || [];
432
+ if (conflicts.some((c) => seen.includes(c))) {
433
+ // The fix is to drop one of the two, so the useful hint is
434
+ // the tokens that WOULD compose with what is already there.
435
+ const compatible = tokens.filter((v) =>
436
+ v !== entry && !seen.includes(v) &&
437
+ !(def.tokens[v].conflictsWith || []).some((c) => seen.includes(c)));
438
+ push("TOKEN_CONFLICT", path, entry, compatible, tokens);
439
+ }
440
+ seen.push(entry);
441
+ return;
442
+ }
443
+
444
+ const kind = entry && entry.kind;
445
+ if (!kindNames.includes(kind)) {
446
+ push("UNKNOWN_KIND", `${path}.kind`, kind, suggest(kind, kindNames), kindNames);
447
+ return;
448
+ }
449
+ checkEffect(entry, path, push);
450
+ });
451
+ }
452
+
453
+ function checkEffect(entry, path, push){
454
+ if (entry.effect === undefined) return;
455
+ const effects = Object.keys(REGISTRY.effect.tokens);
456
+ if (!Array.isArray(entry.effect)) {
457
+ push("UNKNOWN_TOKEN", `${path}.effect`, entry.effect, [], effects);
458
+ return;
459
+ }
460
+ const [token, ...drivers] = entry.effect;
461
+ if (!effects.includes(token)) {
462
+ push("UNKNOWN_TOKEN", `${path}.effect[0]`, token, suggest(token, effects), effects);
463
+ return;
464
+ }
465
+ const valid = REGISTRY.effect.tokens[token].drivers;
466
+ drivers.forEach((d, j) => {
467
+ if (!valid.includes(d)) {
468
+ push("UNKNOWN_DRIVER", `${path}.effect[${j + 1}]`, d, suggest(d, valid), valid);
469
+ }
470
+ });
471
+ }
472
+
473
+ // ── bones ────────────────────────────────────────────────────────────
474
+ // A track is a string (`"@split"`, `"1fr"`, `"auto"`, `"240px"`, `"20%"`)
475
+ // or `{ size, min, max, priority }`. Any constrained track routes its
476
+ // whole dimension through the allocator.
477
+ const AXIS_REF = /^@([A-Za-z0-9_]+)$/;
478
+ const FR = /^([0-9.]+)fr$/;
479
+ const PX = /^(-?[0-9.]+)px$/;
480
+ const PCT = /^(-?[0-9.]+)%$/;
481
+
482
+ function checkBones(bones, axes, regions, push){
483
+ if (bones === undefined) {
484
+ push("BAD_BONES", "bones", undefined, [], ["cols", "rows", "gap", "areas"]);
485
+ return;
486
+ }
487
+ const shape = Array.isArray(bones) ? { cols: bones } : bones;
488
+ if (!shape || typeof shape !== "object") {
489
+ push("BAD_BONES", "bones", bones, [], ["cols", "rows", "gap", "areas"]);
490
+ return;
491
+ }
492
+ const keys = ["cols", "rows", "gap", "areas"];
493
+ if (!Array.isArray(bones)) {
494
+ for (const k of Object.keys(bones)) {
495
+ if (!keys.includes(k)) push("BAD_BONES", `bones.${k}`, k, suggest(k, keys), keys);
496
+ }
497
+ }
498
+ for (const dim of ["cols", "rows"]) {
499
+ const list = shape[dim];
500
+ if (list === undefined) continue;
501
+ if (!Array.isArray(list)) { push("BAD_BONES", `bones.${dim}`, list, [], []); continue; }
502
+ list.forEach((t, i) => checkTrack(t, `bones.${dim}[${i}]`, axes, push));
503
+ }
504
+ if (shape.gap !== undefined) checkTrack(shape.gap, "bones.gap", axes, push);
505
+
506
+ if (Array.isArray(shape.areas)) {
507
+ const widths = shape.areas.map((r) => String(r).trim().split(/\s+/).length);
508
+ if (widths.some((w) => w !== widths[0])) {
509
+ push("BAD_BONES", "bones.areas", shape.areas, [], []);
510
+ }
511
+ // A declared grid has to be the shape it says it is.
512
+ if (Array.isArray(shape.cols) && widths[0] !== shape.cols.length) {
513
+ push("BAD_BONES", "bones.areas", shape.areas, [], []);
514
+ }
515
+ if (Array.isArray(shape.rows) && shape.areas.length !== shape.rows.length) {
516
+ push("BAD_BONES", "bones.rows", shape.rows, [], []);
517
+ }
518
+ } else if (regions.length && Array.isArray(shape.rows) && shape.rows.length !== regions.length) {
519
+ // Without `areas`, each region takes a row — so a declared `rows`
520
+ // has to have one track per region. Name the cells in `areas` when
521
+ // the layout is not one-region-per-row.
522
+ push("BAD_BONES", "bones.rows", shape.rows, [], []);
523
+ }
524
+ }
525
+
526
+ function checkTrack(track, path, axes, push){
527
+ const t = (track && typeof track === "object" && !Array.isArray(track)) ? track : { size: track };
528
+ if (track && typeof track === "object" && !Array.isArray(track)) {
529
+ const keys = ["size", "min", "max", "priority"];
530
+ for (const k of Object.keys(track)) {
531
+ if (!keys.includes(k)) push("BAD_BONES", `${path}.${k}`, k, suggest(k, keys), keys);
532
+ }
533
+ }
534
+ if (typeof t.size !== "string") { push("BAD_BONES", `${path}.size`, t.size, [], []); return; }
535
+
536
+ const ref = AXIS_REF.exec(t.size);
537
+ if (ref) {
538
+ const name = ref[1];
539
+ const axisNames = Object.keys(AXES);
540
+ if (!(name in AXES)) {
541
+ push("BAD_BONES", `${path}.size`, t.size, suggest(name, axisNames), axisNames);
542
+ } else if (AXES[name].map.unit === "") {
543
+ // A unitless axis is a factor, not a length: it cannot size a track.
544
+ push("BAD_BONES", `${path}.size`, t.size, [], axisNames.filter((n) => AXES[n].map.unit !== ""));
545
+ } else if (!axes || !(name in axes)) {
546
+ // Routing a track through an axis means stating that axis's value.
547
+ push("BAD_AXIS", `axes.${name}`, undefined, [], axisNames);
548
+ }
549
+ } else if (!FR.test(t.size) && !PX.test(t.size) && !PCT.test(t.size) && t.size !== "auto") {
550
+ push("BAD_BONES", `${path}.size`, t.size, [], ["@<axis>", "<n>fr", "<n>px", "<n>%", "auto"]);
551
+ }
552
+
553
+ for (const bound of ["min", "max"]) {
554
+ if (t[bound] === undefined) continue;
555
+ if (typeof t[bound] !== "string" || !PX.test(t[bound])) {
556
+ push("BAD_BONES", `${path}.${bound}`, t[bound], [], ["<n>px"]);
557
+ }
558
+ }
559
+ if (t.priority !== undefined && !(t.priority in PRIORITY)) {
560
+ const names = Object.keys(PRIORITY);
561
+ push("BAD_BONES", `${path}.priority`, t.priority, suggest(t.priority, names), names);
562
+ }
563
+ }
564
+
565
+ // Normalize one declared track into the record the allocator and the CSS
566
+ // emitter both read.
567
+ function normalizeTrack(track, id){
568
+ const t = (track && typeof track === "object" && !Array.isArray(track)) ? track : { size: track };
569
+ const ref = AXIS_REF.exec(t.size);
570
+ const constrained = t.min !== undefined || t.max !== undefined || t.priority !== undefined;
571
+ return {
572
+ id,
573
+ size: t.size,
574
+ axis: ref ? ref[1] : null,
575
+ fr: FR.test(t.size) ? parseFloat(FR.exec(t.size)[1]) : null,
576
+ min: t.min === undefined ? 0 : parseFloat(PX.exec(t.min)[1]),
577
+ max: t.max === undefined ? null : parseFloat(PX.exec(t.max)[1]),
578
+ priority: PRIORITY[t.priority === undefined ? DEFAULT_PRIORITY : t.priority],
579
+ constrained,
580
+ };
581
+ }
582
+
583
+ // Rows a spec did not declare: one per region, content-sized, except the
584
+ // last which fills what is left. That is what `["auto", "1fr"]` spells out
585
+ // for a nav above a main, so the default and the written form agree.
586
+ function defaultRows(regions){
587
+ if (!regions.length) return ["1fr"];
588
+ return regions.map((_, i) => (i === regions.length - 1 ? "1fr" : "auto"));
589
+ }
590
+
591
+ function normalizeBones(bones, regions){
592
+ const shape = Array.isArray(bones) ? { cols: bones } : bones;
593
+ const cols = (shape.cols || ["1fr"]).map((t, i) => normalizeTrack(t, `cols-${i}`));
594
+ const rows = (shape.rows || defaultRows(regions)).map((t, i) => normalizeTrack(t, `rows-${i}`));
595
+ const gap = shape.gap === undefined ? null : normalizeTrack(shape.gap, "gap");
596
+ // `areas` as declared, or synthesized ROW-MAJOR: each region takes a row
597
+ // and spans every column, in declaration order. A nav belongs across the
598
+ // top, not down one side — and it is what MORPH-PLAN's canonical
599
+ // `areas: ["nav nav", "side main"]` does with its first row. To put a
600
+ // region in a single column, name it in `areas` and let `@split` size
601
+ // that column; that is what the `side` region is for.
602
+ const areas = Array.isArray(shape.areas)
603
+ ? shape.areas.map((r) => String(r).trim().split(/\s+/).join(" "))
604
+ : regions.map((r) => new Array(cols.length).fill(r).join(" "));
605
+ return { cols, rows, gap, areas };
606
+ }
607
+
608
+ // ── The allocator ────────────────────────────────────────────────────
609
+ // Taken verbatim from MORPH-MVP.md's appendix (written and executed
610
+ // 2026-08-08; the traces there are real output). `pref` is a track's
611
+ // preferred size in px; the solver distributes the container in that
612
+ // proportion, freezing any track whose share would break its min or max
613
+ // and re-dividing what is left. Freezes at least one track per pass, so
614
+ // it terminates.
615
+ const REQUIRED = Infinity;
616
+
617
+ function tryAllocate(container, tracks, relaxed) {
618
+ const minOf = t => (relaxed.has(t.id) ? 0 : (t.min ?? 0));
619
+ if (tracks.reduce((s, t) => s + minOf(t), 0) > container) return null;
620
+ const frozen = new Map();
621
+ for (let pass = 0; pass <= tracks.length; pass++) { // freezes ≥1/pass → terminates
622
+ const free = tracks.filter(t => !frozen.has(t.id));
623
+ if (!free.length) break;
624
+ const remaining = container - [...frozen.values()].reduce((a, b) => a + b, 0);
625
+ const sumPref = free.reduce((s, t) => s + t.pref, 0) || 1;
626
+ const ideal = t => remaining * t.pref / sumPref;
627
+ const violator = free.find(t => ideal(t) < minOf(t) || ideal(t) > (t.max ?? Infinity));
628
+ if (!violator) { free.forEach(t => frozen.set(t.id, ideal(t))); break; }
629
+ frozen.set(violator.id,
630
+ Math.min(Math.max(ideal(violator), minOf(violator)), violator.max ?? Infinity));
631
+ }
632
+ return Object.fromEntries(frozen);
633
+ }
634
+
635
+ // Integer px with largest-remainder distribution, so Σ sizes === container
636
+ // EXACTLY — a browser given tracks that sum to 1199 or 1201 reflows the
637
+ // last one, which is visible as a 1px jitter while a slider is dragged.
638
+ // Equal remainders go to the first-declared track (Array.prototype.sort is
639
+ // stable since ES2019, and `order` is built in declaration order).
640
+ function roundSizes(container, tracks, sizes){
641
+ const total = Math.round(container);
642
+ const order = tracks.filter((t) => t.id in sizes);
643
+ if (!order.length) return {};
644
+ const px = order.map((t) => Math.max(0, Math.floor(sizes[t.id])));
645
+ const rem = order.map((t, i) => ({ i, r: Math.max(0, sizes[t.id]) - px[i] }));
646
+ rem.sort((a, b) => b.r - a.r);
647
+ let left = total - px.reduce((a, b) => a + b, 0);
648
+ for (let k = 0; left > 0; k++, left--) px[rem[k % rem.length].i] += 1;
649
+ const out = {};
650
+ order.forEach((t, i) => { out[t.id] = px[i]; });
651
+ return out;
652
+ }
653
+
654
+ function allocate(container, tracks) {
655
+ const relaxed = new Set();
656
+ for (;;) {
657
+ const sizes = tryAllocate(container, tracks, relaxed);
658
+ if (sizes) {
659
+ return { sizes, relaxed: [...relaxed], rounded: roundSizes(container, tracks, sizes) };
660
+ }
661
+ const next = tracks // lowest priority yields first
662
+ .filter(t => (t.min ?? 0) > 0 && !relaxed.has(t.id) && t.priority !== REQUIRED)
663
+ .sort((a, b) => a.priority - b.priority)[0];
664
+ if (!next) throw new Error("unsatisfiable: required mins exceed container");
665
+ relaxed.add(next.id);
666
+ }
667
+ }
668
+
669
+ // ── Solving a dimension ──────────────────────────────────────────────
670
+ // A track's preferred px at the current axis values. `fr` tracks share
671
+ // whatever the sized ones leave over, which is what makes the appendix's
672
+ // desktop trace (side 324 + gap 28 + main 848 = 1200) come out exact.
673
+ function preferredSizes(tracks, axes, container){
674
+ const pref = new Map();
675
+ let fixed = 0, frSum = 0;
676
+ for (const t of tracks) {
677
+ if (t.fr != null) { frSum += t.fr; continue; }
678
+ let px = 0;
679
+ if (t.axis) {
680
+ const v = axisValue(t.axis, axes[t.axis]);
681
+ px = AXES[t.axis].map.unit === "%" ? (v / 100) * container : v;
682
+ } else if (PX.test(t.size)) {
683
+ px = parseFloat(PX.exec(t.size)[1]);
684
+ } else if (PCT.test(t.size)) {
685
+ px = (parseFloat(PCT.exec(t.size)[1]) / 100) * container;
686
+ }
687
+ px *= t.span || 1;
688
+ pref.set(t.id, px);
689
+ fixed += px;
690
+ }
691
+ const leftover = Math.max(0, container - fixed);
692
+ for (const t of tracks) {
693
+ if (t.fr == null) continue;
694
+ pref.set(t.id, frSum > 0 ? (leftover * t.fr) / frSum : 0);
695
+ }
696
+ return tracks.map((t) => ({ ...t, pref: pref.get(t.id) }));
697
+ }
698
+
699
+ // One solved dimension. The gap joins the dimension as a single track
700
+ // carrying all (n-1) gutters, so a 2-column grid reduces exactly to the
701
+ // appendix's three-track model and wider grids stay correct.
702
+ function solveDimension(dim, tracks, gap, container, axes){
703
+ const span = Math.max(0, tracks.length - 1);
704
+ // The gap's bounds are per-gutter; the track carries all of them, so
705
+ // they scale with the gutter count too.
706
+ const gapTrack = gap && {
707
+ ...gap, id: "gap", span,
708
+ min: gap.min * span,
709
+ max: gap.max == null ? null : gap.max * span,
710
+ };
711
+ // Declaration order, gutters sitting between the tracks they separate.
712
+ const list = gapTrack && span > 0
713
+ ? [tracks[0], gapTrack, ...tracks.slice(1)]
714
+ : tracks.slice();
715
+ if (list.some((t) => t.size === "auto")) {
716
+ const e = new Error(
717
+ `[nodality] morph: an "auto" track cannot be resolved by the allocator ` +
718
+ `(bones.${dim}); give it a size, or drop the constraints on that dimension.`);
719
+ e.name = "LayoutSpecError";
720
+ throw e;
721
+ }
722
+ const { sizes, relaxed, rounded } = allocate(container, preferredSizes(list, axes, container));
723
+ const vars = {};
724
+ for (const t of list) {
725
+ if (!(t.id in rounded)) continue;
726
+ vars[`--nod-${t.id}`] = t.id === "gap"
727
+ ? `${Math.round(rounded[t.id] / t.span)}px`
728
+ : `${rounded[t.id]}px`;
729
+ }
730
+ return { vars, sizes, relaxed, list };
731
+ }
732
+
733
+ // Everything the grid shell needs: the CSS for cols/rows/gap plus, when a
734
+ // dimension is constrained, the track model morphController re-solves on
735
+ // every axis stroke.
736
+ function solveBones(bones, axes){
737
+ const vars = {};
738
+ const tracks = {};
739
+ const cssFor = (dim) => bones[dim].map((t) =>
740
+ (t.axis ? axisCalc(t.axis) : t.size)).join(" ");
741
+
742
+ let cols = cssFor("cols");
743
+ let rows = cssFor("rows");
744
+ let gap = bones.gap
745
+ ? (bones.gap.axis ? axisCalc(bones.gap.axis) : bones.gap.size)
746
+ : null;
747
+
748
+ const constrained = (list) => list.some((t) => t.constrained);
749
+ const gapConstrained = !!(bones.gap && bones.gap.constrained);
750
+
751
+ // The gap is one gutter shared by both dimensions, so it is solved with
752
+ // the first dimension that needs solving and read as a fixed size by
753
+ // the second.
754
+ let gapOwner = null;
755
+ for (const dim of ["cols", "rows"]) {
756
+ const needs = constrained(bones[dim]) || (gapConstrained && !gapOwner);
757
+ if (!needs) continue;
758
+ const box = dim === "cols" ? CONTAINER.width : CONTAINER.height;
759
+ const gapForDim = bones.gap && !gapOwner ? bones.gap : null;
760
+ const solved = solveDimension(dim, bones[dim], gapForDim, box, axes);
761
+ Object.assign(vars, solved.vars);
762
+ tracks[dim] = { container: box, list: solved.list };
763
+ if (gapForDim) gapOwner = dim;
764
+ const css = bones[dim].map((t) => `var(--nod-${t.id})`).join(" ");
765
+ if (dim === "cols") cols = css; else rows = css;
766
+ }
767
+ if (gapOwner) gap = "var(--nod-gap)";
768
+
769
+ return { cols, rows, gap, vars, tracks };
770
+ }
771
+
772
+ // Re-solve every constrained dimension at the given axis values. Pure, and
773
+ // shared by expand() (build-time poster values) and morphController()
774
+ // (every stroke) — which is why prerendered and live output agree.
775
+ function solveTracks(tracks, axes){
776
+ const vars = {};
777
+ for (const dim in tracks) {
778
+ const { container, list } = tracks[dim];
779
+ const { rounded } = allocate(container, preferredSizes(list, axes, container));
780
+ for (const t of list) {
781
+ if (!(t.id in rounded)) continue;
782
+ vars[`--nod-${t.id}`] = t.id === "gap"
783
+ ? `${Math.round(rounded[t.id] / t.span)}px`
784
+ : `${rounded[t.id]}px`;
785
+ }
786
+ }
787
+ return vars;
788
+ }
789
+
790
+ // ── Slots ────────────────────────────────────────────────────────────
791
+ // Deterministic and seed-independent: `<region>.<kind>.<role>`, and a kind
792
+ // that repeats within a region takes an index from its second occurrence
793
+ // on (`main.features1.title`).
794
+ function collectSlots(s, regions){
795
+ const out = [];
796
+ for (const region of regions) {
797
+ const def = REGISTRY[region];
798
+ if (!def) continue;
799
+ if (def.slots) {
800
+ for (const role of def.slots) out.push({ id: `${region}.${role}`, kind: region });
801
+ }
802
+ const kinds = kindsOf(def);
803
+ if (!kinds || !Array.isArray(s[region])) continue;
804
+ const seen = {};
805
+ for (const entry of s[region]) {
806
+ if (!entry || typeof entry !== "object") continue; // a token
807
+ const kind = entry.kind;
808
+ const def2 = kinds[kind];
809
+ if (!def2) continue;
810
+ const n = seen[kind] = (seen[kind] || 0);
811
+ seen[kind] = n + 1;
812
+ const name = n === 0 ? kind : `${kind}${n}`;
813
+ for (const role of def2.slots) {
814
+ out.push({ id: `${region}.${name}.${role}`, kind });
815
+ }
816
+ }
817
+ }
818
+ return out;
819
+ }
820
+
821
+ // ── expand ───────────────────────────────────────────────────────────
822
+ function expand(spec){
823
+ const report = validate(spec);
824
+ if (!report.ok) throw layoutSpecError(report);
825
+
826
+ const s = spec;
827
+ const seed = s.seed == null ? DEFAULT_SEED : s.seed;
828
+ const rng = mulberry32(seed);
829
+ const content = (s.content && typeof s.content === "object") ? s.content : {};
830
+
831
+ // AXES declaration order, spec values overriding the design defaults.
832
+ const axes = {};
833
+ for (const name in AXES) {
834
+ axes[name] = (s.axes && typeof s.axes[name] === "number") ? s.axes[name] : AXES[name].default;
835
+ }
836
+
837
+ const regions = regionKeys(s);
838
+ const bones = normalizeBones(s.bones, regions);
839
+ const solved = solveBones(bones, axes);
840
+
841
+ const vars = {};
842
+ for (const name in AXES) vars[AXES[name].css] = axes[name];
843
+ Object.assign(vars, solved.vars);
844
+
845
+ const slots = collectSlots(s, regions);
846
+ const rootId = "nod-" + s.target;
847
+ const children = regions.map((region) => expandRegion(region, s[region], { axes, seed, rng, content }));
848
+
849
+ const root = {
850
+ type: "wrap",
851
+ name: s.target,
852
+ id: rootId,
853
+ disp: "grid",
854
+ cols: solved.cols,
855
+ rows: solved.rows,
856
+ areas: bones.areas.map((r) => `"${r}"`).join(" "),
857
+ vars,
858
+ children,
859
+ };
860
+ if (solved.gap) root.gap = solved.gap;
861
+
862
+ return {
863
+ elements: [root],
864
+ nodes: [],
865
+ slots,
866
+ meta: { rootId, vars, tracks: solved.tracks, slots },
867
+ };
868
+ }
869
+
870
+ function expandRegion(region, value, base){
871
+ const def = REGISTRY[region];
872
+ const el = {
873
+ // A region is an ordinary Wrapper unless the registry names a
874
+ // dedicated element for it — `nav` is a UINavBar.
875
+ type: (def && def.element) || "wrap",
876
+ id: `nod-${region}`,
877
+ name: region,
878
+ area: region,
879
+ children: [],
880
+ };
881
+ if (def && def.tag) el.kind = def.tag;
882
+ if (!def) return el;
883
+
884
+ // Base options first, so a token always has the last word over them.
885
+ if (def.expand) Object.assign(el, def.expand({ ...base, slotId: (r) => `${region}.${r}` }).options);
886
+
887
+ // Region-level slots (nav.brand) come before any tokens touch the box.
888
+ if (def.slots) {
889
+ const ctx = { ...base, slotId: (role) => `${region}.${role}` };
890
+ for (const role of def.slots) el.children.push(textEl(ctx, role));
891
+ }
892
+
893
+ const kinds = kindsOf(def);
894
+ if (Array.isArray(value)) {
895
+ const seen = {};
896
+ for (const entry of value) { // declaration order
897
+ if (typeof entry === "string") {
898
+ const out = def.tokens[entry].expand({ ...base, slotId: (r) => `${region}.${r}` });
899
+ Object.assign(el, out.options);
900
+ if (out.children) el.children.push(...out.children);
901
+ continue;
902
+ }
903
+ const kind = entry.kind;
904
+ const n = seen[kind] = (seen[kind] || 0);
905
+ seen[kind] = n + 1;
906
+ const name = n === 0 ? kind : `${kind}${n}`;
907
+ el.children.push(expandKind(region, name, kind, entry, base, kinds));
908
+ }
909
+ }
910
+ return el;
911
+ }
912
+
913
+ function expandKind(region, name, kind, section, base, kinds){
914
+ const def = kinds[kind];
915
+ const ctx = { ...base, slotId: (role) => `${region}.${name}.${role}` };
916
+ const out = def.expand(ctx);
917
+ // A `bare` kind IS its single element — a nav link is an <a>, not an <a>
918
+ // inside a section wrapper that exists only because the machinery does.
919
+ if (def.bare) return out.children[0];
920
+ const el = {
921
+ type: "wrap",
922
+ kind: "section",
923
+ id: `nod-${region}-${name}`,
924
+ name: `${region}.${name}`,
925
+ children: out.children || [],
926
+ ...out.options,
927
+ };
928
+ // `effect` is not a new element capability — it is the existing
929
+ // `raster:` option, which every class already accepts through
930
+ // commonMethods. One mapping, no wiring.
931
+ if (Array.isArray(section.effect)) {
932
+ const [op, ...drivers] = section.effect;
933
+ const node = { op, seed: Math.floor(base.rng() * 0x100000000) };
934
+ if (drivers.length) node.by = drivers[0];
935
+ el.raster = [node];
936
+ }
937
+ return el;
938
+ }
939
+
940
+ // ── morphController — the only DOM-touching export ───────────────────
941
+ // Two call forms, one implementation:
942
+ //
943
+ // morphController(spec) — derives meta by running the pure
944
+ // expansion, finds #nod-<target> itself
945
+ // morphController(meta, rootEl) — for callers that already expanded
946
+ //
947
+ // Every stroke writes custom properties and nothing else: no DOM
948
+ // mutations, no re-render, no style tag. That is the whole claim, and the
949
+ // demo page's mutation counter is what proves it.
950
+ function morphController(specOrMeta, rootEl){
951
+ const meta = (specOrMeta && specOrMeta.rootId) ? specOrMeta : expand(specOrMeta).meta;
952
+ const root = rootEl ||
953
+ (typeof document !== "undefined" ? document.getElementById(meta.rootId) : null);
954
+ if (!root) {
955
+ throw new Error(`[nodality] morph: no element with id "${meta.rootId}" — ` +
956
+ `mount the expansion before creating the controller, or pass the root element.`);
957
+ }
958
+
959
+ // Current axis values, read back out of the vars the expansion emitted.
960
+ const axes = {};
961
+ for (const name in AXES) axes[name] = meta.vars[AXES[name].css];
962
+ const hasTracks = meta.tracks && Object.keys(meta.tracks).length > 0;
963
+ const slotIds = (meta.slots || []).map((sl) => sl.id);
964
+
965
+ const write = (name) => {
966
+ root.style.setProperty(AXES[name].css, String(axes[name]));
967
+ if (!hasTracks) return;
968
+ const solved = solveTracks(meta.tracks, axes);
969
+ for (const key in solved) root.style.setProperty(key, solved[key]);
970
+ };
971
+
972
+ const api = {
973
+ axis(name, v){
974
+ if (!(name in AXES)) {
975
+ const names = Object.keys(AXES);
976
+ throw new Error(`[nodality] morph: unknown axis "${name}". ` +
977
+ `Valid axes: ${names.join(", ")}.`);
978
+ }
979
+ axes[name] = Math.min(1, Math.max(0, Number(v)));
980
+ write(name);
981
+ return api;
982
+ },
983
+ axes(map){
984
+ for (const name in map) api.axis(name, map[name]);
985
+ return api;
986
+ },
987
+ content(slotId, value){
988
+ if (!slotIds.includes(slotId)) {
989
+ throw new Error(`[nodality] morph: unknown content slot "${slotId}". ` +
990
+ `Valid slots: ${slotIds.join(", ")}.`);
991
+ }
992
+ const id = slotElementId(slotId);
993
+ const node = root.querySelector(`#${id}`) ||
994
+ (typeof document !== "undefined" ? document.getElementById(id) : null);
995
+ if (node) node.textContent = typeof value === "object" && value ? String(value.text) : String(value);
996
+ return api;
997
+ },
998
+ dispose(){ return api; },
999
+ };
1000
+ return api;
1001
+ }
1002
+
1003
+ export { expand, validate, allocate, REGISTRY, AXES, morphController, mulberry32 };