@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,210 @@
1
+ import sharp from "sharp";
2
+ import { defineRule } from "viewlint/plugin";
3
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
4
+ /**
5
+ * Detects text with low contrast against its background.
6
+ *
7
+ * Uses screenshot-based analysis to sample the actual rendered background
8
+ * color behind text elements, then calculates WCAG contrast ratio.
9
+ */
10
+ export default defineRule({
11
+ meta: {
12
+ severity: "warn",
13
+ docs: {
14
+ description: "Detects text with low contrast against background",
15
+ recommended: true,
16
+ },
17
+ },
18
+ async run(context) {
19
+ const MINIMUM_CONTRAST_RATIO = 2.0;
20
+ const domHelpers = await getDomHelpersHandle(context.page);
21
+ const textElements = await context.evaluate(({ scope, args: { domHelpers } }) => {
22
+ const hasDirectTextContent = (el) => {
23
+ return domHelpers.getDirectTextNodes(el).length > 0;
24
+ };
25
+ const parseColor = (colorString) => {
26
+ if (!colorString || colorString === "transparent") {
27
+ return { r: 0, g: 0, b: 0, a: 0 };
28
+ }
29
+ const canvas = document.createElement("canvas");
30
+ canvas.width = 1;
31
+ canvas.height = 1;
32
+ const ctx = canvas.getContext("2d");
33
+ if (!ctx)
34
+ return null;
35
+ ctx.clearRect(0, 0, 1, 1);
36
+ ctx.fillStyle = colorString;
37
+ ctx.fillRect(0, 0, 1, 1);
38
+ const imageData = ctx.getImageData(0, 0, 1, 1);
39
+ const [r, g, b, a] = imageData.data;
40
+ if (r === undefined ||
41
+ g === undefined ||
42
+ b === undefined ||
43
+ a === undefined) {
44
+ return null;
45
+ }
46
+ return { r, g, b, a: a / 255 };
47
+ };
48
+ const getUniqueSelector = (el) => {
49
+ if (window.__viewlint_finder) {
50
+ return window.__viewlint_finder(el);
51
+ }
52
+ // Fallback
53
+ if (el.id)
54
+ return `#${el.id}`;
55
+ return el.tagName.toLowerCase();
56
+ };
57
+ const results = [];
58
+ const allElements = scope.queryAll("*");
59
+ for (const el of allElements) {
60
+ if (!domHelpers.isHtmlElement(el))
61
+ continue;
62
+ if (!domHelpers.isVisible(el))
63
+ continue;
64
+ if (!hasDirectTextContent(el))
65
+ continue;
66
+ const style = window.getComputedStyle(el);
67
+ const textColor = parseColor(style.color);
68
+ if (!textColor)
69
+ continue;
70
+ const rect = domHelpers.getTextBounds(el, 1);
71
+ if (!rect)
72
+ continue;
73
+ // Skip elements outside viewport
74
+ if (rect.bottom <= 0 ||
75
+ rect.right <= 0 ||
76
+ rect.top >= window.innerHeight ||
77
+ rect.left >= window.innerWidth)
78
+ continue;
79
+ results.push({
80
+ selector: getUniqueSelector(el),
81
+ textColor: { r: textColor.r, g: textColor.g, b: textColor.b },
82
+ rect: {
83
+ x: rect.x,
84
+ y: rect.y,
85
+ width: rect.width,
86
+ height: rect.height,
87
+ },
88
+ });
89
+ }
90
+ return results;
91
+ }, { domHelpers });
92
+ if (textElements.length === 0)
93
+ return;
94
+ // Take a screenshot for background sampling
95
+ const screenshotBuffer = await context.page.screenshot({ type: "png" });
96
+ // Decode PNG to get pixel data
97
+ const image = sharp(screenshotBuffer);
98
+ const metadata = await image.metadata();
99
+ const { width: imgWidth, height: imgHeight } = metadata;
100
+ if (!imgWidth || !imgHeight)
101
+ return;
102
+ // Get raw pixel data
103
+ const { data: pixels, info } = await image
104
+ .raw()
105
+ .toBuffer({ resolveWithObject: true });
106
+ const getPixel = (x, y) => {
107
+ const px = Math.floor(x);
108
+ const py = Math.floor(y);
109
+ if (px < 0 || px >= info.width || py < 0 || py >= info.height) {
110
+ return null;
111
+ }
112
+ const idx = (py * info.width + px) * info.channels;
113
+ const r = pixels[idx];
114
+ const g = pixels[idx + 1];
115
+ const b = pixels[idx + 2];
116
+ if (r === undefined || g === undefined || b === undefined) {
117
+ return null;
118
+ }
119
+ return { r, g, b };
120
+ };
121
+ const relativeLuminance = (color) => {
122
+ const sRGB = [color.r, color.g, color.b].map((c) => {
123
+ const s = c / 255;
124
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
125
+ });
126
+ const r = sRGB[0] ?? 0;
127
+ const g = sRGB[1] ?? 0;
128
+ const b = sRGB[2] ?? 0;
129
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
130
+ };
131
+ const contrastRatio = (color1, color2) => {
132
+ const l1 = relativeLuminance(color1);
133
+ const l2 = relativeLuminance(color2);
134
+ const lighter = Math.max(l1, l2);
135
+ const darker = Math.min(l1, l2);
136
+ return (lighter + 0.05) / (darker + 0.05);
137
+ };
138
+ const formatColor = (color) => {
139
+ return `rgb(${color.r}, ${color.g}, ${color.b})`;
140
+ };
141
+ // Sample background color by averaging pixels around the element edges
142
+ const sampleBackground = (rect) => {
143
+ const samples = [];
144
+ // Sample from multiple points around the text bounds.
145
+ // Avoid sampling "around the element" because padding/background from
146
+ // surrounding layout can produce unexpected results.
147
+ const samplePoints = [
148
+ // Corners (slightly inside)
149
+ { x: rect.x + 2, y: rect.y + 2 },
150
+ { x: rect.x + rect.width - 2, y: rect.y + 2 },
151
+ { x: rect.x + 2, y: rect.y + rect.height - 2 },
152
+ { x: rect.x + rect.width - 2, y: rect.y + rect.height - 2 },
153
+ // Edge midpoints (slightly inside)
154
+ { x: rect.x + rect.width / 2, y: rect.y + 2 },
155
+ { x: rect.x + rect.width / 2, y: rect.y + rect.height - 2 },
156
+ { x: rect.x + 2, y: rect.y + rect.height / 2 },
157
+ { x: rect.x + rect.width - 2, y: rect.y + rect.height / 2 },
158
+ // Center
159
+ { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
160
+ ];
161
+ for (const point of samplePoints) {
162
+ const pixel = getPixel(point.x, point.y);
163
+ if (pixel)
164
+ samples.push(pixel);
165
+ }
166
+ if (samples.length === 0)
167
+ return null;
168
+ // Average the samples
169
+ let totalR = 0;
170
+ let totalG = 0;
171
+ let totalB = 0;
172
+ for (const sample of samples) {
173
+ totalR += sample.r;
174
+ totalG += sample.g;
175
+ totalB += sample.b;
176
+ }
177
+ return {
178
+ r: Math.round(totalR / samples.length),
179
+ g: Math.round(totalG / samples.length),
180
+ b: Math.round(totalB / samples.length),
181
+ };
182
+ };
183
+ // Check contrast for each text element
184
+ for (const element of textElements) {
185
+ const bgColor = sampleBackground(element.rect);
186
+ if (!bgColor)
187
+ continue;
188
+ const ratio = contrastRatio(element.textColor, bgColor);
189
+ if (ratio >= MINIMUM_CONTRAST_RATIO)
190
+ continue;
191
+ const ratioFormatted = ratio.toFixed(2);
192
+ // Report via evaluate to get proper element reference
193
+ await context.evaluate(({ report, args }) => {
194
+ const el = document.querySelector(args.selector);
195
+ if (!el || !(el instanceof HTMLElement))
196
+ return;
197
+ report({
198
+ message: `Text has low contrast ratio of ${args.ratioFormatted}:1 (minimum ${args.minimumRatio}:1). Text color: ${args.textColorStr}, background: ${args.bgColorStr}`,
199
+ element: el,
200
+ });
201
+ }, {
202
+ selector: element.selector,
203
+ ratioFormatted,
204
+ minimumRatio: MINIMUM_CONTRAST_RATIO,
205
+ textColorStr: formatColor(element.textColor),
206
+ bgColorStr: formatColor(bgColor),
207
+ });
208
+ }
209
+ },
210
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detects text that extends beyond its container element's bounds.
3
+ *
4
+ * Uses Range API to measure text node bounds and compares against
5
+ * the container element's bounding box.
6
+ */
7
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
8
+ export default _default;
9
+ //# sourceMappingURL=text-overflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-overflow.d.ts","sourceRoot":"","sources":["../../src/rules/text-overflow.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;;AACH,wBA6GE"}
@@ -0,0 +1,86 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects text that extends beyond its container element's bounds.
5
+ *
6
+ * Uses Range API to measure text node bounds and compares against
7
+ * the container element's bounding box.
8
+ */
9
+ export default defineRule({
10
+ meta: {
11
+ severity: "error",
12
+ docs: {
13
+ description: "Detects text that overflows its container element",
14
+ recommended: true,
15
+ },
16
+ },
17
+ async run(context) {
18
+ const domHelpers = await getDomHelpersHandle(context.page);
19
+ await context.evaluate(({ report, scope, args: { domHelpers } }) => {
20
+ const HORIZONTAL_OVERFLOW_THRESHOLD = 1;
21
+ const VERTICAL_OVERFLOW_RATIO = 0.5;
22
+ /**
23
+ * Check if overflow exceeds thresholds (different for horizontal/vertical).
24
+ */
25
+ const hasSignificantOverflow = (overflow, verticalThreshold) => {
26
+ return (overflow.top > verticalThreshold ||
27
+ overflow.right > HORIZONTAL_OVERFLOW_THRESHOLD ||
28
+ overflow.bottom > verticalThreshold ||
29
+ overflow.left > HORIZONTAL_OVERFLOW_THRESHOLD);
30
+ };
31
+ /**
32
+ * Format overflow with separate horizontal/vertical thresholds.
33
+ */
34
+ const formatOverflowWithThresholds = (overflow, verticalThreshold) => {
35
+ const parts = [];
36
+ if (overflow.top > verticalThreshold) {
37
+ parts.push(`${Math.round(overflow.top)}px top`);
38
+ }
39
+ if (overflow.right > HORIZONTAL_OVERFLOW_THRESHOLD) {
40
+ parts.push(`${Math.round(overflow.right)}px right`);
41
+ }
42
+ if (overflow.bottom > verticalThreshold) {
43
+ parts.push(`${Math.round(overflow.bottom)}px bottom`);
44
+ }
45
+ if (overflow.left > HORIZONTAL_OVERFLOW_THRESHOLD) {
46
+ parts.push(`${Math.round(overflow.left)}px left`);
47
+ }
48
+ return parts.join(", ");
49
+ };
50
+ const allElements = scope.queryAll("*");
51
+ for (const el of allElements) {
52
+ if (!domHelpers.isHtmlElement(el))
53
+ continue;
54
+ if (!domHelpers.isVisible(el))
55
+ continue;
56
+ if (!domHelpers.hasElementRectSize(el))
57
+ continue;
58
+ if (domHelpers.hasTextOverflowEllipsis(el))
59
+ continue;
60
+ const containerRect = el.getBoundingClientRect();
61
+ const textNodes = domHelpers.getDirectTextNodes(el);
62
+ for (const textNode of textNodes) {
63
+ const textRect = domHelpers.getTextNodeBounds(textNode);
64
+ if (!textRect)
65
+ continue;
66
+ const fontSize = domHelpers.getFontSize(el);
67
+ const verticalThreshold = fontSize * VERTICAL_OVERFLOW_RATIO;
68
+ // Use domHelpers.getOverflow with threshold=0 to get all overflow values,
69
+ // then apply our custom threshold logic for horizontal vs vertical
70
+ const overflow = domHelpers.getOverflow(containerRect, textRect, 0);
71
+ if (!overflow)
72
+ continue;
73
+ if (!hasSignificantOverflow(overflow, verticalThreshold))
74
+ continue;
75
+ const textPreview = (textNode.textContent || "").trim().slice(0, 30) +
76
+ ((textNode.textContent || "").length > 30 ? "..." : "");
77
+ report({
78
+ message: `Text "${textPreview}" overflows container by ${formatOverflowWithThresholds(overflow, verticalThreshold)}`,
79
+ element: el,
80
+ });
81
+ break;
82
+ }
83
+ }
84
+ }, { domHelpers });
85
+ },
86
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Detects horizontally adjacent text elements that are too close together.
3
+ *
4
+ * When two text blocks are positioned very close horizontally, they can
5
+ * appear as a single text block, causing readability issues where readers
6
+ * cannot distinguish where one text ends and another begins.
7
+ *
8
+ * Uses actual text bounding boxes (via Range API), not element bounds.
9
+ */
10
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
11
+ export default _default;
12
+ //# sourceMappingURL=text-proximity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-proximity.d.ts","sourceRoot":"","sources":["../../src/rules/text-proximity.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;;AACH,wBAyIE"}
@@ -0,0 +1,115 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects horizontally adjacent text elements that are too close together.
5
+ *
6
+ * When two text blocks are positioned very close horizontally, they can
7
+ * appear as a single text block, causing readability issues where readers
8
+ * cannot distinguish where one text ends and another begins.
9
+ *
10
+ * Uses actual text bounding boxes (via Range API), not element bounds.
11
+ */
12
+ export default defineRule({
13
+ meta: {
14
+ severity: "warn",
15
+ docs: {
16
+ description: "Detects horizontally adjacent text elements that are too close together",
17
+ recommended: false,
18
+ },
19
+ },
20
+ async run(context) {
21
+ const domHelpers = await getDomHelpersHandle(context.page);
22
+ await context.evaluate(({ report, scope, args: { domHelpers } }) => {
23
+ // 35% of font-size as threshold
24
+ const MIN_GAP_FACTOR = 0.35;
25
+ // Absolute minimum gap in pixels (text under this is definitely too close)
26
+ const MIN_GAP_PX = 3;
27
+ const MAX_VERTICAL_OVERLAP_TOLERANCE = 0.5;
28
+ const MIN_TEXT_LENGTH = 2;
29
+ const getDirectTextContent = (el) => {
30
+ let text = "";
31
+ for (const node of el.childNodes) {
32
+ if (domHelpers.isTextNode(node)) {
33
+ text += node.textContent || "";
34
+ }
35
+ }
36
+ return text.trim();
37
+ };
38
+ const areHorizontallyAdjacent = (a, b) => {
39
+ const verticalOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
40
+ const minHeight = Math.min(a.height, b.height);
41
+ if (verticalOverlap < minHeight * MAX_VERTICAL_OVERLAP_TOLERANCE) {
42
+ return null;
43
+ }
44
+ if (a.right <= b.left) {
45
+ return { gap: b.left - a.right, leftRect: "a" };
46
+ }
47
+ if (b.right <= a.left) {
48
+ return { gap: a.left - b.right, leftRect: "b" };
49
+ }
50
+ return null;
51
+ };
52
+ const textElements = [];
53
+ const allElements = scope.queryAll("*");
54
+ for (const el of allElements) {
55
+ if (!domHelpers.isHtmlElement(el))
56
+ continue;
57
+ if (!domHelpers.isVisible(el))
58
+ continue;
59
+ const textRect = domHelpers.getTextBounds(el, MIN_TEXT_LENGTH);
60
+ if (!textRect)
61
+ continue;
62
+ const fontSize = domHelpers.getFontSize(el);
63
+ const text = getDirectTextContent(el);
64
+ textElements.push({ el, textRect, fontSize, text });
65
+ }
66
+ const reportedPairs = new Set();
67
+ for (let i = 0; i < textElements.length; i++) {
68
+ const a = textElements[i];
69
+ if (!a)
70
+ continue;
71
+ for (let j = i + 1; j < textElements.length; j++) {
72
+ const b = textElements[j];
73
+ if (!b)
74
+ continue;
75
+ if (a.el.contains(b.el) || b.el.contains(a.el))
76
+ continue;
77
+ const adjacency = areHorizontallyAdjacent(a.textRect, b.textRect);
78
+ if (!adjacency)
79
+ continue;
80
+ const avgFontSize = (a.fontSize + b.fontSize) / 2;
81
+ // Use the higher of: absolute minimum or percentage of font-size
82
+ const minGap = Math.max(MIN_GAP_PX, avgFontSize * MIN_GAP_FACTOR);
83
+ if (adjacency.gap >= minGap)
84
+ continue;
85
+ const leftEl = adjacency.leftRect === "a" ? a : b;
86
+ const rightEl = adjacency.leftRect === "a" ? b : a;
87
+ const leftParent = leftEl.el.parentElement;
88
+ const rightParent = rightEl.el.parentElement;
89
+ if (leftParent !== rightParent)
90
+ continue;
91
+ const pairKey = [a.textRect, b.textRect]
92
+ .map((r) => `${r.left.toFixed(0)},${r.top.toFixed(0)}`)
93
+ .sort()
94
+ .join("|");
95
+ if (reportedPairs.has(pairKey))
96
+ continue;
97
+ reportedPairs.add(pairKey);
98
+ const leftPreview = leftEl.text.slice(0, 15) + (leftEl.text.length > 15 ? "..." : "");
99
+ const rightPreview = rightEl.text.slice(0, 15) +
100
+ (rightEl.text.length > 15 ? "..." : "");
101
+ report({
102
+ message: `Text too close (${Math.round(adjacency.gap)}px gap, min ${Math.round(minGap)}px): "${leftPreview}" and "${rightPreview}"`,
103
+ element: leftEl.el,
104
+ relations: [
105
+ {
106
+ description: "Adjacent text element",
107
+ element: rightEl.el,
108
+ },
109
+ ],
110
+ });
111
+ }
112
+ }
113
+ }, { domHelpers });
114
+ },
115
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Detects text blocks with awkwardly short lines (ragged lines).
3
+ *
4
+ * Analyzes multi-line text to find lines that are significantly shorter
5
+ * than others, which often looks unprofessional and may indicate layout
6
+ * issues or poor text wrapping.
7
+ */
8
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
9
+ export default _default;
10
+ //# sourceMappingURL=text-ragged-lines.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-ragged-lines.d.ts","sourceRoot":"","sources":["../../src/rules/text-ragged-lines.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;;AACH,wBA0JE"}
@@ -0,0 +1,123 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects text blocks with awkwardly short lines (ragged lines).
5
+ *
6
+ * Analyzes multi-line text to find lines that are significantly shorter
7
+ * than others, which often looks unprofessional and may indicate layout
8
+ * issues or poor text wrapping.
9
+ */
10
+ export default defineRule({
11
+ meta: {
12
+ severity: "warn",
13
+ docs: {
14
+ description: "Detects text blocks with awkwardly short lines that disrupt visual flow",
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
+ // 2 lines minimum - detect widows (short last line)
22
+ const MIN_LINES = 2;
23
+ // Orphan/widow threshold for the LAST line
24
+ const ORPHAN_LINE_RATIO = 0.45;
25
+ // Threshold for awkwardly short MIDDLE lines
26
+ const MIDDLE_LINE_RATIO = 0.3;
27
+ // Minimum line width to count as a "substantial" line
28
+ // Very short fragments still count for detecting issues
29
+ const MIN_LINE_WIDTH = 5;
30
+ // Tolerance for grouping text into lines by Y position
31
+ const Y_TOLERANCE = 3;
32
+ // Minimum element width to analyze (skip very narrow elements)
33
+ const MIN_ELEMENT_WIDTH = 40;
34
+ const getTextLines = (el) => {
35
+ const rects = domHelpers
36
+ .getTextRects(el)
37
+ .filter((rect) => rect.width >= MIN_LINE_WIDTH);
38
+ if (rects.length === 0)
39
+ return [];
40
+ rects.sort((a, b) => a.top - b.top);
41
+ const lines = [];
42
+ for (const rect of rects) {
43
+ const existingLine = lines.find((line) => Math.abs(line.y - rect.top) <= Y_TOLERANCE);
44
+ if (!existingLine) {
45
+ lines.push({
46
+ y: rect.top,
47
+ minLeft: rect.left,
48
+ maxRight: rect.right,
49
+ });
50
+ continue;
51
+ }
52
+ existingLine.minLeft = Math.min(existingLine.minLeft, rect.left);
53
+ existingLine.maxRight = Math.max(existingLine.maxRight, rect.right);
54
+ }
55
+ return lines
56
+ .map((line) => ({
57
+ y: line.y,
58
+ width: line.maxRight - line.minLeft,
59
+ }))
60
+ .sort((a, b) => a.y - b.y);
61
+ };
62
+ const analyzeLines = (lines) => {
63
+ if (lines.length < MIN_LINES)
64
+ return null;
65
+ const widths = lines.map((l) => l.width);
66
+ const longestWidth = Math.max(...widths);
67
+ if (longestWidth < MIN_LINE_WIDTH)
68
+ return null;
69
+ // Check the last line for orphan/widow
70
+ const lastLine = lines[lines.length - 1];
71
+ if (!lastLine)
72
+ return null;
73
+ const ratio = lastLine.width / longestWidth;
74
+ if (ratio < ORPHAN_LINE_RATIO) {
75
+ return {
76
+ shortLineIndex: lines.length,
77
+ shortLineWidth: lastLine.width,
78
+ longestWidth,
79
+ };
80
+ }
81
+ // Check for awkwardly short lines in the middle
82
+ for (let i = 1; i < lines.length - 1; i++) {
83
+ const line = lines[i];
84
+ if (!line)
85
+ continue;
86
+ const midRatio = line.width / longestWidth;
87
+ if (midRatio < MIDDLE_LINE_RATIO) {
88
+ return {
89
+ shortLineIndex: i + 1,
90
+ shortLineWidth: line.width,
91
+ longestWidth,
92
+ };
93
+ }
94
+ }
95
+ return null;
96
+ };
97
+ const allElements = scope.queryAll("*");
98
+ for (const el of allElements) {
99
+ if (!domHelpers.isHtmlElement(el))
100
+ continue;
101
+ if (!domHelpers.isVisible(el))
102
+ continue;
103
+ if (!domHelpers.hasElementRectSize(el))
104
+ continue;
105
+ // Skip very narrow containers where multi-line is unavoidable
106
+ const rect = el.getBoundingClientRect();
107
+ if (rect.width < MIN_ELEMENT_WIDTH)
108
+ continue;
109
+ const lines = getTextLines(el);
110
+ if (lines.length < MIN_LINES)
111
+ continue;
112
+ const analysis = analyzeLines(lines);
113
+ if (!analysis)
114
+ continue;
115
+ const percentage = Math.round((analysis.shortLineWidth / analysis.longestWidth) * 100);
116
+ report({
117
+ message: `Text has awkwardly short line ${analysis.shortLineIndex} of ${lines.length}: ${Math.round(analysis.shortLineWidth)}px wide (${percentage}% of longest ${Math.round(analysis.longestWidth)}px line)`,
118
+ element: el,
119
+ });
120
+ }
121
+ }, { domHelpers });
122
+ },
123
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detects small/unexpected scrollbars that indicate layout overflow bugs.
3
+ *
4
+ * Finds elements where scroll distance is small (1-20px), which usually
5
+ * indicates pixel/subpixel layout issues rather than intentional scrolling.
6
+ */
7
+ declare const _default: import("viewlint").RuleDefinition<undefined>;
8
+ export default _default;
9
+ //# sourceMappingURL=unexpected-scrollbar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unexpected-scrollbar.d.ts","sourceRoot":"","sources":["../../src/rules/unexpected-scrollbar.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;;AACH,wBAmFE"}
@@ -0,0 +1,77 @@
1
+ import { defineRule } from "viewlint/plugin";
2
+ import { getDomHelpersHandle } from "../utils/getDomHelpersHandle.js";
3
+ /**
4
+ * Detects small/unexpected scrollbars that indicate layout overflow bugs.
5
+ *
6
+ * Finds elements where scroll distance is small (1-20px), which usually
7
+ * indicates pixel/subpixel layout issues rather than intentional scrolling.
8
+ */
9
+ export default defineRule({
10
+ meta: {
11
+ severity: "error",
12
+ docs: {
13
+ description: "Detects unexpected scrollbars from minor layout overflow",
14
+ recommended: true,
15
+ },
16
+ },
17
+ async run(context) {
18
+ const domHelpers = await getDomHelpersHandle(context.page);
19
+ await context.evaluate(({ report, scope, args: { domHelpers } }) => {
20
+ const MIN_SCROLL_OVERFLOW = 1;
21
+ const MAX_UNEXPECTED_OVERFLOW = 20;
22
+ const hasSize = (el) => {
23
+ return domHelpers.hasClientSize(el);
24
+ };
25
+ const checkElement = (el) => {
26
+ if (!domHelpers.isVisible(el))
27
+ return;
28
+ if (!hasSize(el))
29
+ return;
30
+ const style = window.getComputedStyle(el);
31
+ const overflowX = style.overflowX;
32
+ const overflowY = style.overflowY;
33
+ const canScrollX = domHelpers.canScroll(overflowX);
34
+ const canScrollY = domHelpers.canScroll(overflowY);
35
+ if (!canScrollX && !canScrollY)
36
+ return;
37
+ const { scrollWidth, scrollHeight, clientWidth, clientHeight } = el;
38
+ const overflowAmountX = scrollWidth - clientWidth;
39
+ const overflowAmountY = scrollHeight - clientHeight;
40
+ const unexpectedX = canScrollX &&
41
+ overflowAmountX >= MIN_SCROLL_OVERFLOW &&
42
+ overflowAmountX <= MAX_UNEXPECTED_OVERFLOW;
43
+ const unexpectedY = canScrollY &&
44
+ overflowAmountY >= MIN_SCROLL_OVERFLOW &&
45
+ overflowAmountY <= MAX_UNEXPECTED_OVERFLOW;
46
+ if (!unexpectedX && !unexpectedY)
47
+ return;
48
+ let message;
49
+ if (unexpectedX && unexpectedY) {
50
+ message = `Unexpected scrollbar: element scrolls ${overflowAmountX}px horizontally and ${overflowAmountY}px vertically (likely a layout bug)`;
51
+ }
52
+ else if (unexpectedX) {
53
+ message = `Unexpected horizontal scrollbar: element scrolls ${overflowAmountX}px (likely a layout bug)`;
54
+ }
55
+ else {
56
+ message = `Unexpected vertical scrollbar: element scrolls ${overflowAmountY}px (likely a layout bug)`;
57
+ }
58
+ report({
59
+ message,
60
+ element: el,
61
+ });
62
+ };
63
+ checkElement(document.documentElement);
64
+ if (document.body) {
65
+ checkElement(document.body);
66
+ }
67
+ const allElements = scope.queryAll("*");
68
+ for (const el of allElements) {
69
+ if (!domHelpers.isHtmlElement(el))
70
+ continue;
71
+ if (el === document.documentElement || el === document.body)
72
+ continue;
73
+ checkElement(el);
74
+ }
75
+ }, { domHelpers });
76
+ },
77
+ });