@domandigital/craft 0.21.0 → 0.22.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,11 @@
1
1
  # @domandigital/craft
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3902169: The every-viewport measure counts phone screens to the footer (DOM-1052, part of DOM-772): page height at 375 x 667 over 667, with lazy images loaded. `screens` sets a length budget per route, route pattern or prefix (`{ "/": 6.5, "/services/*": 6, "/areas/[town]": 5, "*": 9 }`, the most specific wins), on `measureViewports`, `measureRoute`, `measureSite` and `viewports.json`. A route over its budget fails a new `screens` check ("page too long for a phone"), baselined like the others; with no budget the count is only reported. `ViewportsResult.screens` and the measured file carry the count, `craft viewports` and `craft phone-first --measured` print it for every route, and `screenBudgetFor`, `checkScreenBudgets` and `formatScreens` are exported. The Bellerose home page ran 12.7 phone screens against a target of 6.5.
8
+
3
9
  ## 0.21.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -371,6 +371,13 @@ the one loaded page in place:
371
371
  `tel:` link.
372
372
  - **On a landscape phone (844 x 390):** header and fixed layers leave at least
373
373
  half the height.
374
+ - **Page length (phone screens):** page height at 375 x 667 over 667, to the
375
+ footer (lazy images loaded). A phone page has a length budget (the Bellerose
376
+ home page ran 12.7 screens against a target of 6.5). Set one per route or
377
+ route pattern with `screens`; a route over its budget fails the `screens`
378
+ check, and with no budget the count is only reported. Keys are a route
379
+ (`/`), a pattern (`/areas/[town]`), a prefix (`/services/*`, the route and
380
+ everything under it) or `*`; the most specific wins.
374
381
  - **At 1440 and 1920:** body lines are at most 80 characters (width over the
375
382
  `ch` of the element's own font) and no image is stretched past its file.
376
383
 
@@ -384,10 +391,16 @@ test.use(PHONE);
384
391
  test(`every viewport: ${route}`, async ({ page }) => {
385
392
  await page.goto(route);
386
393
  // Resizes this page in place; writes phone-first/measured/<route>.json.
387
- await measureViewports(page, { route, mainAction: ".hero a" });
394
+ await measureViewports(page, { route, mainAction: ".hero a", screens: { "/": 6.5, "/services/*": 6 } });
388
395
  });
389
396
  ```
390
397
 
398
+ `result.screens` holds the count, and it is written to the measured file;
399
+ `craft viewports` and `craft phone-first --measured` print it for every route
400
+ (`phone screens to the footer: / 5.8 of 6.5`). In `viewports.json` the budget is
401
+ `"screens": { "/": 6.5, "/services/*": 6 }`; in create-site's
402
+ `site.programme.json` it is `measure.screens`.
403
+
391
404
  `measureViewports` works on the page the suite opened and signed in, so a
392
405
  portal keeps its fixtures. `measureRoute(open, { route, mainAction })` does
393
406
  both halves for such a page: `open("phone")` and `open("wide")` return the
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-OQXTQQAU.js";
4
4
  import {
5
5
  parseFlags
6
- } from "./chunk-4WAIOZ4W.js";
6
+ } from "./chunk-OVWVKMPS.js";
7
7
  import {
8
8
  toJson
9
9
  } from "./chunk-QIEDBL4J.js";
@@ -633,7 +633,7 @@ async function briefFor(flags, cwd) {
633
633
  if (typeof brief === "string" && brief.trim()) return brief;
634
634
  } else if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
635
635
  if (flags.null) {
636
- const { loadNull } = await import("./cli-PBCU7Z64.js");
636
+ const { loadNull } = await import("./cli-SZ4SZLUN.js");
637
637
  return loadNull(flags.null, cwd).brief;
638
638
  }
639
639
  return void 0;
@@ -659,37 +659,37 @@ async function compareWithCompetitor(flags, io) {
659
659
  function run(argv, io) {
660
660
  const [command, ...rest] = argv;
661
661
  if (command === "direction") {
662
- return import("./cli-RHDNHL4U.js").then((m) => m.runDirection(rest, io));
662
+ return import("./cli-R2HBESHI.js").then((m) => m.runDirection(rest, io));
663
663
  }
664
664
  if (command === "report" || command === "retrofit") {
665
- return import("./cli-YSGPGQ7R.js").then((m) => m.runReport(command, rest, io));
665
+ return import("./cli-T7A3BU5J.js").then((m) => m.runReport(command, rest, io));
666
666
  }
667
667
  if (command === "register") {
668
668
  return import("./cli-7QL26YPR.js").then((m) => m.runRegister(rest, io));
669
669
  }
670
670
  if (command === "estate") {
671
- return import("./cli-5QI6NIZN.js").then((m) => m.runEstate(rest, io));
671
+ return import("./cli-WZA5IXSR.js").then((m) => m.runEstate(rest, io));
672
672
  }
673
673
  if (command === "null") {
674
- return import("./cli-PBCU7Z64.js").then((m) => m.runNull(rest, io));
674
+ return import("./cli-SZ4SZLUN.js").then((m) => m.runNull(rest, io));
675
675
  }
676
676
  if (command === "calibrate") {
677
- return import("./cli-5VHX5EJF.js").then((m) => m.runCalibrate(rest, io));
677
+ return import("./cli-AM4FLXGM.js").then((m) => m.runCalibrate(rest, io));
678
678
  }
679
679
  if (command === "tells" && rest[0] === "harvest") {
680
- return import("./cli-PBCU7Z64.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
680
+ return import("./cli-SZ4SZLUN.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
681
681
  io.err(`craft: ${error.message}`);
682
682
  return 2;
683
683
  });
684
684
  }
685
685
  if (command === "phone-first") {
686
- return import("./cli-EYHRJJAX.js").then((m) => m.runPhoneFirst(rest, io));
686
+ return import("./cli-YNIJOEJK.js").then((m) => m.runPhoneFirst(rest, io));
687
687
  }
688
688
  if (command === "viewports") {
689
- return import("./viewports-cli-XYYX4SKV.js").then((m) => m.runViewports(rest, io));
689
+ return import("./viewports-cli-F7KJIVZL.js").then((m) => m.runViewports(rest, io));
690
690
  }
691
691
  if (command === "snapshot" || command === "audit") {
692
- return import("./cli-XAWJWK7I.js").then((m) => m.runAudit(command, rest, io));
692
+ return import("./cli-7DL4BKA7.js").then((m) => m.runAudit(command, rest, io));
693
693
  }
694
694
  if (!command || command === "help" || command === "--help" || command === "-h") {
695
695
  io.out(HELP);
@@ -7,10 +7,10 @@ import {
7
7
  } from "./chunk-G47ZCBD5.js";
8
8
  import {
9
9
  loadNull
10
- } from "./chunk-FNB3NP57.js";
10
+ } from "./chunk-6PZG4MJS.js";
11
11
  import {
12
12
  parseFlags
13
- } from "./chunk-4WAIOZ4W.js";
13
+ } from "./chunk-OVWVKMPS.js";
14
14
  import {
15
15
  toJson
16
16
  } from "./chunk-QIEDBL4J.js";
@@ -147,7 +147,8 @@ var LABEL = {
147
147
  input: "wrong keyboard",
148
148
  autocomplete: "field the phone cannot fill in",
149
149
  tel: "phone number is not a link",
150
- linelength: "line too long"
150
+ linelength: "line too long",
151
+ screens: "page too long for a phone"
151
152
  };
152
153
  function writeBaseline(findings, routes, note, previous) {
153
154
  const byFile = tally(findings, (f) => f.file);
@@ -245,6 +246,102 @@ function judgeRoutes(pages, measuredRoutes, excluded = []) {
245
246
  return { missing: pages.filter((p) => !covered.has(p)), problems, covered: covered.size };
246
247
  }
247
248
 
249
+ // src/phone/viewports.ts
250
+ var LADDER = [
251
+ { name: "320", width: 320, height: 568, phone: true },
252
+ { name: "375", width: 375, height: 812, phone: true },
253
+ { name: "390", width: 390, height: 844, phone: true },
254
+ { name: "430", width: 430, height: 932, phone: true },
255
+ { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
256
+ { name: "768", width: 768, height: 1024, phone: false },
257
+ { name: "1024", width: 1024, height: 768, phone: false },
258
+ { name: "1280", width: 1280, height: 800, phone: false },
259
+ { name: "1440", width: 1440, height: 900, phone: false },
260
+ { name: "1920", width: 1920, height: 1080, phone: false }
261
+ ];
262
+ var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
263
+ var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
264
+ function sweepWidths(sweep = DEFAULT_SWEEP) {
265
+ const out = [];
266
+ for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
267
+ if (out.at(-1) !== sweep.to) out.push(sweep.to);
268
+ return out;
269
+ }
270
+ var DEFAULT_THRESHOLDS = {
271
+ maxLayerShare: 0.25,
272
+ landscapeMaxLayerShare: 0.5,
273
+ oversizeRatio: 2.5,
274
+ upscaleRatio: 1,
275
+ maxLineCh: 80,
276
+ overlapShare: 0.4,
277
+ minImagePx: 32
278
+ };
279
+ var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
280
+ function screenBudgetFor(budgets, route) {
281
+ if (!budgets) return void 0;
282
+ const path = cleanRoute(route);
283
+ let best;
284
+ for (const key of Object.keys(budgets)) {
285
+ const prefix = key.endsWith("/*") ? key.slice(0, -1) : null;
286
+ const hit = key === "*" ? 0 : prefix ? path === key.slice(0, -2) || path.startsWith(prefix) ? 1 + prefix.length : -1 : routeMatches(key, path) ? key === path ? 1e4 : 5e3 + key.length : -1;
287
+ if (hit >= 0 && (!best || hit > best.score)) best = { key, score: hit };
288
+ }
289
+ return best ? budgets[best.key] : void 0;
290
+ }
291
+ function checkScreenBudgets(budgets, where = "screens") {
292
+ if (budgets === void 0) return void 0;
293
+ if (!budgets || typeof budgets !== "object" || Array.isArray(budgets)) throw new Error(`${where} should map a route to its budget in phone screens, such as { "/": 6.5, "/services/*": 6 }`);
294
+ for (const [route, value] of Object.entries(budgets)) {
295
+ if (route !== "*" && !route.startsWith("/")) throw new Error(`${where}: "${route}" should start with / (or be *)`);
296
+ if (typeof value !== "number" || !(value > 0)) throw new Error(`${where}: "${route}" should be a number of phone screens above 0, not ${JSON.stringify(value)}`);
297
+ }
298
+ return budgets;
299
+ }
300
+ function formatScreens(routes, budgets) {
301
+ return routes.filter((r) => r.screens !== void 0).map((r) => {
302
+ const budget = screenBudgetFor(budgets, r.route);
303
+ return `${r.route} ${r.screens}${budget === void 0 ? "" : ` of ${budget}`}`;
304
+ }).join(", ");
305
+ }
306
+ function formatWidths(failing, tested) {
307
+ const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
308
+ const rank2 = new Map(plain.map((name, i) => [name, i]));
309
+ const names = [...new Set(failing)];
310
+ const runs = [];
311
+ for (const name of names.filter((n) => rank2.has(n)).sort((a, b) => rank2.get(a) - rank2.get(b))) {
312
+ const last = runs.at(-1);
313
+ if (last && rank2.get(name) === rank2.get(last.at(-1)) + 1) last.push(name);
314
+ else runs.push([name]);
315
+ }
316
+ const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
317
+ return [...out, ...names.filter((n) => !rank2.has(n))].join(", ");
318
+ }
319
+ var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
320
+ function mergeViewportFindings(perViewport, tested) {
321
+ const merged = /* @__PURE__ */ new Map();
322
+ for (const { viewport, findings } of perViewport) {
323
+ const counts = /* @__PURE__ */ new Map();
324
+ for (const f of findings) {
325
+ const k = keyOf(f);
326
+ const had = counts.get(k);
327
+ if (had) had.n++;
328
+ else counts.set(k, { first: f, n: 1 });
329
+ }
330
+ for (const [k, { first, n }] of counts) {
331
+ const had = merged.get(k);
332
+ if (had) {
333
+ had.at.push(viewport);
334
+ had.most = Math.max(had.most, n);
335
+ } else merged.set(k, { first, at: [viewport], most: n });
336
+ }
337
+ }
338
+ const out = [];
339
+ for (const { first, at, most } of merged.values()) {
340
+ for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
341
+ }
342
+ return out;
343
+ }
344
+
248
345
  export {
249
346
  parseBaseline,
250
347
  EMPTY_BASELINE,
@@ -257,5 +354,15 @@ export {
257
354
  hasUnexcusedEntries,
258
355
  routesFromPages,
259
356
  routesFromManifest,
260
- judgeRoutes
357
+ judgeRoutes,
358
+ LADDER,
359
+ SHORT_PHONE,
360
+ DEFAULT_SWEEP,
361
+ sweepWidths,
362
+ DEFAULT_THRESHOLDS,
363
+ mainActionFor,
364
+ screenBudgetFor,
365
+ checkScreenBudgets,
366
+ formatScreens,
367
+ mergeViewportFindings
261
368
  };
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  describeTypicality,
7
7
  loadNull
8
- } from "./chunk-FNB3NP57.js";
8
+ } from "./chunk-6PZG4MJS.js";
9
9
  import {
10
10
  SOURCE_FILE,
11
11
  finish,
@@ -14,7 +14,7 @@ import {
14
14
  parseFlags,
15
15
  prepareReport,
16
16
  readPaths
17
- } from "./chunk-4WAIOZ4W.js";
17
+ } from "./chunk-OVWVKMPS.js";
18
18
  import {
19
19
  toJson
20
20
  } from "./chunk-QIEDBL4J.js";
@@ -4,12 +4,12 @@ import {
4
4
  merge,
5
5
  resolvePages,
6
6
  runAudit
7
- } from "./chunk-BYXDX66D.js";
7
+ } from "./chunk-ZS7PQNJU.js";
8
8
  import "./chunk-6DRBFDWT.js";
9
9
  import "./chunk-JYNXGCYZ.js";
10
- import "./chunk-FNB3NP57.js";
10
+ import "./chunk-6PZG4MJS.js";
11
11
  import "./chunk-OQXTQQAU.js";
12
- import "./chunk-4WAIOZ4W.js";
12
+ import "./chunk-OVWVKMPS.js";
13
13
  import "./chunk-QIEDBL4J.js";
14
14
  import "./chunk-WUACK2HX.js";
15
15
  import "./chunk-DE2ZAV4G.js";
@@ -4,11 +4,11 @@ import {
4
4
  import {
5
5
  expandNullTargets,
6
6
  loadNull
7
- } from "./chunk-FNB3NP57.js";
7
+ } from "./chunk-6PZG4MJS.js";
8
8
  import "./chunk-OQXTQQAU.js";
9
9
  import {
10
10
  parseFlags
11
- } from "./chunk-4WAIOZ4W.js";
11
+ } from "./chunk-OVWVKMPS.js";
12
12
  import {
13
13
  toJson
14
14
  } from "./chunk-QIEDBL4J.js";
@@ -22,7 +22,7 @@ import {
22
22
  } from "./chunk-G47ZCBD5.js";
23
23
  import {
24
24
  parseFlags
25
- } from "./chunk-4WAIOZ4W.js";
25
+ } from "./chunk-OVWVKMPS.js";
26
26
  import {
27
27
  toJson
28
28
  } from "./chunk-QIEDBL4J.js";
@@ -9,9 +9,9 @@ import {
9
9
  runHarvest,
10
10
  runNull,
11
11
  serveDir
12
- } from "./chunk-FNB3NP57.js";
12
+ } from "./chunk-6PZG4MJS.js";
13
13
  import "./chunk-OQXTQQAU.js";
14
- import "./chunk-4WAIOZ4W.js";
14
+ import "./chunk-OVWVKMPS.js";
15
15
  import "./chunk-QIEDBL4J.js";
16
16
  import "./chunk-WUACK2HX.js";
17
17
  import "./chunk-DE2ZAV4G.js";
@@ -3,21 +3,21 @@ import {
3
3
  } from "./chunk-OKVHWXYV.js";
4
4
  import {
5
5
  merge
6
- } from "./chunk-BYXDX66D.js";
6
+ } from "./chunk-ZS7PQNJU.js";
7
7
  import "./chunk-6DRBFDWT.js";
8
8
  import "./chunk-JYNXGCYZ.js";
9
9
  import {
10
10
  SIBLING_AT,
11
11
  compareToEstate,
12
12
  loadEstate
13
- } from "./chunk-STWH5EYU.js";
13
+ } from "./chunk-RBQCGDQQ.js";
14
14
  import {
15
15
  PAGE_TYPES,
16
16
  validateDirection
17
17
  } from "./chunk-G47ZCBD5.js";
18
18
  import {
19
19
  loadNull
20
- } from "./chunk-FNB3NP57.js";
20
+ } from "./chunk-6PZG4MJS.js";
21
21
  import "./chunk-OQXTQQAU.js";
22
22
  import {
23
23
  COPY_FILE,
@@ -28,7 +28,7 @@ import {
28
28
  loadExceptions,
29
29
  parseFlags,
30
30
  readPaths
31
- } from "./chunk-4WAIOZ4W.js";
31
+ } from "./chunk-OVWVKMPS.js";
32
32
  import {
33
33
  toJson
34
34
  } from "./chunk-QIEDBL4J.js";
@@ -4,11 +4,11 @@ import {
4
4
  formatPairs,
5
5
  loadEstate,
6
6
  runEstate
7
- } from "./chunk-STWH5EYU.js";
7
+ } from "./chunk-RBQCGDQQ.js";
8
8
  import "./chunk-G47ZCBD5.js";
9
- import "./chunk-FNB3NP57.js";
9
+ import "./chunk-6PZG4MJS.js";
10
10
  import "./chunk-OQXTQQAU.js";
11
- import "./chunk-4WAIOZ4W.js";
11
+ import "./chunk-OVWVKMPS.js";
12
12
  import "./chunk-QIEDBL4J.js";
13
13
  import "./chunk-WUACK2HX.js";
14
14
  import "./chunk-DE2ZAV4G.js";
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  EMPTY_BASELINE,
3
3
  checkExceptions,
4
+ formatScreens,
4
5
  hasUnexcusedEntries,
5
6
  judgeGrowth,
6
7
  judgeMeasured,
@@ -11,11 +12,11 @@ import {
11
12
  routesFromPages,
12
13
  scanPhoneFirst,
13
14
  writeBaseline
14
- } from "./chunk-BQ7H23QP.js";
15
+ } from "./chunk-XSKLVEI6.js";
15
16
  import {
16
17
  SOURCE_FILE,
17
18
  readPaths
18
- } from "./chunk-4WAIOZ4W.js";
19
+ } from "./chunk-OVWVKMPS.js";
19
20
  import {
20
21
  toJson
21
22
  } from "./chunk-QIEDBL4J.js";
@@ -213,7 +214,9 @@ ${lines.map((l) => ` ${l}`).join("\n")}` : "";
213
214
  covered && app.routes.length ? `${app.routes.length} page route${app.routes.length === 1 ? "" : "s"} covered` : null
214
215
  ].filter(Boolean).join(", ");
215
216
  if (!failed) {
216
- io.out(`craft phone-first: pass. ${summary}.${[baseNote, routeNote].filter(Boolean).map((n) => `
217
+ const screens = routes ? formatScreens(routes) : "";
218
+ io.out(`craft phone-first: pass. ${summary}.${screens ? `
219
+ phone screens to the footer: ${screens}` : ""}${[baseNote, routeNote].filter(Boolean).map((n) => `
217
220
  note: ${n}`).join("")}`);
218
221
  return 0;
219
222
  }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  run
4
- } from "./chunk-4WAIOZ4W.js";
4
+ } from "./chunk-OVWVKMPS.js";
5
5
  import "./chunk-QIEDBL4J.js";
6
6
  import "./chunk-DE2ZAV4G.js";
7
7
  import "./chunk-ONA73THJ.js";
package/dist/phone.cjs CHANGED
@@ -33,7 +33,9 @@ __export(phone_exports, {
33
33
  WIDE_WIDTHS: () => WIDE_WIDTHS,
34
34
  bandOf: () => bandOf,
35
35
  checkExceptions: () => checkExceptions,
36
+ checkScreenBudgets: () => checkScreenBudgets,
36
37
  cleanRoute: () => cleanRoute,
38
+ formatScreens: () => formatScreens,
37
39
  formatWidths: () => formatWidths,
38
40
  hasReason: () => hasReason,
39
41
  hasUnexcusedEntries: () => hasUnexcusedEntries,
@@ -57,6 +59,7 @@ __export(phone_exports, {
57
59
  routesFromManifest: () => routesFromManifest,
58
60
  routesFromPages: () => routesFromPages,
59
61
  scanPhoneFirst: () => scanPhoneFirst,
62
+ screenBudgetFor: () => screenBudgetFor,
60
63
  sweepWidths: () => sweepWidths,
61
64
  writeBaseline: () => writeBaseline
62
65
  });
@@ -580,7 +583,8 @@ var LABEL = {
580
583
  input: "wrong keyboard",
581
584
  autocomplete: "field the phone cannot fill in",
582
585
  tel: "phone number is not a link",
583
- linelength: "line too long"
586
+ linelength: "line too long",
587
+ screens: "page too long for a phone"
584
588
  };
585
589
  function writeBaseline(findings, routes, note, previous) {
586
590
  const byFile = tally(findings, (f) => f.file);
@@ -718,6 +722,32 @@ var DEFAULT_THRESHOLDS = {
718
722
  minImagePx: 32
719
723
  };
720
724
  var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
725
+ function screenBudgetFor(budgets, route) {
726
+ if (!budgets) return void 0;
727
+ const path = cleanRoute(route);
728
+ let best;
729
+ for (const key of Object.keys(budgets)) {
730
+ const prefix = key.endsWith("/*") ? key.slice(0, -1) : null;
731
+ const hit = key === "*" ? 0 : prefix ? path === key.slice(0, -2) || path.startsWith(prefix) ? 1 + prefix.length : -1 : routeMatches(key, path) ? key === path ? 1e4 : 5e3 + key.length : -1;
732
+ if (hit >= 0 && (!best || hit > best.score)) best = { key, score: hit };
733
+ }
734
+ return best ? budgets[best.key] : void 0;
735
+ }
736
+ function checkScreenBudgets(budgets, where = "screens") {
737
+ if (budgets === void 0) return void 0;
738
+ if (!budgets || typeof budgets !== "object" || Array.isArray(budgets)) throw new Error(`${where} should map a route to its budget in phone screens, such as { "/": 6.5, "/services/*": 6 }`);
739
+ for (const [route, value] of Object.entries(budgets)) {
740
+ if (route !== "*" && !route.startsWith("/")) throw new Error(`${where}: "${route}" should start with / (or be *)`);
741
+ if (typeof value !== "number" || !(value > 0)) throw new Error(`${where}: "${route}" should be a number of phone screens above 0, not ${JSON.stringify(value)}`);
742
+ }
743
+ return budgets;
744
+ }
745
+ function formatScreens(routes, budgets) {
746
+ return routes.filter((r) => r.screens !== void 0).map((r) => {
747
+ const budget = screenBudgetFor(budgets, r.route);
748
+ return `${r.route} ${r.screens}${budget === void 0 ? "" : ` of ${budget}`}`;
749
+ }).join(", ");
750
+ }
721
751
  function formatWidths(failing, tested) {
722
752
  const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
723
753
  const rank2 = new Map(plain.map((name, i) => [name, i]));
@@ -814,6 +844,7 @@ async function collect(page, options) {
814
844
  const mainAction = mainActionFor(options.mainAction, options.route);
815
845
  const perViewport = [];
816
846
  const tested = [];
847
+ let screens;
817
848
  for (const stop of plan(options)) {
818
849
  await page.setViewportSize({ width: stop.width, height: stop.height });
819
850
  await settle(page, stop.ladder ? 150 : 60);
@@ -822,20 +853,36 @@ async function collect(page, options) {
822
853
  const findings = [];
823
854
  if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
824
855
  findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
856
+ if (stop.name === SHORT_PHONE.name) {
857
+ const height = await page.evaluate(() => document.documentElement.scrollHeight, void 0);
858
+ screens = Math.round(height / stop.height * 10) / 10;
859
+ const budget = screenBudgetFor(options.screens, options.route);
860
+ if (budget !== void 0 && screens > budget) findings.push({ check: "screens", element: "<page>", detail: `${screens} phone screens to the footer at ${stop.width} x ${stop.height}, over the budget of ${budget} for ${options.route}` });
861
+ }
825
862
  perViewport.push({ viewport: stop.name, findings });
826
863
  tested.push(stop.name);
827
864
  }
828
- return { perViewport, tested };
865
+ return { perViewport, tested, ...screens !== void 0 ? { screens } : {} };
866
+ }
867
+ function mergeCollected(collected) {
868
+ const screens = collected.find((c) => c.screens !== void 0)?.screens;
869
+ return { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested), ...screens !== void 0 ? { screens } : {} };
829
870
  }
830
871
  function finish(route, collected, out) {
831
- const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
872
+ const result = {
873
+ route,
874
+ viewports: collected.tested,
875
+ ...collected.screens !== void 0 ? { screens: collected.screens } : {},
876
+ findings: mergeViewportFindings(collected.perViewport, collected.tested),
877
+ perViewport: collected.perViewport
878
+ };
832
879
  if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
833
880
  return result;
834
881
  }
835
882
  function writeMeasured(result, dir) {
836
883
  (0, import_node_fs.mkdirSync)(dir, { recursive: true });
837
- const { route, viewport, viewports, findings } = result;
838
- (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
884
+ const { route, viewport, viewports, screens, findings } = result;
885
+ (0, import_node_fs.writeFileSync)((0, import_node_path.join)(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, ...screens !== void 0 ? { screens } : {}, findings }, null, 1)}
839
886
  `);
840
887
  }
841
888
  async function measureViewports(page, options) {
@@ -856,7 +903,7 @@ async function measureRoute(open, options) {
856
903
  await close?.();
857
904
  }
858
905
  }
859
- return finish(options.route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out);
906
+ return finish(options.route, mergeCollected(collected), options.out);
860
907
  }
861
908
  async function measureSite(browser, options) {
862
909
  const result = { routes: [], errors: [] };
@@ -885,7 +932,7 @@ async function measureSite(browser, options) {
885
932
  result.errors.push({ route, error: failed });
886
933
  continue;
887
934
  }
888
- result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
935
+ result.routes.push(finish(route, mergeCollected(collected), options.out));
889
936
  }
890
937
  return result;
891
938
  }
@@ -918,7 +965,9 @@ async function measurePhone(page, options) {
918
965
  WIDE_WIDTHS,
919
966
  bandOf,
920
967
  checkExceptions,
968
+ checkScreenBudgets,
921
969
  cleanRoute,
970
+ formatScreens,
922
971
  formatWidths,
923
972
  hasReason,
924
973
  hasUnexcusedEntries,
@@ -942,6 +991,7 @@ async function measurePhone(page, options) {
942
991
  routesFromManifest,
943
992
  routesFromPages,
944
993
  scanPhoneFirst,
994
+ screenBudgetFor,
945
995
  sweepWidths,
946
996
  writeBaseline
947
997
  });
package/dist/phone.d.cts CHANGED
@@ -28,7 +28,7 @@ interface PhoneFloors {
28
28
  * What a measure can find. The first five are the 375px floors (DOM-775); the
29
29
  * rest are the every-viewport and phone-optimisation checks (DOM-787).
30
30
  */
31
- type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength";
31
+ type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength" | "screens";
32
32
  interface RawPhoneFinding {
33
33
  check: PhoneCheck;
34
34
  /** `<tag> "text"`: what a person looks for on the page. */
@@ -92,6 +92,8 @@ interface MeasuredRoute {
92
92
  };
93
93
  /** Set by the every-viewport measure: the viewports and widths the findings were gathered across, phone first. */
94
94
  viewports?: string[];
95
+ /** Phone screens to the footer (page height at 375 x 667 over 667), from the every-viewport measure. Absent when the short phone was not measured. */
96
+ screens?: number;
95
97
  findings: RawPhoneFinding[];
96
98
  }
97
99
  interface Baseline {
@@ -266,6 +268,23 @@ declare const DEFAULT_THRESHOLDS: Thresholds;
266
268
  /** The main action, as a selector: one for the whole site, or one per route (`*` is the fallback). */
267
269
  type MainAction = string | Record<string, string>;
268
270
  declare const mainActionFor: (config: MainAction | undefined, route: string) => string | undefined;
271
+ /**
272
+ * A length budget per page type, in phone screens to the footer (page height at
273
+ * 375 x 667 over 667). Keys are a route (`/`), a route pattern (`/services/[slug]`),
274
+ * a prefix (`/services/*`: the route and everything under it) or `*` for the
275
+ * rest; the most specific match wins. No match is no budget: the count is
276
+ * reported and never fails.
277
+ */
278
+ type ScreenBudgets = Record<string, number>;
279
+ /** The budget for a route, or undefined when none of the keys covers it. */
280
+ declare function screenBudgetFor(budgets: ScreenBudgets | undefined, route: string): number | undefined;
281
+ /** `screens` as a person types it into config: every budget a positive number. */
282
+ declare function checkScreenBudgets(budgets: unknown, where?: string): ScreenBudgets | undefined;
283
+ /** `/ 12.7, /services 4.1`: the count per route, for the line a run prints whether or not a budget is set. */
284
+ declare function formatScreens(routes: {
285
+ route: string;
286
+ screens?: number;
287
+ }[], budgets?: ScreenBudgets): string;
269
288
  /**
270
289
  * Widths as a person reads them: `320-352, 400, 1440`. A run is consecutive
271
290
  * tested widths that all fail. A landscape or short-phone name (`844x390`)
@@ -385,6 +404,13 @@ interface MeasureViewportsOptions {
385
404
  floors?: Partial<PhoneFloors>;
386
405
  /** The main action's selector: one for all routes, or per route with `*` as the fallback. Absent: not checked. */
387
406
  mainAction?: MainAction;
407
+ /**
408
+ * A length budget per route, in phone screens to the footer (page height at
409
+ * 375 x 667 over 667): `{ "/": 6.5, "/services/*": 6 }`. A route over its
410
+ * budget fails the `screens` check. Without a budget the count is only
411
+ * reported, in `ViewportsResult.screens`.
412
+ */
413
+ screens?: ScreenBudgets;
388
414
  /** The width sweep, or `false` to measure the ladder only. */
389
415
  sweep?: Partial<Sweep> | false;
390
416
  /** Replace the ladder. */
@@ -522,4 +548,4 @@ interface MeasurePhoneOptions {
522
548
  */
523
549
  declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
524
550
 
525
- export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type ExcludedRoute, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type RouteJudgement, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, cleanRoute, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, sweepWidths, writeBaseline };
551
+ export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type ExcludedRoute, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type RouteJudgement, SHORT_PHONE, type ScreenBudgets, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, checkScreenBudgets, cleanRoute, formatScreens, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, screenBudgetFor, sweepWidths, writeBaseline };
package/dist/phone.d.ts CHANGED
@@ -28,7 +28,7 @@ interface PhoneFloors {
28
28
  * What a measure can find. The first five are the 375px floors (DOM-775); the
29
29
  * rest are the every-viewport and phone-optimisation checks (DOM-787).
30
30
  */
31
- type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength";
31
+ type PhoneCheck = "scroll" | "target" | "field" | "text" | "title" | "overlap" | "action" | "layers" | "landscape" | "image" | "input" | "autocomplete" | "tel" | "linelength" | "screens";
32
32
  interface RawPhoneFinding {
33
33
  check: PhoneCheck;
34
34
  /** `<tag> "text"`: what a person looks for on the page. */
@@ -92,6 +92,8 @@ interface MeasuredRoute {
92
92
  };
93
93
  /** Set by the every-viewport measure: the viewports and widths the findings were gathered across, phone first. */
94
94
  viewports?: string[];
95
+ /** Phone screens to the footer (page height at 375 x 667 over 667), from the every-viewport measure. Absent when the short phone was not measured. */
96
+ screens?: number;
95
97
  findings: RawPhoneFinding[];
96
98
  }
97
99
  interface Baseline {
@@ -266,6 +268,23 @@ declare const DEFAULT_THRESHOLDS: Thresholds;
266
268
  /** The main action, as a selector: one for the whole site, or one per route (`*` is the fallback). */
267
269
  type MainAction = string | Record<string, string>;
268
270
  declare const mainActionFor: (config: MainAction | undefined, route: string) => string | undefined;
271
+ /**
272
+ * A length budget per page type, in phone screens to the footer (page height at
273
+ * 375 x 667 over 667). Keys are a route (`/`), a route pattern (`/services/[slug]`),
274
+ * a prefix (`/services/*`: the route and everything under it) or `*` for the
275
+ * rest; the most specific match wins. No match is no budget: the count is
276
+ * reported and never fails.
277
+ */
278
+ type ScreenBudgets = Record<string, number>;
279
+ /** The budget for a route, or undefined when none of the keys covers it. */
280
+ declare function screenBudgetFor(budgets: ScreenBudgets | undefined, route: string): number | undefined;
281
+ /** `screens` as a person types it into config: every budget a positive number. */
282
+ declare function checkScreenBudgets(budgets: unknown, where?: string): ScreenBudgets | undefined;
283
+ /** `/ 12.7, /services 4.1`: the count per route, for the line a run prints whether or not a budget is set. */
284
+ declare function formatScreens(routes: {
285
+ route: string;
286
+ screens?: number;
287
+ }[], budgets?: ScreenBudgets): string;
269
288
  /**
270
289
  * Widths as a person reads them: `320-352, 400, 1440`. A run is consecutive
271
290
  * tested widths that all fail. A landscape or short-phone name (`844x390`)
@@ -385,6 +404,13 @@ interface MeasureViewportsOptions {
385
404
  floors?: Partial<PhoneFloors>;
386
405
  /** The main action's selector: one for all routes, or per route with `*` as the fallback. Absent: not checked. */
387
406
  mainAction?: MainAction;
407
+ /**
408
+ * A length budget per route, in phone screens to the footer (page height at
409
+ * 375 x 667 over 667): `{ "/": 6.5, "/services/*": 6 }`. A route over its
410
+ * budget fails the `screens` check. Without a budget the count is only
411
+ * reported, in `ViewportsResult.screens`.
412
+ */
413
+ screens?: ScreenBudgets;
388
414
  /** The width sweep, or `false` to measure the ladder only. */
389
415
  sweep?: Partial<Sweep> | false;
390
416
  /** Replace the ladder. */
@@ -522,4 +548,4 @@ interface MeasurePhoneOptions {
522
548
  */
523
549
  declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
524
550
 
525
- export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type ExcludedRoute, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type RouteJudgement, SHORT_PHONE, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, cleanRoute, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, sweepWidths, writeBaseline };
551
+ export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, type ExcludedRoute, type FileException, type InPageGroup, type Judgement, LADDER, type MainAction, type MeasurePhoneOptions, type MeasureSiteOptions, type MeasureViewportsOptions, type MeasuredRoute, type OpenedPage, PHONE, PHONE_FLOORS, type PhoneCheck, type PhoneFloors, type RawPhoneFinding, type RouteJudgement, SHORT_PHONE, type ScreenBudgets, type SiteBrowser, type SitePage, type SiteResult, type StaticFinding, type Sweep, type Thresholds, type Viewport, type ViewportArg, type ViewportPage, type ViewportsResult, WIDE_WIDTHS, bandOf, checkExceptions, checkScreenBudgets, cleanRoute, formatScreens, formatWidths, hasReason, hasUnexcusedEntries, judgeGrowth, judgeMeasured, judgeRoutes, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, parseBaseline, reasonOf, routeFile, routeMatches, routesFromManifest, routesFromPages, scanPhoneFirst, screenBudgetFor, sweepWidths, writeBaseline };
package/dist/phone.js CHANGED
@@ -518,7 +518,8 @@ var LABEL = {
518
518
  input: "wrong keyboard",
519
519
  autocomplete: "field the phone cannot fill in",
520
520
  tel: "phone number is not a link",
521
- linelength: "line too long"
521
+ linelength: "line too long",
522
+ screens: "page too long for a phone"
522
523
  };
523
524
  function writeBaseline(findings, routes, note, previous) {
524
525
  const byFile = tally(findings, (f) => f.file);
@@ -656,6 +657,32 @@ var DEFAULT_THRESHOLDS = {
656
657
  minImagePx: 32
657
658
  };
658
659
  var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
660
+ function screenBudgetFor(budgets, route) {
661
+ if (!budgets) return void 0;
662
+ const path = cleanRoute(route);
663
+ let best;
664
+ for (const key of Object.keys(budgets)) {
665
+ const prefix = key.endsWith("/*") ? key.slice(0, -1) : null;
666
+ const hit = key === "*" ? 0 : prefix ? path === key.slice(0, -2) || path.startsWith(prefix) ? 1 + prefix.length : -1 : routeMatches(key, path) ? key === path ? 1e4 : 5e3 + key.length : -1;
667
+ if (hit >= 0 && (!best || hit > best.score)) best = { key, score: hit };
668
+ }
669
+ return best ? budgets[best.key] : void 0;
670
+ }
671
+ function checkScreenBudgets(budgets, where = "screens") {
672
+ if (budgets === void 0) return void 0;
673
+ if (!budgets || typeof budgets !== "object" || Array.isArray(budgets)) throw new Error(`${where} should map a route to its budget in phone screens, such as { "/": 6.5, "/services/*": 6 }`);
674
+ for (const [route, value] of Object.entries(budgets)) {
675
+ if (route !== "*" && !route.startsWith("/")) throw new Error(`${where}: "${route}" should start with / (or be *)`);
676
+ if (typeof value !== "number" || !(value > 0)) throw new Error(`${where}: "${route}" should be a number of phone screens above 0, not ${JSON.stringify(value)}`);
677
+ }
678
+ return budgets;
679
+ }
680
+ function formatScreens(routes, budgets) {
681
+ return routes.filter((r) => r.screens !== void 0).map((r) => {
682
+ const budget = screenBudgetFor(budgets, r.route);
683
+ return `${r.route} ${r.screens}${budget === void 0 ? "" : ` of ${budget}`}`;
684
+ }).join(", ");
685
+ }
659
686
  function formatWidths(failing, tested) {
660
687
  const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
661
688
  const rank2 = new Map(plain.map((name, i) => [name, i]));
@@ -752,6 +779,7 @@ async function collect(page, options) {
752
779
  const mainAction = mainActionFor(options.mainAction, options.route);
753
780
  const perViewport = [];
754
781
  const tested = [];
782
+ let screens;
755
783
  for (const stop of plan(options)) {
756
784
  await page.setViewportSize({ width: stop.width, height: stop.height });
757
785
  await settle(page, stop.ladder ? 150 : 60);
@@ -760,20 +788,36 @@ async function collect(page, options) {
760
788
  const findings = [];
761
789
  if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
762
790
  findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
791
+ if (stop.name === SHORT_PHONE.name) {
792
+ const height = await page.evaluate(() => document.documentElement.scrollHeight, void 0);
793
+ screens = Math.round(height / stop.height * 10) / 10;
794
+ const budget = screenBudgetFor(options.screens, options.route);
795
+ if (budget !== void 0 && screens > budget) findings.push({ check: "screens", element: "<page>", detail: `${screens} phone screens to the footer at ${stop.width} x ${stop.height}, over the budget of ${budget} for ${options.route}` });
796
+ }
763
797
  perViewport.push({ viewport: stop.name, findings });
764
798
  tested.push(stop.name);
765
799
  }
766
- return { perViewport, tested };
800
+ return { perViewport, tested, ...screens !== void 0 ? { screens } : {} };
801
+ }
802
+ function mergeCollected(collected) {
803
+ const screens = collected.find((c) => c.screens !== void 0)?.screens;
804
+ return { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested), ...screens !== void 0 ? { screens } : {} };
767
805
  }
768
806
  function finish(route, collected, out) {
769
- const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
807
+ const result = {
808
+ route,
809
+ viewports: collected.tested,
810
+ ...collected.screens !== void 0 ? { screens: collected.screens } : {},
811
+ findings: mergeViewportFindings(collected.perViewport, collected.tested),
812
+ perViewport: collected.perViewport
813
+ };
770
814
  if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
771
815
  return result;
772
816
  }
773
817
  function writeMeasured(result, dir) {
774
818
  mkdirSync(dir, { recursive: true });
775
- const { route, viewport, viewports, findings } = result;
776
- writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
819
+ const { route, viewport, viewports, screens, findings } = result;
820
+ writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, ...screens !== void 0 ? { screens } : {}, findings }, null, 1)}
777
821
  `);
778
822
  }
779
823
  async function measureViewports(page, options) {
@@ -794,7 +838,7 @@ async function measureRoute(open, options) {
794
838
  await close?.();
795
839
  }
796
840
  }
797
- return finish(options.route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out);
841
+ return finish(options.route, mergeCollected(collected), options.out);
798
842
  }
799
843
  async function measureSite(browser, options) {
800
844
  const result = { routes: [], errors: [] };
@@ -823,7 +867,7 @@ async function measureSite(browser, options) {
823
867
  result.errors.push({ route, error: failed });
824
868
  continue;
825
869
  }
826
- result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
870
+ result.routes.push(finish(route, mergeCollected(collected), options.out));
827
871
  }
828
872
  return result;
829
873
  }
@@ -855,7 +899,9 @@ export {
855
899
  WIDE_WIDTHS,
856
900
  bandOf,
857
901
  checkExceptions,
902
+ checkScreenBudgets,
858
903
  cleanRoute,
904
+ formatScreens,
859
905
  formatWidths,
860
906
  hasReason,
861
907
  hasUnexcusedEntries,
@@ -879,6 +925,7 @@ export {
879
925
  routesFromManifest,
880
926
  routesFromPages,
881
927
  scanPhoneFirst,
928
+ screenBudgetFor,
882
929
  sweepWidths,
883
930
  writeBaseline
884
931
  };
@@ -1,9 +1,19 @@
1
1
  import {
2
+ DEFAULT_SWEEP,
3
+ DEFAULT_THRESHOLDS,
2
4
  EMPTY_BASELINE,
5
+ LADDER,
6
+ SHORT_PHONE,
7
+ checkScreenBudgets,
8
+ formatScreens,
3
9
  judgeMeasured,
10
+ mainActionFor,
11
+ mergeViewportFindings,
4
12
  parseBaseline,
13
+ screenBudgetFor,
14
+ sweepWidths,
5
15
  writeBaseline
6
- } from "./chunk-BQ7H23QP.js";
16
+ } from "./chunk-XSKLVEI6.js";
7
17
  import {
8
18
  loadPlaywright
9
19
  } from "./chunk-6DRBFDWT.js";
@@ -390,76 +400,6 @@ function measureViewportInPage(arg) {
390
400
  return out;
391
401
  }
392
402
 
393
- // src/phone/viewports.ts
394
- var LADDER = [
395
- { name: "320", width: 320, height: 568, phone: true },
396
- { name: "375", width: 375, height: 812, phone: true },
397
- { name: "390", width: 390, height: 844, phone: true },
398
- { name: "430", width: 430, height: 932, phone: true },
399
- { name: "844x390", width: 844, height: 390, phone: true, landscape: true },
400
- { name: "768", width: 768, height: 1024, phone: false },
401
- { name: "1024", width: 1024, height: 768, phone: false },
402
- { name: "1280", width: 1280, height: 800, phone: false },
403
- { name: "1440", width: 1440, height: 900, phone: false },
404
- { name: "1920", width: 1920, height: 1080, phone: false }
405
- ];
406
- var SHORT_PHONE = { name: "375x667", width: 375, height: 667, phone: true };
407
- var DEFAULT_SWEEP = { from: 320, to: 1920, step: 16, wideStep: 32, wideFrom: 1024, height: 800 };
408
- function sweepWidths(sweep = DEFAULT_SWEEP) {
409
- const out = [];
410
- for (let w = sweep.from; w <= sweep.to; w += w < sweep.wideFrom ? sweep.step : sweep.wideStep) out.push(w);
411
- if (out.at(-1) !== sweep.to) out.push(sweep.to);
412
- return out;
413
- }
414
- var DEFAULT_THRESHOLDS = {
415
- maxLayerShare: 0.25,
416
- landscapeMaxLayerShare: 0.5,
417
- oversizeRatio: 2.5,
418
- upscaleRatio: 1,
419
- maxLineCh: 80,
420
- overlapShare: 0.4,
421
- minImagePx: 32
422
- };
423
- var mainActionFor = (config, route) => typeof config === "string" ? config : config ? config[route] ?? config["*"] : void 0;
424
- function formatWidths(failing, tested) {
425
- const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
426
- const rank = new Map(plain.map((name, i) => [name, i]));
427
- const names = [...new Set(failing)];
428
- const runs = [];
429
- for (const name of names.filter((n) => rank.has(n)).sort((a, b) => rank.get(a) - rank.get(b))) {
430
- const last = runs.at(-1);
431
- if (last && rank.get(name) === rank.get(last.at(-1)) + 1) last.push(name);
432
- else runs.push([name]);
433
- }
434
- const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
435
- return [...out, ...names.filter((n) => !rank.has(n))].join(", ");
436
- }
437
- var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
438
- function mergeViewportFindings(perViewport, tested) {
439
- const merged = /* @__PURE__ */ new Map();
440
- for (const { viewport, findings } of perViewport) {
441
- const counts = /* @__PURE__ */ new Map();
442
- for (const f of findings) {
443
- const k = keyOf(f);
444
- const had = counts.get(k);
445
- if (had) had.n++;
446
- else counts.set(k, { first: f, n: 1 });
447
- }
448
- for (const [k, { first, n }] of counts) {
449
- const had = merged.get(k);
450
- if (had) {
451
- had.at.push(viewport);
452
- had.most = Math.max(had.most, n);
453
- } else merged.set(k, { first, at: [viewport], most: n });
454
- }
455
- }
456
- const out = [];
457
- for (const { first, at, most } of merged.values()) {
458
- for (let i = 0; i < most; i++) out.push({ ...first, detail: `${first.detail} (at ${formatWidths(at, tested)})` });
459
- }
460
- return out;
461
- }
462
-
463
403
  // src/phone/run.ts
464
404
  function plan(options) {
465
405
  const ladder = options.ladder ?? LADDER;
@@ -515,6 +455,7 @@ async function collect(page, options) {
515
455
  const mainAction = mainActionFor(options.mainAction, options.route);
516
456
  const perViewport = [];
517
457
  const tested = [];
458
+ let screens;
518
459
  for (const stop of plan(options)) {
519
460
  await page.setViewportSize({ width: stop.width, height: stop.height });
520
461
  await settle(page, stop.ladder ? 150 : 60);
@@ -523,20 +464,36 @@ async function collect(page, options) {
523
464
  const findings = [];
524
465
  if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
525
466
  findings.push(...await page.evaluate(measureViewportInPage, { groups: stop.groups, thresholds, mainAction, metaViewport: stop.width < 768 }));
467
+ if (stop.name === SHORT_PHONE.name) {
468
+ const height = await page.evaluate(() => document.documentElement.scrollHeight, void 0);
469
+ screens = Math.round(height / stop.height * 10) / 10;
470
+ const budget = screenBudgetFor(options.screens, options.route);
471
+ if (budget !== void 0 && screens > budget) findings.push({ check: "screens", element: "<page>", detail: `${screens} phone screens to the footer at ${stop.width} x ${stop.height}, over the budget of ${budget} for ${options.route}` });
472
+ }
526
473
  perViewport.push({ viewport: stop.name, findings });
527
474
  tested.push(stop.name);
528
475
  }
529
- return { perViewport, tested };
476
+ return { perViewport, tested, ...screens !== void 0 ? { screens } : {} };
477
+ }
478
+ function mergeCollected(collected) {
479
+ const screens = collected.find((c) => c.screens !== void 0)?.screens;
480
+ return { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested), ...screens !== void 0 ? { screens } : {} };
530
481
  }
531
482
  function finish(route, collected, out) {
532
- const result = { route, viewports: collected.tested, findings: mergeViewportFindings(collected.perViewport, collected.tested), perViewport: collected.perViewport };
483
+ const result = {
484
+ route,
485
+ viewports: collected.tested,
486
+ ...collected.screens !== void 0 ? { screens: collected.screens } : {},
487
+ findings: mergeViewportFindings(collected.perViewport, collected.tested),
488
+ perViewport: collected.perViewport
489
+ };
533
490
  if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
534
491
  return result;
535
492
  }
536
493
  function writeMeasured(result, dir) {
537
494
  mkdirSync(dir, { recursive: true });
538
- const { route, viewport, viewports, findings } = result;
539
- writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, findings }, null, 1)}
495
+ const { route, viewport, viewports, screens, findings } = result;
496
+ writeFileSync(join(dir, routeFile(route)), `${JSON.stringify({ route, ...viewport ? { viewport } : {}, ...viewports ? { viewports } : {}, ...screens !== void 0 ? { screens } : {}, findings }, null, 1)}
540
497
  `);
541
498
  }
542
499
  var DESKTOP = { viewport: { width: 1024, height: 768 }, deviceScaleFactor: 1 };
@@ -567,7 +524,7 @@ async function measureSite(browser, options) {
567
524
  result.errors.push({ route, error: failed });
568
525
  continue;
569
526
  }
570
- result.routes.push(finish(route, { perViewport: collected.flatMap((c) => c.perViewport), tested: collected.flatMap((c) => c.tested) }, options.out));
527
+ result.routes.push(finish(route, mergeCollected(collected), options.out));
571
528
  }
572
529
  return result;
573
530
  }
@@ -633,6 +590,7 @@ ${USAGE}`);
633
590
  ...config.mainAction ? { mainAction: config.mainAction } : {},
634
591
  ...config.thresholds ? { thresholds: config.thresholds } : {},
635
592
  ...config.floors ? { floors: config.floors } : {},
593
+ ...config.screens ? { screens: checkScreenBudgets(config.screens, "viewports.json screens") } : {},
636
594
  sweep: flags.sweep ? config.sweep : false,
637
595
  ...captures ? {
638
596
  onViewport: async (page, viewport, route) => {
@@ -657,7 +615,7 @@ ${USAGE}`);
657
615
  const judged = judgeMeasured(site.routes, { ...baseline, measured: baseline.measured.filter((m) => measuredRoutes.has(m.route)) });
658
616
  const failed = site.errors.length + judged.fresh.length + judged.stale.length > 0;
659
617
  if (flags.json) {
660
- io.out(toJson({ pass: !failed, routes: site.routes.map((r) => ({ route: r.route, viewports: r.viewports, findings: r.findings.length })), errors: site.errors, measured: judged }));
618
+ io.out(toJson({ pass: !failed, routes: site.routes.map((r) => ({ route: r.route, viewports: r.viewports, screens: r.screens ?? null, findings: r.findings.length })), errors: site.errors, measured: judged }));
661
619
  return failed ? 1 : 0;
662
620
  }
663
621
  const section = (title, lines) => lines.length ? `
@@ -665,13 +623,16 @@ ${title}
665
623
  ${lines.map((l) => ` ${l}`).join("\n")}` : "";
666
624
  const viewports = site.routes[0]?.viewports ?? [];
667
625
  const summary = `${site.routes.length} route${site.routes.length === 1 ? "" : "s"} at ${viewports.length} viewports${judged.known ? `, ${judged.known} known breach${judged.known === 1 ? "" : "es"} in the baseline` : ""}`;
626
+ const screens = formatScreens(site.routes, config.screens);
627
+ const screensLine = screens ? `
628
+ phone screens to the footer: ${screens}` : "";
668
629
  if (!failed) {
669
- io.out(`craft viewports: pass. ${summary}.`);
630
+ io.out(`craft viewports: pass. ${summary}.${screensLine}`);
670
631
  return 0;
671
632
  }
672
633
  io.err(
673
634
  [
674
- `craft viewports: fail. ${summary}.`,
635
+ `craft viewports: fail. ${summary}.${screensLine}`,
675
636
  section("Did not answer (not measured, so not passed):", site.errors.map((e) => `${e.route}: ${e.error}`)),
676
637
  section("Measured across the viewports, phone first:", judged.fresh),
677
638
  section("The baseline claims more than is there (good: lower it, so the fix stays fixed):", judged.stale)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "private": false,
5
5
  "description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
6
6
  "license": "Apache-2.0",