@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.
- package/README.md +5 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/rules/clipped-content.d.ts +10 -0
- package/dist/rules/clipped-content.d.ts.map +1 -0
- package/dist/rules/clipped-content.js +222 -0
- package/dist/rules/container-overflow.d.ts +9 -0
- package/dist/rules/container-overflow.d.ts.map +1 -0
- package/dist/rules/container-overflow.js +185 -0
- package/dist/rules/corner-radius-coherence.d.ts +13 -0
- package/dist/rules/corner-radius-coherence.d.ts.map +1 -0
- package/dist/rules/corner-radius-coherence.js +171 -0
- package/dist/rules/hit-target-obscured.d.ts +10 -0
- package/dist/rules/hit-target-obscured.d.ts.map +1 -0
- package/dist/rules/hit-target-obscured.js +237 -0
- package/dist/rules/misalignment.d.ts +10 -0
- package/dist/rules/misalignment.d.ts.map +1 -0
- package/dist/rules/misalignment.js +154 -0
- package/dist/rules/overlapped-elements.d.ts +10 -0
- package/dist/rules/overlapped-elements.d.ts.map +1 -0
- package/dist/rules/overlapped-elements.js +252 -0
- package/dist/rules/space-misuse.d.ts +7 -0
- package/dist/rules/space-misuse.d.ts.map +1 -0
- package/dist/rules/space-misuse.js +204 -0
- package/dist/rules/text-contrast.d.ts +14 -0
- package/dist/rules/text-contrast.d.ts.map +1 -0
- package/dist/rules/text-contrast.js +210 -0
- package/dist/rules/text-overflow.d.ts +9 -0
- package/dist/rules/text-overflow.d.ts.map +1 -0
- package/dist/rules/text-overflow.js +86 -0
- package/dist/rules/text-proximity.d.ts +12 -0
- package/dist/rules/text-proximity.d.ts.map +1 -0
- package/dist/rules/text-proximity.js +115 -0
- package/dist/rules/text-ragged-lines.d.ts +10 -0
- package/dist/rules/text-ragged-lines.d.ts.map +1 -0
- package/dist/rules/text-ragged-lines.js +123 -0
- package/dist/rules/unexpected-scrollbar.d.ts +9 -0
- package/dist/rules/unexpected-scrollbar.d.ts.map +1 -0
- package/dist/rules/unexpected-scrollbar.js +77 -0
- package/dist/utils/domHelpers.d.ts +66 -0
- package/dist/utils/domHelpers.d.ts.map +1 -0
- package/dist/utils/domHelpers.js +548 -0
- package/dist/utils/getDomHelpersHandle.d.ts +4 -0
- package/dist/utils/getDomHelpersHandle.d.ts.map +1 -0
- package/dist/utils/getDomHelpersHandle.js +28 -0
- package/package.json +36 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { defineRule } from "viewlint/plugin";
|
|
2
|
+
import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects child elements whose corner radius violates the corner radius law.
|
|
5
|
+
*
|
|
6
|
+
* The corner radius law states that for nested rounded corners to look visually
|
|
7
|
+
* coherent, the child's radius should equal `parent_radius - inset`, where inset
|
|
8
|
+
* is the distance between the parent's inner edge and the child's outer edge.
|
|
9
|
+
*
|
|
10
|
+
* Only reports when the inset is small enough relative to the parent's radius
|
|
11
|
+
* that the relationship matters visually (inset <= 0.5 * parent_radius).
|
|
12
|
+
*/
|
|
13
|
+
export default defineRule({
|
|
14
|
+
meta: {
|
|
15
|
+
severity: "warn",
|
|
16
|
+
docs: {
|
|
17
|
+
description: "Detects child elements with corner radius that violates the corner radius law for nested rounded corners",
|
|
18
|
+
recommended: false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
async run(context) {
|
|
22
|
+
const domHelpers = await getDomHelpersHandle(context.page);
|
|
23
|
+
await context.evaluate(({ report, scope, args: { domHelpers } }) => {
|
|
24
|
+
const TOLERANCE = 2;
|
|
25
|
+
const parseRadius = (value) => {
|
|
26
|
+
const parsed = parseFloat(value);
|
|
27
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Gets the effective corner radius for an element.
|
|
31
|
+
* Returns an object with radii for each corner.
|
|
32
|
+
*/
|
|
33
|
+
const getCornerRadii = (style) => {
|
|
34
|
+
return {
|
|
35
|
+
topLeft: parseRadius(style.borderTopLeftRadius),
|
|
36
|
+
topRight: parseRadius(style.borderTopRightRadius),
|
|
37
|
+
bottomRight: parseRadius(style.borderBottomRightRadius),
|
|
38
|
+
bottomLeft: parseRadius(style.borderBottomLeftRadius),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Calculates inset for each corner (distance from parent inner edge to child outer edge).
|
|
43
|
+
*/
|
|
44
|
+
const getCornerInsets = (parentRect, childRect) => {
|
|
45
|
+
const topInset = childRect.top - parentRect.top;
|
|
46
|
+
const rightInset = parentRect.right - childRect.right;
|
|
47
|
+
const bottomInset = parentRect.bottom - childRect.bottom;
|
|
48
|
+
const leftInset = childRect.left - parentRect.left;
|
|
49
|
+
return {
|
|
50
|
+
topLeft: Math.min(topInset, leftInset),
|
|
51
|
+
topRight: Math.min(topInset, rightInset),
|
|
52
|
+
bottomRight: Math.min(bottomInset, rightInset),
|
|
53
|
+
bottomLeft: Math.min(bottomInset, leftInset),
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
const hasRoundedCorners = (radii) => {
|
|
57
|
+
return (radii.topLeft > 0 ||
|
|
58
|
+
radii.topRight > 0 ||
|
|
59
|
+
radii.bottomRight > 0 ||
|
|
60
|
+
radii.bottomLeft > 0);
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Checks if the element has visible styling that would make corner radius visible.
|
|
64
|
+
* An element needs a border, background, or box-shadow for its corners to be seen.
|
|
65
|
+
*/
|
|
66
|
+
const hasVisibleCorners = (style) => {
|
|
67
|
+
// Check for visible border on any side
|
|
68
|
+
const borderWidths = [
|
|
69
|
+
parseFloat(style.borderTopWidth) || 0,
|
|
70
|
+
parseFloat(style.borderRightWidth) || 0,
|
|
71
|
+
parseFloat(style.borderBottomWidth) || 0,
|
|
72
|
+
parseFloat(style.borderLeftWidth) || 0,
|
|
73
|
+
];
|
|
74
|
+
const hasVisibleBorder = borderWidths.some((w) => w > 0);
|
|
75
|
+
if (hasVisibleBorder)
|
|
76
|
+
return true;
|
|
77
|
+
// Check for visible background color (not transparent)
|
|
78
|
+
const bgColor = style.backgroundColor;
|
|
79
|
+
if (bgColor &&
|
|
80
|
+
bgColor !== "transparent" &&
|
|
81
|
+
bgColor !== "rgba(0, 0, 0, 0)") {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// Check for background image
|
|
85
|
+
const bgImage = style.backgroundImage;
|
|
86
|
+
if (bgImage && bgImage !== "none") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
// Check for box shadow
|
|
90
|
+
const boxShadow = style.boxShadow;
|
|
91
|
+
if (boxShadow && boxShadow !== "none") {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
// Check for outline (although not affected by border-radius, can indicate intent)
|
|
95
|
+
const outlineWidth = parseFloat(style.outlineWidth) || 0;
|
|
96
|
+
if (outlineWidth > 0) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
};
|
|
101
|
+
const allElements = scope.queryAll("*");
|
|
102
|
+
for (const el of allElements) {
|
|
103
|
+
if (!domHelpers.isHtmlElement(el))
|
|
104
|
+
continue;
|
|
105
|
+
if (!domHelpers.isVisible(el))
|
|
106
|
+
continue;
|
|
107
|
+
if (!domHelpers.hasElementRectSize(el))
|
|
108
|
+
continue;
|
|
109
|
+
const parent = el.parentElement;
|
|
110
|
+
if (!domHelpers.isHtmlElement(parent))
|
|
111
|
+
continue;
|
|
112
|
+
if (!domHelpers.isVisible(parent))
|
|
113
|
+
continue;
|
|
114
|
+
if (!domHelpers.hasElementRectSize(parent))
|
|
115
|
+
continue;
|
|
116
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
117
|
+
const parentRadii = getCornerRadii(parentStyle);
|
|
118
|
+
if (!hasRoundedCorners(parentRadii))
|
|
119
|
+
continue;
|
|
120
|
+
const childStyle = window.getComputedStyle(el);
|
|
121
|
+
// Skip elements without visible styling that would show corner radius
|
|
122
|
+
// (no border, background, or box-shadow = invisible corners)
|
|
123
|
+
if (!hasVisibleCorners(childStyle))
|
|
124
|
+
continue;
|
|
125
|
+
const childRadii = getCornerRadii(childStyle);
|
|
126
|
+
const parentRect = parent.getBoundingClientRect();
|
|
127
|
+
const childRect = el.getBoundingClientRect();
|
|
128
|
+
const insets = getCornerInsets(parentRect, childRect);
|
|
129
|
+
const corners = [
|
|
130
|
+
"topLeft",
|
|
131
|
+
"topRight",
|
|
132
|
+
"bottomRight",
|
|
133
|
+
"bottomLeft",
|
|
134
|
+
];
|
|
135
|
+
const violations = [];
|
|
136
|
+
for (const corner of corners) {
|
|
137
|
+
const parentRadius = parentRadii[corner];
|
|
138
|
+
const childRadius = childRadii[corner];
|
|
139
|
+
const inset = insets[corner];
|
|
140
|
+
if (parentRadius <= 0)
|
|
141
|
+
continue;
|
|
142
|
+
if (inset > 0.5 * parentRadius)
|
|
143
|
+
continue;
|
|
144
|
+
if (inset < 0)
|
|
145
|
+
continue;
|
|
146
|
+
const expectedChildRadius = Math.max(0, parentRadius - inset);
|
|
147
|
+
const difference = Math.abs(childRadius - expectedChildRadius);
|
|
148
|
+
if (difference <= TOLERANCE)
|
|
149
|
+
continue;
|
|
150
|
+
const cornerLabel = corner
|
|
151
|
+
.replace(/([A-Z])/g, "-$1")
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.replace(/^-/, "");
|
|
154
|
+
violations.push(`${cornerLabel}: expected ~${Math.round(expectedChildRadius)}px, found ${Math.round(childRadius)}px`);
|
|
155
|
+
}
|
|
156
|
+
if (violations.length > 0) {
|
|
157
|
+
report({
|
|
158
|
+
message: `Corner radius violates nesting law: ${violations.join("; ")}`,
|
|
159
|
+
element: el,
|
|
160
|
+
relations: [
|
|
161
|
+
{
|
|
162
|
+
description: "Parent with rounded corners",
|
|
163
|
+
element: parent,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, { domHelpers });
|
|
170
|
+
},
|
|
171
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects interactive elements that are obscured by other elements,
|
|
3
|
+
* making them unclickable or partially unclickable.
|
|
4
|
+
*
|
|
5
|
+
* Uses elementFromPoint to sample click positions and verify the element
|
|
6
|
+
* or its descendants receive the click.
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
9
|
+
export default _default;
|
|
10
|
+
//# sourceMappingURL=hit-target-obscured.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hit-target-obscured.d.ts","sourceRoot":"","sources":["../../src/rules/hit-target-obscured.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;;AACH,wBAiRE"}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { defineRule } from "viewlint/plugin";
|
|
2
|
+
import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects interactive elements that are obscured by other elements,
|
|
5
|
+
* making them unclickable or partially unclickable.
|
|
6
|
+
*
|
|
7
|
+
* Uses elementFromPoint to sample click positions and verify the element
|
|
8
|
+
* or its descendants receive the click.
|
|
9
|
+
*/
|
|
10
|
+
export default defineRule({
|
|
11
|
+
meta: {
|
|
12
|
+
severity: "error",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Detects clickable elements that are obscured by other elements",
|
|
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 isLabelControlPair = (label, candidate) => {
|
|
22
|
+
if (!(label instanceof HTMLLabelElement))
|
|
23
|
+
return false;
|
|
24
|
+
if (!candidate)
|
|
25
|
+
return false;
|
|
26
|
+
const control = label.control;
|
|
27
|
+
if (!control)
|
|
28
|
+
return false;
|
|
29
|
+
return control === candidate || control.contains(candidate);
|
|
30
|
+
};
|
|
31
|
+
const parseAlphaFromRgba = (value) => {
|
|
32
|
+
const match = value
|
|
33
|
+
.trim()
|
|
34
|
+
.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)$/i);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
const parsed = Number.parseFloat(match[1] ?? "");
|
|
38
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
39
|
+
};
|
|
40
|
+
const isEffectivelyInvisibleOverlay = (el) => {
|
|
41
|
+
if (!domHelpers.isVisible(el))
|
|
42
|
+
return true;
|
|
43
|
+
const style = window.getComputedStyle(el);
|
|
44
|
+
const opacity = Number.parseFloat(style.opacity);
|
|
45
|
+
if (Number.isFinite(opacity) && opacity === 0)
|
|
46
|
+
return true;
|
|
47
|
+
const tag = el.tagName.toLowerCase();
|
|
48
|
+
const isFormControl = tag === "input" ||
|
|
49
|
+
tag === "select" ||
|
|
50
|
+
tag === "textarea" ||
|
|
51
|
+
tag === "button";
|
|
52
|
+
if (isFormControl)
|
|
53
|
+
return false;
|
|
54
|
+
const bg = style.backgroundColor;
|
|
55
|
+
const bgAlpha = bg === "transparent" ? 0 : (parseAlphaFromRgba(bg) ?? 1);
|
|
56
|
+
const hasOpaqueBackground = bgAlpha > 0;
|
|
57
|
+
const hasBackgroundImage = style.backgroundImage !== "none";
|
|
58
|
+
const hasShadow = style.boxShadow !== "none";
|
|
59
|
+
const hasOutline = Number.parseFloat(style.outlineWidth) > 0;
|
|
60
|
+
const borderWidths = [
|
|
61
|
+
Number.parseFloat(style.borderTopWidth) || 0,
|
|
62
|
+
Number.parseFloat(style.borderRightWidth) || 0,
|
|
63
|
+
Number.parseFloat(style.borderBottomWidth) || 0,
|
|
64
|
+
Number.parseFloat(style.borderLeftWidth) || 0,
|
|
65
|
+
];
|
|
66
|
+
const hasBorder = borderWidths.some((w) => w > 0);
|
|
67
|
+
const hasVisibleFillOrStroke = hasOpaqueBackground ||
|
|
68
|
+
hasBackgroundImage ||
|
|
69
|
+
hasBorder ||
|
|
70
|
+
hasShadow ||
|
|
71
|
+
hasOutline;
|
|
72
|
+
if (hasVisibleFillOrStroke)
|
|
73
|
+
return false;
|
|
74
|
+
const hasText = el.innerText.trim().length > 0;
|
|
75
|
+
if (hasText)
|
|
76
|
+
return false;
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
const isInteractive = (el) => {
|
|
80
|
+
const tagName = el.tagName.toLowerCase();
|
|
81
|
+
const interactiveTags = [
|
|
82
|
+
"a",
|
|
83
|
+
"button",
|
|
84
|
+
"input",
|
|
85
|
+
"select",
|
|
86
|
+
"textarea",
|
|
87
|
+
"label",
|
|
88
|
+
];
|
|
89
|
+
if (interactiveTags.includes(tagName))
|
|
90
|
+
return true;
|
|
91
|
+
if (el.hasAttribute("onclick"))
|
|
92
|
+
return true;
|
|
93
|
+
if (el.hasAttribute("tabindex")) {
|
|
94
|
+
const tabindex = el.getAttribute("tabindex");
|
|
95
|
+
if (tabindex && parseInt(tabindex, 10) >= 0)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const role = el.getAttribute("role");
|
|
99
|
+
if (role === "button" || role === "link" || role === "menuitem") {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
104
|
+
const isDisabled = (el) => {
|
|
105
|
+
if (el.hasAttribute("disabled"))
|
|
106
|
+
return true;
|
|
107
|
+
if (el.getAttribute("aria-disabled") === "true")
|
|
108
|
+
return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
const getRect = (el) => {
|
|
112
|
+
const rect = el.getBoundingClientRect();
|
|
113
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
114
|
+
return null;
|
|
115
|
+
return rect;
|
|
116
|
+
};
|
|
117
|
+
const isInViewport = (rect) => {
|
|
118
|
+
return (rect.bottom > 0 &&
|
|
119
|
+
rect.right > 0 &&
|
|
120
|
+
rect.top < window.innerHeight &&
|
|
121
|
+
rect.left < window.innerWidth);
|
|
122
|
+
};
|
|
123
|
+
const getSamplePoints = (rect) => {
|
|
124
|
+
const points = [];
|
|
125
|
+
const padding = 2;
|
|
126
|
+
const left = rect.left + padding;
|
|
127
|
+
const right = rect.right - padding;
|
|
128
|
+
const top = rect.top + padding;
|
|
129
|
+
const bottom = rect.bottom - padding;
|
|
130
|
+
if (left >= right || top >= bottom) {
|
|
131
|
+
points.push({
|
|
132
|
+
x: rect.left + rect.width / 2,
|
|
133
|
+
y: rect.top + rect.height / 2,
|
|
134
|
+
});
|
|
135
|
+
return points;
|
|
136
|
+
}
|
|
137
|
+
const centerX = (left + right) / 2;
|
|
138
|
+
const centerY = (top + bottom) / 2;
|
|
139
|
+
points.push({ x: centerX, y: centerY });
|
|
140
|
+
points.push({ x: left, y: top });
|
|
141
|
+
points.push({ x: right, y: top });
|
|
142
|
+
points.push({ x: left, y: bottom });
|
|
143
|
+
points.push({ x: right, y: bottom });
|
|
144
|
+
points.push({ x: centerX, y: top });
|
|
145
|
+
points.push({ x: centerX, y: bottom });
|
|
146
|
+
points.push({ x: left, y: centerY });
|
|
147
|
+
points.push({ x: right, y: centerY });
|
|
148
|
+
return points.filter((p) => p.x >= 0 &&
|
|
149
|
+
p.y >= 0 &&
|
|
150
|
+
p.x < window.innerWidth &&
|
|
151
|
+
p.y < window.innerHeight);
|
|
152
|
+
};
|
|
153
|
+
const isElementOrDescendant = (target, elementAtPoint) => {
|
|
154
|
+
if (!elementAtPoint)
|
|
155
|
+
return false;
|
|
156
|
+
if (target === elementAtPoint)
|
|
157
|
+
return true;
|
|
158
|
+
if (target.contains(elementAtPoint))
|
|
159
|
+
return true;
|
|
160
|
+
if (elementAtPoint.contains(target))
|
|
161
|
+
return true;
|
|
162
|
+
return false;
|
|
163
|
+
};
|
|
164
|
+
const allElements = scope.queryAll("*");
|
|
165
|
+
for (const el of allElements) {
|
|
166
|
+
if (!domHelpers.isHtmlElement(el))
|
|
167
|
+
continue;
|
|
168
|
+
if (!isInteractive(el))
|
|
169
|
+
continue;
|
|
170
|
+
if (!domHelpers.isVisible(el, { checkPointerEvents: true }))
|
|
171
|
+
continue;
|
|
172
|
+
if (isDisabled(el))
|
|
173
|
+
continue;
|
|
174
|
+
const clippingAncestor = domHelpers.findIntentionallyClippedAncestor(el);
|
|
175
|
+
if (clippingAncestor &&
|
|
176
|
+
domHelpers.isElementClippedBy(el, clippingAncestor)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const rect = getRect(el);
|
|
180
|
+
if (!rect)
|
|
181
|
+
continue;
|
|
182
|
+
if (!isInViewport(rect))
|
|
183
|
+
continue;
|
|
184
|
+
const samplePoints = getSamplePoints(rect);
|
|
185
|
+
if (samplePoints.length === 0)
|
|
186
|
+
continue;
|
|
187
|
+
let obscuredCount = 0;
|
|
188
|
+
let totalChecked = 0;
|
|
189
|
+
let obscuringElement = null;
|
|
190
|
+
for (const point of samplePoints) {
|
|
191
|
+
const elementAtPoint = document.elementFromPoint(point.x, point.y);
|
|
192
|
+
totalChecked++;
|
|
193
|
+
if (domHelpers.isHtmlElement(elementAtPoint)) {
|
|
194
|
+
if (isLabelControlPair(el, elementAtPoint)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (isEffectivelyInvisibleOverlay(elementAtPoint)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!isElementOrDescendant(el, elementAtPoint)) {
|
|
202
|
+
obscuredCount++;
|
|
203
|
+
if (!obscuringElement &&
|
|
204
|
+
elementAtPoint &&
|
|
205
|
+
domHelpers.isHtmlElement(elementAtPoint)) {
|
|
206
|
+
obscuringElement = elementAtPoint;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (obscuredCount > 0 && totalChecked > 0) {
|
|
211
|
+
const obscuredRatio = obscuredCount / totalChecked;
|
|
212
|
+
if (obscuredRatio >= 0.5) {
|
|
213
|
+
const percentage = Math.round(obscuredRatio * 100);
|
|
214
|
+
if (obscuringElement) {
|
|
215
|
+
report({
|
|
216
|
+
message: `Interactive element is ~${percentage}% obscured and may not be clickable`,
|
|
217
|
+
element: el,
|
|
218
|
+
relations: [
|
|
219
|
+
{
|
|
220
|
+
description: "Obscuring element",
|
|
221
|
+
element: obscuringElement,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
report({
|
|
228
|
+
message: `Interactive element is ~${percentage}% obscured and may not be clickable`,
|
|
229
|
+
element: el,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}, { domHelpers });
|
|
236
|
+
},
|
|
237
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects elements that appear to be intended for alignment but are slightly misaligned.
|
|
3
|
+
*
|
|
4
|
+
* Finds the closest alignment edge between siblings and reports only if that edge
|
|
5
|
+
* is in the "mistake range" - close enough that it looks like an alignment attempt
|
|
6
|
+
* but not quite aligned perfectly.
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
9
|
+
export default _default;
|
|
10
|
+
//# sourceMappingURL=misalignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"misalignment.d.ts","sourceRoot":"","sources":["../../src/rules/misalignment.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;;AACH,wBA6LE"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { defineRule } from "viewlint/plugin";
|
|
2
|
+
import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Detects elements that appear to be intended for alignment but are slightly misaligned.
|
|
5
|
+
*
|
|
6
|
+
* Finds the closest alignment edge between siblings and reports only if that edge
|
|
7
|
+
* is in the "mistake range" - close enough that it looks like an alignment attempt
|
|
8
|
+
* but not quite aligned perfectly.
|
|
9
|
+
*/
|
|
10
|
+
export default defineRule({
|
|
11
|
+
meta: {
|
|
12
|
+
severity: "warn",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Detects elements that should be aligned but are slightly off",
|
|
15
|
+
recommended: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async run(context) {
|
|
19
|
+
const domHelpers = await getDomHelpersHandle(context.page);
|
|
20
|
+
await context.evaluate(({ report, scope, args: { domHelpers } }) => {
|
|
21
|
+
const PERFECT_THRESHOLD = 1;
|
|
22
|
+
const MIN_MISALIGN = 2;
|
|
23
|
+
const MAX_MISALIGN = 6;
|
|
24
|
+
const MIN_ELEMENT_SIZE = 24;
|
|
25
|
+
const hasSize = (el) => {
|
|
26
|
+
return domHelpers.hasElementRectSize(el, MIN_ELEMENT_SIZE, MIN_ELEMENT_SIZE);
|
|
27
|
+
};
|
|
28
|
+
const isLayoutChild = (el) => {
|
|
29
|
+
const parent = el.parentElement;
|
|
30
|
+
if (!parent)
|
|
31
|
+
return false;
|
|
32
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
33
|
+
const display = parentStyle.display;
|
|
34
|
+
return display === "flex" || display === "inline-flex";
|
|
35
|
+
};
|
|
36
|
+
const getFlexDirection = (el) => {
|
|
37
|
+
const parent = el.parentElement;
|
|
38
|
+
if (!parent)
|
|
39
|
+
return null;
|
|
40
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
41
|
+
if (parentStyle.display !== "flex" &&
|
|
42
|
+
parentStyle.display !== "inline-flex") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const dir = parentStyle.flexDirection;
|
|
46
|
+
return dir === "column" || dir === "column-reverse" ? "column" : "row";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Gets alignment offsets for edges that make sense given the flex direction.
|
|
50
|
+
* For row layouts: check vertical alignment (top, bottom, center-y)
|
|
51
|
+
* For column layouts: check horizontal alignment (left, right, center-x)
|
|
52
|
+
*/
|
|
53
|
+
const getRelevantAlignments = (a, b, direction) => {
|
|
54
|
+
if (direction === "row") {
|
|
55
|
+
return [
|
|
56
|
+
{ edge: "top", offset: Math.abs(a.top - b.top) },
|
|
57
|
+
{ edge: "bottom", offset: Math.abs(a.bottom - b.bottom) },
|
|
58
|
+
{
|
|
59
|
+
edge: "center-y",
|
|
60
|
+
offset: Math.abs((a.top + a.bottom) / 2 - (b.top + b.bottom) / 2),
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
{ edge: "left", offset: Math.abs(a.left - b.left) },
|
|
66
|
+
{ edge: "right", offset: Math.abs(a.right - b.right) },
|
|
67
|
+
{
|
|
68
|
+
edge: "center-x",
|
|
69
|
+
offset: Math.abs((a.left + a.right) / 2 - (b.left + b.right) / 2),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Finds misalignment only if:
|
|
75
|
+
* 1. One edge is clearly the "intended" alignment (closest)
|
|
76
|
+
* 2. But it's not quite perfect (in the mistake range)
|
|
77
|
+
* 3. No other edge is perfectly aligned
|
|
78
|
+
*/
|
|
79
|
+
const findMisalignment = (a, b, direction) => {
|
|
80
|
+
const alignments = getRelevantAlignments(a, b, direction);
|
|
81
|
+
alignments.sort((x, y) => x.offset - y.offset);
|
|
82
|
+
const hasAnyPerfectAlignment = alignments.some((al) => al.offset <= PERFECT_THRESHOLD);
|
|
83
|
+
if (hasAnyPerfectAlignment)
|
|
84
|
+
return null;
|
|
85
|
+
const closest = alignments[0];
|
|
86
|
+
if (!closest)
|
|
87
|
+
return null;
|
|
88
|
+
if (closest.offset >= MIN_MISALIGN &&
|
|
89
|
+
closest.offset <= MAX_MISALIGN) {
|
|
90
|
+
return closest;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const reportedPairs = new Set();
|
|
95
|
+
const getSiblings = (el) => {
|
|
96
|
+
const parent = el.parentElement;
|
|
97
|
+
if (!parent)
|
|
98
|
+
return [];
|
|
99
|
+
const siblings = [];
|
|
100
|
+
for (const child of parent.children) {
|
|
101
|
+
if (child === el)
|
|
102
|
+
continue;
|
|
103
|
+
if (!domHelpers.isHtmlElement(child))
|
|
104
|
+
continue;
|
|
105
|
+
if (!domHelpers.isVisible(child))
|
|
106
|
+
continue;
|
|
107
|
+
if (!hasSize(child))
|
|
108
|
+
continue;
|
|
109
|
+
siblings.push(child);
|
|
110
|
+
}
|
|
111
|
+
return siblings;
|
|
112
|
+
};
|
|
113
|
+
const allElements = scope.queryAll("*");
|
|
114
|
+
for (const el of allElements) {
|
|
115
|
+
if (!domHelpers.isHtmlElement(el))
|
|
116
|
+
continue;
|
|
117
|
+
if (!domHelpers.isVisible(el))
|
|
118
|
+
continue;
|
|
119
|
+
if (!hasSize(el))
|
|
120
|
+
continue;
|
|
121
|
+
if (!isLayoutChild(el))
|
|
122
|
+
continue;
|
|
123
|
+
const direction = getFlexDirection(el);
|
|
124
|
+
if (!direction)
|
|
125
|
+
continue;
|
|
126
|
+
const rect = el.getBoundingClientRect();
|
|
127
|
+
const siblings = getSiblings(el);
|
|
128
|
+
for (const sibling of siblings) {
|
|
129
|
+
const siblingRect = sibling.getBoundingClientRect();
|
|
130
|
+
const pairKey = [rect, siblingRect]
|
|
131
|
+
.map((r) => `${r.left.toFixed(0)},${r.top.toFixed(0)}`)
|
|
132
|
+
.sort()
|
|
133
|
+
.join("|");
|
|
134
|
+
if (reportedPairs.has(pairKey))
|
|
135
|
+
continue;
|
|
136
|
+
const misalignment = findMisalignment(rect, siblingRect, direction);
|
|
137
|
+
if (!misalignment)
|
|
138
|
+
continue;
|
|
139
|
+
reportedPairs.add(pairKey);
|
|
140
|
+
report({
|
|
141
|
+
message: `Sibling elements misaligned: ${misalignment.edge} edges differ by ${Math.round(misalignment.offset)}px`,
|
|
142
|
+
element: el,
|
|
143
|
+
relations: [
|
|
144
|
+
{
|
|
145
|
+
description: "Misaligned sibling",
|
|
146
|
+
element: sibling,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, { domHelpers });
|
|
153
|
+
},
|
|
154
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects elements that overlap unintentionally within the same layout context.
|
|
3
|
+
* - Elements must be visible in the viewport.
|
|
4
|
+
* - Elements positioned absolute/fixed are not candidates.
|
|
5
|
+
* - Overlap is only checked when both elements share the same nearest
|
|
6
|
+
* absolute/fixed ancestor (or neither has one).
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: import("viewlint").RuleDefinition<undefined>;
|
|
9
|
+
export default _default;
|
|
10
|
+
//# sourceMappingURL=overlapped-elements.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlapped-elements.d.ts","sourceRoot":"","sources":["../../src/rules/overlapped-elements.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;;AACH,wBAuRE"}
|