@krizaka/page-audit 0.7.0 → 0.8.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 CHANGED
@@ -17,23 +17,32 @@ const problems = await auditRoutes({ base, routes: ["/en", "/fr"], checks: { h1:
17
17
  ```
18
18
 
19
19
  `auditContext` / `loadSettled` / `resizeSettled` open a context with reduced motion and no View Transition, and wait for none to run
20
- before anything is measured (yomizaka#354). `prepare(page, route)` signs in or seeds; `launch` gives your own browser.
20
+ before anything is measured (verikora#354). `prepare(page, route)` signs in or seeds; `launch` gives your own browser.
21
21
 
22
22
  ## The dev-preview crawls, in one audit (0.3)
23
23
 
24
24
  Opt-in through `checks` (nothing changes for a repo that does not ask): `status` (HTTP 4xx/5xx or no response), `emptyMain`, `frame`
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
- `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), `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.
27
+ `hardColors`, `maps` (an empty map, folioviv#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), `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 — folioviv#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, routes: [...routes, { url: "/login", chrome: "auth" }, { url: "/chat", session: true, chrome: "app" }],
34
- widths: [{ width: 1440, height: 900 }, { width: 390, height: 844 }],
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: { app: "my-app", baseline: "a11y-baseline.json", widths: [390, 1440], session: { app: "my-app-app", baseline: "a11y-baseline.app.json" } },
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, expect,
54
- signIn: async (page) => { /* the seeded persona signs in through the form */ },
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"] }, // the HTML the server sent
59
- { name: "home", path: "/fr/app", session: true, firstPaint: { hold: ["**/api/me"] } }, // hydrated, account query pending
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.7.0",
3
+ "version": "0.8.1",
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.19.0"
68
+ "@krizaka/config": "^0.21.0"
69
69
  },
70
70
  "engines": {
71
71
  "node": ">=22"
package/src/fixtures.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Small pages that reproduce what the layout audit must catch (yomizaka#435) and one that is fine, for `check-page-audit.mjs`:
1
+ // Small pages that reproduce what the layout audit must catch (verikora#435) and one that is fine, for `check-page-audit.mjs`:
2
2
  // the guard is proven on its own fixtures before it is trusted on the app.
3
3
 
4
4
  const page = (body, css = "") =>
@@ -33,7 +33,7 @@ export const FOLDED_SECTION = page(
33
33
 
34
34
  /** A page whose `<main>` draws nothing. */
35
35
  export const EMPTY_MAIN = page("");
36
- /** The signed-in frame asks 1000 px of a 700 px screen under a 60 px notice (akinaza#444). */
36
+ /** The signed-in frame asks 1000 px of a 700 px screen under a 60 px notice (folioviv#444). */
37
37
  export const FRAME_FORCES_SCROLL = page(
38
38
  `<div style="height:60px">notice</div><div data-app-frame-slot><div style="min-height:1000px">shell</div></div>`,
39
39
  );
@@ -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. */
@@ -55,14 +63,14 @@ export const CHROME_BAD = `<!doctype html><html><body><main><p>Hello</p></main><
55
63
 
56
64
  /** A short token in a table row stretched by a tall neighbour: the cell is tall, its text is one line. */
57
65
  export const TOKEN_IN_STRETCHED_ROW = page(
58
- `<table style="width:600px"><tbody><tr><td>orazaka-media-pack</td><td>${"A long description. ".repeat(30)}</td></tr></tbody></table>`,
66
+ `<table style="width:600px"><tbody><tr><td>sovkora-media-pack</td><td>${"A long description. ".repeat(30)}</td></tr></tbody></table>`,
59
67
  );
60
68
  /** An identifier in a narrow cell that breaks every few characters. */
61
69
  export const TOKEN_WRAPPED = page(
62
70
  `<table style="width:100px;table-layout:fixed"><tbody><tr><td style="word-break:break-all;font-size:20px">abcdefghijklmnopqrstuvwxyz0123456789</td></tr></tbody></table>`,
63
71
  );
64
72
 
65
- /** A map that laid out at 0 px: `absolute inset-0` in a frame with no height of its own (akinaza#488). */
73
+ /** A map that laid out at 0 px: `absolute inset-0` in a frame with no height of its own (folioviv#488). */
66
74
  export const MAP_COLLAPSED = page(
67
75
  `<section><div style="position:relative"><div class="maplibregl-map" style="position:absolute;inset:0"><canvas width="0" height="0"></canvas></div></div></section>`,
68
76
  );
@@ -77,7 +85,7 @@ export const MAP_NO_CANVAS = page(
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>`);
79
87
 
80
- /** Two controls on top of each other: a title button over a row's button (akinaza#214, the composer's « Style » over « Réservation »). */
88
+ /** Two controls on top of each other: a title button over a row's button (folioviv#214, the composer's « Style » over « Réservation »). */
81
89
  export const CONTROLS_OVERLAP = page(
82
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>`,
83
91
  );
package/src/index.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface ProblemChecks {
23
23
  maps?: boolean | { min?: number; required?: boolean };
24
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
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;
26
28
  }
27
29
  export function problemsOf(where: string, result: Record<string, unknown>, checks?: ProblemChecks): string[];
28
30
  export function auditOptions<T extends object>(options?: T): T & { reducedMotion: "reduce" };
@@ -153,6 +153,20 @@ function controlOverlapsOf() {
153
153
  return overlaps;
154
154
  }
155
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
+
156
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 »). */
157
171
  function overlapsOf(panels, spill) {
158
172
  const overlaps = [];
@@ -193,7 +207,7 @@ function cutColumnsOf() {
193
207
  return [...new Set(cut)].slice(0, 3);
194
208
  }
195
209
 
196
- /** The top chrome never overlaps (yomizaka#449): the in-flow blocks above the page (`[data-top-chrome]`, e.g. the development-version notice) and a bar that is fixed or sticky at the top of the viewport. Two of them that share pixels hide one another. */
210
+ /** The top chrome never overlaps (verikora#449): the in-flow blocks above the page (`[data-top-chrome]`, e.g. the development-version notice) and a bar that is fixed or sticky at the top of the viewport. Two of them that share pixels hide one another. */
197
211
  function topBoxOf(el) {
198
212
  return el.getBoundingClientRect().height === 0 && el.firstElementChild ? el.firstElementChild : el;
199
213
  }
@@ -265,7 +279,7 @@ function wrappedOf() {
265
279
  .map((el) => (el.textContent || "").trim().slice(0, 24));
266
280
  }
267
281
 
268
- /** The whole measure of a page. Decorative layers (aria-hidden), fixed elements and anything inside a scroller are left out of the layout probes (yomizaka#435). */
282
+ /** The whole measure of a page. Decorative layers (aria-hidden), fixed elements and anything inside a scroller are left out of the layout probes (verikora#435). */
269
283
  /** A heading with no text (and no image or labelled icon) says nothing to a screen reader. */
270
284
  function emptyHeadingsOf() {
271
285
  return [...document.querySelectorAll("h1, h2, h3")].filter(
@@ -293,7 +307,7 @@ function actionsOf() {
293
307
  }
294
308
 
295
309
  /**
296
- * The signed-in frame's own screen (akinaza#444): its shell (`min-height`) and its sticky rail must fit in what is left of the viewport
310
+ * The signed-in frame's own screen (folioviv#444): its shell (`min-height`) and its sticky rail must fit in what is left of the viewport
297
311
  * under the notice, or every page, an empty one included, scrolls by the notice's height. `null` when the page has no `[data-app-frame-slot]`.
298
312
  */
299
313
  function frameOf(doc) {
@@ -426,6 +440,7 @@ function measurePage() {
426
440
  cutColumns: cutColumnsOf(),
427
441
  chromeOverlaps: chromeOverlapsOf(doc).slice(0, 3),
428
442
  controlOverlaps: controlOverlapsOf().slice(0, 5),
443
+ smallTargets: smallTargetsOf().slice(0, 8),
429
444
  clips: clipsOf(),
430
445
  emptyHeadings: emptyHeadingsOf(),
431
446
  unlabelledFiles: unlabelledFilesOf(),
@@ -463,6 +478,7 @@ const PROBES = [
463
478
  spillOf,
464
479
  seenBox,
465
480
  controlOverlapsOf,
481
+ smallTargetsOf,
466
482
  overlapsOf,
467
483
  cutBy,
468
484
  cutColumnsOf,
@@ -524,6 +540,11 @@ const FLAGGED_CHECKS = [
524
540
  : [],
525
541
  ],
526
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
+ ],
527
548
  [
528
549
  "controlOverlaps",
529
550
  (where, result) =>
@@ -558,7 +579,7 @@ export function problemsOf(
558
579
  export const MIN_MAP_SIDE = 120;
559
580
 
560
581
  /**
561
- * An empty map (akinaza#488): a box under `min` px on a side, a GL map with no canvas, a Leaflet map with no loaded tile.
582
+ * An empty map (folioviv#488): a box under `min` px on a side, a GL map with no canvas, a Leaflet map with no loaded tile.
562
583
  * `maps` is `true` (the default minimum) or `{ min, required }`; with `required`, a page that was expected to draw a map and has none is a problem too.
563
584
  */
564
585
  function mapProblems(where, found, option) {
package/src/self-test.mjs CHANGED
@@ -21,6 +21,8 @@ import {
21
21
  MAP_NO_CANVAS,
22
22
  SECTIONS_OVERLAP,
23
23
  SMALL_ACTION,
24
+ SMALL_TARGET,
25
+ TARGETS_FINE,
24
26
  TOKEN_IN_STRETCHED_ROW,
25
27
  TOKEN_WRAPPED,
26
28
  } from "./fixtures.mjs";
@@ -36,6 +38,7 @@ const EXPECT = [
36
38
  ["an empty <main>", EMPTY_MAIN, /empty <main>/, { emptyMain: true }],
37
39
  ["a frame that forces a scroll", FRAME_FORCES_SCROLL, /the frame forces a scroll/, { frame: true }],
38
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 }],
39
42
  ["a landmark wider than its box", BOX_TOO_WIDE, /content wider than its box/, { boxes: true }],
40
43
  ["a broken picture", BROKEN_PARTS, /broken picture/, { pictures: true }],
41
44
  ["a tile that draws nothing", BROKEN_PARTS, /empty tile/, { emptyControls: true }],
@@ -70,12 +73,14 @@ const ALL = {
70
73
  landmarks: "public",
71
74
  maps: true,
72
75
  controlOverlaps: true,
76
+ smallTargets: true,
73
77
  };
74
78
  const FINES_STRICT = [
75
79
  ["a frame that fits its screen", FRAME_FITS, { frame: true, emptyMain: false }],
76
80
  ["the family chrome", CHROME_FINE, ALL],
77
81
  ["maps with a size and something drawn", MAP_FINE, { maps: true }],
78
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 }],
79
84
  ["a map that is not drawn (display none)", MAP_HIDDEN, { maps: true }],
80
85
  ];
81
86
 
package/src/settle.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // One way for every browser audit to measure (yomizaka#354): never while a View Transition is swapping the page.
1
+ // One way for every browser audit to measure (verikora#354): never while a View Transition is swapping the page.
2
2
  //
3
3
  // The flake: the page was read while a transition was running (« Viewport size changed », 0 of 2 h1). The cure is the
4
4
  // same everywhere, so it lives here and nowhere else: the context emulates reduced motion (the app honours it, the page