nodality 1.3.0 → 1.3.2

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 (208) hide show
  1. package/dist/animator.cjs.js.LICENSE.txt +1 -1
  2. package/dist/animator.esm.js.LICENSE.txt +1 -1
  3. package/dist/audionew.cjs.js.LICENSE.txt +1 -1
  4. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  5. package/dist/base.cjs.js.LICENSE.txt +1 -1
  6. package/dist/base.esm.js.LICENSE.txt +1 -1
  7. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
  8. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  9. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
  10. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  11. package/dist/bundle.umd.js +1 -1
  12. package/dist/bundle.umd.js.LICENSE.txt +1 -1
  13. package/dist/button.cjs.js.LICENSE.txt +1 -1
  14. package/dist/button.esm.js.LICENSE.txt +1 -1
  15. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  16. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  17. package/dist/center.cjs.js.LICENSE.txt +1 -1
  18. package/dist/center.esm.js.LICENSE.txt +1 -1
  19. package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
  20. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  21. package/dist/code.cjs.js.LICENSE.txt +1 -1
  22. package/dist/code.esm.js.LICENSE.txt +1 -1
  23. package/dist/container.cjs.js.LICENSE.txt +1 -1
  24. package/dist/container.esm.js.LICENSE.txt +1 -1
  25. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  26. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  27. package/dist/designer.cjs.js +1 -1
  28. package/dist/designer.cjs.js.LICENSE.txt +1 -1
  29. package/dist/designer.esm.js +1 -1
  30. package/dist/designer.esm.js.LICENSE.txt +1 -1
  31. package/dist/element-mapper.cjs.js +1 -1
  32. package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
  33. package/dist/element-mapper.esm.js +1 -1
  34. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  35. package/dist/finalresult.esm.js +1 -1
  36. package/dist/finalresult.esm.js.LICENSE.txt +1 -1
  37. package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
  38. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  39. package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
  40. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  41. package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
  42. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  43. package/dist/floating-input.cjs.js +1 -1
  44. package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
  45. package/dist/floating-input.esm.js +1 -1
  46. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  47. package/dist/free.cjs.js.LICENSE.txt +1 -1
  48. package/dist/free.esm.js.LICENSE.txt +1 -1
  49. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  50. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  51. package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
  52. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  53. package/dist/image.cjs.js.LICENSE.txt +1 -1
  54. package/dist/image.esm.js.LICENSE.txt +1 -1
  55. package/dist/index.cjs.js +1 -1
  56. package/dist/index.cjs.js.LICENSE.txt +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.esm.js +1 -1
  59. package/dist/index.esm.js.LICENSE.txt +1 -1
  60. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  61. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  62. package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
  63. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  64. package/dist/link.cjs.js.LICENSE.txt +1 -1
  65. package/dist/link.esm.js.LICENSE.txt +1 -1
  66. package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
  67. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  68. package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
  69. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  70. package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
  71. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  72. package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
  73. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  74. package/dist/picker.cjs.js +1 -1
  75. package/dist/picker.cjs.js.LICENSE.txt +1 -1
  76. package/dist/picker.esm.js +1 -1
  77. package/dist/picker.esm.js.LICENSE.txt +1 -1
  78. package/dist/progress.cjs.js.LICENSE.txt +1 -1
  79. package/dist/progress.esm.js.LICENSE.txt +1 -1
  80. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  81. package/dist/radio.esm.js.LICENSE.txt +1 -1
  82. package/dist/range.cjs.js.LICENSE.txt +1 -1
  83. package/dist/range.esm.js.LICENSE.txt +1 -1
  84. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  85. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  86. package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
  87. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  88. package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
  89. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  90. package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
  91. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  92. package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
  93. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  94. package/dist/spacer.cjs.js.LICENSE.txt +1 -1
  95. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  96. package/dist/stack.cjs.js.LICENSE.txt +1 -1
  97. package/dist/stack.esm.js.LICENSE.txt +1 -1
  98. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  99. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  100. package/dist/table.cjs.js.LICENSE.txt +1 -1
  101. package/dist/table.esm.js.LICENSE.txt +1 -1
  102. package/dist/text-field.cjs.js.LICENSE.txt +1 -1
  103. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  104. package/dist/text.cjs.js.LICENSE.txt +1 -1
  105. package/dist/text.esm.js.LICENSE.txt +1 -1
  106. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  107. package/dist/theme.esm.js.LICENSE.txt +1 -1
  108. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  109. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  110. package/dist/ulist.cjs.js.LICENSE.txt +1 -1
  111. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  112. package/dist/video.cjs.js.LICENSE.txt +1 -1
  113. package/dist/video.esm.js.LICENSE.txt +1 -1
  114. package/dist/wrap.cjs.js.LICENSE.txt +1 -1
  115. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  116. package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
  117. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  118. package/layout/animator.js +1 -1
  119. package/layout/audio.js +1 -1
  120. package/layout/audionew.js +1 -1
  121. package/layout/base.js +1 -1
  122. package/layout/beta-desktop-bar.js +1 -1
  123. package/layout/beta-mobile-bar.js +1 -1
  124. package/layout/button.js +1 -1
  125. package/layout/center.js +1 -1
  126. package/layout/checkbox.js +1 -1
  127. package/layout/circle.js +1 -1
  128. package/layout/code.js +1 -1
  129. package/layout/container.js +1 -1
  130. package/layout/dropdown-2025.js +1 -1
  131. package/layout/flex-card.js +1 -1
  132. package/layout/flex-grid.js +1 -1
  133. package/layout/flex-row.js +1 -1
  134. package/layout/form-components/custom.js +1 -1
  135. package/layout/form-components/data-list.js +1 -1
  136. package/layout/form-components/floating-input.js +12 -6
  137. package/layout/form-components/form-all.js +1 -1
  138. package/layout/form-components/form.js +1 -1
  139. package/layout/form-components/image-picker.js +1 -1
  140. package/layout/form-components/picker.js +37 -2
  141. package/layout/form-components/radio.js +1 -1
  142. package/layout/form-components/radiogroup.js +1 -1
  143. package/layout/form-components/range.js +1 -1
  144. package/layout/free.js +1 -1
  145. package/layout/grid-switcher.js +1 -1
  146. package/layout/grid.js +1 -1
  147. package/layout/horizontal-scroller.js +1 -1
  148. package/layout/image.js +1 -1
  149. package/layout/index.js +1 -1
  150. package/layout/link.js +1 -1
  151. package/layout/list.js +1 -1
  152. package/layout/meta-adder.js +1 -1
  153. package/layout/modal-2025.js +1 -1
  154. package/layout/morph.js +1 -1
  155. package/layout/multiswitcher.js +1 -1
  156. package/layout/nav-bar.js +1 -1
  157. package/layout/nav-factor/custom-div.js +1 -1
  158. package/layout/new-nav-bar.js +1 -1
  159. package/layout/polygon.js +1 -1
  160. package/layout/prerender-site.js +1 -1
  161. package/layout/prerender.js +1 -1
  162. package/layout/progress.js +1 -1
  163. package/layout/row.js +1 -1
  164. package/layout/scroll-video.js +1 -1
  165. package/layout/side-bar.js +1 -1
  166. package/layout/side-nav-bar.js +1 -1
  167. package/layout/simple-bar.js +1 -1
  168. package/layout/slider-2025.js +1 -1
  169. package/layout/spacer.js +1 -1
  170. package/layout/stack.js +1 -1
  171. package/layout/svg.js +1 -1
  172. package/layout/switcher.js +1 -1
  173. package/layout/table.js +1 -1
  174. package/layout/text-field.js +1 -1
  175. package/layout/text.js +1 -1
  176. package/layout/ulist.js +1 -1
  177. package/layout/video.js +1 -1
  178. package/layout/wrap.js +1 -1
  179. package/layout/zoom-card.js +1 -1
  180. package/lib/agent-surface.js +1 -1
  181. package/lib/card-getter.js +1 -1
  182. package/lib/check-page.js +173 -24
  183. package/lib/codegen.js +1 -1
  184. package/lib/data.js +1 -1
  185. package/lib/designer.js +1 -1
  186. package/lib/element-mapper.js +11 -3
  187. package/lib/element-params.generated.js +2 -2
  188. package/lib/keyframe-animation.js +1 -1
  189. package/lib/link-getter.js +1 -1
  190. package/lib/morph-node.js +1 -1
  191. package/lib/normalize-spec.js +1 -1
  192. package/lib/parse-html.js +1 -1
  193. package/lib/raster-inspect.js +1 -1
  194. package/lib/raster-ops.js +1 -1
  195. package/lib/raster-presets.js +1 -1
  196. package/lib/resolve-refs.js +1 -1
  197. package/lib/scroll-video.js +1 -1
  198. package/lib/seo.js +1 -1
  199. package/lib/stacker.js +1 -1
  200. package/lib/suggest.js +1 -1
  201. package/lib/theme.js +1 -1
  202. package/lib/transform-anim.js +1 -1
  203. package/lib/transition.js +1 -1
  204. package/lib/validate-nodes.js +52 -1
  205. package/lib/webmcp-adapter.js +71 -6
  206. package/package.json +1 -1
  207. package/schema.json +769 -0
  208. package/scripts/generate-schema.mjs +9 -1
package/lib/check-page.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -45,18 +45,85 @@ export const DEFAULT_VIEWPORTS = [
45
45
  */
46
46
  const IN_PAGE = () => {
47
47
  const out = [];
48
- const add = (code, path, got, detail) => out.push({ code, path, got, detail });
49
48
 
50
- /** A short, stable selector for a node, so a report can be acted on. */
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
+ */
51
60
  const sel = (el) => {
52
61
  if (!el || !el.tagName) return "?";
53
- if (el.id) return `#${CSS.escape(el.id)}`;
54
- const tag = el.tagName.toLowerCase();
55
- const cls = (el.getAttribute("class") || "").trim().split(/\s+/).filter(Boolean)[0];
56
- const base = cls ? `${tag}.${CSS.escape(cls)}` : tag;
57
- const sibs = el.parentElement
58
- ? [...el.parentElement.children].filter((n) => n.tagName === el.tagName) : [];
59
- return sibs.length > 1 ? `${base}:nth-of-type(${sibs.indexOf(el) + 1})` : base;
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
+ /**
104
+ * A name assistive technology can announce.
105
+ *
106
+ * Was `aria-label` alone, which is only one of the four ways to give one
107
+ * and the least used. A control correctly labelled by a `<label for>` —
108
+ * the ordinary HTML way — was reported as unnamed, and a false finding
109
+ * sends a repair at something that was never broken, which this file
110
+ * argues elsewhere is worse than a miss.
111
+ */
112
+ const hasName = (el) => {
113
+ if ((el.getAttribute("aria-label") || "").trim()) return true;
114
+ if ((el.getAttribute("title") || "").trim()) return true;
115
+ const by = (el.getAttribute("aria-labelledby") || "").trim();
116
+ if (by && by.split(/\s+/).some((id) => {
117
+ const t = document.getElementById(id);
118
+ return t && (t.textContent || "").trim();
119
+ })) return true;
120
+ if (el.id) {
121
+ const l = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
122
+ if (l && (l.textContent || "").trim()) return true;
123
+ }
124
+ const wrap = el.closest && el.closest("label");
125
+ if (wrap && (wrap.textContent || "").trim()) return true;
126
+ return false;
60
127
  };
61
128
 
62
129
  const all = [...document.body.querySelectorAll("*")];
@@ -78,7 +145,7 @@ const IN_PAGE = () => {
78
145
  // 2. An element sticking out past the right edge. Left/top are not
79
146
  // checked: off-canvas menus and slide-in panels park there legitimately.
80
147
  if (r.right > vw + 1 && cs.position !== "fixed") {
81
- add("ELEMENT_OVERFLOWS_VIEWPORT", sel(el), Math.round(r.right),
148
+ add("ELEMENT_OVERFLOWS_VIEWPORT", el, Math.round(r.right),
82
149
  `extends ${Math.round(r.right - vw)}px past the ${vw}px viewport`);
83
150
  }
84
151
 
@@ -87,7 +154,7 @@ const IN_PAGE = () => {
87
154
  if ((cs.overflow === "hidden" || cs.overflowY === "hidden") &&
88
155
  el.scrollHeight > el.clientHeight + 2 && el.clientHeight > 0 &&
89
156
  (el.textContent || "").trim().length > 0) {
90
- add("CONTENT_CLIPPED", sel(el), el.scrollHeight,
157
+ add("CONTENT_CLIPPED", el, el.scrollHeight,
91
158
  `content is ${el.scrollHeight}px in a ${el.clientHeight}px box with overflow hidden`);
92
159
  }
93
160
 
@@ -95,24 +162,38 @@ const IN_PAGE = () => {
95
162
  // too, which is what this library's cards emit.
96
163
  const tag = el.tagName.toLowerCase();
97
164
  if (tag === "img" && !el.hasAttribute("alt")) {
98
- add("IMAGE_WITHOUT_ALT", sel(el), el.getAttribute("src") || "", "no alt attribute");
165
+ add("IMAGE_WITHOUT_ALT", el, el.getAttribute("src") || "", "no alt attribute");
99
166
  }
100
167
  if (el.getAttribute("role") === "img" && !el.getAttribute("aria-label")) {
101
- add("IMAGE_WITHOUT_ALT", sel(el), "role=img", "role=img with no aria-label");
168
+ add("IMAGE_WITHOUT_ALT", el, "role=img", "role=img with no aria-label");
102
169
  }
103
170
 
104
171
  // 5. A target too small to hit. 24px is the WCAG 2.2 AA minimum.
172
+ //
173
+ // `select` and `textarea` were both missing from this set, so neither
174
+ // check below ever ran on one. A picker could not be given an
175
+ // accessible name at all — `label` reached the component and nothing
176
+ // read it — and no report said so, because the checker was not
177
+ // looking. Two blind spots that hid each other.
105
178
  const interactive = tag === "a" || tag === "button" ||
106
- (tag === "input" && el.type !== "hidden") || el.getAttribute("role") === "button";
179
+ (tag === "input" && el.type !== "hidden") ||
180
+ tag === "select" || tag === "textarea" ||
181
+ el.getAttribute("role") === "button";
107
182
  if (interactive && r.width > 0 && (r.width < 24 || r.height < 24)) {
108
- add("TAP_TARGET_TOO_SMALL", sel(el), `${Math.round(r.width)}x${Math.round(r.height)}`,
183
+ add("TAP_TARGET_TOO_SMALL", el, `${Math.round(r.width)}x${Math.round(r.height)}`,
109
184
  "below the 24x24 minimum");
110
185
  }
111
186
 
112
187
  // 6. A control with nothing to announce.
113
- if (interactive && !(el.textContent || "").trim() &&
114
- !el.getAttribute("aria-label") && !el.querySelector("img[alt]:not([alt=''])")) {
115
- add("CONTROL_WITHOUT_LABEL", sel(el), tag, "no text and no accessible name");
188
+ //
189
+ // A <select>'s own text is its OPTIONS and a <textarea>'s is its
190
+ // VALUE. Neither names the control, so the textContent shortcut that
191
+ // clears a button by its caption must not clear these.
192
+ const ownText = (tag === "select" || tag === "textarea")
193
+ ? "" : (el.textContent || "").trim();
194
+ if (interactive && !ownText && !hasName(el) &&
195
+ !el.querySelector("img[alt]:not([alt=''])")) {
196
+ add("CONTROL_WITHOUT_LABEL", el, tag, "no text and no accessible name");
116
197
  }
117
198
  }
118
199
 
@@ -123,7 +204,7 @@ const IN_PAGE = () => {
123
204
  for (const h of heads) {
124
205
  const lvl = Number(h.tagName[1]);
125
206
  if (prev && lvl > prev + 1) {
126
- add("HEADING_LEVEL_SKIPPED", sel(h), h.tagName,
207
+ add("HEADING_LEVEL_SKIPPED", h, h.tagName,
127
208
  `h${prev} is followed by ${h.tagName.toLowerCase()}`);
128
209
  }
129
210
  prev = lvl;
@@ -163,19 +244,83 @@ const IN_PAGE = () => {
163
244
  const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700);
164
245
  const need = large ? 3 : 4.5;
165
246
  if (ratio < need) {
166
- add("LOW_CONTRAST", sel(el), `${ratio.toFixed(2)}:1`,
247
+ add("LOW_CONTRAST", el, `${ratio.toFixed(2)}:1`,
167
248
  `needs ${need}:1 at ${Math.round(size)}px`);
168
249
  }
169
250
  }
170
251
  return out;
171
252
  };
172
253
 
254
+ /**
255
+ * How to repair each finding, in THIS library's vocabulary.
256
+ *
257
+ * `suggestions` and `valid` are in the finding shape because `validate_nodes`
258
+ * puts repairs there, and check_page left both empty on every finding it has
259
+ * ever emitted — a field that is always blank is a field an agent learns to
260
+ * ignore. Tier 7 measured the cost. Told only "54x23, below the 24x24
261
+ * minimum", the strong model guessed `keySet: {key, value}` and was right, the
262
+ * weaker one invented `keySet: {"--tap-target-size": "44px"}` and was wrong,
263
+ * and the page stayed broken through the repair turn. Naming the element was
264
+ * not enough; the report has to name the edit.
265
+ *
266
+ * Kept deliberately short. These are the repair that fits the overwhelming
267
+ * majority of cases, not a treatment of every way a page can be wrong — an
268
+ * agent that needs more has `npx nodality schema <type>`.
269
+ */
270
+ const REPAIRS = {
271
+ TAP_TARGET_TOO_SMALL: {
272
+ suggestions: [`keySet: { key: "min-height", value: "44px" }`,
273
+ `keySet: { key: "min-width", value: "44px" }`,
274
+ `pad: [{ a: 12 }]`],
275
+ valid: ["at least 24x24 CSS pixels, per WCAG 2.2 AA target size (minimum)"],
276
+ },
277
+ CONTROL_WITHOUT_LABEL: {
278
+ suggestions: [`label: "<what the field is for>" (input, labelInput, picker)`,
279
+ `text: "<what the control does>" (button, a)`],
280
+ valid: ["every control needs a name assistive technology can announce; " +
281
+ "a placeholder is not one"],
282
+ },
283
+ IMAGE_WITHOUT_ALT: {
284
+ suggestions: [`alt: "<what the image shows>"`,
285
+ `alt: "" if it is decorative and repeats nearby text`],
286
+ valid: ["img takes `alt`; a background image takes it too and becomes aria-label"],
287
+ },
288
+ LOW_CONTRAST: {
289
+ suggestions: [`color: "<a darker or lighter text colour>"`,
290
+ `background: "<a background with more separation>"`],
291
+ valid: ["4.5:1 for body text, 3:1 at 24px or at 18.66px bold"],
292
+ },
293
+ HEADING_LEVEL_SKIPPED: {
294
+ suggestions: [`tag: "h<the next level down>"`],
295
+ valid: ["`tag` sets the heading level independently of the size scale, " +
296
+ "so the outline can be correct without changing the design"],
297
+ },
298
+ ELEMENT_OVERFLOWS_VIEWPORT: {
299
+ suggestions: [`maxWidth: "100%"`, `width: "100%"`, `breakWord: true`],
300
+ valid: ["nothing may extend past the right edge of the viewport"],
301
+ },
302
+ HORIZONTAL_OVERFLOW: {
303
+ suggestions: [`maxWidth: "100%" on whichever element the other findings name`],
304
+ valid: ["the document may not be wider than the viewport"],
305
+ },
306
+ CONTENT_CLIPPED: {
307
+ suggestions: [`height: "auto"`, `overflow: "visible"`],
308
+ valid: ["a box with `overflow: hidden` must be tall enough for its text"],
309
+ },
310
+ };
311
+
173
312
  /**
174
313
  * Check rendered HTML and return a report in the shape everything else here
175
314
  * returns. `html` is a full document or a fragment; a fragment is wrapped.
176
315
  */
177
316
  export async function checkPage(html, opts = {}) {
178
317
  const viewports = opts.viewports || DEFAULT_VIEWPORTS;
318
+ // `load` is right for a self-contained page, which is what this library
319
+ // emits. A page whose styling arrives over the network — a CDN stylesheet,
320
+ // a webfont — has not been laid out yet at `load`, and measuring it there
321
+ // reports the unstyled document. Callers checking such a page pass
322
+ // "networkidle".
323
+ const waitUntil = opts.waitUntil || "load";
179
324
  let chromium;
180
325
  try {
181
326
  ({ chromium } = await import("playwright"));
@@ -208,16 +353,20 @@ export async function checkPage(html, opts = {}) {
208
353
  for (const vp of viewports) {
209
354
  const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height } });
210
355
  try {
211
- await page.setContent(doc, { waitUntil: "load" });
356
+ await page.setContent(doc, { waitUntil });
212
357
  const found = await page.evaluate(IN_PAGE);
213
358
  for (const f of found) {
214
359
  errors.push({
215
360
  code: f.code,
216
361
  path: f.path,
217
362
  got: f.got,
218
- suggestions: [],
219
- valid: [],
363
+ suggestions: REPAIRS[f.code]?.suggestions ?? [],
364
+ valid: REPAIRS[f.code]?.valid ?? [],
220
365
  detail: f.detail,
366
+ // The spec node, when the page was annotated. Null
367
+ // otherwise, rather than absent, so the field's
368
+ // meaning does not depend on whether it is there.
369
+ nod: f.nod ?? null,
221
370
  viewport: vp.name,
222
371
  });
223
372
  }
package/lib/codegen.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
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.3.0
2
+ * nodality v1.3.2
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.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -566,8 +566,16 @@ animation: {
566
566
  // wrote `label`. The field it produced then had no
567
567
  // accessible name at all — a placeholder is not one, since
568
568
  // it disappears the moment anyone types.
569
- ...(el.label !== undefined && el.title === undefined
570
- ? { title: el.label } : {}),
569
+ //
570
+ // `text` joined them after Tier 7. A model wrote
571
+ // `{type:"labelInput", text:"Name"}` and the field rendered
572
+ // the literal string "undefined" as its label, its
573
+ // placeholder and its accessible name, while validate_nodes
574
+ // reported ok — the worst available outcome for a spelling
575
+ // this natural. Tier 6's rule applies: take what the world
576
+ // already writes. `title` still wins, then `label`.
577
+ ...(el.title === undefined && (el.label !== undefined || el.text !== undefined)
578
+ ? { title: el.label !== undefined ? el.label : el.text } : {}),
571
579
  ...this.elOpts(el),
572
580
  }), el, obj.customOptions);
573
581
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -8,4 +8,4 @@
8
8
  // Every parameter name any element type reads, recovered from source.
9
9
  // Used for near-miss typo detection in validate-nodes.js. A drift test
10
10
  // regenerates this and fails if it differs, so it cannot rot silently.
11
- export const ELEMENT_PARAM_NAMES = ["absolute","accept","action","align","alignIts","alt","animate","animation","area","arrayMargin","arrayPadding","at","auto","backTo","background","backgroundCard","backgroundColor","backgroundOp","behaviour","blast","blastData","block","bold","border","borderObj","borderObja","borderRadius","brand","breakWord","breakpoints","cellAlign","cellPadding","center","centerColumn","centered","checkedBackgroundColor","children","clampc","class","clipPath","code","color","column","contentWidth","cursor","customAlign","customJustify","customStyle","data","diameter","disp","dropdown","em","exact","filter","filtera","fixMobileTap","fixed","flex","flexCenter","flexDir","fluid","fluidc","font","frame","ga","gap","gpos","gradient","hamColour","hamburgerColour","headStyle","height","hideField","hover","icon","id","img","index","initLetter","inline","inpad","inputType","isFull","isHidden","isNavA","italic","items","keySet","kind","label","layout","left","length","link","map","mar","margin","marginOp","maxHeight","maxWidth","mboth","method","mobile","multiple","name","new","nowrap","offcanvas","okayColor","onScroll","onTap","opacity","overflow","pad","padding","placeholder","position","positions","preffersId","radius","ranges","raster","react","removeDecoration","required","resmar","respad","responsive","resprop","rounded","scale","sequence","shadow","sides","simpleBorder","simpleCenter","size","span","sticky","stretch","stroke","style","svg","tag","tags","templateCols","text","theme","tint","title","top","transform","transition","type","url","useBrightness","value","vtn","weight","width","x","y","zIndex"];
11
+ export const ELEMENT_PARAM_NAMES = ["absolute","accept","action","align","alignIts","alignTo","aligns","alt","animate","animation","area","arrayMargin","arrayPadding","as","at","auto","backTo","background","backgroundCard","backgroundColor","backgroundOp","behaviour","blast","blastData","block","bold","border","borderObj","borderObja","borderRadius","brand","breakWord","breakpoints","cellAlign","cellPadding","center","centerColumn","centerSelf","centered","checkedBackgroundColor","child","children","clampc","class","clipPath","code","colat","color","column","contentWidth","cursor","customAlign","customJustify","customStyle","data","decoding","diameter","disp","dropdown","em","exact","fetchPriority","filter","filtera","fitContent","fixMobileTap","fixed","flex","flexCenter","flexDir","fluid","fluidc","font","frame","ga","gap","gpos","gradient","hamColour","hamburgerColour","headStyle","height","hideField","hideOverflow","hover","icon","id","img","index","initLetter","inline","inpad","inputType","isBackground","isFull","isHidden","isLink","isNavA","italic","items","justifo","justify","keySet","kind","label","layout","left","length","link","loading","map","mar","margin","marginOp","maxHeight","maxWidth","mboth","method","minHeight","mobile","multiple","name","new","nowrap","objectFit","objectPosition","offcanvas","okayColor","onScroll","onTap","opacity","overflow","owrap","pad","padding","placeholder","position","positions","preffersId","radius","ranges","raster","react","removeDecoration","required","resmar","respad","responsive","resprop","rounded","scale","sequence","shadow","sides","simpleBorder","simpleCenter","size","sizes","span","srcset","sticky","stretch","stroke","style","stype","svg","tag","tags","templateCols","text","theme","tint","title","toColumn","top","transform","transition","type","url","useBrightness","value","vtn","weight","width","wrap","x","y","zIndex"];
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/morph-node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/parse-html.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/seo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/stacker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/suggest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/theme.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/transition.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.0
2
+ * nodality v1.3.2
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -163,6 +163,15 @@ export const CONTENT_SLOT = {
163
163
  row: "children", form: "children", wrap: "children",
164
164
  };
165
165
 
166
+ // Of the types that read `items`, which read the entries as ELEMENT SPECS
167
+ // rather than as data. `ulist` maps every object entry through the mapper;
168
+ // `cards`, `nav`, `sideNav` and `table` read theirs as data — nav entries are
169
+ // `{title, link}`, table entries are rows — and walking those as elements
170
+ // would report every correct page. Pinned against the mapper by a test rather
171
+ // than trusted, because a hand-kept table beside a mapper is exactly what
172
+ // drifted before 1.2.8.
173
+ export const SPEC_ITEMS = new Set(["ulist"]);
174
+
166
175
  export function validateNodes(nodes, elements, defs) {
167
176
  const errors = [];
168
177
  const push = (code, path, got, suggestions, valid) => {
@@ -382,6 +391,48 @@ export function validateNodes(nodes, elements, defs) {
382
391
  if (Array.isArray(entry)) walkEls(entry, `${p}.items[${k}]`);
383
392
  });
384
393
  }
394
+
395
+ // Phase S7: an object entry with no `type`, in a slot whose
396
+ // entries ARE elements. `{type:"ulist", items:[{text:"Fast"}]}`
397
+ // validated clean and then threw from deep in the mapper —
398
+ // `Unknown element type "undefined"` — which is the Stage 3
399
+ // contract exactly inverted: validate-then-render handed the
400
+ // agent a stack trace in the one place the tool exists to
401
+ // prevent it.
402
+ //
403
+ // Tier 7 measured what that cost. The throw names every valid
404
+ // type and not one word about WHICH entry, so it is the same
405
+ // string for every fault of this shape on any page; the weaker
406
+ // of the two models repaired none of them and returned a
407
+ // byte-identical spec. The suggestion below therefore hands
408
+ // back the replacement rather than describing it.
409
+ //
410
+ // A string entry is the shorthand and stays valid.
411
+ if (SPEC_ITEMS.has(el.type) && Array.isArray(el.items)) {
412
+ el.items.forEach((entry, k) => {
413
+ const at = `${p}.items[${k}]`;
414
+ if (typeof entry === "string" || typeof entry === "number") return;
415
+ if (Array.isArray(entry)) return;
416
+ if (!entry || typeof entry !== "object") {
417
+ push("BAD_ELEMENT", at, entry, [],
418
+ ["a string, or { type: \"<element type>\", ... }"]);
419
+ return;
420
+ }
421
+ if (isRef(entry)) return;
422
+ if (typeof entry.type !== "string") {
423
+ const text = ["text", "title", "label"]
424
+ .map((k2) => entry[k2]).find((v) => typeof v === "string");
425
+ push("MISSING_FIELD", `${at}.type`, entry.type,
426
+ text === undefined ? []
427
+ : [JSON.stringify(text),
428
+ `{ "type": "p", "text": ${JSON.stringify(text)} }`],
429
+ [`"${el.type}" reads each item as an element; a plain string is the shorthand`]);
430
+ } else if (!ELEMENT_TYPES.includes(entry.type)) {
431
+ push("UNKNOWN_ELEMENT_TYPE", `${at}.type`, entry.type,
432
+ suggest(entry.type, ELEMENT_TYPES), ELEMENT_TYPES);
433
+ }
434
+ });
435
+ }
385
436
  });
386
437
  };
387
438
  walkEls(toWalk, "elements");