@domandigital/craft 0.20.5 → 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 +23 -0
- package/CHARACTER.md +2 -1
- package/COPY.md +6 -2
- package/README.md +87 -4
- package/dist/{audit-5I2WWTL6.js → audit-LQ2TDISV.js} +3 -3
- package/dist/audit.cjs +64 -1
- package/dist/audit.js +64 -1
- package/dist/{chunk-YFS32BZ2.js → chunk-6DRBFDWT.js} +1 -1
- package/dist/{chunk-BBJOLR3R.js → chunk-6PZG4MJS.js} +5 -5
- package/dist/{chunk-PFVSKIPM.js → chunk-DE2ZAV4G.js} +4 -2
- package/dist/{chunk-GJY2VMPD.js → chunk-G47ZCBD5.js} +2 -2
- package/dist/{chunk-FBWBDBW4.js → chunk-ONA73THJ.js} +61 -0
- package/dist/{chunk-TSO5W32N.js → chunk-OVWVKMPS.js} +14 -14
- package/dist/{chunk-N32UUN3S.js → chunk-RBQCGDQQ.js} +5 -5
- package/dist/chunk-XSKLVEI6.js +368 -0
- package/dist/{chunk-DMAMGFV7.js → chunk-ZS7PQNJU.js} +4 -4
- package/dist/{cli-OH6427MC.js → cli-7DL4BKA7.js} +6 -6
- package/dist/{cli-NIH3WTNE.js → cli-7QL26YPR.js} +1 -1
- package/dist/{cli-BI6XCMCL.js → cli-AM4FLXGM.js} +5 -5
- package/dist/{cli-JQYZXFRH.js → cli-R2HBESHI.js} +5 -5
- package/dist/{cli-V4QXTBWR.js → cli-SZ4SZLUN.js} +4 -4
- package/dist/{cli-W6FNSHRN.js → cli-T7A3BU5J.js} +9 -9
- package/dist/{cli-SQC27VYC.js → cli-WZA5IXSR.js} +6 -6
- package/dist/cli-YNIJOEJK.js +244 -0
- package/dist/cli.js +3 -3
- package/dist/index.cjs +64 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +64 -1
- package/dist/phone.cjs +194 -18
- package/dist/phone.d.cts +113 -13
- package/dist/phone.d.ts +113 -13
- package/dist/phone.js +182 -18
- package/dist/{viewports-cli-C4DWICYI.js → viewports-cli-F7KJIVZL.js} +51 -86
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-IJUZ7RV4.js +0 -151
- package/dist/cli-56H5IPWT.js +0 -122
package/dist/phone.d.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
interface SourceFile {
|
|
2
|
-
/** Repo-relative path. The extension decides how the file is read. */
|
|
3
|
-
path: string;
|
|
4
|
-
text: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
1
|
/**
|
|
8
2
|
* The in-page half of the phone-first gate.
|
|
9
3
|
*
|
|
@@ -23,12 +17,18 @@ interface PhoneFloors {
|
|
|
23
17
|
targetPx: number;
|
|
24
18
|
fieldFontPx: number;
|
|
25
19
|
textPx: number;
|
|
20
|
+
/**
|
|
21
|
+
* The height of a text field, select or textarea (portal standard rule 5:
|
|
22
|
+
* 48px inputs). Left out, 48. Below `targetPx` the field also fails the
|
|
23
|
+
* target check; this floor is what catches 44 to 47px.
|
|
24
|
+
*/
|
|
25
|
+
fieldHeightPx?: number;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
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. */
|
|
@@ -44,6 +44,12 @@ declare function measurePhoneInPage(floors: PhoneFloors & {
|
|
|
44
44
|
only?: PhoneCheck[];
|
|
45
45
|
}): RawPhoneFinding[];
|
|
46
46
|
|
|
47
|
+
interface SourceFile {
|
|
48
|
+
/** Repo-relative path. The extension decides how the file is read. */
|
|
49
|
+
path: string;
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
/**
|
|
48
54
|
* The judging half of the phone-first gate: pure text work, tested without a
|
|
49
55
|
* browser or a disk.
|
|
@@ -62,6 +68,11 @@ declare function measurePhoneInPage(floors: PhoneFloors & {
|
|
|
62
68
|
* the day it goes in while the existing ones stay listed in the open. The
|
|
63
69
|
* baseline only shrinks: a count higher than what is found fails as well
|
|
64
70
|
* ("lower the baseline"), so a fix cannot leave room for the next breach.
|
|
71
|
+
*
|
|
72
|
+
* Shrinking is also enforced against the base branch (DOM-1053): a baseline
|
|
73
|
+
* entry, or a higher count, that main's copy does not have fails, unless the
|
|
74
|
+
* entry carries a `reason` (a named exception: third-party UI, a
|
|
75
|
+
* destructive-only form).
|
|
65
76
|
*/
|
|
66
77
|
|
|
67
78
|
interface StaticFinding {
|
|
@@ -81,23 +92,37 @@ interface MeasuredRoute {
|
|
|
81
92
|
};
|
|
82
93
|
/** Set by the every-viewport measure: the viewports and widths the findings were gathered across, phone first. */
|
|
83
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;
|
|
84
97
|
findings: RawPhoneFinding[];
|
|
85
98
|
}
|
|
86
99
|
interface Baseline {
|
|
87
100
|
/** Why the baseline exists and which issue burns it down. Free text, for a person. */
|
|
88
101
|
note?: string;
|
|
102
|
+
/**
|
|
103
|
+
* `reason` makes an entry a named exception: it may be added, or raised,
|
|
104
|
+
* after the baseline went in. Absent on the faults that were there first.
|
|
105
|
+
*/
|
|
89
106
|
static: {
|
|
90
107
|
file: string;
|
|
91
108
|
count: number;
|
|
92
109
|
found?: string[];
|
|
110
|
+
reason?: string;
|
|
93
111
|
}[];
|
|
94
112
|
measured: {
|
|
95
113
|
route: string;
|
|
96
114
|
check: PhoneCheck;
|
|
97
115
|
element: string;
|
|
98
116
|
count: number;
|
|
117
|
+
reason?: string;
|
|
99
118
|
}[];
|
|
100
119
|
}
|
|
120
|
+
/** An entry is a named exception when its `reason` says something. */
|
|
121
|
+
declare const hasReason: (entry: {
|
|
122
|
+
reason?: unknown;
|
|
123
|
+
}) => boolean;
|
|
124
|
+
/** A baseline file, read. Old files (no `reason`, no `note`) read as before; a `reason` that is not text is an error. */
|
|
125
|
+
declare function parseBaseline(raw: unknown, where: string): Baseline;
|
|
101
126
|
declare const EMPTY_BASELINE: Baseline;
|
|
102
127
|
/** The reason in a `mobile-first-ok: <reason>` comment, "" for the marker with no reason, null for none. */
|
|
103
128
|
declare function reasonOf(line: string | undefined): string | null;
|
|
@@ -106,7 +131,10 @@ declare function reasonOf(line: string | undefined): string | null;
|
|
|
106
131
|
* `mobile-first-ok: <reason>` on the line or the line above, or the whole file
|
|
107
132
|
* in `exceptions` with a reason. A marker with no reason is itself a finding.
|
|
108
133
|
*/
|
|
109
|
-
declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[]
|
|
134
|
+
declare function scanPhoneFirst(files: SourceFile[], exceptions?: FileException[], options?: {
|
|
135
|
+
/** True for a file in an app whose tailwindcss is v3: it is also scanned for `/NN` opacity on a `var()` colour. */
|
|
136
|
+
tailwind3?: (path: string) => boolean;
|
|
137
|
+
}): {
|
|
110
138
|
findings: StaticFinding[];
|
|
111
139
|
excused: number;
|
|
112
140
|
};
|
|
@@ -128,7 +156,48 @@ interface Judgement {
|
|
|
128
156
|
declare function judgeStatic(findings: StaticFinding[], baseline: Baseline): Judgement;
|
|
129
157
|
declare function judgeMeasured(routes: MeasuredRoute[], baseline: Baseline): Judgement;
|
|
130
158
|
/** The baseline that makes this run pass: what is there now, counted. */
|
|
131
|
-
declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string): Baseline;
|
|
159
|
+
declare function writeBaseline(findings: StaticFinding[], routes: MeasuredRoute[], note?: string, previous?: Baseline): Baseline;
|
|
160
|
+
/**
|
|
161
|
+
* Entries the baseline holds that the base branch's copy does not, or holds
|
|
162
|
+
* at a lower count, and that name no reason. The baseline lists the faults
|
|
163
|
+
* that were there when the gate arrived; a new breach is fixed where it is.
|
|
164
|
+
* An entry with a `reason` is a named exception and may be added or raised.
|
|
165
|
+
*/
|
|
166
|
+
declare function judgeGrowth(current: Baseline, base: Baseline, baseName?: string): string[];
|
|
167
|
+
/** Whether the baseline holds anything a reason does not name: only then is the base branch's copy needed. */
|
|
168
|
+
declare const hasUnexcusedEntries: (b: Baseline) => boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Page routes from the page files of a Next app directory, as paths relative
|
|
171
|
+
* to it (`portal/(shell)/invoices/[id]/page.tsx`). Route groups `(x)`, parallel
|
|
172
|
+
* slots `@x` and private folders `_x` are not part of the URL; an intercepting
|
|
173
|
+
* route `(.)x` is a second way into a page, not a page of its own.
|
|
174
|
+
*/
|
|
175
|
+
declare function routesFromPages(files: string[]): string[];
|
|
176
|
+
/** Page routes from Next's `app-path-routes-manifest.json` (`{ "/portal/page": "/portal" }`). Route handlers and internal `_` routes are not pages. */
|
|
177
|
+
declare function routesFromManifest(manifest: unknown): string[];
|
|
178
|
+
interface ExcludedRoute {
|
|
179
|
+
route: string;
|
|
180
|
+
reason: string;
|
|
181
|
+
}
|
|
182
|
+
/** A route as the gate names it: no query, no hash, no trailing slash. */
|
|
183
|
+
declare const cleanRoute: (route: string) => string;
|
|
184
|
+
/** Whether a measured route (`/portal/projects/p1`, or the pattern itself) is a page route (`/portal/projects/[id]`). */
|
|
185
|
+
declare function routeMatches(pattern: string, route: string): boolean;
|
|
186
|
+
interface RouteJudgement {
|
|
187
|
+
/** Page routes nothing measured and no exclusion covers: the gate fails on these. */
|
|
188
|
+
missing: string[];
|
|
189
|
+
/** Problems with `excludeRoutes` itself: no reason, or names no page. */
|
|
190
|
+
problems: string[];
|
|
191
|
+
/** Pages measured or excluded with a reason. */
|
|
192
|
+
covered: number;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Every page route of the app is in the gate's route list. A measured route
|
|
196
|
+
* counts for the most specific page it matches, so `/portal/projects/new` does
|
|
197
|
+
* not cover `/portal/projects/[id]`. A page that only redirects measures
|
|
198
|
+
* nothing: it is excluded with that reason.
|
|
199
|
+
*/
|
|
200
|
+
declare function judgeRoutes(pages: string[], measuredRoutes: string[], excluded?: ExcludedRoute[]): RouteJudgement;
|
|
132
201
|
|
|
133
202
|
/**
|
|
134
203
|
* What "every viewport" means (STANDARD.md section 10, DOM-787): the ladder
|
|
@@ -199,6 +268,23 @@ declare const DEFAULT_THRESHOLDS: Thresholds;
|
|
|
199
268
|
/** The main action, as a selector: one for the whole site, or one per route (`*` is the fallback). */
|
|
200
269
|
type MainAction = string | Record<string, string>;
|
|
201
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;
|
|
202
288
|
/**
|
|
203
289
|
* Widths as a person reads them: `320-352, 400, 1440`. A run is consecutive
|
|
204
290
|
* tested widths that all fail. A landscape or short-phone name (`844x390`)
|
|
@@ -263,8 +349,13 @@ declare const PHONE: {
|
|
|
263
349
|
readonly isMobile: true;
|
|
264
350
|
readonly hasTouch: true;
|
|
265
351
|
};
|
|
266
|
-
/**
|
|
267
|
-
|
|
352
|
+
/**
|
|
353
|
+
* The house floors at phone width. A site may raise `textPx`, or lower
|
|
354
|
+
* `fieldHeightPx` with a reason; they are not per-repo taste. The field height
|
|
355
|
+
* is portal standard rule 5 (48px inputs); until DOM-1053 the gate held fields
|
|
356
|
+
* to the 44px target floor only.
|
|
357
|
+
*/
|
|
358
|
+
declare const PHONE_FLOORS: Required<PhoneFloors>;
|
|
268
359
|
/** Where `measurePhone` writes and `craft phone-first --measured` reads, unless told otherwise. */
|
|
269
360
|
declare const DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
270
361
|
/** A route as a file name: `/portal/invoices` is `portal-invoices.json`, `/` is `home.json`. */
|
|
@@ -309,10 +400,17 @@ interface MeasureViewportsOptions {
|
|
|
309
400
|
scope?: "all" | "phone" | "wide";
|
|
310
401
|
/** Override house thresholds. */
|
|
311
402
|
thresholds?: Partial<Thresholds>;
|
|
312
|
-
/** Override the 12 / 16 / 44px floors. The defaults are the house standard; a site may set `textPx` higher. */
|
|
403
|
+
/** Override the 12 / 16 / 44px floors and the 48px field height (`fieldHeightPx`). The defaults are the house standard; a site may set `textPx` higher. */
|
|
313
404
|
floors?: Partial<PhoneFloors>;
|
|
314
405
|
/** The main action's selector: one for all routes, or per route with `*` as the fallback. Absent: not checked. */
|
|
315
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;
|
|
316
414
|
/** The width sweep, or `false` to measure the ladder only. */
|
|
317
415
|
sweep?: Partial<Sweep> | false;
|
|
318
416
|
/** Replace the ladder. */
|
|
@@ -440,6 +538,8 @@ interface MeasurePhoneOptions {
|
|
|
440
538
|
route: string;
|
|
441
539
|
/** The folder to write `<route>.json` into. Default `phone-first/measured`, from the working directory. */
|
|
442
540
|
out?: string;
|
|
541
|
+
/** Override the 12 / 16 / 44 / 48px floors (text, field text, target, field height). The defaults are the house standard. */
|
|
542
|
+
floors?: Partial<PhoneFloors>;
|
|
443
543
|
}
|
|
444
544
|
/**
|
|
445
545
|
* Measure the page as it is now and write the result for `craft phone-first`.
|
|
@@ -448,4 +548,4 @@ interface MeasurePhoneOptions {
|
|
|
448
548
|
*/
|
|
449
549
|
declare function measurePhone(page: MinimalPage, options: MeasurePhoneOptions): Promise<MeasuredRoute>;
|
|
450
550
|
|
|
451
|
-
export { BANDS, type Band, type Baseline, DEFAULT_MEASURED_DIR, DEFAULT_SWEEP, DEFAULT_THRESHOLDS, DESKTOP, EMPTY_BASELINE, 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, 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, formatWidths, judgeMeasured, judgeStatic, mainActionFor, measurePhone, measurePhoneInPage, measureRoute, measureSite, measureViewportInPage, measureViewports, mergeViewportFindings, normaliseElement, reasonOf, routeFile, 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
|
@@ -9,13 +9,14 @@ var PHONE = {
|
|
|
9
9
|
isMobile: true,
|
|
10
10
|
hasTouch: true
|
|
11
11
|
};
|
|
12
|
-
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12 };
|
|
12
|
+
var PHONE_FLOORS = { targetPx: 44, fieldFontPx: 16, textPx: 12, fieldHeightPx: 48 };
|
|
13
13
|
var DEFAULT_MEASURED_DIR = "phone-first/measured";
|
|
14
14
|
var routeFile = (route) => `${route === "/" ? "home" : route.replace(/^\/+|\/+$/g, "").replace(/[^\w.-]+/g, "-")}.json`;
|
|
15
15
|
|
|
16
16
|
// src/phone/measure-in-page.ts
|
|
17
17
|
function measurePhoneInPage(floors) {
|
|
18
18
|
const out = [];
|
|
19
|
+
const fieldHeightPx = floors.fieldHeightPx ?? 48;
|
|
19
20
|
const only = floors.only;
|
|
20
21
|
const on = (c) => !only || only.includes(c);
|
|
21
22
|
const vw = document.documentElement.clientWidth;
|
|
@@ -59,6 +60,9 @@ function measurePhoneInPage(floors) {
|
|
|
59
60
|
if (!shown(e) || quiet(e) || e.tagName === "INPUT" && NOT_TEXT.has(e.type)) continue;
|
|
60
61
|
const px = parseFloat(getComputedStyle(e).fontSize);
|
|
61
62
|
if (px < floors.fieldFontPx) out.push({ check: "field", element: describe(e), detail: `set at ${px}px, under ${floors.fieldFontPx}px, so Safari on iOS zooms on focus` });
|
|
63
|
+
const box = e.getBoundingClientRect();
|
|
64
|
+
const height = Math.round(box.height);
|
|
65
|
+
if (box.width > 1 && height > 1 && height < fieldHeightPx) out.push({ check: "field", element: describe(e), detail: `${height}px tall, under ${fieldHeightPx}px (use min-h-12)` });
|
|
62
66
|
}
|
|
63
67
|
for (const e of on("text") ? Array.from(document.body.querySelectorAll("*")) : []) {
|
|
64
68
|
if (!shown(e) || quiet(e) || ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(e.tagName)) continue;
|
|
@@ -366,17 +370,26 @@ function measureViewportInPage(arg) {
|
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
// src/phone/judge.ts
|
|
373
|
+
var hasReason = (entry) => typeof entry.reason === "string" && entry.reason.trim() !== "";
|
|
374
|
+
function parseBaseline(raw, where) {
|
|
375
|
+
const b = raw ?? {};
|
|
376
|
+
const entries = [...b.static ?? [], ...b.measured ?? []];
|
|
377
|
+
const bad = entries.find((e) => e.reason !== void 0 && typeof e.reason !== "string");
|
|
378
|
+
if (bad) throw new Error(`${where}: an entry has a "reason" that is not text: ${JSON.stringify(bad)}`);
|
|
379
|
+
return { ...b.note ? { note: b.note } : {}, static: b.static ?? [], measured: b.measured ?? [] };
|
|
380
|
+
}
|
|
369
381
|
var EMPTY_BASELINE = { static: [], measured: [] };
|
|
370
382
|
var MEDIA = /@(?:custom-)?media\b([^{;]*)/g;
|
|
371
383
|
var CAPS_WIDTH = /\bmax-(?:device-)?width\s*:|\bwidth\s*<|>=?\s*(?:device-)?width\b/i;
|
|
372
384
|
var VARIANT = /(?<![\w@-])max-(\[[^\]\s]+\]|[a-z0-9]+):/g;
|
|
373
385
|
var NOT_VARIANTS = /* @__PURE__ */ new Set(["width", "height", "lines", "resolution", "zoom", "inline-size", "block-size"]);
|
|
374
386
|
var OK = /mobile-first-ok\b:?(.*)/;
|
|
387
|
+
var VAR_OPACITY = /[^\s"'`]*var\([^)]*\)\]\/[0-9]+/g;
|
|
375
388
|
function reasonOf(line) {
|
|
376
389
|
const m = line?.match(OK);
|
|
377
390
|
return m ? m[1].replace(/\*\/|-->|[{}]/g, "").trim() : null;
|
|
378
391
|
}
|
|
379
|
-
function scanPhoneFirst(files, exceptions = []) {
|
|
392
|
+
function scanPhoneFirst(files, exceptions = [], options = {}) {
|
|
380
393
|
const excusedFiles = new Set(exceptions.filter((e) => e.reason?.trim()).map((e) => e.file));
|
|
381
394
|
const findings = [];
|
|
382
395
|
let excused = 0;
|
|
@@ -391,7 +404,18 @@ function scanPhoneFirst(files, exceptions = []) {
|
|
|
391
404
|
for (const m of text.matchAll(VARIANT)) {
|
|
392
405
|
if (!NOT_VARIANTS.has(m[1])) found.push({ line: lineOf(m.index), what: m[0] });
|
|
393
406
|
}
|
|
394
|
-
|
|
407
|
+
const silent = /* @__PURE__ */ new Set();
|
|
408
|
+
if (options.tailwind3?.(path)) {
|
|
409
|
+
for (const m of text.matchAll(VAR_OPACITY)) {
|
|
410
|
+
silent.add(found.push({ line: lineOf(m.index), what: `${m[0]} (Tailwind 3 emits no CSS for opacity on a var() colour)` }) - 1);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const order = found.map((f, i) => ({ ...f, silent: silent.has(i) })).sort((a, b) => a.line - b.line);
|
|
414
|
+
for (const { line, what, silent: cannotExcuse } of order) {
|
|
415
|
+
if (cannotExcuse) {
|
|
416
|
+
findings.push({ file: path, line, what });
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
395
419
|
const here = reasonOf(lines[line - 1]);
|
|
396
420
|
const above = reasonOf(lines[line - 2]);
|
|
397
421
|
if (here || above) {
|
|
@@ -494,19 +518,104 @@ var LABEL = {
|
|
|
494
518
|
input: "wrong keyboard",
|
|
495
519
|
autocomplete: "field the phone cannot fill in",
|
|
496
520
|
tel: "phone number is not a link",
|
|
497
|
-
linelength: "line too long"
|
|
521
|
+
linelength: "line too long",
|
|
522
|
+
screens: "page too long for a phone"
|
|
498
523
|
};
|
|
499
|
-
function writeBaseline(findings, routes, note) {
|
|
524
|
+
function writeBaseline(findings, routes, note, previous) {
|
|
500
525
|
const byFile = tally(findings, (f) => f.file);
|
|
501
526
|
const measured = routes.flatMap(
|
|
502
527
|
({ route, findings: findings2 }) => [...tally(findings2, (f) => measuredKey(route, f.check, normaliseElement(f.element))).values()].map(({ item, count }) => ({ route, check: item.check, element: normaliseElement(item.element), count }))
|
|
503
528
|
);
|
|
529
|
+
const staticReasons = new Map((previous?.static ?? []).filter(hasReason).map((b) => [b.file, b.reason]));
|
|
530
|
+
const measuredReasons = new Map((previous?.measured ?? []).filter(hasReason).map((b) => [measuredKey(b.route, b.check, b.element), b.reason]));
|
|
504
531
|
return {
|
|
505
532
|
...note ? { note } : {},
|
|
506
|
-
static: [...byFile.values()].map(({ item, count }) => ({ file: item.file, count, found: findings.filter((f) => f.file === item.file).map((f) => f.what) })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
507
|
-
measured: measured.sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
533
|
+
static: [...byFile.values()].map(({ item, count }) => ({ file: item.file, count, found: findings.filter((f) => f.file === item.file).map((f) => f.what), ...staticReasons.has(item.file) ? { reason: staticReasons.get(item.file) } : {} })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
534
|
+
measured: measured.map((m) => measuredReasons.has(measuredKey(m.route, m.check, m.element)) ? { ...m, reason: measuredReasons.get(measuredKey(m.route, m.check, m.element)) } : m).sort((a, b) => a.route.localeCompare(b.route) || a.check.localeCompare(b.check) || a.element.localeCompare(b.element))
|
|
508
535
|
};
|
|
509
536
|
}
|
|
537
|
+
function judgeGrowth(current, base, baseName = "main") {
|
|
538
|
+
const out = [];
|
|
539
|
+
const baseStatic = new Map(base.static.map((b) => [b.file, b.count]));
|
|
540
|
+
for (const b of current.static) {
|
|
541
|
+
const was = baseStatic.get(b.file) ?? 0;
|
|
542
|
+
if (b.count > was && !hasReason(b)) out.push(`${b.file}: ${was ? `${b.count} desktop-first rules, ${baseName} allows ${was}` : `${b.count} desktop-first rule${b.count === 1 ? "" : "s"} added to the baseline`}`);
|
|
543
|
+
}
|
|
544
|
+
const baseMeasured = new Map(base.measured.map((b) => [measuredKey(b.route, b.check, b.element), b.count]));
|
|
545
|
+
for (const b of current.measured) {
|
|
546
|
+
const was = baseMeasured.get(measuredKey(b.route, b.check, b.element)) ?? 0;
|
|
547
|
+
if (b.count > was && !hasReason(b)) out.push(`${b.route}: ${LABEL[b.check]} ${b.element}: ${was ? `${b.count} in the baseline, ${baseName} allows ${was}` : `added to the baseline (${b.count})`}`);
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
var hasUnexcusedEntries = (b) => [...b.static, ...b.measured].some((e) => !hasReason(e));
|
|
552
|
+
var PAGE_FILE = /(?:^|\/)page\.(?:tsx|ts|jsx|js|mdx)$/;
|
|
553
|
+
var INTERCEPTING = /^\(\.{1,3}\)/;
|
|
554
|
+
function routesFromPages(files) {
|
|
555
|
+
const routes = /* @__PURE__ */ new Set();
|
|
556
|
+
for (const file of files) {
|
|
557
|
+
const rel = file.replace(/\\/g, "/");
|
|
558
|
+
if (!PAGE_FILE.test(rel)) continue;
|
|
559
|
+
const dirs = rel.split("/").slice(0, -1);
|
|
560
|
+
if (dirs.some((d) => INTERCEPTING.test(d) || d.startsWith("_"))) continue;
|
|
561
|
+
const url = dirs.filter((d) => !/^\(.*\)$/.test(d) && !d.startsWith("@"));
|
|
562
|
+
routes.add(`/${url.join("/")}`);
|
|
563
|
+
}
|
|
564
|
+
return [...routes].sort();
|
|
565
|
+
}
|
|
566
|
+
function routesFromManifest(manifest) {
|
|
567
|
+
const routes = /* @__PURE__ */ new Set();
|
|
568
|
+
for (const [key, route] of Object.entries(manifest ?? {})) {
|
|
569
|
+
if (!/(?:^|\/)page$/.test(key) || typeof route !== "string") continue;
|
|
570
|
+
if (route.split("/").some((d) => d.startsWith("_"))) continue;
|
|
571
|
+
routes.add(route);
|
|
572
|
+
}
|
|
573
|
+
return [...routes].sort();
|
|
574
|
+
}
|
|
575
|
+
var segmentsOf = (route) => route.split("/").filter(Boolean);
|
|
576
|
+
var rank = (seg) => seg.startsWith("[[...") ? 3 : seg.startsWith("[...") ? 2 : seg.startsWith("[") ? 1 : 0;
|
|
577
|
+
var cleanRoute = (route) => {
|
|
578
|
+
const path = route.replace(/[?#].*$/, "").replace(/\/+$/, "");
|
|
579
|
+
return path === "" ? "/" : path;
|
|
580
|
+
};
|
|
581
|
+
function routeMatches(pattern, route) {
|
|
582
|
+
if (pattern === route) return true;
|
|
583
|
+
const want = segmentsOf(pattern);
|
|
584
|
+
const have = segmentsOf(route);
|
|
585
|
+
for (let i = 0; i < want.length; i++) {
|
|
586
|
+
const seg = want[i];
|
|
587
|
+
if (seg.startsWith("[[...")) return true;
|
|
588
|
+
if (seg.startsWith("[...")) return have.length > i;
|
|
589
|
+
if (have[i] === void 0) return false;
|
|
590
|
+
if (!seg.startsWith("[") && seg !== have[i]) return false;
|
|
591
|
+
}
|
|
592
|
+
return want.length === have.length;
|
|
593
|
+
}
|
|
594
|
+
var moreSpecific = (a, b) => {
|
|
595
|
+
const x = segmentsOf(a).map(rank);
|
|
596
|
+
const y = segmentsOf(b).map(rank);
|
|
597
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i];
|
|
598
|
+
return x.length > y.length;
|
|
599
|
+
};
|
|
600
|
+
var excludes = (entry, page) => entry.endsWith("/*") ? page === entry.slice(0, -2) || page.startsWith(entry.slice(0, -1)) : entry === page;
|
|
601
|
+
function judgeRoutes(pages, measuredRoutes, excluded = []) {
|
|
602
|
+
const problems = [];
|
|
603
|
+
excluded.forEach((e, i) => {
|
|
604
|
+
const at = `excludeRoutes[${i}]`;
|
|
605
|
+
const x = e;
|
|
606
|
+
if (typeof x?.route !== "string" || !x.route.startsWith("/")) problems.push(`${at}: names no route (start it with /)`);
|
|
607
|
+
else if (typeof x.reason !== "string" || !x.reason.trim()) problems.push(`${at} (${x.route}): no reason. Say why the gate cannot measure this page, or remove the entry.`);
|
|
608
|
+
else if (!pages.some((p) => excludes(x.route, p))) problems.push(`${at}: ${x.route} is not a page route of the app. Remove the entry.`);
|
|
609
|
+
});
|
|
610
|
+
const named = excluded.filter((e) => typeof e?.route === "string" && typeof e.reason === "string" && e.reason.trim());
|
|
611
|
+
const covered = /* @__PURE__ */ new Set();
|
|
612
|
+
for (const route of measuredRoutes.map(cleanRoute)) {
|
|
613
|
+
const hit = pages.filter((p) => routeMatches(p, route)).sort((a, b) => moreSpecific(a, b) ? -1 : moreSpecific(b, a) ? 1 : 0)[0];
|
|
614
|
+
if (hit) covered.add(hit);
|
|
615
|
+
}
|
|
616
|
+
for (const page of pages) if (named.some((e) => excludes(e.route, page))) covered.add(page);
|
|
617
|
+
return { missing: pages.filter((p) => !covered.has(p)), problems, covered: covered.size };
|
|
618
|
+
}
|
|
510
619
|
|
|
511
620
|
// src/phone/viewports.ts
|
|
512
621
|
var BANDS = [
|
|
@@ -548,18 +657,44 @@ var DEFAULT_THRESHOLDS = {
|
|
|
548
657
|
minImagePx: 32
|
|
549
658
|
};
|
|
550
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
|
+
}
|
|
551
686
|
function formatWidths(failing, tested) {
|
|
552
687
|
const plain = tested.filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(a) - Number(b));
|
|
553
|
-
const
|
|
688
|
+
const rank2 = new Map(plain.map((name, i) => [name, i]));
|
|
554
689
|
const names = [...new Set(failing)];
|
|
555
690
|
const runs = [];
|
|
556
|
-
for (const name of names.filter((n) =>
|
|
691
|
+
for (const name of names.filter((n) => rank2.has(n)).sort((a, b) => rank2.get(a) - rank2.get(b))) {
|
|
557
692
|
const last = runs.at(-1);
|
|
558
|
-
if (last &&
|
|
693
|
+
if (last && rank2.get(name) === rank2.get(last.at(-1)) + 1) last.push(name);
|
|
559
694
|
else runs.push([name]);
|
|
560
695
|
}
|
|
561
696
|
const out = runs.map((r) => r.length > 1 ? `${r[0]}-${r.at(-1)}` : r[0]);
|
|
562
|
-
return [...out, ...names.filter((n) => !
|
|
697
|
+
return [...out, ...names.filter((n) => !rank2.has(n))].join(", ");
|
|
563
698
|
}
|
|
564
699
|
var keyOf = (f) => `${f.check}\0${f.element.replace(/("[^]*)$/, (t) => t.replace(/\d+/g, "#"))}`;
|
|
565
700
|
function mergeViewportFindings(perViewport, tested) {
|
|
@@ -644,6 +779,7 @@ async function collect(page, options) {
|
|
|
644
779
|
const mainAction = mainActionFor(options.mainAction, options.route);
|
|
645
780
|
const perViewport = [];
|
|
646
781
|
const tested = [];
|
|
782
|
+
let screens;
|
|
647
783
|
for (const stop of plan(options)) {
|
|
648
784
|
await page.setViewportSize({ width: stop.width, height: stop.height });
|
|
649
785
|
await settle(page, stop.ladder ? 150 : 60);
|
|
@@ -652,20 +788,36 @@ async function collect(page, options) {
|
|
|
652
788
|
const findings = [];
|
|
653
789
|
if (stop.ladder) findings.push(...await page.evaluate(measurePhoneInPage, { ...floors, only: stop.touch ? ["target", "field", "text"] : ["text"] }));
|
|
654
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
|
+
}
|
|
655
797
|
perViewport.push({ viewport: stop.name, findings });
|
|
656
798
|
tested.push(stop.name);
|
|
657
799
|
}
|
|
658
|
-
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 } : {} };
|
|
659
805
|
}
|
|
660
806
|
function finish(route, collected, out) {
|
|
661
|
-
const result = {
|
|
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
|
+
};
|
|
662
814
|
if (out !== false) writeMeasured(result, out ?? DEFAULT_MEASURED_DIR);
|
|
663
815
|
return result;
|
|
664
816
|
}
|
|
665
817
|
function writeMeasured(result, dir) {
|
|
666
818
|
mkdirSync(dir, { recursive: true });
|
|
667
|
-
const { route, viewport, viewports, findings } = result;
|
|
668
|
-
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)}
|
|
669
821
|
`);
|
|
670
822
|
}
|
|
671
823
|
async function measureViewports(page, options) {
|
|
@@ -686,7 +838,7 @@ async function measureRoute(open, options) {
|
|
|
686
838
|
await close?.();
|
|
687
839
|
}
|
|
688
840
|
}
|
|
689
|
-
return finish(options.route,
|
|
841
|
+
return finish(options.route, mergeCollected(collected), options.out);
|
|
690
842
|
}
|
|
691
843
|
async function measureSite(browser, options) {
|
|
692
844
|
const result = { routes: [], errors: [] };
|
|
@@ -715,7 +867,7 @@ async function measureSite(browser, options) {
|
|
|
715
867
|
result.errors.push({ route, error: failed });
|
|
716
868
|
continue;
|
|
717
869
|
}
|
|
718
|
-
result.routes.push(finish(route,
|
|
870
|
+
result.routes.push(finish(route, mergeCollected(collected), options.out));
|
|
719
871
|
}
|
|
720
872
|
return result;
|
|
721
873
|
}
|
|
@@ -724,7 +876,7 @@ async function measureSite(browser, options) {
|
|
|
724
876
|
async function measurePhone(page, options) {
|
|
725
877
|
if (!options.route.startsWith("/")) throw new Error(`measurePhone: route "${options.route}" should start with /`);
|
|
726
878
|
await page.evaluate(() => document.fonts?.ready.then(() => void 0), void 0);
|
|
727
|
-
const findings = await page.evaluate(measurePhoneInPage, PHONE_FLOORS);
|
|
879
|
+
const findings = await page.evaluate(measurePhoneInPage, { ...PHONE_FLOORS, ...options.floors ?? {} });
|
|
728
880
|
const viewport = page.viewportSize?.() ?? void 0;
|
|
729
881
|
const result = { route: options.route, ...viewport ? { viewport } : {}, findings };
|
|
730
882
|
const dir = options.out ?? DEFAULT_MEASURED_DIR;
|
|
@@ -747,8 +899,15 @@ export {
|
|
|
747
899
|
WIDE_WIDTHS,
|
|
748
900
|
bandOf,
|
|
749
901
|
checkExceptions,
|
|
902
|
+
checkScreenBudgets,
|
|
903
|
+
cleanRoute,
|
|
904
|
+
formatScreens,
|
|
750
905
|
formatWidths,
|
|
906
|
+
hasReason,
|
|
907
|
+
hasUnexcusedEntries,
|
|
908
|
+
judgeGrowth,
|
|
751
909
|
judgeMeasured,
|
|
910
|
+
judgeRoutes,
|
|
752
911
|
judgeStatic,
|
|
753
912
|
mainActionFor,
|
|
754
913
|
measurePhone,
|
|
@@ -759,9 +918,14 @@ export {
|
|
|
759
918
|
measureViewports,
|
|
760
919
|
mergeViewportFindings,
|
|
761
920
|
normaliseElement,
|
|
921
|
+
parseBaseline,
|
|
762
922
|
reasonOf,
|
|
763
923
|
routeFile,
|
|
924
|
+
routeMatches,
|
|
925
|
+
routesFromManifest,
|
|
926
|
+
routesFromPages,
|
|
764
927
|
scanPhoneFirst,
|
|
928
|
+
screenBudgetFor,
|
|
765
929
|
sweepWidths,
|
|
766
930
|
writeBaseline
|
|
767
931
|
};
|