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.
- package/bin/mcp-server.mjs +45 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +1 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +1 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +1 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +1 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +1 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/layout/animator.js +15 -2
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +1 -1
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +1 -1
- package/layout/circle.js +1 -1
- package/layout/code.js +1 -1
- package/layout/container.js +1 -1
- package/layout/dropdown-2025.js +10 -3
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +1 -1
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +1 -1
- package/layout/form-components/floating-input.js +23 -4
- package/layout/form-components/form-all.js +1 -1
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +5 -3
- package/layout/form-components/picker.js +1 -1
- package/layout/form-components/radio.js +1 -1
- package/layout/form-components/radiogroup.js +1 -1
- package/layout/form-components/range.js +1 -1
- package/layout/free.js +1 -1
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +15 -2
- package/layout/index.js +1 -1
- package/layout/link.js +1 -1
- package/layout/list.js +1 -1
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1 -1
- package/layout/multiswitcher.js +1 -1
- package/layout/nav-bar.js +1 -1
- package/layout/nav-factor/custom-div.js +1 -1
- package/layout/new-nav-bar.js +1 -1
- package/layout/polygon.js +1 -1
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +11 -3
- package/layout/side-nav-bar.js +16 -3
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +1 -1
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +16 -1
- package/layout/text.js +28 -1
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/agent-surface.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/check-page.js +348 -0
- package/lib/codegen.js +1 -1
- package/lib/data.js +1 -1
- package/lib/designer.js +64 -2
- package/lib/element-mapper.js +121 -23
- package/lib/element-params.generated.js +2 -2
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +8 -1
- package/lib/normalize-spec.js +134 -0
- package/lib/parse-html.js +1 -1
- package/lib/raster-inspect.js +1 -1
- package/lib/raster-ops.js +1 -1
- package/lib/raster-presets.js +1 -1
- package/lib/resolve-refs.js +121 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +1 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +1 -1
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +1 -1
- package/lib/validate-nodes.js +111 -3
- package/lib/webmcp-adapter.js +1 -1
- package/package.json +3 -1
- package/schema.json +973 -12
- package/scripts/generate-schema.mjs +9 -1
- 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
package/lib/data.js
CHANGED
package/lib/designer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.
|
|
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
|
-
|
|
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
|