@krizaka/page-audit 0.6.0 → 0.8.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/README.md +20 -8
- package/package.json +2 -2
- package/src/fixtures.mjs +17 -0
- package/src/index.d.ts +4 -0
- package/src/page-audit.mjs +99 -29
- package/src/self-test.mjs +15 -0
package/README.md
CHANGED
|
@@ -25,15 +25,24 @@ Opt-in through `checks` (nothing changes for a repo that does not ask): `status`
|
|
|
25
25
|
(the signed-in frame, `[data-app-frame-slot]`, forces a scroll), `minAction` (a button-styled action under N px), `boxes` (a landmark
|
|
26
26
|
wider than its box), `landmarks` (`"public"` one footer naming Krizaka and a nav, `"app"`, `"auth"`), `pictures`, `emptyControls`,
|
|
27
27
|
`hardColors`, `maps` (an empty map, akinaza#488: a GL / Leaflet / `[data-kz-map]` box under 120 px on a side, a GL map with no canvas,
|
|
28
|
-
a Leaflet map with no loaded tile; `maps: { required: true }` also fails a page that should draw one but has none). Console noise the app tolerates: `ignoreConsole: [/…/]` (`[]` counts every error); `attempts` retries a route.
|
|
28
|
+
a Leaflet map with no loaded tile; `maps: { required: true }` also fails a page that should draw one but has none), `smallTargets` (a control under 44 px on a side — an inline link in a sentence is exempt), `controlOverlaps` (two interactive elements that share pixels on screen, one not inside the other — akinaza#214: the composer's title over a row). Console noise the app tolerates: `ignoreConsole: [/…/]` (`[]` counts every error); `attempts` retries a route.
|
|
29
29
|
|
|
30
30
|
```js
|
|
31
31
|
const routes = await routesFromSitemap(base, { first: ["/", "/docs"], limit: 14 }); // a crawl as the route source (null: nothing answers)
|
|
32
32
|
await auditRoutes({
|
|
33
|
-
base,
|
|
34
|
-
|
|
33
|
+
base,
|
|
34
|
+
routes: [...routes, { url: "/login", chrome: "auth" }, { url: "/chat", session: true, chrome: "app" }],
|
|
35
|
+
widths: [
|
|
36
|
+
{ width: 1440, height: 900 },
|
|
37
|
+
{ width: 390, height: 844 },
|
|
38
|
+
],
|
|
35
39
|
session: { storageState }, // or { signIn(context, { base, theme }) {…}, routes: async (page) => […] } for pages found once signed in
|
|
36
|
-
a11y: {
|
|
40
|
+
a11y: {
|
|
41
|
+
app: "my-app",
|
|
42
|
+
baseline: "a11y-baseline.json",
|
|
43
|
+
widths: [390, 1440],
|
|
44
|
+
session: { app: "my-app-app", baseline: "a11y-baseline.app.json" },
|
|
45
|
+
},
|
|
37
46
|
checks: { status: true, emptyMain: true, minAction: 36, landmarks: "public", frame: true },
|
|
38
47
|
});
|
|
39
48
|
```
|
|
@@ -50,13 +59,16 @@ as the runner. It freezes what moves (clock, motion, caret, View Transition, loc
|
|
|
50
59
|
import { test, expect } from "@playwright/test";
|
|
51
60
|
import { defineJourneyTests } from "@krizaka/page-audit/visual";
|
|
52
61
|
defineJourneyTests({
|
|
53
|
-
test,
|
|
54
|
-
|
|
62
|
+
test,
|
|
63
|
+
expect,
|
|
64
|
+
signIn: async (page) => {
|
|
65
|
+
/* the seeded persona signs in through the form */
|
|
66
|
+
},
|
|
55
67
|
journeys: [
|
|
56
68
|
{ name: "landing", path: "/fr" },
|
|
57
69
|
{ name: "home", path: "/fr/app", session: true, mask: ["[data-live]"] },
|
|
58
|
-
{ name: "home", path: "/fr/app", session: true, firstPaint: true, viewports: ["phone"] },
|
|
59
|
-
{ name: "home", path: "/fr/app", session: true, firstPaint: { hold: ["**/api/me"] } },
|
|
70
|
+
{ name: "home", path: "/fr/app", session: true, firstPaint: true, viewports: ["phone"] }, // the HTML the server sent
|
|
71
|
+
{ name: "home", path: "/fr/app", session: true, firstPaint: { hold: ["**/api/me"] } }, // hydrated, account query pending
|
|
60
72
|
],
|
|
61
73
|
});
|
|
62
74
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/page-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The page audit of every Krizaka app, in one place: HTTP status, empty main, horizontal overflow, a card out of its panel, sections covering each other, a column cut off, top chrome overlaps, unnamed buttons, empty headings. Runs in a real browser (Playwright) over a route list, viewports and themes, with View Transitions neutralised and a self-test on its own fixtures.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"eslint": "^9.39.0",
|
|
66
66
|
"playwright-core": "^1.55.0",
|
|
67
67
|
"vitest": "^4.1.11",
|
|
68
|
-
"@krizaka/config": "^0.
|
|
68
|
+
"@krizaka/config": "^0.20.0"
|
|
69
69
|
},
|
|
70
70
|
"engines": {
|
|
71
71
|
"node": ">=22"
|
package/src/fixtures.mjs
CHANGED
|
@@ -43,6 +43,14 @@ export const FRAME_FITS = page(
|
|
|
43
43
|
);
|
|
44
44
|
/** A button-styled action 24 px tall, under the 36 px tap target. */
|
|
45
45
|
export const SMALL_ACTION = page(`<a class="kz-button" href="/" style="display:block;height:24px;width:120px">Go</a>`);
|
|
46
|
+
/** A button 32 px tall next to a 44 px one, and a link inside a sentence (exempt). */
|
|
47
|
+
export const SMALL_TARGET = page(
|
|
48
|
+
`<button style="display:block;height:32px;width:120px">Add</button><p>Read <a href="/">the guide</a> now</p>`,
|
|
49
|
+
);
|
|
50
|
+
/** Controls of 44 px and more, and an inline link. */
|
|
51
|
+
export const TARGETS_FINE = page(
|
|
52
|
+
`<button style="display:block;height:44px;width:120px">Add</button><a href="/" style="display:block;height:48px;width:48px">x</a><p>Read <a href="/">the guide</a> now</p>`,
|
|
53
|
+
);
|
|
46
54
|
/** A landmark whose content is wider than its box. */
|
|
47
55
|
export const BOX_TOO_WIDE = page(`<aside style="width:200px"><div style="width:500px">wide</div></aside>`);
|
|
48
56
|
/** Broken picture, a tile that draws nothing, an inline hard-coded colour. */
|
|
@@ -76,3 +84,12 @@ export const MAP_NO_CANVAS = page(
|
|
|
76
84
|
);
|
|
77
85
|
/** A map that is not drawn (display none: a closed tab, a small screen): nothing to measure. */
|
|
78
86
|
export const MAP_HIDDEN = page(`<section><div class="maplibregl-map" style="display:none"></div></section>`);
|
|
87
|
+
|
|
88
|
+
/** Two controls on top of each other: a title button over a row's button (akinaza#214, the composer's « Style » over « Réservation »). */
|
|
89
|
+
export const CONTROLS_OVERLAP = page(
|
|
90
|
+
`<button style="display:block;width:200px;height:40px">Réservation</button><button style="display:block;width:200px;height:40px;margin-top:-20px;position:relative">Style</button>`,
|
|
91
|
+
);
|
|
92
|
+
/** Controls that sit side by side, a control inside a label, a hidden checkbox and a control scrolled out of its list: none overlaps. */
|
|
93
|
+
export const CONTROLS_FINE = page(
|
|
94
|
+
`<div style="display:flex;gap:8px"><button style="width:100px;height:40px">A</button><button style="width:100px;height:40px">B</button></div><label style="display:block"><input type="checkbox"> Remember</label><div style="height:60px;overflow:auto"><button style="display:block;width:100px;height:40px">1</button><button style="display:block;width:100px;height:40px">2</button><button style="display:block;width:100px;height:40px">3</button></div><button style="display:block;width:100px;height:40px;margin-top:8px">After</button>`,
|
|
95
|
+
);
|
package/src/index.d.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface ProblemChecks {
|
|
|
21
21
|
hardColors?: boolean;
|
|
22
22
|
/** Empty maps (a GL / Leaflet / `[data-kz-map]` box under 120 px, no canvas, no loaded tile); `{ min, required }` to tune, `required` fails a page with no map. */
|
|
23
23
|
maps?: boolean | { min?: number; required?: boolean };
|
|
24
|
+
/** Two interactive elements (buttons, links, inputs, radios…) that share pixels on screen, one not inside the other: one of them cannot be clicked. The box is cut by clipping and scrolling ancestors. */
|
|
25
|
+
controlOverlaps?: boolean;
|
|
26
|
+
/** A control (button, link outside a sentence, input, tab…) drawn under 44 px on a side: too small for a thumb (WCAG 2.5.5). */
|
|
27
|
+
smallTargets?: boolean;
|
|
24
28
|
}
|
|
25
29
|
export function problemsOf(where: string, result: Record<string, unknown>, checks?: ProblemChecks): string[];
|
|
26
30
|
export function auditOptions<T extends object>(options?: T): T & { reducedMotion: "reduce" };
|
package/src/page-audit.mjs
CHANGED
|
@@ -117,6 +117,56 @@ function spillOf(panels) {
|
|
|
117
117
|
return { overflowing, spill };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** What is seen of a control: its box cut by every clipping or scrolling ancestor (a control scrolled out of its list is not on screen). */
|
|
121
|
+
function seenBox(el) {
|
|
122
|
+
const r = el.getBoundingClientRect();
|
|
123
|
+
let box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
124
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
125
|
+
const st = getComputedStyle(n);
|
|
126
|
+
if (st.overflowX === "visible" && st.overflowY === "visible") continue;
|
|
127
|
+
const c = n.getBoundingClientRect();
|
|
128
|
+
box = {
|
|
129
|
+
left: Math.max(box.left, st.overflowX === "visible" ? box.left : c.left),
|
|
130
|
+
right: Math.min(box.right, st.overflowX === "visible" ? box.right : c.right),
|
|
131
|
+
top: Math.max(box.top, st.overflowY === "visible" ? box.top : c.top),
|
|
132
|
+
bottom: Math.min(box.bottom, st.overflowY === "visible" ? box.bottom : c.bottom),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return box;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `controlOverlaps`: two interactive elements, neither inside the other, that share pixels on screen (a header over a row, a radio over its card's text): one of them cannot be clicked. */
|
|
139
|
+
function controlOverlapsOf() {
|
|
140
|
+
const controls =
|
|
141
|
+
'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="link"], [role="combobox"]';
|
|
142
|
+
const drawn = [...document.querySelectorAll(controls)]
|
|
143
|
+
.filter(
|
|
144
|
+
(el) => !clipped(el) && getComputedStyle(el).visibility !== "hidden" && getComputedStyle(el).display !== "none",
|
|
145
|
+
)
|
|
146
|
+
.map((el) => ({ el, box: seenBox(el) }))
|
|
147
|
+
.filter(({ box }) => box.right - box.left > 4 && box.bottom - box.top > 4);
|
|
148
|
+
const overlaps = [];
|
|
149
|
+
drawn.forEach((a, i) => {
|
|
150
|
+
for (const b of drawn.slice(i + 1))
|
|
151
|
+
if (apart(a.el, b.el) && meets(a.box, b.box)) overlaps.push(nameOf(a.el) + " over " + nameOf(b.el));
|
|
152
|
+
});
|
|
153
|
+
return overlaps;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `smallTargets`: the controls drawn on screen under 44 px on a side (WCAG 2.5.5, the thumb): an inline link in a sentence is exempt. */
|
|
157
|
+
function smallTargetsOf() {
|
|
158
|
+
const controls =
|
|
159
|
+
'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="link"], [role="combobox"]';
|
|
160
|
+
return [...document.querySelectorAll(controls)]
|
|
161
|
+
.filter(
|
|
162
|
+
(el) => !clipped(el) && getComputedStyle(el).visibility !== "hidden" && getComputedStyle(el).display !== "none",
|
|
163
|
+
)
|
|
164
|
+
.filter((el) => !(el.matches("a") && getComputedStyle(el).display === "inline"))
|
|
165
|
+
.map((el) => ({ el, r: el.getBoundingClientRect() }))
|
|
166
|
+
.filter(({ r }) => r.width > 4 && r.height > 4 && (r.width < 44 || r.height < 44))
|
|
167
|
+
.map(({ el, r }) => nameOf(el) + " " + Math.round(r.width) + "x" + Math.round(r.height));
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
/** `overlaps`: two sections that are not nested and cover each other, or what ran out of its panel and lands on another section (the card covering « Dernières vérifications »). */
|
|
121
171
|
function overlapsOf(panels, spill) {
|
|
122
172
|
const overlaps = [];
|
|
@@ -389,6 +439,8 @@ function measurePage() {
|
|
|
389
439
|
overlaps: overlapsOf(panels, spill).slice(0, 3),
|
|
390
440
|
cutColumns: cutColumnsOf(),
|
|
391
441
|
chromeOverlaps: chromeOverlapsOf(doc).slice(0, 3),
|
|
442
|
+
controlOverlaps: controlOverlapsOf().slice(0, 5),
|
|
443
|
+
smallTargets: smallTargetsOf().slice(0, 8),
|
|
392
444
|
clips: clipsOf(),
|
|
393
445
|
emptyHeadings: emptyHeadingsOf(),
|
|
394
446
|
unlabelledFiles: unlabelledFilesOf(),
|
|
@@ -424,6 +476,9 @@ const PROBES = [
|
|
|
424
476
|
notDrawn,
|
|
425
477
|
runsOut,
|
|
426
478
|
spillOf,
|
|
479
|
+
seenBox,
|
|
480
|
+
controlOverlapsOf,
|
|
481
|
+
smallTargetsOf,
|
|
427
482
|
overlapsOf,
|
|
428
483
|
cutBy,
|
|
429
484
|
cutColumnsOf,
|
|
@@ -461,41 +516,56 @@ function accessibilityProblems(where, result, fileLabels) {
|
|
|
461
516
|
return problems;
|
|
462
517
|
}
|
|
463
518
|
|
|
519
|
+
/**
|
|
520
|
+
* The checks switched on by a flag of `problemsOf` (a flag that is off produces nothing). A table keeps the function short: each
|
|
521
|
+
* entry reads the page's result and says what the flag asks for.
|
|
522
|
+
*/
|
|
523
|
+
const FLAGGED_CHECKS = [
|
|
524
|
+
["emptyMain", (where, result) => (result.emptyMain ? [`${where}: empty <main>`] : [])],
|
|
525
|
+
["frame", (where, result) => frameProblems(where, result.frame)],
|
|
526
|
+
["minAction", (where, result, minAction) => actionProblems(where, result.actions ?? [], minAction)],
|
|
527
|
+
["boxes", (where, result) => boxProblems(where, result.boxes ?? [])],
|
|
528
|
+
["landmarks", (where, result, landmarks) => landmarkProblems(where, result.landmarks, landmarks)],
|
|
529
|
+
[
|
|
530
|
+
"pictures",
|
|
531
|
+
(where, result) =>
|
|
532
|
+
result.brokenPictures?.length ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`] : [],
|
|
533
|
+
],
|
|
534
|
+
["emptyControls", (where, result) => (result.emptyControls ?? []).map((c) => `${where}: ${c}`)],
|
|
535
|
+
[
|
|
536
|
+
"hardColors",
|
|
537
|
+
(where, result) =>
|
|
538
|
+
result.hardColors
|
|
539
|
+
? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`]
|
|
540
|
+
: [],
|
|
541
|
+
],
|
|
542
|
+
["maps", (where, result, maps) => mapProblems(where, result.maps ?? [], maps)],
|
|
543
|
+
[
|
|
544
|
+
"smallTargets",
|
|
545
|
+
(where, result) =>
|
|
546
|
+
result.smallTargets?.length ? [`${where}: touch target under 44 px (${result.smallTargets.join(", ")})`] : [],
|
|
547
|
+
],
|
|
548
|
+
[
|
|
549
|
+
"controlOverlaps",
|
|
550
|
+
(where, result) =>
|
|
551
|
+
result.controlOverlaps?.length
|
|
552
|
+
? [`${where}: controls overlap, one cannot be clicked (${result.controlOverlaps.join(", ")})`]
|
|
553
|
+
: [],
|
|
554
|
+
],
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
function flaggedProblems(where, result, flags) {
|
|
558
|
+
return FLAGGED_CHECKS.flatMap(([flag, check]) => (flags[flag] ? check(where, result, flags[flag]) : []));
|
|
559
|
+
}
|
|
560
|
+
|
|
464
561
|
export function problemsOf(
|
|
465
562
|
where,
|
|
466
563
|
result,
|
|
467
|
-
{
|
|
468
|
-
errors = [],
|
|
469
|
-
h1 = false,
|
|
470
|
-
headerAction = false,
|
|
471
|
-
fileLabels = false,
|
|
472
|
-
status,
|
|
473
|
-
emptyMain = false,
|
|
474
|
-
frame = false,
|
|
475
|
-
minAction,
|
|
476
|
-
boxes = false,
|
|
477
|
-
landmarks,
|
|
478
|
-
pictures = false,
|
|
479
|
-
emptyControls = false,
|
|
480
|
-
hardColors = false,
|
|
481
|
-
maps = false,
|
|
482
|
-
} = {},
|
|
564
|
+
{ errors = [], h1 = false, headerAction = false, fileLabels = false, status, ...flags } = {},
|
|
483
565
|
) {
|
|
484
566
|
return [
|
|
485
567
|
...statusProblems(where, status),
|
|
486
|
-
...(
|
|
487
|
-
...(frame ? frameProblems(where, result.frame) : []),
|
|
488
|
-
...(minAction ? actionProblems(where, result.actions ?? [], minAction) : []),
|
|
489
|
-
...(boxes ? boxProblems(where, result.boxes ?? []) : []),
|
|
490
|
-
...(landmarks ? landmarkProblems(where, result.landmarks, landmarks) : []),
|
|
491
|
-
...(pictures && result.brokenPictures?.length
|
|
492
|
-
? [`${where}: broken picture ${result.brokenPictures.join(", ")}`]
|
|
493
|
-
: []),
|
|
494
|
-
...(emptyControls ? (result.emptyControls ?? []).map((c) => `${where}: ${c}`) : []),
|
|
495
|
-
...(hardColors && result.hardColors
|
|
496
|
-
? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`]
|
|
497
|
-
: []),
|
|
498
|
-
...(maps ? mapProblems(where, result.maps ?? [], maps) : []),
|
|
568
|
+
...flaggedProblems(where, result, flags),
|
|
499
569
|
...errorsOf(where, errors),
|
|
500
570
|
...layoutProblems(where, result),
|
|
501
571
|
...(h1 ? namingProblems(where, result) : []),
|
package/src/self-test.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
CHROME_BAD,
|
|
8
8
|
CHROME_FINE,
|
|
9
9
|
COLUMN_CUT,
|
|
10
|
+
CONTROLS_FINE,
|
|
11
|
+
CONTROLS_OVERLAP,
|
|
10
12
|
EMPTY_MAIN,
|
|
11
13
|
FINE,
|
|
12
14
|
FOLDED_SECTION,
|
|
@@ -19,6 +21,8 @@ import {
|
|
|
19
21
|
MAP_NO_CANVAS,
|
|
20
22
|
SECTIONS_OVERLAP,
|
|
21
23
|
SMALL_ACTION,
|
|
24
|
+
SMALL_TARGET,
|
|
25
|
+
TARGETS_FINE,
|
|
22
26
|
TOKEN_IN_STRETCHED_ROW,
|
|
23
27
|
TOKEN_WRAPPED,
|
|
24
28
|
} from "./fixtures.mjs";
|
|
@@ -34,6 +38,7 @@ const EXPECT = [
|
|
|
34
38
|
["an empty <main>", EMPTY_MAIN, /empty <main>/, { emptyMain: true }],
|
|
35
39
|
["a frame that forces a scroll", FRAME_FORCES_SCROLL, /the frame forces a scroll/, { frame: true }],
|
|
36
40
|
["an action under the tap target", SMALL_ACTION, /an action is 24 px tall \(< 36\)/, { minAction: 36 }],
|
|
41
|
+
["a control under 44 px", SMALL_TARGET, /touch target under 44 px \(button\. 120x32\)/, { smallTargets: true }],
|
|
37
42
|
["a landmark wider than its box", BOX_TOO_WIDE, /content wider than its box/, { boxes: true }],
|
|
38
43
|
["a broken picture", BROKEN_PARTS, /broken picture/, { pictures: true }],
|
|
39
44
|
["a tile that draws nothing", BROKEN_PARTS, /empty tile/, { emptyControls: true }],
|
|
@@ -42,6 +47,12 @@ const EXPECT = [
|
|
|
42
47
|
["a map with no canvas", MAP_NO_CANVAS, /has no canvas/, { maps: true }],
|
|
43
48
|
["no map where one is required", FINE, /no map on the page/, { maps: { required: true } }],
|
|
44
49
|
["two page footers and no nav", CHROME_BAD, /2 page footers/, { landmarks: "public" }],
|
|
50
|
+
[
|
|
51
|
+
"two controls on top of each other",
|
|
52
|
+
CONTROLS_OVERLAP,
|
|
53
|
+
/controls overlap, one cannot be clicked/,
|
|
54
|
+
{ controlOverlaps: true },
|
|
55
|
+
],
|
|
45
56
|
["no nav landmark", CHROME_BAD, /no <nav> landmark/, { landmarks: "app" }],
|
|
46
57
|
];
|
|
47
58
|
const FINES = [
|
|
@@ -61,11 +72,15 @@ const ALL = {
|
|
|
61
72
|
hardColors: true,
|
|
62
73
|
landmarks: "public",
|
|
63
74
|
maps: true,
|
|
75
|
+
controlOverlaps: true,
|
|
76
|
+
smallTargets: true,
|
|
64
77
|
};
|
|
65
78
|
const FINES_STRICT = [
|
|
66
79
|
["a frame that fits its screen", FRAME_FITS, { frame: true, emptyMain: false }],
|
|
67
80
|
["the family chrome", CHROME_FINE, ALL],
|
|
68
81
|
["maps with a size and something drawn", MAP_FINE, { maps: true }],
|
|
82
|
+
["controls side by side, in a label, in a scroller", CONTROLS_FINE, { controlOverlaps: true }],
|
|
83
|
+
["controls of 44 px and an inline link", TARGETS_FINE, { smallTargets: true }],
|
|
69
84
|
["a map that is not drawn (display none)", MAP_HIDDEN, { maps: true }],
|
|
70
85
|
];
|
|
71
86
|
|