@viewlint/rules 0.0.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 (47) hide show
  1. package/README.md +5 -0
  2. package/dist/index.d.ts +41 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +69 -0
  5. package/dist/rules/clipped-content.d.ts +10 -0
  6. package/dist/rules/clipped-content.d.ts.map +1 -0
  7. package/dist/rules/clipped-content.js +222 -0
  8. package/dist/rules/container-overflow.d.ts +9 -0
  9. package/dist/rules/container-overflow.d.ts.map +1 -0
  10. package/dist/rules/container-overflow.js +185 -0
  11. package/dist/rules/corner-radius-coherence.d.ts +13 -0
  12. package/dist/rules/corner-radius-coherence.d.ts.map +1 -0
  13. package/dist/rules/corner-radius-coherence.js +171 -0
  14. package/dist/rules/hit-target-obscured.d.ts +10 -0
  15. package/dist/rules/hit-target-obscured.d.ts.map +1 -0
  16. package/dist/rules/hit-target-obscured.js +237 -0
  17. package/dist/rules/misalignment.d.ts +10 -0
  18. package/dist/rules/misalignment.d.ts.map +1 -0
  19. package/dist/rules/misalignment.js +154 -0
  20. package/dist/rules/overlapped-elements.d.ts +10 -0
  21. package/dist/rules/overlapped-elements.d.ts.map +1 -0
  22. package/dist/rules/overlapped-elements.js +252 -0
  23. package/dist/rules/space-misuse.d.ts +7 -0
  24. package/dist/rules/space-misuse.d.ts.map +1 -0
  25. package/dist/rules/space-misuse.js +204 -0
  26. package/dist/rules/text-contrast.d.ts +14 -0
  27. package/dist/rules/text-contrast.d.ts.map +1 -0
  28. package/dist/rules/text-contrast.js +210 -0
  29. package/dist/rules/text-overflow.d.ts +9 -0
  30. package/dist/rules/text-overflow.d.ts.map +1 -0
  31. package/dist/rules/text-overflow.js +86 -0
  32. package/dist/rules/text-proximity.d.ts +12 -0
  33. package/dist/rules/text-proximity.d.ts.map +1 -0
  34. package/dist/rules/text-proximity.js +115 -0
  35. package/dist/rules/text-ragged-lines.d.ts +10 -0
  36. package/dist/rules/text-ragged-lines.d.ts.map +1 -0
  37. package/dist/rules/text-ragged-lines.js +123 -0
  38. package/dist/rules/unexpected-scrollbar.d.ts +9 -0
  39. package/dist/rules/unexpected-scrollbar.d.ts.map +1 -0
  40. package/dist/rules/unexpected-scrollbar.js +77 -0
  41. package/dist/utils/domHelpers.d.ts +66 -0
  42. package/dist/utils/domHelpers.d.ts.map +1 -0
  43. package/dist/utils/domHelpers.js +548 -0
  44. package/dist/utils/getDomHelpersHandle.d.ts +4 -0
  45. package/dist/utils/getDomHelpersHandle.d.ts.map +1 -0
  46. package/dist/utils/getDomHelpersHandle.js +28 -0
  47. package/package.json +36 -0
@@ -0,0 +1,252 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects elements that overlap unintentionally within the same layout context.
5
+ * - Elements must be visible in the viewport.
6
+ * - Elements positioned absolute/fixed are not candidates.
7
+ * - Overlap is only checked when both elements share the same nearest
8
+ * absolute/fixed ancestor (or neither has one).
9
+ */
10
+ export default defineRule({
11
+ meta: {
12
+ severity: "error",
13
+ docs: {
14
+ description: "Detects elements that overlap unintentionally",
15
+ recommended: true,
16
+ },
17
+ },
18
+ async run(context) {
19
+ const domHelpers = await getDomHelpersHandle(context.page);
20
+ await context.evaluate(({ report, scope, args: { domHelpers } }) => {
21
+ const OVERLAP_THRESHOLD = 5;
22
+ const MIN_ELEMENT_SIZE = 10;
23
+ const MIN_OVERLAP_PERCENT = 5;
24
+ const MIN_THIN_OVERLAP_PX = 12;
25
+ const MAX_THIN_OVERLAP_PERCENT = 20;
26
+ const MIN_NEGATIVE_MARGIN_OVERLAP_PERCENT = 50;
27
+ const getClippingAncestors = (el) => {
28
+ const ancestors = [];
29
+ let current = el.parentElement;
30
+ while (current) {
31
+ const style = window.getComputedStyle(current);
32
+ const clipsX = domHelpers.isClippingOverflowValue(style.overflowX);
33
+ const clipsY = domHelpers.isClippingOverflowValue(style.overflowY);
34
+ if (clipsX || clipsY) {
35
+ ancestors.push({
36
+ rect: current.getBoundingClientRect(),
37
+ clipsX,
38
+ clipsY,
39
+ });
40
+ }
41
+ current = current.parentElement;
42
+ }
43
+ return ancestors;
44
+ };
45
+ const clipRectByAncestors = (rect, ancestors) => {
46
+ let left = rect.left, right = rect.right, top = rect.top, bottom = rect.bottom;
47
+ for (const ancestor of ancestors) {
48
+ if (ancestor.clipsX) {
49
+ left = Math.max(left, ancestor.rect.left);
50
+ right = Math.min(right, ancestor.rect.right);
51
+ }
52
+ if (ancestor.clipsY) {
53
+ top = Math.max(top, ancestor.rect.top);
54
+ bottom = Math.min(bottom, ancestor.rect.bottom);
55
+ }
56
+ }
57
+ const width = right - left, height = bottom - top;
58
+ if (width <= 0 || height <= 0)
59
+ return null;
60
+ return new DOMRect(left, top, width, height);
61
+ };
62
+ const getRects = (el) => {
63
+ const clippingAncestors = getClippingAncestors(el);
64
+ const results = [];
65
+ for (const rect of el.getClientRects()) {
66
+ const clipped = clipRectByAncestors(rect, clippingAncestors);
67
+ if (!clipped)
68
+ continue;
69
+ if (clipped.width < MIN_ELEMENT_SIZE ||
70
+ clipped.height < MIN_ELEMENT_SIZE)
71
+ continue;
72
+ results.push(clipped);
73
+ }
74
+ return results;
75
+ };
76
+ const getArea = (rects) => {
77
+ let total = 0;
78
+ for (const rect of rects)
79
+ total += rect.width * rect.height;
80
+ return total;
81
+ };
82
+ const isCandidate = (el) => {
83
+ if (!domHelpers.isVisibleInViewport(el))
84
+ return false;
85
+ const style = window.getComputedStyle(el);
86
+ return (style.position !== "absolute" &&
87
+ style.position !== "fixed" &&
88
+ style.position !== "sticky");
89
+ };
90
+ const hasTextDescendant = (el) => {
91
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
92
+ let node = walker.nextNode();
93
+ while (node) {
94
+ if (node.textContent && node.textContent.trim().length > 0)
95
+ return true;
96
+ node = walker.nextNode();
97
+ }
98
+ return false;
99
+ };
100
+ const isFloatWrapPair = (a, b) => {
101
+ const styleA = window.getComputedStyle(a);
102
+ const styleB = window.getComputedStyle(b);
103
+ const floatA = styleA.float !== "none";
104
+ const floatB = styleB.float !== "none";
105
+ if (floatA === floatB)
106
+ return false;
107
+ if (a.parentElement !== b.parentElement)
108
+ return false;
109
+ const otherEl = floatA ? b : a;
110
+ const otherStyle = floatA ? styleB : styleA;
111
+ if (otherStyle.position === "absolute" ||
112
+ otherStyle.position === "fixed")
113
+ return false;
114
+ if (otherStyle.float !== "none")
115
+ return false;
116
+ if (!hasTextDescendant(otherEl))
117
+ return false;
118
+ return true;
119
+ };
120
+ const findLayoutRoot = (el) => {
121
+ let current = el.parentElement;
122
+ while (current) {
123
+ const style = window.getComputedStyle(current);
124
+ if (style.position === "absolute" || style.position === "fixed")
125
+ return current;
126
+ current = current.parentElement;
127
+ }
128
+ return null;
129
+ };
130
+ const rectsOverlap = (a, b) => {
131
+ if (a.right <= b.left + OVERLAP_THRESHOLD)
132
+ return false;
133
+ if (a.left >= b.right - OVERLAP_THRESHOLD)
134
+ return false;
135
+ if (a.bottom <= b.top + OVERLAP_THRESHOLD)
136
+ return false;
137
+ if (a.top >= b.bottom - OVERLAP_THRESHOLD)
138
+ return false;
139
+ return true;
140
+ };
141
+ const candidates = [];
142
+ const candidateByElement = new Map();
143
+ for (const el of scope.queryAll("*")) {
144
+ if (!domHelpers.isHtmlElement(el))
145
+ continue;
146
+ if (!isCandidate(el))
147
+ continue;
148
+ const rects = getRects(el);
149
+ if (rects.length === 0)
150
+ continue;
151
+ const area = getArea(rects);
152
+ if (area === 0)
153
+ continue;
154
+ const candidate = {
155
+ el,
156
+ rects,
157
+ area,
158
+ layoutRoot: findLayoutRoot(el),
159
+ };
160
+ candidates.push(candidate);
161
+ candidateByElement.set(el, candidate);
162
+ }
163
+ const computeOverlapMetrics = (first, second) => {
164
+ let maxOverlapArea = 0, maxOverlapWidth = 0, maxOverlapHeight = 0;
165
+ for (const rectA of first.rects) {
166
+ for (const rectB of second.rects) {
167
+ if (!rectsOverlap(rectA, rectB))
168
+ continue;
169
+ const intersection = domHelpers.getIntersectionRect(rectA, rectB);
170
+ if (!intersection)
171
+ continue;
172
+ const area = intersection.width * intersection.height;
173
+ if (area > maxOverlapArea) {
174
+ maxOverlapArea = area;
175
+ maxOverlapWidth = intersection.width;
176
+ maxOverlapHeight = intersection.height;
177
+ }
178
+ }
179
+ }
180
+ if (maxOverlapArea === 0)
181
+ return { percent: 0, area: 0, width: 0, height: 0 };
182
+ const smallerArea = Math.min(first.area, second.area);
183
+ if (smallerArea === 0) {
184
+ return {
185
+ percent: 0,
186
+ area: maxOverlapArea,
187
+ width: maxOverlapWidth,
188
+ height: maxOverlapHeight,
189
+ };
190
+ }
191
+ return {
192
+ percent: Math.round((maxOverlapArea / smallerArea) * 100),
193
+ area: maxOverlapArea,
194
+ width: maxOverlapWidth,
195
+ height: maxOverlapHeight,
196
+ };
197
+ };
198
+ const parentOverlaps = (child, other) => {
199
+ const parentElement = child.el.parentElement;
200
+ if (!parentElement)
201
+ return false;
202
+ const parentCandidate = candidateByElement.get(parentElement);
203
+ if (!parentCandidate)
204
+ return false;
205
+ if (parentCandidate.layoutRoot !== other.layoutRoot)
206
+ return false;
207
+ if (parentCandidate.el.contains(other.el))
208
+ return false;
209
+ return (computeOverlapMetrics(parentCandidate, other).percent >=
210
+ MIN_OVERLAP_PERCENT);
211
+ };
212
+ for (let i = 0; i < candidates.length; i++) {
213
+ const a = candidates[i];
214
+ if (!a)
215
+ continue;
216
+ for (let j = i + 1; j < candidates.length; j++) {
217
+ const b = candidates[j];
218
+ if (!b)
219
+ continue;
220
+ if (a.layoutRoot !== b.layoutRoot)
221
+ continue;
222
+ if (a.el.contains(b.el) || b.el.contains(a.el))
223
+ continue;
224
+ const overlap = computeOverlapMetrics(a, b);
225
+ if (overlap.percent < MIN_OVERLAP_PERCENT)
226
+ continue;
227
+ if (parentOverlaps(a, b) || parentOverlaps(b, a))
228
+ continue;
229
+ if (isFloatWrapPair(a.el, b.el))
230
+ continue;
231
+ const isThinOverlap = (overlap.width < MIN_THIN_OVERLAP_PX ||
232
+ overlap.height < MIN_THIN_OVERLAP_PX) &&
233
+ overlap.percent < MAX_THIN_OVERLAP_PERCENT;
234
+ if (isThinOverlap)
235
+ continue;
236
+ const hasNegMargin = domHelpers.hasNegativeMargin(a.el) ||
237
+ domHelpers.hasNegativeMargin(b.el);
238
+ if (hasNegMargin &&
239
+ overlap.percent < MIN_NEGATIVE_MARGIN_OVERLAP_PERCENT)
240
+ continue;
241
+ report({
242
+ message: `Elements overlap by ${overlap.percent}% of the smaller element's area`,
243
+ element: a.el,
244
+ relations: [
245
+ { description: "Overlapping element", element: b.el },
246
+ ],
247
+ });
248
+ }
249
+ }
250
+ }, { domHelpers });
251
+ },
252
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Detects containers with irregular spacing around their content.
3
+ * Reports when empty space around content is severely asymmetric.
4
+ */
5
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
6
+ export default _default;
7
+ //# sourceMappingURL=space-misuse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"space-misuse.d.ts","sourceRoot":"","sources":["../../src/rules/space-misuse.ts"],"names":[],"mappings":"AAGA;;;GAGG;;AACH,wBA+RE"}
@@ -0,0 +1,204 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects containers with irregular spacing around their content.
5
+ * Reports when empty space around content is severely asymmetric.
6
+ */
7
+ export default defineRule({
8
+ meta: {
9
+ severity: "warn",
10
+ docs: {
11
+ description: "Detects containers with irregularly distributed empty space around content",
12
+ recommended: false,
13
+ },
14
+ },
15
+ async run(context) {
16
+ const domHelpers = await getDomHelpersHandle(context.page);
17
+ await context.evaluate(({ report, scope, args: { domHelpers } }) => {
18
+ void report;
19
+ // Thresholds
20
+ const MIN_CONTAINER_SIZE = 50, MIN_CONTENT_SIZE = 10;
21
+ const IRREGULARITY_RATIO = 6, MIN_GAP_DIFF = 40, MIN_LARGE_GAP = 60;
22
+ const SIBLING_GAP_THRESHOLD = 20, SIBLING_TOLERANCE = 10;
23
+ const LOW_FILL = 0.25, MIN_WASTED = 100, BALANCED_RATIO = 3;
24
+ const MAX_SIMPLE_CHILDREN = 3, MAX_SIMPLE_AREA = 0.3;
25
+ const DOMINANT_CHILD = 0.5, MIN_FILL = 0.2;
26
+ const getChildrenBounds = (container) => {
27
+ let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;
28
+ let hasContent = false;
29
+ for (const child of container.children) {
30
+ if (!domHelpers.isRenderableElement(child) ||
31
+ !domHelpers.isVisible(child))
32
+ continue;
33
+ const r = child.getBoundingClientRect();
34
+ if (!domHelpers.hasRectSize(r))
35
+ continue;
36
+ hasContent = true;
37
+ minL = Math.min(minL, r.left);
38
+ minT = Math.min(minT, r.top);
39
+ maxR = Math.max(maxR, r.right);
40
+ maxB = Math.max(maxB, r.bottom);
41
+ }
42
+ return hasContent
43
+ ? new DOMRect(minL, minT, maxR - minL, maxB - minT)
44
+ : null;
45
+ };
46
+ const isLeafContainer = (el) => {
47
+ const style = window.getComputedStyle(el);
48
+ if (style.display === "grid")
49
+ return false;
50
+ const rect = el.getBoundingClientRect();
51
+ if (!domHelpers.hasRectSize(rect))
52
+ return false;
53
+ let count = 0, maxRatio = 0, totalArea = 0;
54
+ const containerArea = rect.width * rect.height;
55
+ for (const child of el.children) {
56
+ if (!domHelpers.isRenderableElement(child) ||
57
+ !domHelpers.isVisible(child))
58
+ continue;
59
+ const r = child.getBoundingClientRect();
60
+ if (!domHelpers.hasRectSize(r))
61
+ continue;
62
+ count++;
63
+ const area = r.width * r.height;
64
+ totalArea += area;
65
+ if (containerArea > 0)
66
+ maxRatio = Math.max(maxRatio, area / containerArea);
67
+ }
68
+ if (count === 0)
69
+ return false;
70
+ const totalRatio = containerArea > 0 ? totalArea / containerArea : 0;
71
+ return count <= MAX_SIMPLE_CHILDREN
72
+ ? totalRatio < MAX_SIMPLE_AREA
73
+ : maxRatio >= DOMINANT_CHILD;
74
+ };
75
+ const getSiblingFilled = (container, cRect, gaps) => {
76
+ const filled = {
77
+ top: false,
78
+ right: false,
79
+ bottom: false,
80
+ left: false,
81
+ };
82
+ const parent = container.parentElement;
83
+ if (!parent)
84
+ return filled;
85
+ for (const sib of parent.children) {
86
+ if (!domHelpers.isRenderableElement(sib) ||
87
+ !(sib instanceof HTMLElement))
88
+ continue;
89
+ if (sib === container || !domHelpers.isVisible(sib))
90
+ continue;
91
+ const s = sib.getBoundingClientRect();
92
+ if (!domHelpers.hasRectSize(s))
93
+ continue;
94
+ const hOverlap = s.left < cRect.right && s.right > cRect.left;
95
+ const vOverlap = s.top < cRect.bottom && s.bottom > cRect.top;
96
+ if (gaps.top > SIBLING_GAP_THRESHOLD &&
97
+ hOverlap &&
98
+ s.bottom <= cRect.top + SIBLING_TOLERANCE &&
99
+ s.bottom >= cRect.top - gaps.top) {
100
+ filled.top = true;
101
+ }
102
+ if (gaps.bottom > SIBLING_GAP_THRESHOLD &&
103
+ hOverlap &&
104
+ s.top >= cRect.bottom - SIBLING_TOLERANCE &&
105
+ s.top <= cRect.bottom + gaps.bottom) {
106
+ filled.bottom = true;
107
+ }
108
+ if (gaps.left > SIBLING_GAP_THRESHOLD &&
109
+ vOverlap &&
110
+ s.right <= cRect.left + SIBLING_TOLERANCE &&
111
+ s.right >= cRect.left - gaps.left) {
112
+ filled.left = true;
113
+ }
114
+ if (gaps.right > SIBLING_GAP_THRESHOLD &&
115
+ vOverlap &&
116
+ s.left >= cRect.right - SIBLING_TOLERANCE &&
117
+ s.left <= cRect.right + gaps.right) {
118
+ filled.right = true;
119
+ }
120
+ }
121
+ return filled;
122
+ };
123
+ const checkAxis = (gapA, gapB, sideA, sideB, filledA, filledB) => {
124
+ const min = Math.min(gapA, gapB), max = Math.max(gapA, gapB);
125
+ if (max < MIN_LARGE_GAP || max - min < MIN_GAP_DIFF)
126
+ return null;
127
+ const largeSide = gapA > gapB ? sideA : sideB;
128
+ if (gapA > gapB ? filledA : filledB)
129
+ return null;
130
+ if (min <= 2 && max >= MIN_LARGE_GAP) {
131
+ const flush = gapA <= 2 ? sideA : sideB;
132
+ const opp = gapA <= 2 ? sideB : sideA;
133
+ return `content is flush with ${flush} edge, ${Math.round(max)}px gap on ${opp}`;
134
+ }
135
+ if (min > 0) {
136
+ const ratio = max / min;
137
+ if (ratio < IRREGULARITY_RATIO)
138
+ return null;
139
+ const smallSide = gapA > gapB ? sideB : sideA;
140
+ return `${largeSide} gap (${Math.round(max)}px) is ${ratio.toFixed(1)}x larger than ${smallSide} (${Math.round(min)}px)`;
141
+ }
142
+ return null;
143
+ };
144
+ const checkPadding = (fill, gapA, gapB, dim) => {
145
+ if (fill >= LOW_FILL)
146
+ return null;
147
+ const total = gapA + gapB;
148
+ if (total < MIN_WASTED)
149
+ return null;
150
+ const min = Math.min(gapA, gapB), max = Math.max(gapA, gapB);
151
+ if (min > 0 && max / min > BALANCED_RATIO)
152
+ return null;
153
+ return `content fills only ${Math.round(fill * 100)}% of ${dim} space (${Math.round(total)}px unused)`;
154
+ };
155
+ const analyze = (container, cRect, content) => {
156
+ const fillH = content.width / cRect.width;
157
+ const fillV = content.height / cRect.height;
158
+ if (fillH < MIN_FILL && fillV < MIN_FILL)
159
+ return null;
160
+ const gaps = {
161
+ top: content.top - cRect.top,
162
+ right: cRect.right - content.right,
163
+ bottom: cRect.bottom - content.bottom,
164
+ left: content.left - cRect.left,
165
+ };
166
+ const filled = getSiblingFilled(container, cRect, gaps);
167
+ const issues = [];
168
+ const hIssue = checkAxis(gaps.left, gaps.right, "left", "right", filled.left, filled.right);
169
+ if (hIssue)
170
+ issues.push(hIssue);
171
+ const vIssue = checkAxis(gaps.top, gaps.bottom, "top", "bottom", filled.top, filled.bottom);
172
+ if (vIssue)
173
+ issues.push(vIssue);
174
+ const hPad = checkPadding(fillH, gaps.left, gaps.right, "horizontal");
175
+ if (hPad && !hIssue)
176
+ issues.push(hPad);
177
+ const vPad = checkPadding(fillV, gaps.top, gaps.bottom, "vertical");
178
+ if (vPad && !vIssue)
179
+ issues.push(vPad);
180
+ return issues.length > 0
181
+ ? `Irregular spacing: ${issues.join("; ")}`
182
+ : null;
183
+ };
184
+ for (const el of scope.queryAll("*")) {
185
+ if (!domHelpers.isHtmlElement(el) || !domHelpers.isVisible(el))
186
+ continue;
187
+ if (el.children.length === 0)
188
+ continue;
189
+ const cRect = el.getBoundingClientRect();
190
+ if (!domHelpers.hasRectSize(cRect, MIN_CONTAINER_SIZE, MIN_CONTAINER_SIZE))
191
+ continue;
192
+ if (!isLeafContainer(el))
193
+ continue;
194
+ const content = getChildrenBounds(el);
195
+ if (!content ||
196
+ !domHelpers.hasRectSize(content, MIN_CONTENT_SIZE, MIN_CONTENT_SIZE))
197
+ continue;
198
+ const msg = analyze(el, cRect, content);
199
+ if (msg)
200
+ report({ message: msg, element: el });
201
+ }
202
+ }, { domHelpers });
203
+ },
204
+ });
@@ -0,0 +1,14 @@
1
+ declare global {
2
+ interface Window {
3
+ __viewlint_finder?: (el: Element) => string;
4
+ }
5
+ }
6
+ /**
7
+ * Detects text with low contrast against its background.
8
+ *
9
+ * Uses screenshot-based analysis to sample the actual rendered background
10
+ * color behind text elements, then calculates WCAG contrast ratio.
11
+ */
12
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
13
+ export default _default;
14
+ //# sourceMappingURL=text-contrast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-contrast.d.ts","sourceRoot":"","sources":["../../src/rules/text-contrast.ts"],"names":[],"mappings":"AAIA,OAAO,CAAC,MAAM,CAAC;IACd,UAAU,MAAM;QACf,iBAAiB,CAAC,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,MAAM,CAAA;KAC3C;CACD;AAED;;;;;GAKG;;AACH,wBAkRE"}