nodality 1.2.11 → 1.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 (282) hide show
  1. package/bin/mcp-server.mjs +45 -0
  2. package/dist/animator.cjs.js +1 -1
  3. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  4. package/dist/animator.esm.js +1 -1
  5. package/dist/animator.esm.js.LICENSE.txt +1 -1
  6. package/dist/audionew.cjs.js +1 -1
  7. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  8. package/dist/audionew.esm.js +1 -1
  9. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  10. package/dist/base.cjs.js +1 -1
  11. package/dist/base.cjs.js.LICENSE.txt +1 -1
  12. package/dist/base.esm.js +1 -1
  13. package/dist/base.esm.js.LICENSE.txt +1 -1
  14. package/dist/beta-desktop-bar.cjs.js +1 -1
  15. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  16. package/dist/beta-desktop-bar.esm.js +1 -1
  17. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  18. package/dist/beta-mobile-bar.cjs.js +1 -1
  19. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  20. package/dist/beta-mobile-bar.esm.js +1 -1
  21. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  22. package/dist/bundle.umd.js +1 -1
  23. package/dist/bundle.umd.js.LICENSE.txt +1 -1
  24. package/dist/button.cjs.js +1 -1
  25. package/dist/button.cjs.js.LICENSE.txt +1 -1
  26. package/dist/button.esm.js +1 -1
  27. package/dist/button.esm.js.LICENSE.txt +1 -1
  28. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  29. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  30. package/dist/center.cjs.js +1 -1
  31. package/dist/center.cjs.js.LICENSE.txt +1 -1
  32. package/dist/center.esm.js +1 -1
  33. package/dist/center.esm.js.LICENSE.txt +1 -1
  34. package/dist/checkbox.cjs.js +1 -1
  35. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  36. package/dist/checkbox.esm.js +1 -1
  37. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  38. package/dist/code.cjs.js +1 -1
  39. package/dist/code.cjs.js.LICENSE.txt +1 -1
  40. package/dist/code.esm.js +1 -1
  41. package/dist/code.esm.js.LICENSE.txt +1 -1
  42. package/dist/container.cjs.js +1 -1
  43. package/dist/container.cjs.js.LICENSE.txt +1 -1
  44. package/dist/container.esm.js +1 -1
  45. package/dist/container.esm.js.LICENSE.txt +1 -1
  46. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  47. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  48. package/dist/designer.cjs.js +1 -1
  49. package/dist/designer.cjs.js.LICENSE.txt +1 -1
  50. package/dist/designer.esm.js +1 -1
  51. package/dist/designer.esm.js.LICENSE.txt +1 -1
  52. package/dist/element-mapper.cjs.js +1 -1
  53. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  54. package/dist/element-mapper.esm.js +1 -1
  55. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  56. package/dist/finalresult.esm.js +1 -1
  57. package/dist/finalresult.esm.js.LICENSE.txt +1 -1
  58. package/dist/flex-card.cjs.js +1 -1
  59. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  60. package/dist/flex-card.esm.js +1 -1
  61. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  62. package/dist/flex-grid.cjs.js +1 -1
  63. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  64. package/dist/flex-grid.esm.js +1 -1
  65. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  66. package/dist/flex-row.cjs.js +1 -1
  67. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  68. package/dist/flex-row.esm.js +1 -1
  69. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  70. package/dist/floating-input.cjs.js +1 -1
  71. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  72. package/dist/floating-input.esm.js +1 -1
  73. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  74. package/dist/free.cjs.js +1 -1
  75. package/dist/free.cjs.js.LICENSE.txt +1 -1
  76. package/dist/free.esm.js +1 -1
  77. package/dist/free.esm.js.LICENSE.txt +1 -1
  78. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  79. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  80. package/dist/image-picker.cjs.js +1 -1
  81. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  82. package/dist/image-picker.esm.js +1 -1
  83. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  84. package/dist/image.cjs.js +1 -1
  85. package/dist/image.cjs.js.LICENSE.txt +1 -1
  86. package/dist/image.esm.js +1 -1
  87. package/dist/image.esm.js.LICENSE.txt +1 -1
  88. package/dist/index.cjs.js +1 -1
  89. package/dist/index.cjs.js.LICENSE.txt +1 -1
  90. package/dist/index.d.ts +3 -2
  91. package/dist/index.esm.js +1 -1
  92. package/dist/index.esm.js.LICENSE.txt +1 -1
  93. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  94. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  95. package/dist/link-getter.cjs.js +1 -1
  96. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  97. package/dist/link-getter.esm.js +1 -1
  98. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  99. package/dist/link.cjs.js +1 -1
  100. package/dist/link.cjs.js.LICENSE.txt +1 -1
  101. package/dist/link.esm.js +1 -1
  102. package/dist/link.esm.js.LICENSE.txt +1 -1
  103. package/dist/meta-adder.cjs.js +1 -1
  104. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  105. package/dist/meta-adder.esm.js +1 -1
  106. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  107. package/dist/modal-2025.cjs.js +1 -1
  108. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  109. package/dist/modal-2025.esm.js +1 -1
  110. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  111. package/dist/multiswitcher.cjs.js +1 -1
  112. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  113. package/dist/multiswitcher.esm.js +1 -1
  114. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  115. package/dist/new-nav-bar.cjs.js +1 -1
  116. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  117. package/dist/new-nav-bar.esm.js +1 -1
  118. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  119. package/dist/picker.cjs.js +1 -1
  120. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  121. package/dist/picker.esm.js +1 -1
  122. package/dist/picker.esm.js.LICENSE.txt +1 -1
  123. package/dist/progress.cjs.js +1 -1
  124. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  125. package/dist/progress.esm.js +1 -1
  126. package/dist/progress.esm.js.LICENSE.txt +1 -1
  127. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  128. package/dist/radio.esm.js.LICENSE.txt +1 -1
  129. package/dist/range.cjs.js.LICENSE.txt +1 -1
  130. package/dist/range.esm.js.LICENSE.txt +1 -1
  131. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  132. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  133. package/dist/side-bar.cjs.js +1 -1
  134. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  135. package/dist/side-bar.esm.js +1 -1
  136. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  137. package/dist/side-nav-bar.cjs.js +1 -1
  138. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  139. package/dist/side-nav-bar.esm.js +1 -1
  140. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  141. package/dist/simple-bar.cjs.js +1 -1
  142. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  143. package/dist/simple-bar.esm.js +1 -1
  144. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  145. package/dist/slider-2025.cjs.js +1 -1
  146. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  147. package/dist/slider-2025.esm.js +1 -1
  148. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  149. package/dist/spacer.cjs.js +1 -1
  150. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  151. package/dist/spacer.esm.js +1 -1
  152. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  153. package/dist/stack.cjs.js +1 -1
  154. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  155. package/dist/stack.esm.js +1 -1
  156. package/dist/stack.esm.js.LICENSE.txt +1 -1
  157. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  158. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  159. package/dist/table.cjs.js +1 -1
  160. package/dist/table.cjs.js.LICENSE.txt +1 -1
  161. package/dist/table.esm.js +1 -1
  162. package/dist/table.esm.js.LICENSE.txt +1 -1
  163. package/dist/text-field.cjs.js +1 -1
  164. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  165. package/dist/text-field.esm.js +1 -1
  166. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  167. package/dist/text.cjs.js +1 -1
  168. package/dist/text.cjs.js.LICENSE.txt +1 -1
  169. package/dist/text.esm.js +1 -1
  170. package/dist/text.esm.js.LICENSE.txt +1 -1
  171. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  172. package/dist/theme.esm.js.LICENSE.txt +1 -1
  173. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  174. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  175. package/dist/ulist.cjs.js +1 -1
  176. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  177. package/dist/ulist.esm.js +1 -1
  178. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  179. package/dist/video.cjs.js +1 -1
  180. package/dist/video.cjs.js.LICENSE.txt +1 -1
  181. package/dist/video.esm.js +1 -1
  182. package/dist/video.esm.js.LICENSE.txt +1 -1
  183. package/dist/wrap.cjs.js +1 -1
  184. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  185. package/dist/wrap.esm.js +1 -1
  186. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  187. package/dist/zoom-card.cjs.js +1 -1
  188. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  189. package/dist/zoom-card.esm.js +1 -1
  190. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  191. package/layout/animator.js +15 -2
  192. package/layout/audio.js +1 -1
  193. package/layout/audionew.js +1 -1
  194. package/layout/base.js +1 -1
  195. package/layout/beta-desktop-bar.js +1 -1
  196. package/layout/beta-mobile-bar.js +1 -1
  197. package/layout/button.js +1 -1
  198. package/layout/center.js +1 -1
  199. package/layout/checkbox.js +1 -1
  200. package/layout/circle.js +1 -1
  201. package/layout/code.js +1 -1
  202. package/layout/container.js +1 -1
  203. package/layout/dropdown-2025.js +10 -3
  204. package/layout/flex-card.js +1 -1
  205. package/layout/flex-grid.js +1 -1
  206. package/layout/flex-row.js +1 -1
  207. package/layout/form-components/custom.js +1 -1
  208. package/layout/form-components/data-list.js +1 -1
  209. package/layout/form-components/floating-input.js +23 -4
  210. package/layout/form-components/form-all.js +1 -1
  211. package/layout/form-components/form.js +1 -1
  212. package/layout/form-components/image-picker.js +5 -3
  213. package/layout/form-components/picker.js +1 -1
  214. package/layout/form-components/radio.js +1 -1
  215. package/layout/form-components/radiogroup.js +1 -1
  216. package/layout/form-components/range.js +1 -1
  217. package/layout/free.js +1 -1
  218. package/layout/grid-switcher.js +1 -1
  219. package/layout/grid.js +1 -1
  220. package/layout/horizontal-scroller.js +1 -1
  221. package/layout/image.js +15 -2
  222. package/layout/index.js +1 -1
  223. package/layout/link.js +1 -1
  224. package/layout/list.js +1 -1
  225. package/layout/meta-adder.js +1 -1
  226. package/layout/modal-2025.js +1 -1
  227. package/layout/morph.js +1 -1
  228. package/layout/multiswitcher.js +1 -1
  229. package/layout/nav-bar.js +1 -1
  230. package/layout/nav-factor/custom-div.js +1 -1
  231. package/layout/new-nav-bar.js +1 -1
  232. package/layout/polygon.js +1 -1
  233. package/layout/prerender-site.js +1 -1
  234. package/layout/prerender.js +1 -1
  235. package/layout/progress.js +1 -1
  236. package/layout/row.js +1 -1
  237. package/layout/scroll-video.js +1 -1
  238. package/layout/side-bar.js +11 -3
  239. package/layout/side-nav-bar.js +16 -3
  240. package/layout/simple-bar.js +1 -1
  241. package/layout/slider-2025.js +1 -1
  242. package/layout/spacer.js +1 -1
  243. package/layout/stack.js +1 -1
  244. package/layout/svg.js +1 -1
  245. package/layout/switcher.js +1 -1
  246. package/layout/table.js +1 -1
  247. package/layout/text-field.js +16 -1
  248. package/layout/text.js +28 -1
  249. package/layout/ulist.js +1 -1
  250. package/layout/video.js +1 -1
  251. package/layout/wrap.js +1 -1
  252. package/layout/zoom-card.js +1 -1
  253. package/lib/agent-surface.js +1 -1
  254. package/lib/card-getter.js +1 -1
  255. package/lib/check-page.js +348 -0
  256. package/lib/codegen.js +1 -1
  257. package/lib/data.js +1 -1
  258. package/lib/designer.js +64 -2
  259. package/lib/element-mapper.js +121 -23
  260. package/lib/element-params.generated.js +2 -2
  261. package/lib/keyframe-animation.js +1 -1
  262. package/lib/link-getter.js +1 -1
  263. package/lib/morph-node.js +8 -1
  264. package/lib/normalize-spec.js +134 -0
  265. package/lib/parse-html.js +1 -1
  266. package/lib/raster-inspect.js +1 -1
  267. package/lib/raster-ops.js +1 -1
  268. package/lib/raster-presets.js +1 -1
  269. package/lib/resolve-refs.js +121 -0
  270. package/lib/scroll-video.js +1 -1
  271. package/lib/seo.js +1 -1
  272. package/lib/stacker.js +1 -1
  273. package/lib/suggest.js +1 -1
  274. package/lib/theme.js +1 -1
  275. package/lib/transform-anim.js +1 -1
  276. package/lib/transition.js +1 -1
  277. package/lib/validate-nodes.js +111 -3
  278. package/lib/webmcp-adapter.js +1 -1
  279. package/package.json +3 -1
  280. package/schema.json +973 -12
  281. package/scripts/generate-schema.mjs +9 -1
  282. package/skills/nodality/SKILL.md +50 -0
@@ -0,0 +1,348 @@
1
+ /*!
2
+ * nodality v1.3.1
3
+ * (c) 2026 Filip Vabrousek
4
+ * License: MIT
5
+ */
6
+
7
+ /**
8
+ * Tier 1 of AGENTIC-FIRST-PLAN.md §10 — what a page actually does.
9
+ *
10
+ * Everything else in this library checks the INPUT. `validate_nodes` checks
11
+ * vocabulary, the schema says what a type accepts, the parser says what a page
12
+ * was made from. None of them can tell an agent that the page it just produced
13
+ * overflows its viewport, clips its own text, or is unreadable. The loop is
14
+ * write-only: a spec can be perfect and the page still wrong.
15
+ *
16
+ * This closes it. The checks need REAL LAYOUT — geometry, computed styles,
17
+ * stacking — which jsdom does not have, so this drives a browser through
18
+ * Playwright. Playwright is not a dependency of this package, for the same
19
+ * reason jsdom is not: it is a build-time concern and bundling it would put a
20
+ * large download into every install that never renders anything. Its absence
21
+ * arrives as a report, never as a stack trace.
22
+ *
23
+ * The report shape is deliberately the one `validate_nodes` returns —
24
+ * `{ ok, errors: [{ code, path, got, suggestions, valid }] }` — because an
25
+ * agent should parse one thing. `path` is a CSS selector rather than a spec
26
+ * path: these are facts about the rendered page, and the selector is what
27
+ * locates them in it.
28
+ *
29
+ * WHAT IT DOES NOT DO. It does not judge whether a design is good. Every check
30
+ * here is a fact that can be measured and that a reasonable page does not
31
+ * exhibit — text outside its box, a target too small to hit, a heading level
32
+ * skipped. Taste is not in scope and should not be faked.
33
+ */
34
+
35
+ /** Viewports every page is checked at unless the caller says otherwise. */
36
+ export const DEFAULT_VIEWPORTS = [
37
+ { name: "mobile", width: 390, height: 844 },
38
+ { name: "desktop", width: 1280, height: 900 },
39
+ ];
40
+
41
+ /**
42
+ * The checks, as a function evaluated inside the page. Written as one string
43
+ * so it crosses the browser boundary intact; everything it needs is in scope
44
+ * there and nothing from this module is.
45
+ */
46
+ const IN_PAGE = () => {
47
+ const out = [];
48
+
49
+ /**
50
+ * A PAGE-UNIQUE selector, so a report can be acted on.
51
+ *
52
+ * The first version was parent-relative and returned `input` for any input
53
+ * that was the only one under its own div. A form with three unlabelled
54
+ * fields produced the string "input" three times, the merge at the bottom
55
+ * of this file keyed on it, and three findings collapsed into one row
56
+ * reading `viewport: "mobile, mobile, mobile"`. The agent was told neither
57
+ * which control nor that there were three — Tier 7 watched a model fail to
58
+ * repair exactly that. A path walks to the nearest id, or to the body.
59
+ */
60
+ const sel = (el) => {
61
+ if (!el || !el.tagName) return "?";
62
+ const seg = (n) => {
63
+ const tag = n.tagName.toLowerCase();
64
+ const sibs = n.parentElement
65
+ ? [...n.parentElement.children].filter((x) => x.tagName === n.tagName) : [];
66
+ return sibs.length > 1 ? `${tag}:nth-of-type(${sibs.indexOf(n) + 1})` : tag;
67
+ };
68
+ const parts = [];
69
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
70
+ if (n.id) { parts.unshift(`#${CSS.escape(n.id)}`); break; }
71
+ parts.unshift(seg(n));
72
+ }
73
+ return parts.join(" > ") || el.tagName.toLowerCase();
74
+ };
75
+
76
+ /**
77
+ * The SPEC node an element came from, when the page carries annotation.
78
+ * A selector says where in the DOM; this says which descriptor to edit,
79
+ * which is the thing an agent repairing a spec actually needs. Free when
80
+ * the annotation is there, null when it is not — it is opt-in, and the
81
+ * attribute name is spelled out here because this function crosses into
82
+ * the browser as a string and cannot import `SPEC_ATTR`. A test pins the
83
+ * two together.
84
+ */
85
+ const nod = (el) => {
86
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
87
+ const v = n.getAttribute && n.getAttribute("data-nod");
88
+ if (v) return v;
89
+ }
90
+ return null;
91
+ };
92
+
93
+ // `target` is the element itself, so a finding can carry both. A string is
94
+ // passed for findings about the document rather than about an element.
95
+ const add = (code, target, got, detail) => {
96
+ const isEl = !!(target && target.tagName);
97
+ out.push({
98
+ code, path: isEl ? sel(target) : String(target), got, detail,
99
+ nod: isEl ? nod(target) : null,
100
+ });
101
+ };
102
+
103
+ const all = [...document.body.querySelectorAll("*")];
104
+ const vw = window.innerWidth;
105
+
106
+ // 1. The page scrolls sideways. Almost never intended, and the single most
107
+ // common way a generated layout is visibly broken on a phone.
108
+ if (document.documentElement.scrollWidth > vw + 1) {
109
+ add("HORIZONTAL_OVERFLOW", "html", document.documentElement.scrollWidth,
110
+ `the document is ${document.documentElement.scrollWidth}px wide in a ${vw}px viewport`);
111
+ }
112
+
113
+ for (const el of all) {
114
+ const r = el.getBoundingClientRect();
115
+ if (!r.width && !r.height) continue;
116
+ const cs = getComputedStyle(el);
117
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
118
+
119
+ // 2. An element sticking out past the right edge. Left/top are not
120
+ // checked: off-canvas menus and slide-in panels park there legitimately.
121
+ if (r.right > vw + 1 && cs.position !== "fixed") {
122
+ add("ELEMENT_OVERFLOWS_VIEWPORT", el, Math.round(r.right),
123
+ `extends ${Math.round(r.right - vw)}px past the ${vw}px viewport`);
124
+ }
125
+
126
+ // 3. Content taller than its own box, with the overflow hidden — the
127
+ // signature of clipped text, which reads as missing content.
128
+ if ((cs.overflow === "hidden" || cs.overflowY === "hidden") &&
129
+ el.scrollHeight > el.clientHeight + 2 && el.clientHeight > 0 &&
130
+ (el.textContent || "").trim().length > 0) {
131
+ add("CONTENT_CLIPPED", el, el.scrollHeight,
132
+ `content is ${el.scrollHeight}px in a ${el.clientHeight}px box with overflow hidden`);
133
+ }
134
+
135
+ // 4. An image nobody can describe. Covers the background-image form
136
+ // too, which is what this library's cards emit.
137
+ const tag = el.tagName.toLowerCase();
138
+ if (tag === "img" && !el.hasAttribute("alt")) {
139
+ add("IMAGE_WITHOUT_ALT", el, el.getAttribute("src") || "", "no alt attribute");
140
+ }
141
+ if (el.getAttribute("role") === "img" && !el.getAttribute("aria-label")) {
142
+ add("IMAGE_WITHOUT_ALT", el, "role=img", "role=img with no aria-label");
143
+ }
144
+
145
+ // 5. A target too small to hit. 24px is the WCAG 2.2 AA minimum.
146
+ const interactive = tag === "a" || tag === "button" ||
147
+ (tag === "input" && el.type !== "hidden") || el.getAttribute("role") === "button";
148
+ if (interactive && r.width > 0 && (r.width < 24 || r.height < 24)) {
149
+ add("TAP_TARGET_TOO_SMALL", el, `${Math.round(r.width)}x${Math.round(r.height)}`,
150
+ "below the 24x24 minimum");
151
+ }
152
+
153
+ // 6. A control with nothing to announce.
154
+ if (interactive && !(el.textContent || "").trim() &&
155
+ !el.getAttribute("aria-label") && !el.querySelector("img[alt]:not([alt=''])")) {
156
+ add("CONTROL_WITHOUT_LABEL", el, tag, "no text and no accessible name");
157
+ }
158
+ }
159
+
160
+ // 7. Heading levels that skip. A generated page is often read by machines
161
+ // before people, and the outline is the structure they read.
162
+ const heads = all.filter((e) => /^H[1-6]$/.test(e.tagName));
163
+ let prev = 0;
164
+ for (const h of heads) {
165
+ const lvl = Number(h.tagName[1]);
166
+ if (prev && lvl > prev + 1) {
167
+ add("HEADING_LEVEL_SKIPPED", h, h.tagName,
168
+ `h${prev} is followed by ${h.tagName.toLowerCase()}`);
169
+ }
170
+ prev = lvl;
171
+ }
172
+
173
+ // 8. Text nobody can read. Only where a background colour is actually
174
+ // resolvable — guessing through an image would produce false alarms,
175
+ // and a false alarm here costs more than a miss.
176
+ const lum = (c) => {
177
+ const m = c.match(/[\d.]+/g);
178
+ if (!m || m.length < 3) return null;
179
+ if (m.length > 3 && Number(m[3]) === 0) return null;
180
+ const [r, g, b] = m.slice(0, 3).map((v) => {
181
+ const s = Number(v) / 255;
182
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
183
+ });
184
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
185
+ };
186
+ const bgOf = (el) => {
187
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
188
+ const cs = getComputedStyle(n);
189
+ if (cs.backgroundImage && cs.backgroundImage !== "none") return null;
190
+ const l = lum(cs.backgroundColor);
191
+ if (l !== null) return l;
192
+ }
193
+ return lum(getComputedStyle(document.body).backgroundColor);
194
+ };
195
+ for (const el of all) {
196
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
197
+ if (!own) continue;
198
+ const cs = getComputedStyle(el);
199
+ if (cs.display === "none" || cs.visibility === "hidden") continue;
200
+ const fg = lum(cs.color), bg = bgOf(el);
201
+ if (fg === null || bg === null) continue;
202
+ const ratio = (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05);
203
+ const size = parseFloat(cs.fontSize) || 16;
204
+ const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700);
205
+ const need = large ? 3 : 4.5;
206
+ if (ratio < need) {
207
+ add("LOW_CONTRAST", el, `${ratio.toFixed(2)}:1`,
208
+ `needs ${need}:1 at ${Math.round(size)}px`);
209
+ }
210
+ }
211
+ return out;
212
+ };
213
+
214
+ /**
215
+ * How to repair each finding, in THIS library's vocabulary.
216
+ *
217
+ * `suggestions` and `valid` are in the finding shape because `validate_nodes`
218
+ * puts repairs there, and check_page left both empty on every finding it has
219
+ * ever emitted — a field that is always blank is a field an agent learns to
220
+ * ignore. Tier 7 measured the cost. Told only "54x23, below the 24x24
221
+ * minimum", the strong model guessed `keySet: {key, value}` and was right, the
222
+ * weaker one invented `keySet: {"--tap-target-size": "44px"}` and was wrong,
223
+ * and the page stayed broken through the repair turn. Naming the element was
224
+ * not enough; the report has to name the edit.
225
+ *
226
+ * Kept deliberately short. These are the repair that fits the overwhelming
227
+ * majority of cases, not a treatment of every way a page can be wrong — an
228
+ * agent that needs more has `npx nodality schema <type>`.
229
+ */
230
+ const REPAIRS = {
231
+ TAP_TARGET_TOO_SMALL: {
232
+ suggestions: [`keySet: { key: "min-height", value: "44px" }`,
233
+ `keySet: { key: "min-width", value: "44px" }`,
234
+ `pad: [{ a: 12 }]`],
235
+ valid: ["at least 24x24 CSS pixels, per WCAG 2.2 AA target size (minimum)"],
236
+ },
237
+ CONTROL_WITHOUT_LABEL: {
238
+ suggestions: [`label: "<what the field is for>" (labelInput)`,
239
+ `text: "<what the control does>" (button, a)`],
240
+ valid: ["every control needs a name assistive technology can announce; " +
241
+ "a placeholder is not one"],
242
+ },
243
+ IMAGE_WITHOUT_ALT: {
244
+ suggestions: [`alt: "<what the image shows>"`,
245
+ `alt: "" if it is decorative and repeats nearby text`],
246
+ valid: ["img takes `alt`; a background image takes it too and becomes aria-label"],
247
+ },
248
+ LOW_CONTRAST: {
249
+ suggestions: [`color: "<a darker or lighter text colour>"`,
250
+ `background: "<a background with more separation>"`],
251
+ valid: ["4.5:1 for body text, 3:1 at 24px or at 18.66px bold"],
252
+ },
253
+ HEADING_LEVEL_SKIPPED: {
254
+ suggestions: [`tag: "h<the next level down>"`],
255
+ valid: ["`tag` sets the heading level independently of the size scale, " +
256
+ "so the outline can be correct without changing the design"],
257
+ },
258
+ ELEMENT_OVERFLOWS_VIEWPORT: {
259
+ suggestions: [`maxWidth: "100%"`, `width: "100%"`, `breakWord: true`],
260
+ valid: ["nothing may extend past the right edge of the viewport"],
261
+ },
262
+ HORIZONTAL_OVERFLOW: {
263
+ suggestions: [`maxWidth: "100%" on whichever element the other findings name`],
264
+ valid: ["the document may not be wider than the viewport"],
265
+ },
266
+ CONTENT_CLIPPED: {
267
+ suggestions: [`height: "auto"`, `overflow: "visible"`],
268
+ valid: ["a box with `overflow: hidden` must be tall enough for its text"],
269
+ },
270
+ };
271
+
272
+ /**
273
+ * Check rendered HTML and return a report in the shape everything else here
274
+ * returns. `html` is a full document or a fragment; a fragment is wrapped.
275
+ */
276
+ export async function checkPage(html, opts = {}) {
277
+ const viewports = opts.viewports || DEFAULT_VIEWPORTS;
278
+ // `load` is right for a self-contained page, which is what this library
279
+ // emits. A page whose styling arrives over the network — a CDN stylesheet,
280
+ // a webfont — has not been laid out yet at `load`, and measuring it there
281
+ // reports the unstyled document. Callers checking such a page pass
282
+ // "networkidle".
283
+ const waitUntil = opts.waitUntil || "load";
284
+ let chromium;
285
+ try {
286
+ ({ chromium } = await import("playwright"));
287
+ } catch (e) {
288
+ // The same arrangement `preview` uses for jsdom: a missing build-time
289
+ // package is a report, not a crash, and it names what to install.
290
+ return {
291
+ ok: false,
292
+ errors: [{
293
+ code: "MISSING_PEER_DEPENDENCY",
294
+ path: "check_page",
295
+ got: "playwright",
296
+ suggestions: ["npm install --save-dev playwright", "npx playwright install chromium"],
297
+ valid: [],
298
+ detail: "checking a page needs real layout — geometry and computed " +
299
+ "styles — which only a browser has. The library does not bundle " +
300
+ "one: it is needed for checking, not for rendering, and bundling " +
301
+ "it would put a large download into every install.",
302
+ }],
303
+ };
304
+ }
305
+
306
+ const doc = /<html[\s>]/i.test(html)
307
+ ? html
308
+ : `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>${html}</body></html>`;
309
+
310
+ const browser = await chromium.launch();
311
+ const errors = [];
312
+ try {
313
+ for (const vp of viewports) {
314
+ const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } });
315
+ try {
316
+ await page.setContent(doc, { waitUntil });
317
+ const found = await page.evaluate(IN_PAGE);
318
+ for (const f of found) {
319
+ errors.push({
320
+ code: f.code,
321
+ path: f.path,
322
+ got: f.got,
323
+ suggestions: REPAIRS[f.code]?.suggestions ?? [],
324
+ valid: REPAIRS[f.code]?.valid ?? [],
325
+ detail: f.detail,
326
+ // The spec node, when the page was annotated. Null
327
+ // otherwise, rather than absent, so the field's
328
+ // meaning does not depend on whether it is there.
329
+ nod: f.nod ?? null,
330
+ viewport: vp.name,
331
+ });
332
+ }
333
+ } finally { await page.close(); }
334
+ }
335
+ } finally { await browser.close(); }
336
+
337
+ // The same finding at two viewports is one problem, reported once, with
338
+ // the widths it was seen at. An agent repairing a page should see a list
339
+ // of problems, not a list of measurements.
340
+ const merged = new Map();
341
+ for (const e of errors) {
342
+ const key = `${e.code}|${e.path}|${e.detail}`;
343
+ if (merged.has(key)) merged.get(key).viewport += `, ${e.viewport}`;
344
+ else merged.set(key, { ...e });
345
+ }
346
+ const list = [...merged.values()];
347
+ return { ok: list.length === 0, errors: list, viewports: viewports.map((v) => v.name) };
348
+ }
package/lib/codegen.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/data.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/designer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.2.11
2
+ * nodality v1.3.1
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -13,6 +13,8 @@ import { ElementMapper } from "../lib/element-mapper.js";
13
13
  import { applyMorphNodes } from "../lib/morph-node.js";
14
14
  import { installAgentSurface } from "../lib/webmcp-adapter.js";
15
15
  import { annotateRoundTrip } from "../lib/parse-html.js";
16
+ import { resolveRefs, collectRefs } from "../lib/resolve-refs.js";
17
+ import { normalizeSpec } from "../lib/normalize-spec.js";
16
18
  import { Animator } from "../layout/animator.js";
17
19
  import { Base } from "../layout/base.js";
18
20
  import { Text } from "../layout/text.js";
@@ -435,7 +437,33 @@ new Text("row.").set({
435
437
  // Kept for phase T: a morph node resolves `from`/`to` ids against
436
438
  // the rendered children BY POSITION, so it needs the descriptor
437
439
  // order — components do not all carry their id into the DOM.
438
- this._elements = Array.isArray(arr) ? arr : [];
440
+ // Tier 3: `{ $ref: "name" }` is expanded here, so everything
441
+ // downstream — the mapper, morph nodes resolving by position, the
442
+ // agent surface, annotation — sees ordinary elements and needs no
443
+ // knowledge of references at all. No defs, no change: `resolveRefs`
444
+ // returns its input untouched.
445
+ arr = resolveRefs(Array.isArray(arr) ? arr : [], this._defs);
446
+
447
+ // Tier 6: `src`/`href` mean `url`, `options` means `items`. Rewritten
448
+ // once, here, so the mapper and every component below it only ever see
449
+ // the canonical names and none of them needs to know an alias exists.
450
+ // Additive — a spec using the canonical names comes through unchanged.
451
+ arr = normalizeSpec(arr);
452
+
453
+ // A name with no definition is refused here, naming what is defined.
454
+ // Left alone it reaches the mapper as an element with no `type` and
455
+ // fails as `Unknown element type "undefined"`, which sends the reader
456
+ // looking for a typo in a type rather than a missing definition.
457
+ {
458
+ const dangling = collectRefs(arr).filter(
459
+ (r) => !this._defs || !Object.prototype.hasOwnProperty.call(this._defs, r.name));
460
+ if (dangling.length) {
461
+ const defined = Object.keys(this._defs || {});
462
+ throw new Error(
463
+ `[nodality] no definition for $ref "${dangling[0].name}" at ` +
464
+ `${dangling[0].path}. Defined: ${defined.length ? defined.join(", ") : "(none)"}`);
465
+ }
466
+ }
439
467
 
440
468
  this.css = [];
441
469
  this.elCSS = [];
@@ -466,6 +494,15 @@ customOptions.forEach(item => {
466
494
  ...arr.filter((e) => !e || (e.type !== "nav" && e.type !== "sideNav")),
467
495
  ];
468
496
 
497
+ // Recorded AFTER the lift, not before. Everything that consumes
498
+ // `_elements` matches it against rendered children BY POSITION —
499
+ // morph resolving `from`/`to`, and the round-trip writing each
500
+ // descriptor onto the node it produced. The array was being stored in
501
+ // the author's order while the page rendered in this one, so any spec
502
+ // with a nav after another element mapped descriptors to the wrong
503
+ // nodes. Found while wiring Tier 3, which needed the same array.
504
+ this._elements = arr;
505
+
469
506
  for (var i = 0; i < arr.length; i++) {
470
507
  let el = arr[i];
471
508
  let ela = null;
@@ -1339,6 +1376,31 @@ new Slider(texts, null, { tintColor: "#e74c3c", inactiveColor: "#ccc" })
1339
1376
  return this;
1340
1377
  }
1341
1378
 
1379
+ /**
1380
+ * Shared fragments, referenced from E as `{ $ref: "name" }`.
1381
+ *
1382
+ * Ordinary data: one object, imported by every page's entry, so the
1383
+ * sharing is a plain import rather than a new authoring mode. Worth it
1384
+ * from two pages up — at one page a reference costs more than inlining,
1385
+ * which is why nothing here is automatic.
1386
+ *
1387
+ * MUST be called before `add`. `add` renders as it goes, so definitions
1388
+ * arriving afterwards are simply too late — and the failure was
1389
+ * "Unknown element type undefined" from deep in the mapper, which says
1390
+ * nothing about the real mistake. The wrong order is now refused where it
1391
+ * happens, in the words of the thing the caller did.
1392
+ */
1393
+ defs(map) {
1394
+ if (this._elements !== undefined) {
1395
+ throw new Error(
1396
+ '[nodality] .defs() must come before .add(): add() renders as it ' +
1397
+ 'goes, so definitions given afterwards cannot be used. Chain it as ' +
1398
+ 'new Des().defs({ ... }).nodes(N).add(E)');
1399
+ }
1400
+ this._defs = (map && typeof map === "object" && !Array.isArray(map)) ? map : {};
1401
+ return this;
1402
+ }
1403
+
1342
1404
  set(obj) {
1343
1405
  // Emit-only mode: invoked from `nodality compile`. Capture the
1344
1406
  // imperative code array (the same content the `code: true` on-page