@struq/skill 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -100
- package/package.json +5 -3
- package/skills/struq/SKILL.md +168 -165
- package/skills/struq/blocks/CATALOG.md +49 -46
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
- package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
- package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
- package/skills/struq/blocks/catalog.json +50 -5
- package/skills/struq/blocks/film.json +11 -7
- package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
- package/skills/struq/blocks/headers/header-bar.tsx +144 -29
- package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
- package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
- package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
- package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
- package/skills/struq/blocks/kit/accent.ts +29 -3
- package/skills/struq/blocks/kit/blocks.css +530 -6
- package/skills/struq/blocks/kit/feel.ts +183 -0
- package/skills/struq/blocks/kit/gsap.ts +9 -6
- package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
- package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
- package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
- package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
- package/skills/struq/blocks/kit/middle/hours.ts +154 -0
- package/skills/struq/blocks/kit/middle/index.ts +14 -0
- package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
- package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
- package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
- package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
- package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
- package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
- package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
- package/skills/struq/blocks/kit/motion/index.ts +11 -0
- package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
- package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
- package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
- package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
- package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
- package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
- package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
- package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
- package/skills/struq/blocks/kit/subject/build.ts +66 -0
- package/skills/struq/blocks/kit/subject/draw.ts +196 -0
- package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
- package/skills/struq/blocks/kit/subject/format.ts +122 -0
- package/skills/struq/blocks/kit/subject/index.ts +11 -0
- package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
- package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/art-direction.md +51 -0
- package/skills/struq/scripts/blocks.mjs +56 -20
- package/skills/struq/scripts/canon.mjs +36 -7
- package/skills/struq/scripts/check.mjs +417 -11
- package/skills/struq/scripts/draw.mjs +49 -0
- package/skills/struq/scripts/film-check.mjs +28 -4
- package/skills/struq/scripts/gate.mjs +13 -3
- package/skills/struq/scripts/hand.mjs +222 -0
- package/skills/struq/scripts/images.mjs +12 -3
- package/skills/struq/scripts/look.mjs +1184 -0
- package/skills/struq/scripts/stamp.mjs +47 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Struq look: render the built site and measure what a reader would see, at a phone and at a desktop.
|
|
3
|
+
//
|
|
4
|
+
// npx next build && npx next start -p 4321 first, in the project, and leave it running
|
|
5
|
+
// node <skill-dir>/scripts/look.mjs then this, from the project root
|
|
6
|
+
// node <skill-dir>/scripts/look.mjs --url http://localhost:4321 --dir ./my-site
|
|
7
|
+
//
|
|
8
|
+
// check.mjs reads the source; this one opens the page. At 390x844 and 1440x900, motion on, it waits for the fonts and
|
|
9
|
+
// the blocks' own ready signals, then scrolls top to bottom with real wheel events (Lenis follows those, not
|
|
10
|
+
// scrollTo) in steps of 60 percent of the screen, settles, screenshots and measures every step:
|
|
11
|
+
//
|
|
12
|
+
// collision the fixed header's text over other text; the site name twice on the first screen FAIL
|
|
13
|
+
// clipped glyphs cut by an overflow mask (descenders under a line mask, a word past the edge) FAIL
|
|
14
|
+
// overflow the document wider than the screen FAIL
|
|
15
|
+
// contrast text against its solid ground under 4.5 (3 from 24px) FAIL
|
|
16
|
+
// unreadable text over a photo, video or canvas: the glyphs hidden, the worse quartile of the pixels FAIL
|
|
17
|
+
// behind them under 3 (24px, 18.66px bold, a wordmark in the header or nav) or 4.5; WARN as
|
|
18
|
+
// contrast where the pixels cannot be read
|
|
19
|
+
// blank a scroll stop with no text seen and the screen one flat colour (header and cursor aside) FAIL
|
|
20
|
+
// broken-number a number ("3 900.00", "EUR 1.250,00", "12%") whose glyphs sit on two lines FAIL
|
|
21
|
+
// font-missing a face the visible text asks for first that never loaded (a fallback shows) FAIL
|
|
22
|
+
// empty a screen-high stretch without text or image between the hero and the footer WARN
|
|
23
|
+
// error console errors, page errors and images that did not load FAIL
|
|
24
|
+
//
|
|
25
|
+
// Screenshots go to .struq/look/ (desktop-NN.png, phone-NN.png): open the ones a finding names and look. The findings
|
|
26
|
+
// and a stamp of the source go to .struq/look.json, which check.mjs holds to: a look older than the source fails.
|
|
27
|
+
// Playwright comes from the project; when it is missing, look installs it as a pinned devDependency (or set
|
|
28
|
+
// STRUQ_PLAYWRIGHT to a folder that has it). Flags: --no-install, --no-log. Exit 0 on LOOK: PASS, 1 on FAIL, 2 when
|
|
29
|
+
// it cannot look at all.
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { createRequire } from "node:module";
|
|
33
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
34
|
+
import { pathToFileURL } from "node:url";
|
|
35
|
+
import { inflateSync } from "node:zlib";
|
|
36
|
+
import { appendLog } from "./log.mjs";
|
|
37
|
+
import { sourceStamp } from "./stamp.mjs";
|
|
38
|
+
|
|
39
|
+
const PLAYWRIGHT = "playwright@1.64.1";
|
|
40
|
+
const VIEWPORTS = [
|
|
41
|
+
{ name: "phone", width: 390, height: 844 },
|
|
42
|
+
{ name: "desktop", width: 1440, height: 900 },
|
|
43
|
+
];
|
|
44
|
+
const MAX_STEPS = 60;
|
|
45
|
+
const SEVERITY = { collision: "FAIL", clipped: "FAIL", overflow: "FAIL", contrast: "FAIL", unreadable: "FAIL", blank: "FAIL", "broken-number": "FAIL", "font-missing": "FAIL", error: "FAIL", "self-link": "FAIL", empty: "WARN" };
|
|
46
|
+
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
const flag = (name, fallback) => {
|
|
49
|
+
const i = args.indexOf(name);
|
|
50
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
51
|
+
};
|
|
52
|
+
const root = resolve(flag("--dir", process.cwd()));
|
|
53
|
+
const url = flag("--url", "http://localhost:4321").replace(/\/$/, "") || "http://localhost:4321";
|
|
54
|
+
const install = !args.includes("--no-install");
|
|
55
|
+
const outDir = join(root, ".struq", "look");
|
|
56
|
+
const relOut = (file) => relative(root, file).split(sep).join("/");
|
|
57
|
+
|
|
58
|
+
/* ------------------------------------------------------------- PLAYWRIGHT */
|
|
59
|
+
function resolveFrom(base) {
|
|
60
|
+
try {
|
|
61
|
+
return createRequire(join(resolve(base), "noop.js")).resolve("playwright");
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadChromium() {
|
|
68
|
+
let entry = resolveFrom(root) ?? (process.env.STRUQ_PLAYWRIGHT ? resolveFrom(process.env.STRUQ_PLAYWRIGHT) : null);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
const line = `npm install -D ${PLAYWRIGHT}`;
|
|
71
|
+
if (!install || !existsSync(join(root, "package.json"))) {
|
|
72
|
+
console.log(`Playwright is not installed in ${root}. Run: ${line} (then run look.mjs again)`);
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
console.log(`Installing Playwright into the project, once: ${line}`);
|
|
76
|
+
if (spawnSync(line, { cwd: root, stdio: "inherit", shell: true }).status !== 0) {
|
|
77
|
+
console.log(`The install failed. Run it yourself: ${line}`);
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
entry = resolveFrom(root);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
console.log(`Playwright still does not resolve from ${root}. Run: ${line}`);
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
87
|
+
const chromium = mod.chromium ?? mod.default?.chromium;
|
|
88
|
+
const launch = () => chromium.launch({ headless: true });
|
|
89
|
+
try {
|
|
90
|
+
return { browser: await launch() };
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (!/Executable doesn't exist|playwright install/i.test(String(err?.message))) throw err;
|
|
93
|
+
let cli = join(dirname(entry), "cli.js");
|
|
94
|
+
if (!existsSync(cli)) cli = null;
|
|
95
|
+
const line = cli ? `"${process.execPath}" "${cli}" install chromium` : "npx playwright install chromium";
|
|
96
|
+
if (!install) {
|
|
97
|
+
console.log("Playwright has no Chromium yet. Run: npx playwright install chromium (then run look.mjs again)");
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
console.log("Installing Playwright's Chromium, once: npx playwright install chromium");
|
|
101
|
+
if (spawnSync(line, { cwd: root, stdio: "inherit", shell: true }).status !== 0) {
|
|
102
|
+
console.log("The browser install failed. Run it yourself: npx playwright install chromium");
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
return { browser: await launch() };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* --------------------------------------------------------- IN THE PAGE */
|
|
110
|
+
// Runs inside the page (serialised by Playwright, so it must not close over anything). Returns the step's findings,
|
|
111
|
+
// the document stretches that hold content, and the page metrics.
|
|
112
|
+
function measurePage(opts) {
|
|
113
|
+
const vw = document.documentElement.clientWidth || innerWidth;
|
|
114
|
+
const vh = innerHeight;
|
|
115
|
+
const sy = scrollY;
|
|
116
|
+
const force = document.createElement("style");
|
|
117
|
+
force.textContent = "*,*::before,*::after{pointer-events:auto!important}";
|
|
118
|
+
document.head.appendChild(force);
|
|
119
|
+
const findings = [];
|
|
120
|
+
const content = [];
|
|
121
|
+
try {
|
|
122
|
+
const csCache = new Map();
|
|
123
|
+
const cs = (el) => {
|
|
124
|
+
let s = csCache.get(el);
|
|
125
|
+
if (!s) csCache.set(el, (s = getComputedStyle(el)));
|
|
126
|
+
return s;
|
|
127
|
+
};
|
|
128
|
+
const canvas = document.createElement("canvas");
|
|
129
|
+
canvas.width = canvas.height = 1;
|
|
130
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
131
|
+
const colorCache = new Map();
|
|
132
|
+
const rgba = (str) => {
|
|
133
|
+
if (colorCache.has(str)) return colorCache.get(str);
|
|
134
|
+
let out = [0, 0, 0, 0];
|
|
135
|
+
if (str && str !== "transparent") {
|
|
136
|
+
const m = str.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/);
|
|
137
|
+
if (m) {
|
|
138
|
+
const a = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
|
|
139
|
+
out = [+m[1], +m[2], +m[3], a];
|
|
140
|
+
} else {
|
|
141
|
+
// oklch(), color(), hex: let the canvas turn it into sRGB, then read the alpha from the syntax.
|
|
142
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
143
|
+
ctx.fillStyle = "#000";
|
|
144
|
+
ctx.fillStyle = str;
|
|
145
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
146
|
+
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
147
|
+
const alpha = d[3] / 255;
|
|
148
|
+
out = alpha > 0 ? [d[0] / alpha, d[1] / alpha, d[2] / alpha, alpha] : [0, 0, 0, 0];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
colorCache.set(str, out);
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
const over = (top, base) => {
|
|
155
|
+
const a = top[3];
|
|
156
|
+
return [top[0] * a + base[0] * (1 - a), top[1] * a + base[1] * (1 - a), top[2] * a + base[2] * (1 - a), 1];
|
|
157
|
+
};
|
|
158
|
+
const lum = (c) => {
|
|
159
|
+
const f = (v) => {
|
|
160
|
+
v /= 255;
|
|
161
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
162
|
+
};
|
|
163
|
+
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
|
|
164
|
+
};
|
|
165
|
+
const ratio = (a, b) => {
|
|
166
|
+
const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
|
|
167
|
+
return (x + 0.05) / (y + 0.05);
|
|
168
|
+
};
|
|
169
|
+
const opacityCache = new Map();
|
|
170
|
+
const opacity = (el) => {
|
|
171
|
+
if (!el || el.nodeType !== 1) return 1;
|
|
172
|
+
if (opacityCache.has(el)) return opacityCache.get(el);
|
|
173
|
+
const s = cs(el);
|
|
174
|
+
let o = s.visibility === "hidden" || s.visibility === "collapse" || s.display === "none" ? 0 : parseFloat(s.opacity);
|
|
175
|
+
if (o > 0) o *= opacity(el.parentElement);
|
|
176
|
+
opacityCache.set(el, o);
|
|
177
|
+
return o;
|
|
178
|
+
};
|
|
179
|
+
const translation = (el) => {
|
|
180
|
+
const t = cs(el).transform;
|
|
181
|
+
if (!t || t === "none") return { x: 0, y: 0 };
|
|
182
|
+
try {
|
|
183
|
+
const m = new DOMMatrixReadOnly(t);
|
|
184
|
+
return { x: m.m41, y: m.m42 };
|
|
185
|
+
} catch {
|
|
186
|
+
return { x: 0, y: 0 };
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const isMedia = (el) => /^(IMG|VIDEO|CANVAS|PICTURE|IFRAME)$/.test(el.tagName) || (el.tagName === "image" && el.ownerSVGElement);
|
|
190
|
+
const hasImageGround = (el) => {
|
|
191
|
+
const b = cs(el).backgroundImage;
|
|
192
|
+
return b && b !== "none";
|
|
193
|
+
};
|
|
194
|
+
const path = (el) => {
|
|
195
|
+
const parts = [];
|
|
196
|
+
for (let e = el; e && e.nodeType === 1 && e !== document.body && parts.length < 4; e = e.parentElement) {
|
|
197
|
+
let p = e.tagName.toLowerCase();
|
|
198
|
+
if (e.id) {
|
|
199
|
+
parts.unshift(`${p}#${e.id}`);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
const data = [...e.attributes].find((a) => a.name.startsWith("data-") && !/^data-(ready|shown|live|motion|armed|t0)$/.test(a.name));
|
|
203
|
+
if (data) p += data.value && data.value.length < 30 ? `[${data.name}="${data.value}"]` : `[${data.name}]`;
|
|
204
|
+
else if (e.parentElement) {
|
|
205
|
+
const same = [...e.parentElement.children].filter((c) => c.tagName === e.tagName);
|
|
206
|
+
if (same.length > 1) p += `:nth-of-type(${same.indexOf(e) + 1})`;
|
|
207
|
+
}
|
|
208
|
+
parts.unshift(p);
|
|
209
|
+
}
|
|
210
|
+
return parts.join(" > ");
|
|
211
|
+
};
|
|
212
|
+
const norm = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
213
|
+
const add = (f) => findings.push({ scrollY: sy, ...f });
|
|
214
|
+
|
|
215
|
+
/* --- every visible text node, line by line, with its ink box */
|
|
216
|
+
const texts = [];
|
|
217
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
218
|
+
const range = document.createRange();
|
|
219
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
220
|
+
if (!/\S/.test(node.data)) continue;
|
|
221
|
+
const el = node.parentElement;
|
|
222
|
+
if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TITLE|OPTION|TEXTAREA)$/.test(el.tagName) || el.closest("svg")) continue;
|
|
223
|
+
range.selectNodeContents(node);
|
|
224
|
+
const lines = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5 && r.bottom > 0 && r.top < vh);
|
|
225
|
+
if (!lines.length) continue;
|
|
226
|
+
const o = opacity(el);
|
|
227
|
+
if (o < 0.05) continue;
|
|
228
|
+
const s = cs(el);
|
|
229
|
+
const color = rgba(s.webkitTextFillColor || s.color);
|
|
230
|
+
if (color[3] * o < 0.05) continue;
|
|
231
|
+
const fontSize = parseFloat(s.fontSize) || 16;
|
|
232
|
+
let str = node.data.trim();
|
|
233
|
+
if (s.textTransform === "uppercase") str = str.toUpperCase();
|
|
234
|
+
else if (s.textTransform === "lowercase") str = str.toLowerCase();
|
|
235
|
+
ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
236
|
+
const m = ctx.measureText(str);
|
|
237
|
+
const asc = m.fontBoundingBoxAscent;
|
|
238
|
+
const desc = m.fontBoundingBoxDescent;
|
|
239
|
+
const ink = lines.map((r) => {
|
|
240
|
+
if (!(asc + desc > 0)) return { l: r.left, r: r.right, t: r.top, b: r.bottom + 0.25 * fontSize };
|
|
241
|
+
const k = r.height / (asc + desc);
|
|
242
|
+
const base = r.top + asc * k;
|
|
243
|
+
return { l: r.left, r: r.right, t: base - m.actualBoundingBoxAscent * k, b: base + Math.max(m.actualBoundingBoxDescent, 0) * k };
|
|
244
|
+
});
|
|
245
|
+
texts.push({ node, el, lines, ink, color, o, fontSize, weight: parseFloat(s.fontWeight) || 400, family: s.fontFamily, font: ctx.font, transform: s.textTransform, asc, desc, text: node.data.replace(/\s+/g, " ").trim() });
|
|
246
|
+
}
|
|
247
|
+
/** The ink of each glyph of a text that falls inside a box: a line box is mostly air, its letters are not. */
|
|
248
|
+
const glyphsIn = (t, box) => {
|
|
249
|
+
const out = [];
|
|
250
|
+
ctx.font = t.font;
|
|
251
|
+
for (let i = 0; i < t.node.data.length; i++) {
|
|
252
|
+
let ch = t.node.data[i];
|
|
253
|
+
if (!/\S/.test(ch)) continue;
|
|
254
|
+
range.setStart(t.node, i);
|
|
255
|
+
range.setEnd(t.node, i + 1);
|
|
256
|
+
for (const r of range.getClientRects()) {
|
|
257
|
+
if (r.right < box.l || r.left > box.r || r.bottom < box.t - t.fontSize || r.top > box.b + t.fontSize) continue;
|
|
258
|
+
if (t.transform === "uppercase") ch = ch.toUpperCase();
|
|
259
|
+
const m = ctx.measureText(ch);
|
|
260
|
+
const k = t.asc + t.desc > 0 ? r.height / (t.asc + t.desc) : 1;
|
|
261
|
+
const base = r.top + t.asc * k;
|
|
262
|
+
const g = { l: r.left, r: r.right, t: base - m.actualBoundingBoxAscent * k, b: base + Math.max(m.actualBoundingBoxDescent, 0) * k };
|
|
263
|
+
if (Math.min(g.r, box.r) - Math.max(g.l, box.l) >= 2 && Math.min(g.b, box.b) - Math.max(g.t, box.t) >= 2) out.push(g);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/* --- the clip rect each text sits in: overflow hidden or clip ancestors, and the screen's sides */
|
|
270
|
+
const clipOf = (el) => {
|
|
271
|
+
const c = { l: 0, r: vw, t: -Infinity, b: Infinity, byX: null, byY: null, tiny: false };
|
|
272
|
+
// Only the containing-block chain clips: an absolute box skips static ancestors, a fixed one all but transformed.
|
|
273
|
+
const flowOf = (s) => (s.position === "absolute" ? "abs" : s.position === "fixed" ? "fixed" : "flow");
|
|
274
|
+
let flow = "flow";
|
|
275
|
+
for (let a = el; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
276
|
+
const s = cs(a);
|
|
277
|
+
if (a !== el) {
|
|
278
|
+
if (flow === "fixed" && s.transform === "none") continue;
|
|
279
|
+
if (flow === "abs" && s.position === "static" && s.transform === "none") continue;
|
|
280
|
+
}
|
|
281
|
+
flow = flowOf(s);
|
|
282
|
+
const cx = s.overflowX === "hidden" || s.overflowX === "clip";
|
|
283
|
+
const cy = s.overflowY === "hidden" || s.overflowY === "clip";
|
|
284
|
+
if (!cx && !cy) continue;
|
|
285
|
+
const r = a.getBoundingClientRect();
|
|
286
|
+
if (r.width <= 2 || r.height <= 2) c.tiny = true;
|
|
287
|
+
const bl = parseFloat(s.borderLeftWidth) || 0;
|
|
288
|
+
const bt = parseFloat(s.borderTopWidth) || 0;
|
|
289
|
+
if (cx && r.left + bl > c.l) [c.l, c.byX] = [r.left + bl, a];
|
|
290
|
+
if (cx && r.left + bl + a.clientWidth < c.r) [c.r, c.byX] = [r.left + bl + a.clientWidth, a];
|
|
291
|
+
if (cy && r.top + bt > c.t) [c.t, c.byY] = [r.top + bt, a];
|
|
292
|
+
if (cy && r.top + bt + a.clientHeight < c.b) [c.b, c.byY] = [r.top + bt + a.clientHeight, a];
|
|
293
|
+
}
|
|
294
|
+
return c;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
for (const t of texts) {
|
|
298
|
+
t.clip = clipOf(t.el);
|
|
299
|
+
if (t.clip.tiny) {
|
|
300
|
+
t.hidden = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Lines wholly outside their clip are hidden on purpose (a mask before its reveal, a slide off screen).
|
|
304
|
+
t.shown = t.ink.map((k) => k.r > t.clip.l + 1 && k.l < t.clip.r - 1 && k.b > t.clip.t + 1 && k.t < t.clip.b - 1);
|
|
305
|
+
if (!t.shown.some(Boolean)) t.hidden = true;
|
|
306
|
+
}
|
|
307
|
+
const shown = texts.filter((t) => !t.hidden);
|
|
308
|
+
|
|
309
|
+
/* --- 2. clipped glyphs */
|
|
310
|
+
for (const t of shown) {
|
|
311
|
+
const tol = Math.max(1.5, 0.03 * t.fontSize);
|
|
312
|
+
t.ink.forEach((k, i) => {
|
|
313
|
+
if (!t.shown[i]) return;
|
|
314
|
+
const c = t.clip;
|
|
315
|
+
const cutB = k.b - c.b;
|
|
316
|
+
const cutT = c.t - k.t;
|
|
317
|
+
const cutL = c.l - k.l;
|
|
318
|
+
const cutR = k.r - c.r;
|
|
319
|
+
if (cutB > tol || cutT > tol) {
|
|
320
|
+
const inkH = k.b - k.t;
|
|
321
|
+
const seen = (Math.min(k.b, c.b) - Math.max(k.t, c.t)) / inkH;
|
|
322
|
+
// A line still travelling through its mask is a reveal in progress, not a cut.
|
|
323
|
+
let moving = false;
|
|
324
|
+
for (let a = t.el; a && a !== c.byY; a = a.parentElement) if (Math.abs(translation(a).y) > 0.5) moving = true;
|
|
325
|
+
if (!moving && seen >= 0.5) {
|
|
326
|
+
const where = cutB > tol ? `the bottom ${Math.round(cutB)}px (descenders)` : `the top ${Math.round(cutT)}px`;
|
|
327
|
+
add({ kind: "clipped", text: t.text, selector: path(t.el), detail: `${where} cut by the overflow of ${c.byY ? path(c.byY) : "the screen"}`, key: `clipped|${path(t.el)}|${t.text.slice(0, 40)}|v`, x: k.l });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (cutL > tol || cutR > tol) {
|
|
331
|
+
// A text that fits every box up to the clip is a card or belt partly off screen, by design. A text wider than
|
|
332
|
+
// a box that holds it, with no sliding track in between, runs off the edge.
|
|
333
|
+
let verdict = "fits";
|
|
334
|
+
for (let a = t.el; a && a !== document.body; a = a.parentElement) {
|
|
335
|
+
if (Math.abs(translation(a).x) > 0.5 && a !== t.el) {
|
|
336
|
+
verdict = "track";
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
const r = a.getBoundingClientRect();
|
|
340
|
+
if (k.l < r.left - tol || k.r > r.right + tol) {
|
|
341
|
+
verdict = "overflows";
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
if (a === c.byX) break;
|
|
345
|
+
}
|
|
346
|
+
if (verdict === "overflows") {
|
|
347
|
+
const side = cutR > tol ? `${Math.round(cutR)}px past the right` : `${Math.round(cutL)}px past the left`;
|
|
348
|
+
add({ kind: "clipped", text: t.text, selector: path(t.el), detail: `runs ${side} edge of ${c.byX ? path(c.byX) : "the screen"}`, key: `clipped|${path(t.el)}|${t.text.slice(0, 40)}|h`, x: k.l });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* --- 1. the header: fixed or sticky, across the top, holding text */
|
|
355
|
+
// A header may be drawn more than once (header-bar and menu-curtain paint a copy per ground), and may sit inside a
|
|
356
|
+
// full-screen fixed layer (the closed menu): every outermost fixed or sticky bar across the top counts.
|
|
357
|
+
const bars = new Set();
|
|
358
|
+
for (const x of [0.08, 0.3, 0.5, 0.7, 0.92]) {
|
|
359
|
+
for (const y of [4, 16, 32, 56]) {
|
|
360
|
+
for (const e of document.elementsFromPoint(x * vw, y)) {
|
|
361
|
+
let bar = null;
|
|
362
|
+
for (let a = e; a && a !== document.body; a = a.parentElement) {
|
|
363
|
+
const p = cs(a).position;
|
|
364
|
+
if (p !== "fixed" && p !== "sticky") continue;
|
|
365
|
+
const r = a.getBoundingClientRect();
|
|
366
|
+
if (r.top < 0.15 * vh && r.bottom >= 0 && r.height < 0.35 * vh && r.width >= 0.5 * vw) bar = a;
|
|
367
|
+
}
|
|
368
|
+
if (bar) bars.add(bar);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const b of [...bars]) for (const o of bars) if (o !== b && o.contains(b)) bars.delete(b);
|
|
373
|
+
const header = bars.size ? { contains: (el) => [...bars].some((b) => b.contains(el)) } : null;
|
|
374
|
+
const inHeader = (t) => header && header.contains(t.el);
|
|
375
|
+
const headerTexts = header ? shown.filter(inHeader) : [];
|
|
376
|
+
const coveredBetween = (stack, a, b) => {
|
|
377
|
+
for (let i = Math.min(a, b) + 1; i < Math.max(a, b); i++) {
|
|
378
|
+
const e = stack[i];
|
|
379
|
+
const s = cs(e);
|
|
380
|
+
if (isMedia(e) || rgba(s.backgroundColor)[3] * opacity(e) >= 0.9 || (s.backdropFilter && s.backdropFilter !== "none") || hasImageGround(e)) return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
};
|
|
384
|
+
for (const h of headerTexts) {
|
|
385
|
+
h.lines.forEach((hr, i) => {
|
|
386
|
+
if (!h.shown[i]) return;
|
|
387
|
+
for (const b of shown) {
|
|
388
|
+
if (inHeader(b)) continue;
|
|
389
|
+
b.lines.forEach((br, j) => {
|
|
390
|
+
if (!b.shown[j]) return;
|
|
391
|
+
const l = Math.max(hr.left, br.left, 0);
|
|
392
|
+
const r = Math.min(hr.right, br.right, vw);
|
|
393
|
+
const t = Math.max(hr.top, br.top, 0);
|
|
394
|
+
const bt = Math.min(hr.bottom, br.bottom, vh);
|
|
395
|
+
if (r - l < 2 || bt - t < 2) return;
|
|
396
|
+
// The boxes meet; now the letters: the header's ink against the glyphs of the text under it.
|
|
397
|
+
const hk = h.ink[i];
|
|
398
|
+
const hit = glyphsIn(b, { l: Math.max(hk.l, 0), r: Math.min(hk.r, vw), t: Math.max(hk.t, 0), b: Math.min(hk.b, vh) })[0];
|
|
399
|
+
if (!hit) return;
|
|
400
|
+
const cx = (Math.max(hit.l, hk.l) + Math.min(hit.r, hk.r)) / 2;
|
|
401
|
+
const cy = (Math.max(hit.t, hk.t, 0) + Math.min(hit.b, hk.b, vh)) / 2;
|
|
402
|
+
const stack = document.elementsFromPoint(cx, cy);
|
|
403
|
+
const ih = stack.findIndex((e) => e === h.el || h.el.contains(e));
|
|
404
|
+
const ib = stack.findIndex((e) => e === b.el || b.el.contains(e));
|
|
405
|
+
if (ih < 0 || ib < 0 || coveredBetween(stack, ih, ib)) return;
|
|
406
|
+
add({ kind: "collision", text: b.text, selector: path(b.el), detail: `under the header's "${h.text.slice(0, 40)}"`, key: `collision|${path(b.el)}|${b.text.slice(0, 40)}` });
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A short heading can slip under the header between two steps (running text has lines enough to be caught). Text in the page's flow moves exactly with the scroll,
|
|
413
|
+
// so find the first scroll distance within the next step where its letters would meet a see-through header's: the
|
|
414
|
+
// run stops there too and measures it for real.
|
|
415
|
+
let next = null;
|
|
416
|
+
const known = new Set(opts.known ?? []);
|
|
417
|
+
const rides = (el) => {
|
|
418
|
+
for (let a = el; a && a !== document.body; a = a.parentElement) {
|
|
419
|
+
const p = cs(a).position;
|
|
420
|
+
if (p === "fixed" || p === "sticky") return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
};
|
|
424
|
+
for (const h of headerTexts) {
|
|
425
|
+
h.ink.forEach((hk, i) => {
|
|
426
|
+
if (!h.shown[i] || hk.b <= 0 || hk.t >= vh) return;
|
|
427
|
+
const stack = document.elementsFromPoint(Math.min(Math.max((hk.l + hk.r) / 2, 0), vw - 1), Math.min(Math.max((hk.t + hk.b) / 2, 0), vh - 1));
|
|
428
|
+
const ih = stack.findIndex((e) => e === h.el || h.el.contains(e));
|
|
429
|
+
const below = stack.findIndex((e, k) => k > ih && !header.contains(e));
|
|
430
|
+
if (ih < 0 || below < 0 || coveredBetween(stack, ih, below)) return;
|
|
431
|
+
for (const b of shown) {
|
|
432
|
+
if (b.fontSize < 20 || inHeader(b) || rides(b.el) || known.has(`collision|${path(b.el)}|${b.text.slice(0, 40)}`)) continue;
|
|
433
|
+
if (!b.lines.some((r) => r.top < hk.b + opts.stepPx && r.bottom > hk.b && r.right > hk.l && r.left < hk.r)) continue;
|
|
434
|
+
for (const g of glyphsIn(b, { l: hk.l, r: hk.r, t: hk.b, b: hk.b + opts.stepPx })) {
|
|
435
|
+
const lo = g.t - hk.b + 2;
|
|
436
|
+
const hi = g.b - hk.t - 2;
|
|
437
|
+
const d = Math.round((Math.max(lo, 0) + hi) / 2);
|
|
438
|
+
if (hi > lo && d >= 40 && d < opts.stepPx && (next === null || d < next)) next = d;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/* --- 1b. the site name twice on the first screen */
|
|
445
|
+
const MARK = "[data-wordmark],[data-hb-word]";
|
|
446
|
+
const pick = (sel) => [...bars].map((b) => b.querySelector(sel)).find((e) => e && opacity(e) > 0.05 && /\S/.test(e.textContent ?? ""));
|
|
447
|
+
const homeMark = header ? (pick(MARK) ?? pick('a[href="/"]') ?? headerTexts[0]?.el ?? null) : null;
|
|
448
|
+
if (opts.first && header) {
|
|
449
|
+
const mark = homeMark;
|
|
450
|
+
const name = mark && opacity(mark) > 0.05 ? norm(mark.innerText || mark.textContent || "") : "";
|
|
451
|
+
if (name.length >= 2) {
|
|
452
|
+
const seen = new Set();
|
|
453
|
+
for (const t of shown) {
|
|
454
|
+
if (inHeader(t)) continue;
|
|
455
|
+
for (let a = t.el, n = 0; a && a !== document.body && n < 4; a = a.parentElement, n++) {
|
|
456
|
+
if (seen.has(a)) break;
|
|
457
|
+
seen.add(a);
|
|
458
|
+
const r = a.getBoundingClientRect();
|
|
459
|
+
if (r.width < 4 || r.height < 4 || r.bottom <= 0 || r.top >= vh) continue;
|
|
460
|
+
if (norm(a.innerText || "") === name) {
|
|
461
|
+
add({ kind: "collision", text: a.innerText.trim(), selector: path(a), detail: "the site name shows twice on the first screen: in the header and here", key: `name|${path(a)}` });
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* --- 4. contrast against the ground under the text */
|
|
470
|
+
const done = new Set();
|
|
471
|
+
const photo = [];
|
|
472
|
+
for (const t of shown) {
|
|
473
|
+
if (done.has(t.el)) continue;
|
|
474
|
+
done.add(t.el);
|
|
475
|
+
let blend = false;
|
|
476
|
+
for (let a = t.el; a; a = a.parentElement) if (cs(a).mixBlendMode !== "normal") blend = true;
|
|
477
|
+
if (blend) continue;
|
|
478
|
+
const i = t.shown.findIndex(Boolean);
|
|
479
|
+
const lr = t.lines[i];
|
|
480
|
+
const px = Math.min(Math.max((lr.left + lr.right) / 2, 1), vw - 1);
|
|
481
|
+
const py = Math.min(Math.max((lr.top + lr.bottom) / 2, 1), vh - 1);
|
|
482
|
+
const stack = document.elementsFromPoint(px, py);
|
|
483
|
+
const at = stack.findIndex((e) => e === t.el || t.el.contains(e));
|
|
484
|
+
if (at < 0) continue;
|
|
485
|
+
const layers = [];
|
|
486
|
+
let ground = null;
|
|
487
|
+
let image = false;
|
|
488
|
+
for (let k = at; k < stack.length; k++) {
|
|
489
|
+
const e = stack[k];
|
|
490
|
+
if (k > at && t.el.contains(e)) continue;
|
|
491
|
+
if (k > at && isMedia(e)) {
|
|
492
|
+
image = true;
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
if (hasImageGround(e)) {
|
|
496
|
+
image = true;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
const bg = rgba(cs(e).backgroundColor);
|
|
500
|
+
const a = bg[3] * parseFloat(cs(e).opacity);
|
|
501
|
+
if (a <= 0.01) continue;
|
|
502
|
+
if (a >= 0.99) {
|
|
503
|
+
ground = [bg[0], bg[1], bg[2], 1];
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
layers.push([bg[0], bg[1], bg[2], a]);
|
|
507
|
+
}
|
|
508
|
+
const ink = [t.color[0], t.color[1], t.color[2], t.color[3] * t.o];
|
|
509
|
+
const need = t.fontSize >= 24 ? 3 : 4.5;
|
|
510
|
+
const flatten = (base) => {
|
|
511
|
+
let g = base;
|
|
512
|
+
for (let k = layers.length - 1; k >= 0; k--) g = over(layers[k], g);
|
|
513
|
+
return g;
|
|
514
|
+
};
|
|
515
|
+
if (image) {
|
|
516
|
+
// Over a photo the pixels decide; a scrim that holds against both black and white settles it here. Large text
|
|
517
|
+
// (24px, or 18.66px bold) and the wordmark in the header or nav need 3, smaller text 4.5.
|
|
518
|
+
const large = t.fontSize >= 24 || (t.fontSize >= 18.66 && t.weight >= 700);
|
|
519
|
+
const mark = Boolean(t.el.closest(MARK) || (homeMark && homeMark.contains(t.el)) || ((inHeader(t) || t.el.closest("nav")) && t.el.closest('a[href="/"]')));
|
|
520
|
+
const needOnPhoto = large || mark ? 3 : 4.5;
|
|
521
|
+
const worst = Math.min(...[[0, 0, 0, 1], [255, 255, 255, 1]].map((p) => {
|
|
522
|
+
const g = flatten(p);
|
|
523
|
+
return ratio(over(ink, g), g);
|
|
524
|
+
}));
|
|
525
|
+
if (worst < needOnPhoto) {
|
|
526
|
+
// The run hides the text, screenshots the ground and reads these boxes (the ink of each line on screen).
|
|
527
|
+
const g = t.el.closest(`a,button,h1,h2,h3,h4,h5,h6,p,li,label,figcaption,blockquote,${MARK}`) ?? t.el;
|
|
528
|
+
// Points across the ink of each line on screen, kept only where nothing opaque lies over the text (a panel
|
|
529
|
+
// sliding over a hero title covers part of it; those pixels are not its ground).
|
|
530
|
+
const boxes = t.ink
|
|
531
|
+
.map((k, j) => (t.shown[j] ? { l: Math.max(k.l, t.clip.l, 0), r: Math.min(k.r, t.clip.r, vw), t: Math.max(k.t, t.clip.t, 0), b: Math.min(k.b, t.clip.b, vh) } : null))
|
|
532
|
+
.filter((b) => b && b.r - b.l >= 1 && b.b - b.t >= 1);
|
|
533
|
+
const area = boxes.reduce((n, b) => n + (b.r - b.l) * (b.b - b.t), 0);
|
|
534
|
+
const gap = Math.max(3, Math.sqrt(area / 80));
|
|
535
|
+
const points = [];
|
|
536
|
+
for (const b of boxes) {
|
|
537
|
+
for (let y = b.t + gap / 2; y < b.b; y += gap) {
|
|
538
|
+
for (let x = b.l + gap / 2; x < b.r; x += gap) {
|
|
539
|
+
const stack = document.elementsFromPoint(Math.min(x, vw - 1), Math.min(y, vh - 1));
|
|
540
|
+
const at = stack.findIndex((e) => e === t.el || t.el.contains(e));
|
|
541
|
+
if (at >= 0 && !coveredBetween(stack, -1, at)) points.push([Math.round(x), Math.round(y)]);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
photo.push({
|
|
546
|
+
group: path(g),
|
|
547
|
+
// A word set twice for a hover roll reads once.
|
|
548
|
+
text: (g.innerText || t.text).replace(/\s+/g, " ").trim().replace(/^(.+?)(?: \1)+$/, "$1").slice(0, 120),
|
|
549
|
+
selector: path(t.el),
|
|
550
|
+
own: t.text,
|
|
551
|
+
ink,
|
|
552
|
+
points,
|
|
553
|
+
need: needOnPhoto,
|
|
554
|
+
mark,
|
|
555
|
+
size: Math.round(t.fontSize),
|
|
556
|
+
rect: { x: lr.left, y: lr.top, w: lr.width, h: lr.height },
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const g = flatten(ground ?? [255, 255, 255, 1]);
|
|
562
|
+
const r = ratio(over(ink, g), g);
|
|
563
|
+
if (r < need) {
|
|
564
|
+
add({ kind: "contrast", text: t.text, selector: path(t.el), detail: `${r.toFixed(2)} against its ground, needs ${need} at ${Math.round(t.fontSize)}px`, key: `contrast|${path(t.el)}|${t.text.slice(0, 40)}` });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* --- 4b. a number broken over two lines: "3 900.00" wrapping at its group space reads as two numbers. Every token
|
|
569
|
+
(an optional currency, digits with thousands groups, decimals, a percent or a unit) gets its own Range, and the
|
|
570
|
+
centres of its client rects say how many line boxes its glyphs sit on. */
|
|
571
|
+
const NUMBER = /(?<![\p{L}\p{N}])(?:(?:[€$£¥]|EUR|USD|GBP|CHF)[ ]?)?[+-]?(?:\d{1,3}(?:[ .,'’]\d{3})+|\d+)(?:[.,]\d+)?(?:,-)?(?:[ ]?(?:%|‰|€|EUR|USD|km|kg|m²|m2|m³|cm|mm|ml|uur|min|mln|mld))?(?![\p{L}\p{N}])/gu;
|
|
572
|
+
for (const t of shown) {
|
|
573
|
+
if (!/\d/.test(t.node.data)) continue;
|
|
574
|
+
for (const m of t.node.data.matchAll(NUMBER)) {
|
|
575
|
+
if (m[0].length < 2) continue;
|
|
576
|
+
range.setStart(t.node, m.index);
|
|
577
|
+
range.setEnd(t.node, m.index + m[0].length);
|
|
578
|
+
const rs = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
|
|
579
|
+
if (rs.length < 2) continue;
|
|
580
|
+
const h = Math.min(...rs.map((r) => r.height));
|
|
581
|
+
const mids = rs.map((r) => r.top + r.height / 2).sort((a, b) => a - b);
|
|
582
|
+
let boxes = 1;
|
|
583
|
+
for (let i = 1; i < mids.length; i++) if (mids[i] - mids[i - 1] > h / 2) boxes++;
|
|
584
|
+
if (boxes < 2) continue;
|
|
585
|
+
const top = rs.reduce((a, b) => (b.top < a.top ? b : a));
|
|
586
|
+
if (top.bottom <= 0 || top.top >= vh) continue;
|
|
587
|
+
add({ kind: "broken-number", text: m[0], selector: path(t.el), detail: `the number breaks over ${boxes} lines: keep it whole (a no-break space between its groups, or white-space: nowrap on it)`, key: `number|${path(t.el)}|${m[0]}` });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* --- 4c. for the blank-screen test: is any text on this screen actually seen (not under a curtain or a panel)?
|
|
592
|
+
The header is on every screen and does not count; its bars, small fixed or sticky chrome and the cursor are left
|
|
593
|
+
out of the pixels. */
|
|
594
|
+
const seenText = shown.some((t) => !inHeader(t) && t.lines.some((r, i) => {
|
|
595
|
+
if (!t.shown[i]) return false;
|
|
596
|
+
const stack = document.elementsFromPoint(Math.min(Math.max((r.left + r.right) / 2, 0), vw - 1), Math.min(Math.max((r.top + r.bottom) / 2, 0), vh - 1));
|
|
597
|
+
const at = stack.findIndex((e) => e === t.el || t.el.contains(e));
|
|
598
|
+
return at >= 0 && !coveredBetween(stack, -1, at);
|
|
599
|
+
}));
|
|
600
|
+
const mask = [];
|
|
601
|
+
if (!seenText) {
|
|
602
|
+
const box = (e) => {
|
|
603
|
+
const r = e.getBoundingClientRect();
|
|
604
|
+
return { l: r.left, t: r.top, r: r.right, b: r.bottom };
|
|
605
|
+
};
|
|
606
|
+
for (const b of bars) mask.push(box(b));
|
|
607
|
+
for (const e of document.querySelectorAll("body *")) {
|
|
608
|
+
const p = cs(e).position;
|
|
609
|
+
if (p !== "fixed" && p !== "sticky") continue;
|
|
610
|
+
const r = e.getBoundingClientRect();
|
|
611
|
+
if (r.width > 0 && r.height > 0 && r.height < 0.35 * vh && r.bottom > 0 && r.top < vh) mask.push(box(e));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* --- 4d. the faces the visible text asks for first; the run asks document.fonts about them at the end */
|
|
616
|
+
const families = {};
|
|
617
|
+
for (const t of shown) {
|
|
618
|
+
const first = (t.family.match(/^\s*("[^"]*"|'[^']*'|[^,]+)/)?.[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
619
|
+
if (first && !(first in families)) families[first] = t.text.slice(0, 60);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* --- 5. what holds content on this screen, in document coordinates. Chrome that rides along with the scroll
|
|
623
|
+
(the header, a small fixed button or cursor) is on every screen, so it holds nothing. A pinned scene does. */
|
|
624
|
+
const chromeCache = new Map();
|
|
625
|
+
const chrome = (el) => {
|
|
626
|
+
if (!el || el === document.body) return false;
|
|
627
|
+
if (chromeCache.has(el)) return chromeCache.get(el);
|
|
628
|
+
const p = cs(el).position;
|
|
629
|
+
const v = (header && header.contains(el)) || ((p === "fixed" || p === "sticky") && el.getBoundingClientRect().height < 0.35 * vh) || chrome(el.parentElement);
|
|
630
|
+
chromeCache.set(el, v);
|
|
631
|
+
return v;
|
|
632
|
+
};
|
|
633
|
+
const hold = (e) => {
|
|
634
|
+
const r = e.getBoundingClientRect();
|
|
635
|
+
const w = Math.min(r.right, vw) - Math.max(r.left, 0);
|
|
636
|
+
const h = Math.min(r.bottom, vh) - Math.max(r.top, 0);
|
|
637
|
+
if (w < 48 || h < 48 || opacity(e) < 0.05 || chrome(e)) return;
|
|
638
|
+
content.push([Math.max(r.top, 0) + sy, Math.min(r.bottom, vh) + sy]);
|
|
639
|
+
};
|
|
640
|
+
for (const t of shown) {
|
|
641
|
+
if ((t.text.length <= 3 && t.fontSize < 20) || chrome(t.el)) continue;
|
|
642
|
+
t.lines.forEach((r, i) => t.shown[i] && content.push([r.top + sy, r.bottom + sy]));
|
|
643
|
+
}
|
|
644
|
+
for (const e of document.querySelectorAll("img,video,canvas,picture,svg,iframe")) hold(e);
|
|
645
|
+
for (const e of document.querySelectorAll("body *")) if (hasImageGround(e)) hold(e);
|
|
646
|
+
|
|
647
|
+
const footer = [...document.querySelectorAll("footer")].pop();
|
|
648
|
+
const broken = opts.last
|
|
649
|
+
? [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && (i.currentSrc || i.src)).map((i) => ({ src: i.currentSrc || i.src, selector: path(i) }))
|
|
650
|
+
: [];
|
|
651
|
+
return {
|
|
652
|
+
findings,
|
|
653
|
+
content,
|
|
654
|
+
scrollY: sy,
|
|
655
|
+
viewport: { width: vw, height: vh },
|
|
656
|
+
docWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth),
|
|
657
|
+
docHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
|
|
658
|
+
footerTop: footer ? footer.getBoundingClientRect().top + sy : null,
|
|
659
|
+
broken,
|
|
660
|
+
next,
|
|
661
|
+
photo,
|
|
662
|
+
quiet: !seenText,
|
|
663
|
+
mask,
|
|
664
|
+
families,
|
|
665
|
+
};
|
|
666
|
+
} finally {
|
|
667
|
+
force.remove();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/* ------------------------------------------------------------- PIXELS */
|
|
672
|
+
// Playwright's screenshots are 8-bit, non-interlaced PNGs; this reads one into raw pixels without a dependency.
|
|
673
|
+
function decodePng(buf) {
|
|
674
|
+
let pos = 8;
|
|
675
|
+
let w = 0;
|
|
676
|
+
let h = 0;
|
|
677
|
+
let depth = 0;
|
|
678
|
+
let type = -1;
|
|
679
|
+
let interlace = 0;
|
|
680
|
+
const idat = [];
|
|
681
|
+
while (pos + 8 <= buf.length) {
|
|
682
|
+
const len = buf.readUInt32BE(pos);
|
|
683
|
+
const name = buf.toString("latin1", pos + 4, pos + 8);
|
|
684
|
+
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
685
|
+
if (name === "IHDR") [w, h, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
|
|
686
|
+
else if (name === "IDAT") idat.push(data);
|
|
687
|
+
else if (name === "IEND") break;
|
|
688
|
+
pos += 12 + len;
|
|
689
|
+
}
|
|
690
|
+
const ch = { 0: 1, 2: 3, 4: 2, 6: 4 }[type];
|
|
691
|
+
if (depth !== 8 || interlace || !ch || !w || !h) return null;
|
|
692
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
693
|
+
const stride = w * ch;
|
|
694
|
+
const out = Buffer.alloc(stride * h);
|
|
695
|
+
for (let y = 0; y < h; y++) {
|
|
696
|
+
const f = raw[y * (stride + 1)];
|
|
697
|
+
const src = y * (stride + 1) + 1;
|
|
698
|
+
const cur = y * stride;
|
|
699
|
+
const prev = cur - stride;
|
|
700
|
+
for (let x = 0; x < stride; x++) {
|
|
701
|
+
const a = x >= ch ? out[cur + x - ch] : 0;
|
|
702
|
+
const b = y ? out[prev + x] : 0;
|
|
703
|
+
const c = x >= ch && y ? out[prev + x - ch] : 0;
|
|
704
|
+
let v = raw[src + x];
|
|
705
|
+
if (f === 1) v += a;
|
|
706
|
+
else if (f === 2) v += b;
|
|
707
|
+
else if (f === 3) v += (a + b) >> 1;
|
|
708
|
+
else if (f === 4) {
|
|
709
|
+
const p = a + b - c;
|
|
710
|
+
const pa = Math.abs(p - a);
|
|
711
|
+
const pb = Math.abs(p - b);
|
|
712
|
+
const pc = Math.abs(p - c);
|
|
713
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
714
|
+
}
|
|
715
|
+
out[cur + x] = v & 255;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const gray = ch < 3;
|
|
719
|
+
return { width: w, height: h, at: (x, y) => { const i = (y * w + x) * ch; return gray ? [out[i], out[i], out[i]] : [out[i], out[i + 1], out[i + 2]]; } };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const LINEAR = Array.from({ length: 256 }, (_, v) => (v / 255 <= 0.04045 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
|
|
723
|
+
const lumOf = (c) => 0.2126 * LINEAR[Math.round(c[0])] + 0.7152 * LINEAR[Math.round(c[1])] + 0.0722 * LINEAR[Math.round(c[2])];
|
|
724
|
+
const hex = (c) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
|
725
|
+
|
|
726
|
+
/** Blank: 8px blocks over the screen (chrome left out); one flat colour when no block strays from the median, or only
|
|
727
|
+
* thin runs of them do (a seam, a hairline, the slit of a curtain opening) and at most a few loose ones. */
|
|
728
|
+
function flatScreen(img, mask) {
|
|
729
|
+
const B = 8;
|
|
730
|
+
const cols = Math.floor(img.width / B);
|
|
731
|
+
const rows = Math.floor(img.height / B);
|
|
732
|
+
const means = new Array(cols * rows).fill(null);
|
|
733
|
+
let kept = 0;
|
|
734
|
+
for (let by = 0; by < rows; by++) {
|
|
735
|
+
for (let bx = 0; bx < cols; bx++) {
|
|
736
|
+
const [l, t] = [bx * B, by * B];
|
|
737
|
+
if (mask.some((m) => l < m.r && l + B > m.l && t < m.b && t + B > m.t)) continue;
|
|
738
|
+
const s = [0, 0, 0];
|
|
739
|
+
for (let y = t; y < t + B; y++) {
|
|
740
|
+
for (let x = l; x < l + B; x++) {
|
|
741
|
+
const p = img.at(x, y);
|
|
742
|
+
s[0] += p[0];
|
|
743
|
+
s[1] += p[1];
|
|
744
|
+
s[2] += p[2];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
means[by * cols + bx] = s.map((v) => v / (B * B));
|
|
748
|
+
kept++;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (!kept || kept < 0.4 * cols * rows) return { blank: false };
|
|
752
|
+
const blocks = means.filter(Boolean);
|
|
753
|
+
const med = [0, 1, 2].map((k) => blocks.map((v) => v[k]).sort((a, b) => a - b)[blocks.length >> 1]);
|
|
754
|
+
const off = means.map((v) => Boolean(v) && Math.max(...v.map((c, k) => Math.abs(c - med[k]))) > 14);
|
|
755
|
+
// Strays that touch form shapes; a shape three blocks (24px) thin or less across is a line, not content.
|
|
756
|
+
let solid = 0;
|
|
757
|
+
const seen = new Uint8Array(off.length);
|
|
758
|
+
for (let i = 0; i < off.length; i++) {
|
|
759
|
+
if (!off[i] || seen[i]) continue;
|
|
760
|
+
const queue = [i];
|
|
761
|
+
seen[i] = 1;
|
|
762
|
+
let [x0, x1, y0, y1, n] = [cols, -1, rows, -1, 0];
|
|
763
|
+
while (queue.length) {
|
|
764
|
+
const j = queue.pop();
|
|
765
|
+
const [x, y] = [j % cols, Math.floor(j / cols)];
|
|
766
|
+
[x0, x1, y0, y1, n] = [Math.min(x0, x), Math.max(x1, x), Math.min(y0, y), Math.max(y1, y), n + 1];
|
|
767
|
+
for (const k of [x > 0 ? j - 1 : -1, x < cols - 1 ? j + 1 : -1, j - cols, j + cols]) {
|
|
768
|
+
if (k >= 0 && k < off.length && off[k] && !seen[k]) {
|
|
769
|
+
seen[k] = 1;
|
|
770
|
+
queue.push(k);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (Math.min(x1 - x0 + 1, y1 - y0 + 1) > 3) solid += n;
|
|
775
|
+
}
|
|
776
|
+
const strays = off.filter(Boolean).length;
|
|
777
|
+
return { blank: solid <= 16 && strays <= 0.08 * kept, color: hex(med) };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The worse quartile of the contrast between a text's colour and the pixels behind its ink (3x3 around each uncovered
|
|
781
|
+
* point), per group. */
|
|
782
|
+
function groundRatios(img, groups) {
|
|
783
|
+
const out = new Map();
|
|
784
|
+
for (const g of groups) {
|
|
785
|
+
const ratios = [];
|
|
786
|
+
for (const item of g.items) {
|
|
787
|
+
const [ir, ig, ib, ia] = item.ink;
|
|
788
|
+
for (const [px, py] of item.points) {
|
|
789
|
+
for (let y = Math.max(0, py - 1); y <= Math.min(img.height - 1, py + 1); y++) {
|
|
790
|
+
for (let x = Math.max(0, px - 1); x <= Math.min(img.width - 1, px + 1); x++) {
|
|
791
|
+
const p = img.at(x, y);
|
|
792
|
+
const text = [ir * ia + p[0] * (1 - ia), ig * ia + p[1] * (1 - ia), ib * ia + p[2] * (1 - ia)];
|
|
793
|
+
const [hi, lo] = [lumOf(text), lumOf(p)].sort((a, b) => b - a);
|
|
794
|
+
ratios.push((hi + 0.05) / (lo + 0.05));
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
ratios.sort((a, b) => a - b);
|
|
800
|
+
out.set(g.key, { n: ratios.length, ratio: ratios.length ? ratios[Math.floor(0.25 * (ratios.length - 1))] : null });
|
|
801
|
+
}
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const MIN_PIXELS = 27;
|
|
806
|
+
|
|
807
|
+
/** Group the texts over a photo by the link, heading or paragraph they belong to (a word split into letters is one). */
|
|
808
|
+
function photoGroups(photo, seen) {
|
|
809
|
+
const groups = new Map();
|
|
810
|
+
for (const c of photo) {
|
|
811
|
+
const key = `unreadable|${c.group}|${c.text.slice(0, 40)}`;
|
|
812
|
+
if (seen.has(key)) continue;
|
|
813
|
+
let g = groups.get(key);
|
|
814
|
+
if (!g) groups.set(key, (g = { key, group: c.group, text: c.text, need: c.need, mark: c.mark, size: c.size, rect: c.rect, warn: c, items: [] }));
|
|
815
|
+
g.items.push({ ink: c.ink, points: c.points });
|
|
816
|
+
g.need = Math.min(g.need, c.need);
|
|
817
|
+
g.mark ||= c.mark;
|
|
818
|
+
g.size = Math.max(g.size, c.size);
|
|
819
|
+
}
|
|
820
|
+
return [...groups.values()];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Hide every glyph (color transparent, no transition), screenshot the ground, show them again, and measure. */
|
|
824
|
+
async function behind(page, groups) {
|
|
825
|
+
if (!groups.length) return new Map();
|
|
826
|
+
let png = null;
|
|
827
|
+
try {
|
|
828
|
+
await page.evaluate(() => {
|
|
829
|
+
const s = document.createElement("style");
|
|
830
|
+
s.id = "struq-look-hide";
|
|
831
|
+
s.textContent = "*,*::before,*::after{color:transparent!important;-webkit-text-fill-color:transparent!important;text-decoration-color:transparent!important;caret-color:transparent!important;transition:none!important}";
|
|
832
|
+
document.head.appendChild(s);
|
|
833
|
+
return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
834
|
+
});
|
|
835
|
+
png = await capture(page);
|
|
836
|
+
} catch {
|
|
837
|
+
png = null;
|
|
838
|
+
} finally {
|
|
839
|
+
await page.evaluate(() => document.getElementById("struq-look-hide")?.remove()).catch(() => {});
|
|
840
|
+
}
|
|
841
|
+
const img = png ? decodePng(png) : null;
|
|
842
|
+
return img ? groundRatios(img, groups) : new Map();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const GENERIC_FACES = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong", "inherit", "initial", "unset", "-apple-system", "blinkmacsystemfont"]);
|
|
846
|
+
const SYSTEM_FACES = /^(arial|helvetica|helvetica neue|segoe ui|times|times new roman|georgia|verdana|tahoma|trebuchet ms|courier|courier new|menlo|monaco|consolas|lucida grande|lucida console|lucida sans unicode|calibri|cambria|impact|comic sans ms|palatino|palatino linotype|book antiqua|ubuntu|cantarell|oxygen|sf pro.*|sf mono|new york|apple color emoji|segoe ui (emoji|symbol)|noto color emoji|liberation .*|dejavu .*)$/i;
|
|
847
|
+
const prettyFace = (f) => f.replace(/^__(.+?)_[0-9a-f]{5,}$/i, "$1").replace(/_/g, " ");
|
|
848
|
+
|
|
849
|
+
/* -------------------------------------------------------------- THE RUN */
|
|
850
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
851
|
+
|
|
852
|
+
/** A screenshot, tried again twice: a busy machine sometimes refuses one capture ("Unable to capture screenshot"). */
|
|
853
|
+
async function capture(page, opts = {}) {
|
|
854
|
+
for (let i = 0; ; i++) {
|
|
855
|
+
try {
|
|
856
|
+
return await page.screenshot(opts);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
if (i >= 2 || !/capture|screenshot/i.test(String(err?.message))) throw err;
|
|
859
|
+
await wait(400);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function settle(page) {
|
|
865
|
+
// Lenis eases toward its target: wait until the scroll position holds still, then give the reveals time to land.
|
|
866
|
+
let last = -1;
|
|
867
|
+
let still = 0;
|
|
868
|
+
for (let i = 0; i < 40 && still < 3; i++) {
|
|
869
|
+
await wait(100);
|
|
870
|
+
const y = await page.evaluate(() => scrollY);
|
|
871
|
+
still = Math.abs(y - last) < 0.5 ? still + 1 : 0;
|
|
872
|
+
last = y;
|
|
873
|
+
}
|
|
874
|
+
await wait(900);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function ready(page) {
|
|
878
|
+
await page.evaluate(() => document.fonts?.ready.then(() => true)).catch(() => {});
|
|
879
|
+
// A preloader keeps <html data-curtain="closed"> while it covers the page.
|
|
880
|
+
await page.waitForFunction(() => document.documentElement.dataset.curtain !== "closed", null, { timeout: 8000 }).catch(() => {});
|
|
881
|
+
// The blocks set data-ready once their start state is live; the kit's CSS lets go after three seconds regardless.
|
|
882
|
+
await page
|
|
883
|
+
.waitForFunction(() => {
|
|
884
|
+
const vh = innerHeight;
|
|
885
|
+
return ![...document.querySelectorAll("[data-split]:not([data-ready]),[data-reveal]:not([data-ready]),[data-hero-still]:not([data-ready]),[data-hero-split]:not([data-ready]),[data-hero-index]:not([data-ready])")].some((e) => {
|
|
886
|
+
const r = e.getBoundingClientRect();
|
|
887
|
+
return r.bottom > 0 && r.top < vh;
|
|
888
|
+
});
|
|
889
|
+
}, null, { timeout: 4000 })
|
|
890
|
+
.catch(() => {});
|
|
891
|
+
await wait(800);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function lookAt(browser, vp, errors) {
|
|
895
|
+
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, reducedMotion: "no-preference", deviceScaleFactor: 1 });
|
|
896
|
+
const page = await context.newPage();
|
|
897
|
+
page.on("console", (msg) => {
|
|
898
|
+
if (msg.type() !== "error") return;
|
|
899
|
+
const where = msg.location()?.url ?? "";
|
|
900
|
+
if (/favicon\.ico/.test(where)) return;
|
|
901
|
+
errors.add(`console: ${msg.text().slice(0, 200)}${where && !msg.text().includes(where) ? ` (${where})` : ""}`);
|
|
902
|
+
});
|
|
903
|
+
page.on("pageerror", (err) => errors.add(`page error: ${String(err?.message ?? err).slice(0, 200)}`));
|
|
904
|
+
page.on("response", (res) => {
|
|
905
|
+
if (res.request().resourceType() === "image" && res.status() >= 400) errors.add(`image ${res.status()}: ${res.url()}`);
|
|
906
|
+
});
|
|
907
|
+
page.on("requestfailed", (req) => {
|
|
908
|
+
if (req.resourceType() === "image" && !/ERR_ABORTED/.test(req.failure()?.errorText ?? "")) errors.add(`image failed: ${req.url()} (${req.failure()?.errorText})`);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
await page.goto(url, { waitUntil: "load", timeout: 60000 });
|
|
912
|
+
await ready(page);
|
|
913
|
+
await page.mouse.move(vp.width / 2, vp.height / 2);
|
|
914
|
+
|
|
915
|
+
const findings = [];
|
|
916
|
+
// A link that points at the section it sits in looks clickable and goes nowhere (proof run 2026-11-15: every row of
|
|
917
|
+
// a list linked to its own section). Only the rendered page knows where a link ends up, so it is measured here.
|
|
918
|
+
const selfLinks = await page.evaluate(() =>
|
|
919
|
+
[...document.querySelectorAll('a[href^="#"]')]
|
|
920
|
+
.filter((a) => {
|
|
921
|
+
const id = decodeURIComponent(a.getAttribute("href").slice(1));
|
|
922
|
+
const target = id && id !== "top" ? document.getElementById(id) : null;
|
|
923
|
+
return Boolean(target && target.contains(a));
|
|
924
|
+
})
|
|
925
|
+
.map((a) => ({ text: (a.textContent ?? "").trim().slice(0, 60), href: a.getAttribute("href") })),
|
|
926
|
+
);
|
|
927
|
+
for (const s of selfLinks) {
|
|
928
|
+
findings.push({ kind: "self-link", viewport: vp.name, step: 0, scrollY: 0, text: s.text, selector: `a[href="${s.href}"]`, detail: `links to ${s.href}, the section it sits in: point it somewhere real or make it plain text`, shot: null });
|
|
929
|
+
}
|
|
930
|
+
const seen = new Set();
|
|
931
|
+
const content = [];
|
|
932
|
+
const steps = [];
|
|
933
|
+
let overflow = null;
|
|
934
|
+
let footerTop = null;
|
|
935
|
+
let docHeight = 0;
|
|
936
|
+
let crops = 0;
|
|
937
|
+
let blankRun = null;
|
|
938
|
+
const faces = new Map();
|
|
939
|
+
const measured = new Set();
|
|
940
|
+
const stepPx = Math.round(vp.height * 0.6);
|
|
941
|
+
let extra = 0;
|
|
942
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
943
|
+
const first = step === 0;
|
|
944
|
+
let m = await page.evaluate(measurePage, { first, last: false, stepPx, known: [...seen] });
|
|
945
|
+
if (m.findings.length) {
|
|
946
|
+
// Measure once more after a pause: only what holds still is a defect, not an animation passing through.
|
|
947
|
+
await wait(1200);
|
|
948
|
+
const again = await page.evaluate(measurePage, { first, last: false, stepPx, known: [...seen] });
|
|
949
|
+
const before = new Map(m.findings.map((f) => [f.key, f]));
|
|
950
|
+
again.findings = again.findings.filter((f) => before.has(f.key) && (f.x === undefined || Math.abs(f.x - before.get(f.key).x) < 2));
|
|
951
|
+
m = again;
|
|
952
|
+
}
|
|
953
|
+
const shot = join(outDir, `${vp.name}-${String(step).padStart(2, "0")}.png`);
|
|
954
|
+
let png = await capture(page, { path: shot });
|
|
955
|
+
steps.push({ step, scrollY: m.scrollY, shot });
|
|
956
|
+
content.push(...m.content);
|
|
957
|
+
for (const [face, text] of Object.entries(m.families)) if (!faces.has(face)) faces.set(face, { text, step, scrollY: m.scrollY, shot });
|
|
958
|
+
docHeight = Math.max(docHeight, m.docHeight);
|
|
959
|
+
if (m.footerTop !== null) footerTop = m.footerTop;
|
|
960
|
+
if (!overflow && m.docWidth > m.viewport.width + 1) {
|
|
961
|
+
overflow = { kind: "overflow", viewport: vp.name, step, scrollY: m.scrollY, text: "", selector: "html", detail: `the page is ${m.docWidth}px wide on a ${m.viewport.width}px screen`, shot };
|
|
962
|
+
}
|
|
963
|
+
const crop = async (rect, tag) => {
|
|
964
|
+
if (!rect || crops >= 12) return undefined;
|
|
965
|
+
const pad = 16;
|
|
966
|
+
const x = Math.max(0, rect.x - pad);
|
|
967
|
+
const y = Math.max(0, rect.y - pad);
|
|
968
|
+
const clip = { x, y, width: Math.min(vp.width - x, rect.w + 2 * pad), height: Math.min(vp.height - y, rect.h + 2 * pad) };
|
|
969
|
+
if (clip.width <= 4 || clip.height <= 4) return undefined;
|
|
970
|
+
const file = join(outDir, `${vp.name}-${String(step).padStart(2, "0")}-${tag}-${++crops}.png`);
|
|
971
|
+
await capture(page, { path: file, clip });
|
|
972
|
+
return file;
|
|
973
|
+
};
|
|
974
|
+
for (const f of m.findings) {
|
|
975
|
+
if (seen.has(f.key)) continue;
|
|
976
|
+
seen.add(f.key);
|
|
977
|
+
findings.push({ kind: f.kind, viewport: vp.name, step, scrollY: Math.round(f.scrollY), text: f.text.slice(0, 120), selector: f.selector, detail: f.detail, shot });
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Blank: no text seen on this screen and the pixels one flat colour. Held for a moment longer, so a panel passing
|
|
981
|
+
// through is not one; a run of blank stops is one finding.
|
|
982
|
+
if (m.quiet) {
|
|
983
|
+
const cx = vp.width / 2;
|
|
984
|
+
const cy = vp.height / 2;
|
|
985
|
+
const cursor = { l: cx - 60, r: cx + 60, t: cy - 60, b: cy + 60 };
|
|
986
|
+
let flat = flatScreen(decodePng(png) ?? { width: 0, height: 0 }, [...m.mask, cursor]);
|
|
987
|
+
if (flat.blank) {
|
|
988
|
+
await wait(1200);
|
|
989
|
+
const again = await page.evaluate(measurePage, { first, last: false, stepPx, known: [...seen] });
|
|
990
|
+
png = await capture(page, { path: shot });
|
|
991
|
+
flat = again.quiet ? flatScreen(decodePng(png) ?? { width: 0, height: 0 }, [...again.mask, cursor]) : { blank: false };
|
|
992
|
+
}
|
|
993
|
+
if (flat.blank && blankRun && blankRun.step === step - 1) {
|
|
994
|
+
blankRun.step = step;
|
|
995
|
+
blankRun.more++;
|
|
996
|
+
blankRun.finding.detail = `the whole screen is one flat colour (${flat.color}), with nothing to read or see, for ${blankRun.more + 1} scroll stops in a row`;
|
|
997
|
+
} else if (flat.blank) {
|
|
998
|
+
const finding = { kind: "blank", viewport: vp.name, step, scrollY: Math.round(m.scrollY), text: "", selector: "", detail: `the whole screen is one flat colour (${flat.color}), with nothing to read or see`, shot };
|
|
999
|
+
findings.push(finding);
|
|
1000
|
+
blankRun = { step, more: 0, finding };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Text over a photo, a video or a canvas: hide the glyphs, read the pixels behind each line and hold the worse
|
|
1005
|
+
// quartile to the text colour. A fail is read once more after a pause; what cannot be read keeps the old warning.
|
|
1006
|
+
const groups = photoGroups(m.photo, seen);
|
|
1007
|
+
if (groups.length) {
|
|
1008
|
+
const read = await behind(page, groups);
|
|
1009
|
+
let failing = groups.filter((g) => read.get(g.key)?.n >= MIN_PIXELS && read.get(g.key).ratio < g.need);
|
|
1010
|
+
const unmeasured = groups.filter((g) => !(read.get(g.key)?.n >= MIN_PIXELS));
|
|
1011
|
+
if (failing.length) {
|
|
1012
|
+
await wait(1200);
|
|
1013
|
+
const again = await page.evaluate(measurePage, { first: false, last: false, stepPx, known: [...seen] });
|
|
1014
|
+
const regroup = photoGroups(again.photo, seen).filter((g) => failing.some((f) => f.key === g.key));
|
|
1015
|
+
const second = await behind(page, regroup);
|
|
1016
|
+
failing = regroup.filter((g) => second.get(g.key)?.n >= MIN_PIXELS && second.get(g.key).ratio < g.need).map((g) => ({ ...g, ratio: second.get(g.key).ratio }));
|
|
1017
|
+
}
|
|
1018
|
+
for (const g of failing) {
|
|
1019
|
+
seen.add(g.key);
|
|
1020
|
+
const who = g.mark ? "the wordmark" : `${g.size}px text`;
|
|
1021
|
+
findings.push({ kind: "unreadable", viewport: vp.name, step, scrollY: Math.round(m.scrollY), text: g.text, selector: g.group, detail: `${g.ratio.toFixed(2)} against the photo behind it (its worse quarter), ${who} needs ${g.need}: a scrim, a calmer crop or another place for the text`, shot, crop: await crop(g.rect, "unreadable") });
|
|
1022
|
+
}
|
|
1023
|
+
for (const g of groups) if (read.get(g.key)?.n >= MIN_PIXELS) measured.add(g.key);
|
|
1024
|
+
for (const g of unmeasured) {
|
|
1025
|
+
// Read at an earlier stop already (a line leaving the screen is mostly gone): nothing to warn about.
|
|
1026
|
+
const key = `contrast|${g.warn.selector}|${g.warn.own.slice(0, 40)}`;
|
|
1027
|
+
if (seen.has(key) || measured.has(g.key)) continue;
|
|
1028
|
+
seen.add(key);
|
|
1029
|
+
findings.push({ kind: "contrast", severity: "WARN", viewport: vp.name, step, scrollY: Math.round(m.scrollY), text: g.warn.own, selector: g.warn.selector, detail: "text over a photo or canvas that could not be measured: look at the crop", shot, crop: await crop(g.rect, "contrast") });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const before = m.scrollY;
|
|
1034
|
+
if (before + m.viewport.height >= m.docHeight - 2) break;
|
|
1035
|
+
// A full step, or less where a word is about to slip under the header.
|
|
1036
|
+
const dy = m.next && extra < 25 ? m.next : stepPx;
|
|
1037
|
+
if (dy !== stepPx) extra++;
|
|
1038
|
+
await page.mouse.wheel(0, dy);
|
|
1039
|
+
await settle(page);
|
|
1040
|
+
let after = await page.evaluate(() => scrollY);
|
|
1041
|
+
if (Math.abs(after - before) < 1) {
|
|
1042
|
+
// Nothing moved: a scroll lock or a page without Lenis that ignores the wheel. Nudge it directly once.
|
|
1043
|
+
await page.evaluate((dy) => window.scrollBy(0, dy), dy);
|
|
1044
|
+
await settle(page);
|
|
1045
|
+
after = await page.evaluate(() => scrollY);
|
|
1046
|
+
if (Math.abs(after - before) < 1) break;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const last = await page.evaluate(measurePage, { first: false, last: true, stepPx });
|
|
1050
|
+
for (const b of last.broken) errors.add(`image did not load: ${b.src} (${b.selector})`);
|
|
1051
|
+
if (overflow) findings.push(overflow);
|
|
1052
|
+
|
|
1053
|
+
// A face the visible text asks for first that never loaded: the page reads in a fallback. A loaded face of that
|
|
1054
|
+
// family settles it; otherwise the face is missing when text set in it measures exactly as the fallback alone.
|
|
1055
|
+
const asked = [...faces.keys()].filter((f) => !GENERIC_FACES.has(f.toLowerCase()) && !SYSTEM_FACES.test(f));
|
|
1056
|
+
const missing = asked.length
|
|
1057
|
+
? await page.evaluate(async (list) => {
|
|
1058
|
+
await document.fonts?.ready;
|
|
1059
|
+
const strip = (s) => s.trim().replace(/^["']|["']$/g, "");
|
|
1060
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
1061
|
+
const probe = "mmmmmmmmmmlli0O@#WwQqgjy";
|
|
1062
|
+
return list.filter((face) => {
|
|
1063
|
+
if ([...document.fonts].some((f) => strip(f.family) === face && f.status === "loaded")) return false;
|
|
1064
|
+
const quoted = `"${face.replace(/["\\]/g, "")}"`;
|
|
1065
|
+
return ["monospace", "serif", "sans-serif"].every((base) => {
|
|
1066
|
+
ctx.font = `72px ${base}`;
|
|
1067
|
+
const alone = ctx.measureText(probe).width;
|
|
1068
|
+
ctx.font = `72px ${quoted}, ${base}`;
|
|
1069
|
+
return Math.abs(ctx.measureText(probe).width - alone) < 0.5;
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
}, asked).catch(() => [])
|
|
1073
|
+
: [];
|
|
1074
|
+
for (const face of missing) {
|
|
1075
|
+
const at = faces.get(face);
|
|
1076
|
+
findings.push({ kind: "font-missing", viewport: vp.name, step: at.step, scrollY: Math.round(at.scrollY), text: at.text, selector: "", detail: `the face "${prettyFace(face)}" is asked for first but never loaded, so this text shows a fallback: load it (next/font, or an @font-face that answers) or name a face that does`, shot: at.shot });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// 5. Empty stretches: a screen-high window without content, below the first screen and above the footer.
|
|
1080
|
+
const start = vp.height;
|
|
1081
|
+
const end = footerTop ?? docHeight - vp.height / 2;
|
|
1082
|
+
const spans = content.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
|
|
1083
|
+
let reach = start;
|
|
1084
|
+
const gaps = [];
|
|
1085
|
+
for (const [a, b] of spans) {
|
|
1086
|
+
if (a > reach && Math.min(a, end) - reach >= vp.height) gaps.push([reach, Math.min(a, end)]);
|
|
1087
|
+
reach = Math.max(reach, b);
|
|
1088
|
+
if (reach >= end) break;
|
|
1089
|
+
}
|
|
1090
|
+
if (end - reach >= vp.height) gaps.push([reach, end]);
|
|
1091
|
+
for (const [a, b] of gaps) {
|
|
1092
|
+
const near = steps.reduce((best, s) => (Math.abs(s.scrollY - a) < Math.abs(best.scrollY - a) ? s : best), steps[0]);
|
|
1093
|
+
findings.push({ kind: "empty", viewport: vp.name, step: near.step, scrollY: Math.round(a), text: "", selector: "", detail: `${Math.round(b - a)}px without text or image, from ${Math.round(a)}px down`, shot: near.shot });
|
|
1094
|
+
}
|
|
1095
|
+
await context.close();
|
|
1096
|
+
return { findings, steps: steps.length };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function main() {
|
|
1100
|
+
try {
|
|
1101
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
1102
|
+
if (!res.ok && res.status >= 500) throw new Error(`HTTP ${res.status}`);
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
console.log(`Nothing answers at ${url} (${err?.cause?.code ?? err?.message ?? err}).`);
|
|
1105
|
+
console.log("Build and serve the site first, from the project root, and leave it running:");
|
|
1106
|
+
console.log(" npx next build && npx next start -p 4321");
|
|
1107
|
+
console.log(`Then: node ${relative(process.cwd(), process.argv[1]) || "look.mjs"}${root !== process.cwd() ? ` --dir ${root}` : ""}`);
|
|
1108
|
+
process.exit(2);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
mkdirSync(outDir, { recursive: true });
|
|
1112
|
+
for (const f of readdirSync(outDir)) if (f.endsWith(".png")) rmSync(join(outDir, f), { force: true });
|
|
1113
|
+
|
|
1114
|
+
const stamp = sourceStamp(root);
|
|
1115
|
+
const notes = [];
|
|
1116
|
+
const buildId = join(root, ".next", "BUILD_ID");
|
|
1117
|
+
if (existsSync(buildId) && stamp.newest.mtime > statSync(buildId).mtimeMs + 1000) {
|
|
1118
|
+
notes.push(`${stamp.newest.file} changed after the last next build: rebuild and restart the server, or this look shows the old page.`);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const { browser } = await loadChromium();
|
|
1122
|
+
const findings = [];
|
|
1123
|
+
const steps = {};
|
|
1124
|
+
try {
|
|
1125
|
+
// Both sizes at once, each in its own context: half the wait.
|
|
1126
|
+
const runs = await Promise.all(VIEWPORTS.map(async (vp) => {
|
|
1127
|
+
const errors = new Set();
|
|
1128
|
+
return { vp, errors, ...(await lookAt(browser, vp, errors)) };
|
|
1129
|
+
}));
|
|
1130
|
+
for (const { vp, errors, steps: n, findings: found } of runs) {
|
|
1131
|
+
steps[vp.name] = n;
|
|
1132
|
+
findings.push(...found);
|
|
1133
|
+
for (const e of errors) findings.push({ kind: "error", viewport: vp.name, step: null, scrollY: null, text: "", selector: "", detail: e, shot: null });
|
|
1134
|
+
}
|
|
1135
|
+
} finally {
|
|
1136
|
+
await browser.close();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
for (const f of findings) f.severity ??= SEVERITY[f.kind];
|
|
1140
|
+
// One error or one missing face shows at both sizes: say it once.
|
|
1141
|
+
const out = [];
|
|
1142
|
+
const errorSeen = new Set();
|
|
1143
|
+
for (const f of findings) {
|
|
1144
|
+
if (f.kind === "error" || f.kind === "font-missing") {
|
|
1145
|
+
if (errorSeen.has(f.detail)) continue;
|
|
1146
|
+
errorSeen.add(f.detail);
|
|
1147
|
+
}
|
|
1148
|
+
out.push({ ...f, shot: f.shot ? relOut(f.shot) : null, ...(f.crop ? { crop: relOut(f.crop) } : {}) });
|
|
1149
|
+
}
|
|
1150
|
+
const result = out.some((f) => f.severity === "FAIL") ? "FAIL" : "PASS";
|
|
1151
|
+
const report = { url, at: new Date().toISOString(), result, steps, findings: out, notes, source: stamp };
|
|
1152
|
+
writeFileSync(join(root, ".struq", "look.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
1153
|
+
|
|
1154
|
+
console.log(`Struq look: ${url}, ${steps.phone ?? 0} phone and ${steps.desktop ?? 0} desktop screens in ${relOut(outDir)}/\n`);
|
|
1155
|
+
// Five lines per kind and size; look.json keeps them all.
|
|
1156
|
+
const shown = new Map();
|
|
1157
|
+
for (const f of out) {
|
|
1158
|
+
const group = `${f.severity} ${f.kind} ${f.viewport ?? ""}`;
|
|
1159
|
+
shown.set(group, (shown.get(group) ?? 0) + 1);
|
|
1160
|
+
if (shown.get(group) > 5) continue;
|
|
1161
|
+
const what = [f.text ? `"${f.text.slice(0, 60)}"` : "", f.selector ? `(${f.selector})` : ""].filter(Boolean).join(" ");
|
|
1162
|
+
const where = f.viewport ? `${f.viewport}${f.step !== null && f.step !== undefined ? ` step ${f.step}` : ""}` : "";
|
|
1163
|
+
console.log(`${f.severity} ${f.kind} ${where}: ${what ? `${what} ` : ""}${f.detail}${f.crop ? ` ${f.crop}` : f.shot ? ` ${f.shot}` : ""}`);
|
|
1164
|
+
}
|
|
1165
|
+
for (const [group, n] of shown) if (n > 5) console.log(`${group}: and ${n - 5} more in .struq/look.json`);
|
|
1166
|
+
for (const n of notes) console.log(`NOTE ${n}`);
|
|
1167
|
+
const fails = out.filter((f) => f.severity === "FAIL").length;
|
|
1168
|
+
const warns = out.length - fails;
|
|
1169
|
+
if (!args.includes("--no-log")) {
|
|
1170
|
+
const kinds = (sev) => [...new Set(out.filter((f) => f.severity === sev).map((f) => f.kind))].join(", ");
|
|
1171
|
+
appendLog(root, "Step 7 Look", [
|
|
1172
|
+
`look.mjs: LOOK ${result}, ${fails} fail, ${warns} warn, ${steps.phone ?? 0} phone and ${steps.desktop ?? 0} desktop screens.`,
|
|
1173
|
+
...(fails ? [`Fail: ${kinds("FAIL")}.`] : []),
|
|
1174
|
+
...(warns ? [`Warn: ${kinds("WARN")}.`] : []),
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
console.log(`\nLOOK: ${result}${fails || warns ? ` (${fails} fail, ${warns} warn)` : ""}${result === "FAIL" ? ". Open the screenshots named above, fix, rebuild, and run look.mjs again." : ""}`);
|
|
1178
|
+
process.exit(result === "FAIL" ? 1 : 0);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
main().catch((err) => {
|
|
1182
|
+
console.log(`look.mjs could not finish: ${err?.stack ?? err}`);
|
|
1183
|
+
process.exit(2);
|
|
1184
|
+
});
|