Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_vocabulary.mjs

31.8 KiB, 1 run

created by r2519314175:793, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_vocabulary.mjs — the guide's Social page says true things, in words the
2// app itself uses, and lands where a deep link points it.
3//
4// The page exists so that a user reporting a fault and a person reading the
5// report use the same word for the same thing. That only works if the words are
6// the APP'S words, so the checks below are mostly checks against the app rather
7// than against the page's own internal consistency. A glossary that agrees with
8// itself and disagrees with the interface is worse than none: it teaches a
9// vocabulary that will not be understood.
10//
11// Ten properties:
12//
13// 0. THE PAGE DOES NOT SCROLL SIDEWAYS ON A PHONE. A guide page a phone
14// reader has to drag left and right is a guide page they fight. Measured
15// as the document's own overflow at 360px, which is what the reader's
16// thumb feels, and not as any one element's width.
17//
18// 1. EVERY TERM IS THE APP'S OWN WORD. Each glossary entry names a piece of
19// evidence — a string in `www/i18n/en.js`, or a sentence already in another
20// guide page — and that evidence has to be findable. This is the check that
21// would catch a coined word, which is the one failure that makes the page
22// actively harmful.
23//
24// 2. NO CROP IS BLANK. A crop is taken by selector from the running app, and a
25// selector that matches an element which is on the page but invisible
26// produces a rectangle of one flat colour. That happened during this page's
27// own making: `.attach-btn` is `visibility: hidden` until its row is
28// hovered, and the first paperclip crop was a black square. Measured by
29// counting distinct colours, not by file size.
30//
31// 3. THE DIAGRAM IS LEGIBLE IN BOTH A LIGHT AND A DARK PALETTE. It is drawn in
32// the palette's variables so it can follow the reader, and the whole point
33// of that is lost if the labels vanish on one of them. The label colour is
34// sampled against the surface it is drawn on, in both, and held to the
35// contrast the app's own audit uses.
36//
37// 4. A DEEP LINK LANDS ON ITS SECTION, ON A DESKTOP AND ON A PHONE. The
38// Improve panel is to carry a button that opens this page at a named
39// anchor, so the anchors are an interface and not an implementation
40// detail. Each is navigated to, and the section has to end up BELOW the
41// sticky header rather than under it. The header GROWS after the first
42// landing, at every width, because search.js builds its box and appends
43// it: measured against the code before this page, every anchor landed
44// about 32px under the header and stayed there. Both widths, because the
45// header wraps to four rows on a phone and the failure is larger.
46//
47// 5. THE ANCHORS SURVIVE THE INDEX BUILD. `dev/guide-index.mjs` renumbers
48// every h2 and h3 to a positional id, so an id written on a heading is
49// erased the next time the index is built. The page puts them on section
50// wrappers instead; this asserts that they are still there afterwards.
51//
52// 6. THE PAGE OBSERVES THE HOUSE PUNCTUATION. No em dash, no en dash, no
53// double hyphen in its prose.
54//
55// 7. THE DIAGRAM'S WORDS ARE STILL WORDS ON A PHONE. Scaled to a 360px
56// column the whole schematic put its labels at five pixels. Measured as
57// RENDERED INK — the height of a label's own box at that width — and not
58// as a font-size in the stylesheet, which says nothing once an SVG has
59// been scaled to fit.
60//
61// 8. THE LANGUAGE SWITCHER OFFERS ONLY PAGES THAT EXIST. `data-guide-locales`
62// is what frame.js reads to decide whether a change of language means
63// going anywhere, so a locale named there and not on disk is a 404 the
64// reader is walked into. It is checked both ways: nothing promised that is
65// missing, and nothing on disk that is not offered.
66//
67// 9. AND NO TRANSLATION SCROLLS SIDEWAYS EITHER. Property 0 measures the
68// English page, and the English page is the shortest. The same quoted
69// sentence runs 211 pixels past the edge in French, and every translated
70// copy spills on BOTH quotations where English spills on one, so a fix
71// proved against English alone is a fix proved against the easy case.
72//
73// EACH CHECK IS PROVED AGAINST A BROKEN PAGE FIRST. `--break <name>` damages a
74// copy of a file and serves it to the real browser through `page.route`, or
75// damages the input a static check reads, and the run is expected to FAIL. A
76// break that does not apply cleanly aborts rather than passing quietly.
77//
78// node dev/verify_vocabulary.mjs --break coined # 1 fails
79// node dev/verify_vocabulary.mjs --break blankcrop # 2 fails
80// node dev/verify_vocabulary.mjs --break invisible # 3 fails
81// node dev/verify_vocabulary.mjs --break nomargin # 4 fails
82// node dev/verify_vocabulary.mjs --break heading # 5 fails
83// node dev/verify_vocabulary.mjs --break dash # 6 fails
84// node dev/verify_vocabulary.mjs --break tinylabels # 7 fails
85// node dev/verify_vocabulary.mjs --break nolanding # 4 fails, at 360px only
86// node dev/verify_vocabulary.mjs --break sideways # 0 fails
87// node dev/verify_vocabulary.mjs --break promise # 8 fails, on the half
88// node dev/verify_vocabulary.mjs --break unlisted # 8 fails, on the other
89// node dev/verify_vocabulary.mjs --break sideloc # 9 fails, and 0 does not
90// node dev/verify_vocabulary.mjs # and then, clean
91//
92// eval "$(bash dev/world.sh 6 --up)"
93// node dev/verify_vocabulary.mjs
94//
95// Needs dev/serve.mjs only: the guide is flat files and loads none of the app.
96// Writes its screenshots to dev/shots/vocab-page-*.png.
97import fs from 'node:fs';
98import os from 'node:os';
99import path from 'node:path';
100import { fileURLToPath, pathToFileURL } from 'node:url';
101
102// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
103// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
104// run under `xvfb-run` still went to the compositor and opened a window on the owner's
105// desktop. Importing this strips the two variables from `process.env`, which is all a
106// launcher that spreads `process.env` needs. See dev/display.mjs.
107import './display.mjs';
108const HERE = path.dirname(fileURLToPath(import.meta.url));
109const WWW = path.join(HERE, '..', 'www');
110const GUIDE = path.join(WWW, 'guide');
111const PAGE = path.join(GUIDE, 'social.html');
112const SHOTS = path.join(HERE, 'shots');
113const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
114const PW = process.env.DAIMOND_PW
115 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
116const CHROME = process.env.DAIMOND_CHROME
117 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
118const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
119
120const BREAK = (() => {
121 const i = process.argv.indexOf('--break');
122 return i > 0 ? String(process.argv[i + 1] || '') : '';
123})();
124
125const ok = [], bad = [];
126const check = (name, pass, detail) => {
127 (pass ? ok : bad).push(name);
128 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
129};
130const die = (why) => { console.error('ABORT: ' + why); process.exit(2); };
131
132// ── What the page claims, and where the app says it ──────────────────
133//
134// One row per glossary entry: the anchor it sits at, the word, and a string that
135// has to appear in the app's own English catalogue or in another guide page. The
136// evidence is deliberately a WHOLE PHRASE and not the bare word, so that a term
137// cannot be justified by a coincidence in a comment.
138const TERMS = [
139 ['term-chip', 'chip', 'i18n', "so the chip would refuse every turn"],
140 ['term-tile', 'tile', 'i18n', "'tile.settings': 'Settings for this tile'"],
141 ['term-head', 'head', 'guide', 'on the Diamonds head'],
142 ['term-closer', 'closer', 'guide', "rail's own closer"],
143 ['term-cog', 'cog', 'guide', "tile's own cog"],
144 ['term-light', 'light', 'guide', 'Its light is the root of every other'],
145 ['term-divider', 'divider', 'guide', 'divider you can drag'],
146 ['term-row', 'row', 'guide', 'row in the admin panel'],
147 ['term-spend-row', 'spend row', 'i18n', "The three cells of the rail's spend row"],
148 ['term-composer', 'composer', 'guide', 'the composer stays put'],
149 ['term-face', 'face', 'i18n', "'Which face of this Diamond'"],
150 ['term-tag', 'tag', 'i18n', "'tag.pool_toggle': 'Filter by tag ({n})'"],
151 ['term-dialog', 'dialog', 'i18n', "'dlg.are_you_sure': 'Are you sure?'"],
152 ['term-gallery', 'gallery', 'guide', 'opens the <strong>gallery</strong>'],
153 ['term-goto', 'Go to box', 'i18n', "'pal.close': 'Close the Go to box'"],
154 ['term-sheet', 'sheet', 'i18n', "'sheet.close': 'Close the sheet'"],
155 ['term-drawer', 'drawer', 'guide', 'the rail becomes a drawer'],
156 ['term-paperclip', 'paperclip', 'guide', 'header carries the paperclip'],
157];
158
159/// Walk the page top to bottom, so every `loading="lazy"` crop has pixels
160/// before a full-page screenshot is taken of it.
161async function unlazy(pg) {
162 await pg.evaluate(async () => {
163 const step = window.innerHeight * 0.8;
164 for (let y = 0; y < document.body.scrollHeight; y += step) {
165 window.scrollTo(0, y);
166 await new Promise((r) => setTimeout(r, 120));
167 }
168 window.scrollTo(0, 0);
169 await new Promise((r) => setTimeout(r, 250));
170 });
171}
172
173/// The sections a deep link may name. These are a published interface: the
174/// Improve panel's own button opens the guide at one of them.
175const ANCHORS = ['writing-a-note', 'regions', 'glossary', 'two-words', 'social-panel'];
176
177// ── The breaks ───────────────────────────────────────────────────────
178//
179// Each returns the bytes to serve in place of a real file, or mutates the input
180// a static check reads. Nothing on disk is touched.
181let pageBytes = fs.readFileSync(PAGE, 'utf8');
182let en = fs.readFileSync(path.join(WWW, 'i18n', 'en.js'), 'utf8');
183let guideText = fs.readdirSync(GUIDE).filter((f) => f.endsWith('.html') && f !== 'social.html')
184 .map((f) => fs.readFileSync(path.join(GUIDE, f), 'utf8')).join('\n');
185let blankCrop = null; // a crop name to replace with a flat rectangle
186
187/// The locale folders that actually hold a Social page. A folder is a locale,
188/// not `legal/` or `img/`, on the same shape guide-index.mjs uses.
189const onDiskLocales = fs.readdirSync(GUIDE, { withFileTypes: true })
190 .filter((e) => e.isDirectory() && /^[a-z]{2}(-[A-Za-z]+)?$/.test(e.name))
191 .map((e) => e.name)
192 .filter((l) => fs.existsSync(path.join(GUIDE, l, 'social.html')))
193 .sort();
194
195/// Each translated page's bytes, by locale, so a break can damage them in
196/// flight the way it damages the English page.
197const localeBytes = {};
198for (const l of onDiskLocales) localeBytes[l] = fs.readFileSync(path.join(GUIDE, l, 'social.html'), 'utf8');
199
200const applied = [];
201switch (BREAK) {
202 case '': break;
203 case 'coined': {
204 // A term with no evidence anywhere: the failure this page exists to avoid.
205 TERMS.push(['term-widget', 'widget', 'i18n', "'widget.name': 'Widget'"]);
206 applied.push('added a coined term with no evidence');
207 break;
208 }
209 case 'blankcrop': {
210 blankCrop = 'vocab-paperclip.png';
211 applied.push('served a flat rectangle for ' + blankCrop);
212 break;
213 }
214 case 'invisible': {
215 // The diagram's labels drawn in the surface colour they sit on: legible
216 // on neither palette, and exactly what a hard-coded colour would do on
217 // one of the two.
218 const before = pageBytes;
219 pageBytes = pageBytes.replace('.wm-lab { fill: var(--accent-text); font-weight: 700; }',
220 '.wm-lab { fill: var(--bg-primary); font-weight: 700; }');
221 if (pageBytes === before) die('the invisible break did not apply');
222 applied.push('drew the diagram labels in the background colour');
223 break;
224 }
225 case 'nomargin': {
226 // frame.js installs the rule that keeps a jump clear of the sticky
227 // header. Without it a deep link lands with its section under the header.
228 applied.push('removed the scroll-margin rule frame.js installs');
229 break;
230 }
231 case 'heading': {
232 // The ids moved onto the headings, where dev/guide-index.mjs erases them
233 // on its next run. Simulated by taking them off the sections.
234 const before = pageBytes;
235 for (const a of ANCHORS) pageBytes = pageBytes.replace(`<section id="${a}">`, '<section>');
236 if (pageBytes === before) die('the heading break did not apply');
237 applied.push('moved the section anchors off the sections');
238 break;
239 }
240 case 'nolanding': {
241 applied.push('took the re-landing out of frame.js');
242 break;
243 }
244 case 'tinylabels': {
245 // The width floor removed, so the diagram is scaled to the phone column
246 // and its labels go with it.
247 const before = pageBytes;
248 pageBytes = pageBytes.replace('.diagram.scrolls svg { min-width: 640px; }', '');
249 if (pageBytes === before) die('the tinylabels break did not apply');
250 applied.push('let the diagram scale down to the phone column');
251 break;
252 }
253 case 'dash': {
254 const before = pageBytes;
255 pageBytes = pageBytes.replace('<h1>Social</h1>', '<h1>Social — the vocabulary</h1>');
256 if (pageBytes === before) die('the dash break did not apply');
257 applied.push('put an em dash in the heading');
258 break;
259 }
260 case 'sideways': {
261 // The rule that lets a quoted sentence wrap. Without it the pill in
262 // guide.css keeps `white-space: nowrap`, the longest quotation runs off
263 // the right edge, and the page goes sideways under the reader's thumb.
264 const before = pageBytes;
265 pageBytes = pageBytes.replace('.ui.quoted { white-space: normal; }', '');
266 if (pageBytes === before) die('the sideways break did not apply');
267 applied.push('took the wrapping rule off the quoted app sentences');
268 break;
269 }
270 case 'promise': {
271 // A locale offered that was never written: the language switcher walks
272 // the reader into a 404, which is what an eight-locale declaration over
273 // five pages did.
274 const before = pageBytes;
275 pageBytes = pageBytes.replace(/data-guide-locales="([^"]*)"/, (m, list) => {
276 const gone = ['ja', 'ko', 'zh-Hans'].find((l) => !list.split(' ').includes(l));
277 if (!gone) die('the promise break found every locale already declared');
278 return `data-guide-locales="${list} ${gone}"`;
279 });
280 if (pageBytes === before) die('the promise break did not apply');
281 applied.push('promised a translation that is not on disk');
282 break;
283 }
284 case 'unlisted': {
285 // The other half: a page that exists and is never offered, so a reader
286 // in that language is left on English with no way across.
287 if (!onDiskLocales.length) die('the unlisted break has no translation to hide');
288 const hide = onDiskLocales[onDiskLocales.length - 1];
289 const before = pageBytes;
290 pageBytes = pageBytes.replace(/data-guide-locales="([^"]*)"/,
291 (m, list) => `data-guide-locales="${list.split(' ').filter((l) => l !== hide).join(' ')}"`);
292 if (pageBytes === before) die('the unlisted break did not apply');
293 applied.push(`stopped offering ${hide}, which is on disk`);
294 break;
295 }
296 case 'sideloc': {
297 // The wrapping rule taken off the TRANSLATIONS and left on the English
298 // page, so property 0 stays green and only property 9 goes red.
299 let hit = 0;
300 for (const l of onDiskLocales) {
301 const before = localeBytes[l];
302 localeBytes[l] = before.replace('.ui.quoted { white-space: normal; }', '');
303 if (localeBytes[l] !== before) hit++;
304 }
305 if (!hit) die('the sideloc break reached no translated page');
306 applied.push(`took the wrapping rule off ${hit} translated page(s)`);
307 break;
308 }
309 default: die(`no break called "${BREAK}"`);
310}
311if (BREAK) console.log(`BREAK ${BREAK}: ${applied.join('; ')}\n`);
312
313
314/// frame.js with the late re-landing taken out, and only that: the reader is
315/// put on their anchor once, before the search box has been built, exactly as
316/// the file behaved before this page needed it to be right on a phone. Both
317/// halves have to come out, and each is required to apply, because a break that
318/// half-lands is a break that proves half a check.
319function nolanding(src) {
320 let out = src.replace("\t\tif (!landed || touched) return;", "\t\treturn;");
321 if (out === src) die('the nolanding break did not reach settleLanding');
322 const later = out;
323 out = out.replace(/\n\t\t\twindow\.addEventListener\('load'[\s\S]*?\}, 1000\);/, '');
324 if (out === later) die('the nolanding break did not reach the later measurements');
325 return out;
326}
327
328// ── 1. Every term is the app's own word ──────────────────────────────
329{
330 const missing = [];
331 for (const [id, word, where, evidence] of TERMS) {
332 const hay = where === 'i18n' ? en : guideText;
333 if (!hay.includes(evidence)) missing.push(`${word} (${where})`);
334 if (!pageBytes.includes(`id="${id}"`)) missing.push(`${word}: no entry at #${id}`);
335 }
336 check('every glossary term is a word the app or the guide already uses',
337 missing.length === 0, missing.join(', '));
338}
339
340// ── 6. House punctuation ─────────────────────────────────────────────
341{
342 // The article only. The SVG's path data uses hyphens freely, a hyphen inside
343 // a word is not a dash, and the document title carries the guide's own
344 // "Improving Daimond — Daimond guide" pattern, which every page has and which
345 // is not this page's prose to change.
346 const main = (pageBytes.match(/<main[^>]*>([\s\S]*?)<\/main>/i) || [, ''])[1];
347 const prose = main.replace(/<svg[\s\S]*?<\/svg>/gi, ' ');
348 const hits = [];
349 for (const [re, what] of [[/—/g, 'em dash'], [/–/g, 'en dash'], [/\s--\s/g, 'double hyphen']]) {
350 const m = prose.match(re);
351 if (m) hits.push(`${m.length} ${what}`);
352 }
353 check('no dashes in the page\'s prose', hits.length === 0, hits.join(', '));
354}
355
356// ── 5. The anchors survive the index build ───────────────────────────
357{
358 // Read from disk, not from the possibly-broken copy: this is a fact about the
359 // file the index generator writes, and `--break heading` proves it by taking
360 // the ids off, which is what the generator's renumbering would do to ids
361 // written on the headings instead.
362 const onDisk = BREAK === 'heading' ? pageBytes : fs.readFileSync(PAGE, 'utf8');
363 const gone = ANCHORS.filter((a) => !new RegExp(`<section[^>]*\\sid="${a}"`).test(onDisk));
364 // And the headings themselves must carry the positional ids, which is what
365 // says the index has been built over this page at all.
366 const positional = (onDisk.match(/<h[23] id="s\d+"/g) || []).length;
367 const pass = gone.length === 0 && positional > 0;
368 check('the section anchors survive dev/guide-index.mjs', pass, pass ? ''
369 : gone.length ? `missing: ${gone.join(', ')}`
370 : 'the index has never been built over this page');
371}
372
373// ── 8. The switcher offers only pages that exist ─────────────────────
374{
375 // Read from `pageBytes`, so `--break promise` and `--break unlisted` are
376 // seen; the disk side is read from the disk, because that is the fact the
377 // declaration is being held against.
378 const promised = ((pageBytes.match(/data-guide-locales="([^"]*)"/) || [, ''])[1])
379 .split(' ').filter((l) => l && l !== 'en');
380 const missing = promised.filter((l) => !fs.existsSync(path.join(GUIDE, l, 'social.html')));
381 const unlisted = onDiskLocales.filter((l) => !promised.includes(l));
382 const why = [];
383 if (missing.length) why.push(`offered with no page: ${missing.join(', ')}`);
384 if (unlisted.length) why.push(`on disk and never offered: ${unlisted.join(', ')}`);
385 check('the language switcher offers exactly the translations that exist',
386 why.length === 0, why.join('; ') || `${promised.length} translation(s)`);
387}
388
389// ── The browser ──────────────────────────────────────────────────────
390const { chromium } = await import(pathToFileURL(PW).href);
391const profile = path.join(SCRATCH, 'pw', 'vocabulary' + (BREAK ? '-' + BREAK : ''));
392fs.rmSync(profile, { recursive: true, force: true });
393fs.mkdirSync(profile, { recursive: true });
394fs.mkdirSync(SHOTS, { recursive: true });
395
396// A forwarded DISPLAY means no compositor frames, so requestAnimationFrame never
397// fires and every wait hangs. See dev/harness.mjs.
398const env = { ...process.env };
399delete env.DISPLAY;
400
401const browser = await chromium.launchPersistentContext(profile, {
402 executablePath: CHROME,
403 headless: false,
404 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'],
405 env,
406 viewport: { width: 1100, height: 900 },
407});
408const page = browser.pages()[0] || await browser.newPage();
409const errs = [];
410page.on('pageerror', (e) => errs.push(String(e.message)));
411page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
412
413await page.route('**/guide/social.html*', (route) => {
414 route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: pageBytes });
415});
416for (const l of onDiskLocales) {
417 await page.route(`**/guide/${l}/social.html*`, (route) => {
418 route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: localeBytes[l] });
419 });
420}
421if (blankCrop) {
422 // A one-colour PNG, which is what a crop of an invisible element looks like.
423 const flat = Buffer.from(
424 'iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAAM0lEQVR4nO3BAQEAAACCIP+vbkhA'
425 + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgNxFAAAHrqBWzAAAAAElFTkSuQmCC', 'base64');
426 await page.route(`**/guide/shots/${blankCrop}`, (route) => {
427 route.fulfill({ status: 200, contentType: 'image/png', body: flat });
428 });
429}
430if (BREAK === 'nomargin' || BREAK === 'nolanding') {
431 await page.route('**/guide/frame.js', async (route) => {
432 const src = fs.readFileSync(path.join(GUIDE, 'frame.js'), 'utf8');
433 const hurt = BREAK === 'nomargin'
434 ? src.replace("rule.textContent = 'main [id] { scroll-margin-top: var(--guide-head-h, 7rem); }';",
435 "rule.textContent = '';")
436 : nolanding(src);
437 if (hurt === src) die(`the ${BREAK} break did not apply`);
438 route.fulfill({ status: 200, contentType: 'text/javascript', body: hurt });
439 });
440}
441
442const URL = `${APP}/guide/social.html`;
443await page.goto(URL, { waitUntil: 'load' });
444await page.waitForTimeout(600);
445
446// ── 2. No crop is blank ──────────────────────────────────────────────
447{
448 // Counted in the browser, from the image as it was actually decoded, so a
449 // file that is fine on disk and 404s on the way in also fails.
450 // Fetched again into images of our own rather than read off the page's. The
451 // page's are `loading="lazy"`, so most of them have no pixels at all until
452 // they are scrolled past, and `decode()` on one in that state took the whole
453 // renderer down. A fresh Image also fails loudly on a src that 404s, which is
454 // half of what this check is for.
455 const flat = await page.evaluate(async () => {
456 const out = [];
457 const srcs = [...document.querySelectorAll('.term img')].map((i) => i.getAttribute('src'));
458 for (const src of srcs) {
459 const img = new Image();
460 img.src = src;
461 try { await img.decode(); } catch (e) { out.push({ src, why: 'did not load' }); continue; }
462 const w = img.naturalWidth, h = img.naturalHeight;
463 if (!w || !h) { out.push({ src, why: 'no pixels' }); continue; }
464 const c = document.createElement('canvas');
465 c.width = w; c.height = h;
466 c.getContext('2d').drawImage(img, 0, 0);
467 const d = c.getContext('2d').getImageData(0, 0, w, h).data;
468 const seen = new Set();
469 for (let i = 0; i < d.length; i += 4) {
470 seen.add((d[i] >> 3) + ',' + (d[i + 1] >> 3) + ',' + (d[i + 2] >> 3));
471 if (seen.size > 12) break;
472 }
473 if (seen.size <= 3) out.push({ src, why: `${seen.size} colours` });
474 }
475 return out;
476 });
477 check('no glossary crop is a flat rectangle', flat.length === 0,
478 flat.map((f) => `${f.src}: ${f.why}`).join(', '));
479}
480
481// ── 3. The diagram is legible on a light palette and a dark one ──────
482{
483 // The contrast the app's own theme audit holds ink to against a surface.
484 const FLOOR = 3.0;
485 const lum = (rgb) => {
486 const f = rgb.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
487 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
488 };
489 const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
490 const parse = (s) => (s.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
491
492 const results = [];
493 for (const theme of ['light', 'dark']) {
494 await page.evaluate((t) => {
495 // The same three attributes guide/frame.js sets when the app tells it
496 // which palette to wear.
497 const map = { light: ['light', 'dark'], dark: ['dark', 'light'] };
498 document.documentElement.setAttribute('data-theme', t);
499 document.documentElement.setAttribute('data-tone', map[t][0]);
500 document.documentElement.setAttribute('data-ink', map[t][1]);
501 }, theme);
502 await page.waitForTimeout(300);
503 const pair = await page.evaluate(() => {
504 const lab = document.querySelector('.widgetmap .wm-lab');
505 const pan = document.querySelector('.widgetmap .wm-pan');
506 const win = document.querySelector('.widgetmap .wm-win');
507 if (!lab || !pan || !win) return null;
508 return {
509 ink: getComputedStyle(lab).fill,
510 pan: getComputedStyle(pan).fill,
511 win: getComputedStyle(win).fill,
512 };
513 });
514 if (!pair) { results.push(`${theme}: the diagram is not on the page`); continue; }
515 const r1 = ratio(parse(pair.ink), parse(pair.pan));
516 const r2 = ratio(parse(pair.ink), parse(pair.win));
517 const worst = Math.min(r1, r2);
518 if (worst < FLOOR) results.push(`${theme}: labels at ${worst.toFixed(2)}:1`);
519 await unlazy(page);
520 await page.screenshot({ path: path.join(SHOTS, `vocab-page-${theme}.png`), fullPage: true });
521 }
522 check('the zone diagram\'s labels are legible on a light palette and a dark one',
523 results.length === 0, results.join(', '));
524}
525
526// ── 4. A deep link lands on its section, at both widths ──────────────
527{
528 const under = [];
529 for (const [w, h] of [[1100, 900], [360, 800]]) {
530 await page.setViewportSize({ width: w, height: h });
531 for (const a of ANCHORS) {
532 // A fresh load per anchor, which is what the Improve panel's button
533 // will do: it sets the frame's src, it does not click a link on a page
534 // already scrolled somewhere.
535 //
536 // Through `about:blank`, because `goto` from `#a` to `#b` on one URL
537 // is a SAME-DOCUMENT navigation: the page is never reloaded, frame.js
538 // never runs again, and four of the five anchors were being checked
539 // against a document that had settled minutes earlier.
540 await page.goto('about:blank');
541 await page.goto(`${URL}#${a}`, { waitUntil: 'load' });
542 await page.waitForTimeout(1500);
543 const r = await page.evaluate((id) => {
544 const el = document.getElementById(id);
545 if (!el) return null;
546 const head = document.querySelector('.site-head');
547 const hb = head ? head.getBoundingClientRect().bottom : 0;
548 // The heading inside the section is what the reader has to see.
549 const hh = el.querySelector('h2') || el;
550 const box = hh.getBoundingClientRect();
551 return { top: box.top, headBottom: hb, y: window.scrollY };
552 }, a);
553 if (!r) { under.push(`${w}px: #${a} is not on the page`); continue; }
554 if (r.top < r.headBottom) under.push(`${w}px: #${a} landed ${Math.round(r.headBottom - r.top)}px under the header`);
555 // And it has to have moved at all: an anchor that never scrolls is one
556 // the browser did not find.
557 if (a !== ANCHORS[0] && r.y <= 0) under.push(`${w}px: #${a} did not scroll`);
558 }
559 }
560 check('every published anchor lands below the sticky header, at 1100px and at 360px',
561 under.length === 0, under.join(', '));
562}
563
564// ── 4b. A header that grows afterwards is landed on again ────────────
565{
566 // The header GROWS after a reader has landed: `search.js` builds the search
567 // box and appends it, and on a phone that is a whole extra row. Before this
568 // page, that left a phone reader's heading 32px under the header for about a
569 // second, until something later put it right; a jump under the reader's eye
570 // is a fault whether or not the page ends up correct.
571 //
572 // Asserted as a CALL and not as a position. Where the page ends up after the
573 // header changes size depends on the browser's own scroll anchoring, which
574 // fires or does not depending on what else the page has been doing, and a
575 // check resting on that passes and fails at random. What frame.js owes the
576 // reader is that it lands them again; that is what is counted.
577 await page.setViewportSize({ width: 360, height: 800 });
578 await page.goto('about:blank');
579 await page.goto(`${URL}#glossary`, { waitUntil: 'load' });
580 await page.waitForTimeout(1200);
581 const relands = await page.evaluate(async () => {
582 let n = 0;
583 const orig = Element.prototype.scrollIntoView;
584 Element.prototype.scrollIntoView = function () { n++; return orig.apply(this, arguments); };
585 const grow = document.createElement('div');
586 grow.style.height = '48px';
587 document.querySelector('.site-head').appendChild(grow);
588 await new Promise((r) => setTimeout(r, 400));
589 Element.prototype.scrollIntoView = orig;
590 return n;
591 });
592 check('a header that grows after the landing puts the reader back on their anchor',
593 relands > 0, `${relands} landings after the header changed size`);
594}
595
596// ── The narrowest screen the guide supports ──────────────────────────
597{
598 await page.setViewportSize({ width: 360, height: 900 });
599 await page.goto(URL, { waitUntil: 'load' }); // no hash: the whole page, from the top
600 await page.waitForTimeout(600);
601 const wide = await page.evaluate(() =>
602 document.documentElement.scrollWidth - document.documentElement.clientWidth);
603 await unlazy(page);
604 await page.screenshot({ path: path.join(SHOTS, 'vocab-page-narrow.png'), fullPage: true });
605 check('the page does not scroll sideways at 360px', wide <= 1, `${wide}px of overflow`);
606
607 // ── 7. The diagram's words are still words ───────────────────
608 // The smallest label's rendered height, in CSS pixels on the page as the
609 // reader has it. Eight is the floor: below that the strokes of a lower-case
610 // letter merge at this weight, which is what the phone render was doing.
611 const FLOOR_PX = 8;
612 const ink = await page.evaluate(() => {
613 const labs = [...document.querySelectorAll('.widgetmap .wm-lab')];
614 if (!labs.length) return null;
615 const hs = labs.map((l) => l.getBoundingClientRect().height);
616 return { min: Math.min(...hs), n: labs.length };
617 });
618 check('the diagram\'s labels are still legible at 360px',
619 !!ink && ink.min >= FLOOR_PX,
620 ink ? `smallest label renders ${ink.min.toFixed(1)}px tall` : 'no labels found');
621}
622
623// ── 9. And no translation scrolls sideways either ────────────────────
624{
625 // Still at 360px from the block above. Each translated copy is loaded in
626 // turn and measured the same way, because the sentence that spilled is a
627 // QUOTATION OF THE APP and every language quotes a different one: the French
628 // `post.audience` is 110 characters where the English is 66.
629 const wide = [];
630 for (const l of onDiskLocales) {
631 await page.goto('about:blank');
632 await page.goto(`${APP}/guide/${l}/social.html`, { waitUntil: 'load' });
633 await page.waitForTimeout(400);
634 const over = await page.evaluate(() =>
635 document.documentElement.scrollWidth - document.documentElement.clientWidth);
636 if (over > 1) wide.push(`${l}: ${over}px`);
637 }
638 check('no translation of the page scrolls sideways at 360px either',
639 wide.length === 0, wide.join(', ') || `${onDiskLocales.length} translation(s) measured`);
640}
641
642check('the page threw nothing', errs.length === 0, errs.slice(0, 3).join(' | '));
643
644await browser.close();
645
646console.log(`\n${ok.length} ok, ${bad.length} failed`);
647if (BREAK) {
648 if (bad.length) { console.log(`the break was caught, as it should be`); process.exit(0); }
649 console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing');
650 process.exit(1);
651}
652process.exit(bad.length ? 1 : 0);