frontend-go-live-check 1.3.0 → 1.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0
4
+
5
+ - New layout checks on every page at every screen size, each with Actual and Expected pictures where possible:
6
+ - text that spills out of its box (for example a button with a fixed size that is too small for its label);
7
+ - text drawn on top of other text;
8
+ - content cut off at the edge of the screen where the user cannot scroll to it.
9
+ - New content checks:
10
+ - buttons and links covered by another element;
11
+ - unfinished text: `undefined`, `NaN`, `null`, `[object Object]`, `Invalid Date`, `{{ }}`, translation keys, lorem ipsum, TODO;
12
+ - stretched or squashed images;
13
+ - loading indicators still showing after the page finished loading.
14
+ - Evidence outlines now include text that runs outside its element.
15
+
3
16
  ## 1.3.0
4
17
 
5
18
  - Every problem is shown as **Actual** next to **Expected**: a close-up of the problem outlined in red, and the same spot corrected (focus ring, contrast, text that fits, page that fits the screen, right-to-left…) outlined in green. Problems without a picture get one Expected sentence.
package/README.md CHANGED
@@ -142,9 +142,9 @@ The report lists every sign-in method found (*email and password · Google · UA
142
142
 
143
143
  | | Area | What is checked |
144
144
  | :-: | --- | --- |
145
- | 🧭 | **Functionality** | JavaScript errors, console errors, failed requests, broken links, images and anchors, blank pages, the production build, the "page not found" page |
145
+ | 🧭 | **Functionality** | JavaScript errors, console errors, failed requests, broken links, images and anchors, blank pages, buttons covered by something else, unfinished text (`undefined`, `NaN`, `[object Object]`, translation keys, lorem ipsum, TODO), loading spinners that never stop, the production build, the "page not found" page |
146
146
  | 🛟 | **Resilience** | Each page opened again with its API calls failing: does it crash, go blank, or tell the user? |
147
- | 📱 | **Design and responsive layout** | Every page at every screen size: sideways scrolling, cut-off or overlapping text and buttons, tiny text on phones, right-to-left layout (Arabic, Hebrew…), fonts that fail to load, too many fonts and sizes |
147
+ | 📱 | **Design and responsive layout** | Every page at every screen size: sideways scrolling, text that spills out of its box (a button too small for its label), text on top of other text, content cut off at the screen edge, text cut off inside its box, overlapping buttons, stretched or squashed images, tiny text on phones, right-to-left layout (Arabic, Hebrew…), fonts that fail to load, too many fonts and sizes |
148
148
  | 🖼️ | **Visual changes** | A pixel comparison of every page with the saved baseline, with the changed areas in red |
149
149
  | ♿ | **Accessibility** | axe-core against WCAG 2.0 / 2.1 / 2.2 A and AA; keyboard-only use: visible focus, no traps, nothing that only a mouse can click; form field types |
150
150
  | ⚡ | **Performance** | Size, JavaScript, requests, load time, LCP, layout shift and frozen-page time for every page, against budgets; Lighthouse as a phone and as a desktop |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontend-go-live-check",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Is your frontend ready to go live? One command tests any web frontend (React, Next.js, Vue, Angular, Svelte, plain HTML…): pages, responsive design, accessibility, keyboard, performance and load, API calls, security, SEO, sign-in and APIs from Postman/OpenAPI. Report with evidence, PDF and Word, and a pipeline gate that approves or rejects the merge.",
5
5
  "homepage": "https://www.npmjs.com/package/frontend-go-live-check",
6
6
  "repository": {
@@ -301,6 +301,18 @@ export async function runBrowserChecks({ base, config, reportDir, log, starts =
301
301
  if (!audit.viewportMeta) add({ ruleId: 'missing-viewport-meta', page: landedKey, view: tag });
302
302
  if (audit.overflow.length) await withEvidence({ ruleId: 'horizontal-overflow', page: landedKey, view: tag, detail: `The page is ${audit.docWidth}px wide on a ${audit.width}px screen.`, items: audit.overflow, selectors: audit.targets.overflow }, audit.targets.overflow, { screenEdge: audit.width });
303
303
  if (audit.clipped.length) await withEvidence({ ruleId: 'clipped-text', page: landedKey, view: tag, items: audit.clipped, selectors: audit.targets.clipped }, audit.targets.clipped);
304
+ if (audit.outside.length) await withEvidence({ ruleId: 'text-outside-box', page: landedKey, view: tag, items: audit.outside, selectors: audit.targets.outside }, audit.targets.outside);
305
+ if (audit.textOverlaps.length) await withEvidence({ ruleId: 'text-overlap', page: landedKey, view: tag, items: audit.textOverlaps, selectors: audit.targets.textOverlaps }, audit.targets.textOverlaps);
306
+ if (audit.cutOff.length) await withEvidence({ ruleId: 'cut-off-at-edge', page: landedKey, view: tag, items: audit.cutOff, selectors: audit.targets.cutOff }, audit.targets.cutOff);
307
+ if (audit.covered.length) await withEvidence({ ruleId: 'control-covered', page: landedKey, view: tag, items: audit.covered, selectors: audit.targets.covered }, audit.targets.covered);
308
+ if (audit.placeholders.length) await withEvidence({ ruleId: 'placeholder-text', page: landedKey, view: tag, items: audit.placeholders, selectors: audit.targets.placeholders }, audit.targets.placeholders);
309
+ if (audit.distorted.length) await withEvidence({ ruleId: 'distorted-image', page: landedKey, view: tag, items: audit.distorted, selectors: audit.targets.distorted }, audit.targets.distorted);
310
+ if (audit.loading.length) {
311
+ // Give slow data a little more time before calling it stuck.
312
+ await page.waitForTimeout(4000);
313
+ const again = await page.evaluate(inPageAudit, [Boolean(vp.isMobile) && vp.viewport.width < 600, vp.viewport.width]).catch(() => null);
314
+ if (again?.loading.length) await withEvidence({ ruleId: 'stuck-loading', page: landedKey, view: tag, items: again.loading, selectors: again.targets.loading }, again.targets.loading);
315
+ }
304
316
  if (audit.overlaps.length) await withEvidence({ ruleId: 'overlapping-elements', page: landedKey, view: tag, items: audit.overlaps, selectors: audit.targets.overlaps }, audit.targets.overlaps);
305
317
  if (audit.small.length) await withEvidence({ ruleId: 'small-text', page: landedKey, view: tag, items: audit.small, selectors: audit.targets.small }, audit.targets.small);
306
318
  for (const [i, src] of audit.brokenImages.entries()) await withEvidence({ ruleId: 'broken-image', page: landedKey, view: tag, detail: src, selectors: [audit.targets.brokenImages[i]] }, [audit.targets.brokenImages[i]]);
@@ -23,8 +23,21 @@ function drawOutlines([selectors, label, screenEdge, colour = '#e01e1e']) {
23
23
  }
24
24
  if (el && !found.includes(el)) found.push(el);
25
25
  }
26
+ // The element's box plus wherever its content is drawn (text can run outside the box).
27
+ const extent = (el) => {
28
+ const b = el.getBoundingClientRect();
29
+ const range = document.createRange();
30
+ range.selectNodeContents(el);
31
+ const c = range.getBoundingClientRect();
32
+ if (!c.width || !c.height) return b;
33
+ const left = Math.min(b.left, c.left);
34
+ const top = Math.min(b.top, c.top);
35
+ const right = Math.max(b.right, c.right);
36
+ const bottom = Math.max(b.bottom, c.bottom);
37
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
38
+ };
26
39
  found.forEach((el, i) => {
27
- const r = el.getBoundingClientRect();
40
+ const r = extent(el);
28
41
  const box = document.createElement('div');
29
42
  box.style.cssText = `position:absolute;left:${r.left + scrollX - 3}px;top:${r.top + scrollY - 3}px;width:${Math.max(r.width, 4) + 6}px;height:${Math.max(r.height, 4) + 6}px;border:3px solid ${colour};border-radius:4px;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 0 0 9999px rgba(0,0,0,.08);box-sizing:border-box`;
30
43
  const tag = document.createElement('div');
@@ -51,7 +64,7 @@ function drawOutlines([selectors, label, screenEdge, colour = '#e01e1e']) {
51
64
  // Show the first problem element in the middle of the screen.
52
65
  if (found[0] && !screenEdge) found[0].scrollIntoView({ block: 'center', inline: 'nearest' });
53
66
  // Where the outlined elements are on screen now (for the close-up picture).
54
- const rects = found.map((el) => el.getBoundingClientRect());
67
+ const rects = found.map(extent);
55
68
  const box = rects.length
56
69
  ? { left: Math.min(...rects.map((r) => r.left)), top: Math.min(...rects.map((r) => r.top)), right: Math.max(...rects.map((r) => r.right)), bottom: Math.max(...rects.map((r) => r.bottom)) }
57
70
  : null;
@@ -106,7 +119,7 @@ export async function captureEvidence(page, reportDir, { selectors = [], label =
106
119
  * picture (outlined in green), then the page is put back exactly as it was.
107
120
  * Only for problems a picture can show; others get the Expected sentence only.
108
121
  */
109
- export const EXPECTED_VISUAL = new Set(['horizontal-overflow', 'clipped-text', 'small-text', 'focus-not-visible', 'mouse-only-control', 'rtl-missing', 'color-contrast', 'target-size']);
122
+ export const EXPECTED_VISUAL = new Set(['horizontal-overflow', 'clipped-text', 'text-outside-box', 'cut-off-at-edge', 'small-text', 'focus-not-visible', 'mouse-only-control', 'rtl-missing', 'color-contrast', 'target-size']);
110
123
 
111
124
  /** Runs in the page: corrects the problem on these elements; remembers how to undo it. */
112
125
  function applyExpected([kind, selectors]) {
@@ -155,6 +168,17 @@ function applyExpected([kind, selectors]) {
155
168
  set(el, 'height', 'auto');
156
169
  set(el, 'max-height', 'none');
157
170
  set(el, 'white-space', 'normal');
171
+ } else if (kind === 'text-outside-box') {
172
+ for (const prop of ['width', 'height']) set(el, prop, 'auto');
173
+ set(el, 'max-width', '100%');
174
+ set(el, 'white-space', 'normal');
175
+ set(el, 'overflow-wrap', 'anywhere');
176
+ if (getComputedStyle(el).display === 'inline') set(el, 'display', 'inline-block');
177
+ } else if (kind === 'cut-off-at-edge') {
178
+ for (const prop of ['left', 'right', 'margin-left', 'margin-right', 'text-indent', 'transform']) if (getComputedStyle(el)[prop.replace(/-(.)/g, (_, c) => c.toUpperCase())] !== 'auto') set(el, prop, prop === 'transform' ? 'none' : prop === 'left' || prop === 'right' ? 'auto' : '0');
179
+ set(el, 'max-width', '100%');
180
+ set(el, 'overflow-wrap', 'anywhere');
181
+ set(el, 'white-space', 'normal');
158
182
  } else if (kind === 'small-text') {
159
183
  set(el, 'font-size', '14px');
160
184
  } else if (kind === 'color-contrast') {
@@ -73,6 +73,178 @@ export function inPageAudit([phone, screenWidth]) {
73
73
  if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 2) { clipped.push(describe(el)); targets.clipped.push(cssPath(el)); }
74
74
  }
75
75
 
76
+ // Where an element's own text is drawn (it can be outside the element's box).
77
+ const range = document.createRange();
78
+ const textRect = (el, lines = []) => {
79
+ let box = null;
80
+ for (const n of el.childNodes) {
81
+ if (n.nodeType !== 3 || !n.textContent.trim()) continue;
82
+ range.selectNodeContents(n);
83
+ for (const r of range.getClientRects()) {
84
+ if (r.width < 1 || r.height < 1) continue;
85
+ if (lines.length < 20) lines.push({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
86
+ box = box ? { left: Math.min(box.left, r.left), top: Math.min(box.top, r.top), right: Math.max(box.right, r.right), bottom: Math.max(box.bottom, r.bottom) } : { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
87
+ }
88
+ }
89
+ return box;
90
+ };
91
+ const transparent = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
92
+ const srOnly = (el, cs, r) => r.width <= 2 || r.height <= 2 || (cs.clip && cs.clip !== 'auto') || (cs.clipPath && cs.clipPath !== 'none');
93
+ const inDialog = (el) => Boolean(el.closest('dialog, [role=dialog], [role=alertdialog], [aria-modal=true], [aria-hidden=true]'));
94
+ const texts = [];
95
+ for (const el of all) {
96
+ if (texts.length >= 600) break;
97
+ if (!visible(el) || !el.childNodes.length || inDialog(el)) continue;
98
+ const cs = getComputedStyle(el);
99
+ const r = el.getBoundingClientRect();
100
+ if (srOnly(el, cs, r) || transparent(cs.color)) continue;
101
+ const lines = [];
102
+ const t = textRect(el, lines);
103
+ if (t) texts.push({ el, cs, r, t, lines });
104
+ }
105
+
106
+ // Text that spills out of a box with a visible shape (a button, a badge, a card): the box is
107
+ // too small for its text, and the text runs over whatever is next to it.
108
+ const outside = [];
109
+ targets.outside = [];
110
+ for (const { el, cs, r, t } of texts) {
111
+ if (outside.length >= 5) break;
112
+ if (el === document.body || el === document.documentElement) continue;
113
+ if (/(hidden|clip)/.test(`${cs.overflowX} ${cs.overflowY}`)) continue; // that is clipped-text
114
+ const shaped = !transparent(cs.backgroundColor) || parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) > 0 || el.matches('button, [role=button], input, select, textarea');
115
+ if (!shaped) continue;
116
+ const slackY = Math.max(4, parseFloat(cs.fontSize) * 0.3);
117
+ const past = { right: t.right - r.right, left: r.left - t.left, bottom: t.bottom - r.bottom - slackY, top: r.top - t.top - slackY };
118
+ const worst = Object.entries(past).sort((a, b) => b[1] - a[1])[0];
119
+ if (worst[1] > 3) {
120
+ outside.push(`${describe(el)}: the text goes ${Math.round(worst[1] + (worst[0] === 'top' || worst[0] === 'bottom' ? slackY : 0))}px past the ${worst[0]} edge of its ${Math.round(r.width)} × ${Math.round(r.height)}px box`);
121
+ targets.outside.push(cssPath(el));
122
+ }
123
+ }
124
+
125
+ // Text drawn on top of other text (absolute positioning, negative margins, text spilling out).
126
+ const textOverlaps = [];
127
+ targets.textOverlaps = [];
128
+ const hit = (p, q) => {
129
+ const w = Math.min(p.right, q.right) - Math.max(p.left, q.left);
130
+ const h = Math.min(p.bottom, q.bottom) - Math.max(p.top, q.top);
131
+ if (w < 4 || h < 4) return null;
132
+ const smaller = Math.min((p.right - p.left) * (p.bottom - p.top), (q.right - q.left) * (q.bottom - q.top));
133
+ if (w * h < 0.15 * smaller) return null;
134
+ return { x: (Math.max(p.left, q.left) + Math.min(p.right, q.right)) / 2, y: (Math.max(p.top, q.top) + Math.min(p.bottom, q.bottom)) / 2 };
135
+ };
136
+ const reported = new Set();
137
+ for (let i = 0; i < texts.length && textOverlaps.length < 5; i++) {
138
+ const a = texts[i];
139
+ for (let j = i + 1; j < texts.length; j++) {
140
+ const b = texts[j];
141
+ if (!hit(a.t, b.t) || reported.has(b.el)) continue;
142
+ let point = null;
143
+ for (const p of a.lines) {
144
+ for (const q of b.lines) if ((point = hit(p, q))) break;
145
+ if (point) break;
146
+ }
147
+ if (!point) continue;
148
+ // Only when both texts can really be seen there (not one hidden behind a solid layer).
149
+ const top = document.elementFromPoint(point.x, point.y);
150
+ if (top && !(a.el.contains(top) || b.el.contains(top) || top.contains(a.el) || top.contains(b.el))) continue;
151
+ reported.add(a.el);
152
+ reported.add(b.el);
153
+ textOverlaps.push(`${describe(a.el).replace(/ ".*/, '')} "${[...a.el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(' ').replace(/\s+/g, ' ').trim().slice(0, 30)}" and ${describe(b.el)}`);
154
+ targets.textOverlaps.push(cssPath(a.el), cssPath(b.el));
155
+ break;
156
+ }
157
+ }
158
+
159
+ // Content cut off at the edge of the screen where the user cannot scroll to it (pushed off to
160
+ // the left, or past the right edge when the page hides sideways scrolling).
161
+ const cutOff = [];
162
+ targets.cutOff = [];
163
+ for (const { el, t } of texts) {
164
+ if (cutOff.length >= 5) break;
165
+ if (insideFixedOrScroller(el) || overflow.some((o) => o.el.contains(el))) continue;
166
+ const left = t.left < -2 && t.right > 0;
167
+ const right = t.right > width + 2 && t.left < width && docWidth <= width + 1;
168
+ if (!left && !right) continue;
169
+ if (cutOff.length && targets.cutOff.some((sel) => document.querySelector(sel)?.contains(el))) continue;
170
+ cutOff.push(`${describe(el)}: ${left ? `starts ${Math.round(-t.left)}px before the left edge` : `goes ${Math.round(t.right - width)}px past the right edge`} of the screen`);
171
+ targets.cutOff.push(cssPath(el));
172
+ }
173
+
174
+ // Buttons and links covered by something else, so a click lands on the wrong thing.
175
+ const covered = [];
176
+ targets.covered = [];
177
+ for (const el of document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, [role=button]')) {
178
+ if (covered.length >= 5) break;
179
+ if (!visible(el) || inDialog(el) || el.disabled) continue;
180
+ const r = el.getBoundingClientRect();
181
+ if (r.width < 8 || r.height < 8 || r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue;
182
+ const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
183
+ if (!top || el.contains(top) || top.contains(el)) continue;
184
+ const label = top.closest('label');
185
+ if (label && (label.control === el || label.contains(el))) continue;
186
+ if (top.closest('dialog, [role=dialog], [role=alertdialog], [aria-modal=true]') || /cookie|consent|gdpr/i.test(`${top.closest('[id],[class]')?.id ?? ''} ${top.closest('[class]')?.className ?? ''}`)) continue;
187
+ covered.push(`${describe(el)} is under ${describe(top)}`);
188
+ targets.covered.push(cssPath(el), cssPath(top));
189
+ }
190
+
191
+ // Text that was never filled in: undefined, NaN, [object Object], template braces, lorem ipsum,
192
+ // TODO, translation keys shown instead of the translation.
193
+ const placeholders = [];
194
+ targets.placeholders = [];
195
+ const leftover = [
196
+ [/\bundefined\b/, 'undefined'], [/\bNaN\b/, 'NaN'], [/\[object Object\]/, '[object Object]'], [/Invalid Date/, 'Invalid Date'],
197
+ [/(^null$|:\s*null\b)/, 'null'], [/\{\{[^}]*\}\}|\$\{[^}]*\}/, 'template braces'], [/lorem ipsum/i, 'lorem ipsum'], [/\b(TODO|FIXME|TBD)\b/, 'TODO'],
198
+ ];
199
+ const key = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9_]*){2,}$|^[a-z][a-zA-Z0-9]*\.[a-z]+[A-Z][a-zA-Z0-9]*$|^[A-Z][A-Z0-9]*(_[A-Z0-9]+){2,}$/;
200
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
201
+ for (let n = walker.nextNode(); n && placeholders.length < 5; n = walker.nextNode()) {
202
+ const text = n.textContent.replace(/\s+/g, ' ').trim();
203
+ const el = n.parentElement;
204
+ if (!text || !el || el.closest('script, style, noscript, template, code, pre, samp, kbd, textarea, [contenteditable]') || !visible(el)) continue;
205
+ const found = leftover.find(([re]) => re.test(text))?.[1] ?? (key.test(text) ? 'translation key' : '');
206
+ if (!found || targets.placeholders.includes(cssPath(el))) continue;
207
+ placeholders.push(`${describe(el)} shows "${text.slice(0, 60)}" (${found})`);
208
+ targets.placeholders.push(cssPath(el));
209
+ }
210
+ for (const input of document.querySelectorAll('input[placeholder], input[value]')) {
211
+ if (placeholders.length >= 5 || !visible(input)) continue;
212
+ const text = `${input.value} ${input.placeholder}`;
213
+ const found = leftover.slice(0, 6).find(([re]) => re.test(text))?.[1];
214
+ if (found) {
215
+ placeholders.push(`${describe(input)} shows "${text.trim().slice(0, 60)}" (${found})`);
216
+ targets.placeholders.push(cssPath(input));
217
+ }
218
+ }
219
+
220
+ // Images stretched or squashed (shown at a different shape than the picture).
221
+ const distorted = [];
222
+ targets.distorted = [];
223
+ for (const img of document.images) {
224
+ if (distorted.length >= 5 || !visible(img) || !img.naturalWidth || !img.naturalHeight || /\.svg(\?|#|$)/i.test(img.currentSrc || img.src)) continue;
225
+ const cs = getComputedStyle(img);
226
+ if (cs.objectFit !== 'fill') continue;
227
+ const r = img.getBoundingClientRect();
228
+ const box = { w: r.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), h: r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom) };
229
+ if (box.w < 16 || box.h < 16) continue;
230
+ const ratio = (box.w / box.h) / (img.naturalWidth / img.naturalHeight);
231
+ if (Math.abs(ratio - 1) > 0.1) {
232
+ distorted.push(`${(img.currentSrc || img.src).startsWith('data:') ? 'an embedded image' : (img.currentSrc || img.src).split('/').pop().slice(0, 50)} is ${img.naturalWidth} × ${img.naturalHeight} but shown at ${Math.round(box.w)} × ${Math.round(box.h)} (${ratio > 1 ? 'stretched wide' : 'squashed'})`);
233
+ targets.distorted.push(cssPath(img));
234
+ }
235
+ }
236
+
237
+ // A loading indicator still showing after the page finished loading.
238
+ const loading = [];
239
+ targets.loading = [];
240
+ for (const el of document.querySelectorAll('[aria-busy=true], [role=progressbar]:not([aria-valuenow]), [class*=spinner i], [class*=loader i], [class*=loading i], [class*=skeleton i]')) {
241
+ if (loading.length >= 3 || !visible(el) || inDialog(el) || targets.loading.some((sel) => document.querySelector(sel)?.contains(el))) continue;
242
+ const r = el.getBoundingClientRect();
243
+ if (r.width < 8 || r.height < 8) continue;
244
+ loading.push(describe(el));
245
+ targets.loading.push(cssPath(el));
246
+ }
247
+
76
248
  // Small text on phones.
77
249
  const small = [];
78
250
  if (phone) {
@@ -219,6 +391,13 @@ export function inPageAudit([phone, screenWidth]) {
219
391
  overflow: overflow.map((o) => `${describe(o.el)} reaches ${o.right}px`),
220
392
  targets: { ...targets, overflow: overflow.map((o) => cssPath(o.el)), brokenImages: [...document.images].filter((img) => img.complete && img.naturalWidth === 0 && (img.currentSrc || img.src)).slice(0, 10).map(cssPath) },
221
393
  clipped,
394
+ outside,
395
+ textOverlaps,
396
+ cutOff,
397
+ covered,
398
+ placeholders,
399
+ distorted,
400
+ loading,
222
401
  small,
223
402
  overlaps,
224
403
  brokenImages,
@@ -40,6 +40,13 @@ export const EXPLAIN = {
40
40
  'missing-viewport-meta': { what: 'The page does not tell phones how wide it is, so phones show it zoomed out and tiny.', who: 'Phone users.', code: '<meta name="viewport" content="width=device-width, initial-scale=1">', ref: 'WCAG 1.4.10 Reflow' },
41
41
  'horizontal-overflow': { what: 'Something on the page is wider than the screen, so the whole page scrolls sideways (the dashed line in the picture is the edge of the screen).', who: 'Phone and tablet users: they have to scroll left and right to read the page.', code: '/* Wrap wide tables so only the table scrolls */\n.table-wrap { overflow-x: auto; }\n/* and let other content shrink */\nimg, video { max-width: 100%; }', ref: 'WCAG 1.4.10 Reflow' },
42
42
  'clipped-text': { what: 'Text is cut off inside its box, so part of it cannot be read.', who: 'Users on the screen sizes listed.', code: '/* Let the box grow, or show an ellipsis on purpose */\n.label { overflow: visible; }\n/* or */ .label { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }' },
43
+ 'text-outside-box': { what: 'The box (for example a button) is smaller than its text, so the text runs out of it and over the page around it. Text outside the box is often hard or impossible to read.', who: 'Every user on the screen sizes listed.' },
44
+ 'text-overlap': { what: 'Two pieces of text are drawn on top of each other, so neither can be read properly.', who: 'Every user on the screen sizes listed.' },
45
+ 'cut-off-at-edge': { what: 'Part of the content is outside the screen and the page does not let the user scroll to it, so it cannot be seen at all.', who: 'Users on the screen sizes listed.' },
46
+ 'control-covered': { what: 'Something lies on top of a button or link, so clicking it hits the covering element instead and nothing (or the wrong thing) happens.', who: 'Every user on the screen sizes listed.' },
47
+ 'placeholder-text': { what: 'The page shows text that was never filled in properly: a value the code did not have (undefined, NaN, null), a translation key instead of the words, template braces or sample text.', who: 'Every user of this page; it looks broken and unprofessional.' },
48
+ 'distorted-image': { what: 'An image is shown at a different shape than the picture itself, so it looks stretched or squashed.', who: 'Every user on the screen sizes listed.' },
49
+ 'stuck-loading': { what: 'A loading indicator (spinner, skeleton, busy state) is still showing after the page and all its requests finished, so the user waits for content that never comes.', who: 'Every user of this page.' },
43
50
  'overlapping-elements': { what: 'Buttons or links lie on top of each other, so users may press the wrong one.', who: 'Users on the screen sizes listed, especially on touch screens.' },
44
51
  'small-text': { what: 'Text is smaller than 12 px on a phone and is hard to read.', who: 'Phone users, especially older users.', code: 'body { font-size: 16px; } /* and use rem for smaller text, never below 0.75rem */' },
45
52
  'rtl-missing': { what: 'The page is in a right-to-left language (Arabic…) but is laid out left to right.', who: 'Arabic readers: the text and layout run in the wrong direction.', code: '<html lang="ar" dir="rtl">' },
@@ -170,6 +177,13 @@ export const EXPECTED = {
170
177
  'missing-viewport-meta': 'Phones show the page at its normal size, not zoomed out.',
171
178
  'horizontal-overflow': 'The page fits the screen: no sideways scrolling. Wide content (such as a table) scrolls inside its own box.',
172
179
  'clipped-text': 'All the text is visible inside its box.',
180
+ 'text-outside-box': 'The box grows to fit its text: all the text sits inside it and nothing runs over the page.',
181
+ 'text-overlap': 'Each piece of text has its own space and can be read on its own.',
182
+ 'cut-off-at-edge': 'All the content fits inside the screen.',
183
+ 'control-covered': 'Nothing covers the button or link: a click on it works.',
184
+ 'placeholder-text': 'Every value shows real text: a name, number, date or a clear "not available".',
185
+ 'distorted-image': 'The image keeps its own shape: it fits or is cropped, never stretched.',
186
+ 'stuck-loading': 'When loading ends the indicator goes away and the content (or an error message) is shown.',
173
187
  'overlapping-elements': 'Buttons and links sit apart, each easy to press on its own.',
174
188
  'small-text': 'Text on phones is at least 12 px (16 px for body text) and easy to read.',
175
189
  'rtl-missing': 'The Arabic page reads right to left: text, menus and layout are mirrored.',
@@ -69,6 +69,34 @@ export const RULES = {
69
69
  area: 'responsive', severity: 'minor', title: 'Text is cut off inside its box',
70
70
  fix: 'Let the box grow (remove fixed width/height), allow wrapping, or use text-overflow: ellipsis with a title so the full text can still be read.',
71
71
  },
72
+ 'text-outside-box': {
73
+ area: 'responsive', severity: 'major', title: 'Text spills out of its box',
74
+ fix: 'Let the box fit its text: remove the fixed width/height (use min-width/min-height), allow wrapping, or shorten the text.',
75
+ },
76
+ 'text-overlap': {
77
+ area: 'responsive', severity: 'major', title: 'Text lies on top of other text',
78
+ fix: 'Give each piece of text its own space at this screen size: remove the absolute position or negative margin, or let the container grow.',
79
+ },
80
+ 'cut-off-at-edge': {
81
+ area: 'responsive', severity: 'major', title: 'Content is cut off at the edge of the screen',
82
+ fix: 'Keep the content inside the screen: max-width: 100%, no fixed widths or negative offsets; let long words wrap.',
83
+ },
84
+ 'control-covered': {
85
+ area: 'functionality', severity: 'major', title: 'A button or link is covered by something else',
86
+ fix: 'Move the covering element away, or fix its position/z-index so the button or link can be clicked. Decorative layers should use pointer-events: none.',
87
+ },
88
+ 'placeholder-text': {
89
+ area: 'functionality', severity: 'major', title: 'Unfinished text on the page (undefined, NaN, a translation key…)',
90
+ fix: 'Show real values: handle missing data (show a dash or a message), format dates and numbers, add the missing translations, and remove sample text.',
91
+ },
92
+ 'distorted-image': {
93
+ area: 'responsive', severity: 'minor', title: 'An image is stretched or squashed',
94
+ fix: 'Keep the picture\'s shape: set only the width (height: auto), or use object-fit: cover / contain.',
95
+ },
96
+ 'stuck-loading': {
97
+ area: 'functionality', severity: 'major', title: 'The page is still loading after everything has loaded',
98
+ fix: 'Hide the loading indicator when the data arrives and when a request fails; show the content or an error message.',
99
+ },
72
100
  'overlapping-elements': {
73
101
  area: 'responsive', severity: 'minor', title: 'Buttons or links overlap each other',
74
102
  fix: 'Give the items room at this screen size: wrap them, stack them, or reduce their size. Overlapping controls are hard to tap.',