Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_guidefacts.mjs

24.8 KiB, 1 run

created by r2519314175:453, 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_guidefacts.mjs — the guide says the things a first install cost an hour.
2//
3// dev/verify_guidemachine.mjs already asks whether the Machine Operations page
4// LOOKS right: it renders, it does not scroll sideways, its code blocks keep
5// their overflow. This file asks whether it SAYS the right things, which is a
6// different failure and one no layout check can see.
7//
8// Three groups.
9//
10// The new facts, each of which was learned the expensive way: a snap or
11// flatpak browser cannot run this at all, `apt install chromium-browser` is
12// how you get one by accident, a browser profile does not exist until the
13// browser has been run, the passphrase alone cannot restore an account on a
14// fresh browser, and `install.sh --check` is the first thing to run when
15// something is wrong. A fact stated below the fold of a long page is a fact
16// nobody reads, so the two that STOP a reader are also required to appear
17// before the installation steps do.
18//
19// The old facts, none of which may have been lost in the reordering. This is
20// a friendliness pass, not a simplification pass, so the security detail, the
21// fence's limits and the "what is not protected" list are all looked for.
22//
23// The correction. The guide used to say that anyone who imports a backup
24// becomes you. That is the claim to keep out, and it is wrong for one reason:
25// `doExport` in www/js/daimond.js DOES write the identity -- it calls
26// DaimondIdentity.exportBundle() -- but the private key in that bundle is
27// sealed under PBKDF2(passphrase, salt), so the file opens to the passphrase
28// and to nothing else. Both halves have to be said. Drop the first and a
29// reader who deletes a browser after taking a backup loses the account the
30// backup was holding; drop the second and the file reads as a bearer token.
31// The English page and all seven translations are checked for both.
32//
33// And the callout the stop-facts sit in has to be legible in all eleven
34// palettes, not the two a colorScheme flag reaches.
35//
36// node dev/verify_guidefacts.mjs the checks
37// node dev/verify_guidefacts.mjs --prove each check, against broken text
38//
39// Needs dev/serve.mjs (DAIMOND_PORT, default 8777).
40import fs from 'node:fs';
41import os from 'node:os';
42import path from 'node:path';
43import { pathToFileURL } from 'node:url';
44
45const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
46const { chromium } = await import(pathToFileURL(PW).href);
47const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
48import { fileURLToPath } from 'node:url';
49const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home
50// The world's dev server -- see dev/world.sh. Kept inline rather than imported,
51// so this stays standalone and does not load the harness.
52const BASE = (process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`) + '/guide';
53const OUT = path.join(os.homedir(), '.cache/daimond/guide-shots');
54fs.mkdirSync(OUT, { recursive: true });
55
56const PROVE = process.argv.includes('--prove');
57const LOCS = ['de', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'zh-Hans'];
58/// Every palette the guide can wear, as [tone, ink], mirroring frame.js.
59const PALETTES = {
60 light: ['light', 'dark'], mist: ['light', 'dark'], linen: ['light', 'dark'],
61 lollypop: ['mid', 'dark'], sage: ['mid', 'dark'], dusk: ['mid', 'light'],
62 dark: ['dark', 'light'], amber: ['dark', 'light'], midnight: ['dark', 'light'],
63 forest: ['dark', 'light'], plum: ['dark', 'light'],
64};
65
66let bad = 0, ran = 0;
67const say = (ok, what, detail) => {
68 ran++;
69 if (!ok) bad++;
70 console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail && !ok ? ' — ' + detail : ''}`);
71};
72
73// ── What the page has to say ─────────────────────────────────────────
74//
75// Matched against the RENDERED text, not the markup, so a fact hidden by a
76// stylesheet does not count as said.
77//
78// A fact is a NAMED PROPERTY with a predicate, not a sentence. A check that
79// matches one inflection reports a defect every time the copy is tightened, and
80// reports nothing when the copy keeps the words and loses the meaning.
81
82/// Where a page first says that the passphrase does not follow you to a new
83/// browser. An index, because the ordering check needs one.
84const ACCOUNT_AT = /passphrase[^.]{0,220}\b(fresh|new|another|different)\b[^.]{0,30}browser|\b(fresh|new|another|different)\b[^.]{0,30}browser[^.]{0,220}passphrase/i;
85
86/// The account fact, in any wording: three things, all of which the code makes
87/// true. The signing key is generated at random on the device that made the
88/// account (`generatePair` in www/js/identity.js) and the passphrase only
89/// derives the AES-GCM key that unwraps the stored copy, so (a) the same
90/// passphrase in a fresh browser is a DIFFERENT account, (b) the key itself has
91/// to be carried, and (c) these are the things that carry it. Say (a) without
92/// (c) and the reader has a warning and no way out.
93const ACCOUNT_FACT = (t) =>
94 ACCOUNT_AT.test(t)
95 && /\b(different|separate|second|its own|new)\b[^.]{0,30}account/i.test(t)
96 && /Link another device/.test(t)
97 && /passkey|Export a backup/i.test(t);
98
99/// Sentences, near enough. The guide's prose is ordinary, and an abbreviation
100/// inside one costs a split rather than a verdict.
101const sentences = (t) => t.split(/(?<=[.!?;])\s+|\n+/);
102
103/// What a backup is, in any wording, and sentence by sentence rather than over
104/// the whole page -- which is the difference between a check and a coincidence.
105/// `doExport` writes DaimondIdentity.exportBundle(), so the key IS in the file,
106/// and the private half of it is sealed under PBKDF2(passphrase, salt), so the
107/// file is not a bearer token. Both halves, or the page loses an account or
108/// hands one over. Page-wide matching cannot say this: "your key is not in it"
109/// sits on a page that elsewhere says "encrypted" and "passphrase", and every
110/// term would be found.
111const BACKUP_FACT = (t) => {
112 const s = sentences(t);
113 // One sentence has to put the key IN the file. A sentence that says it is
114 // not there is the defect, so a negation next to the key disqualifies it.
115 const inIt = s.some((x) => /\b(backup|file you keep|exported file)\b/i.test(x)
116 && /\bkey\b/i.test(x)
117 && !/\bkey\b[^.]{0,40}\bnot\b|\bnot\b[^.]{0,40}\bkey\b/i.test(x));
118 // And one has to say it travels wrapped, under the passphrase.
119 const wrapped = s.some((x) => /\bkey\b/i.test(x)
120 && /wrapped|sealed|encrypted/i.test(x) && /passphrase/i.test(x));
121 return inIt && wrapped;
122};
123
124/// Decode the handful of entities a bank string can carry, and flatten
125/// whitespace, so a bank value can be looked for in rendered text.
126const flat = (s) => s
127 .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
128 .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;| /g, ' ')
129 .replace(/&#(\d+);/g, (m, n) => String.fromCharCode(Number(n)))
130 .replace(/\s+/g, ' ').trim();
131
132/// The things a reader is stopped by. Each must also come before the steps.
133const STOPPERS = [
134 ['a snap or flatpak browser cannot do it', /snap or flatpak browser cannot/i],
135 ['and why: the hidden directory', /hidden director(y|ies)/i],
136 ['and what the browser reports instead', /Native host has exited/i],
137 ['apt install chromium-browser is the snap', /apt install chromium-browser/i],
138 ['Ubuntu has not shipped a deb since 20.04', /since 20\.04/],
139 ['what to install instead', /Chrome, Brave, Vivaldi or Edge/i],
140 ['the profile appears on first run', /profile directory .{0,40}created the first time|run that browser once/i],
141 ['the passphrase does not stand up a new browser', ACCOUNT_FACT],
142 ['so pair before you retire the old browser', /Link another device/],
143 ['and export a backup for the work', /Export a backup/],
144];
145
146/// The rest of what is new.
147const NEW_FACTS = [
148 ['--check is the first thing to run', /install\.sh --check/],
149 ['run the hand by hand', /daimond-hand &lt; \/dev\/null|daimond-hand < \/dev\/null/],
150 ['read the journal', /journal\/hand-\*\.jsonl/],
151 ['ask the extension', /DaimondHand\.status\(\)/],
152 ['the installer refuses a confined browser', /refuses rather than writing into it|stops rather than leaving/i],
153 ['--workspace does the folder step', /install\.sh --workspace/],
154];
155
156/// Facts the page carried before the rewrite. None may have been dropped.
157const KEPT = [
158 ['not the same as the Machine workspace', /Not the same thing as the Machine workspace/],
159 ['it is free and not part of Pro', /it is not part of Pro/],
160 ['no tools are implemented', /Daimond implements no tools|implements no tools/],
161 ['the read-only system list', /\/libx32/],
162 ['there is no shell', /There is no shell, so there is no shell syntax/],
163 ['the manifest-path trap', /--manifest-path/],
164 ['mode 700 on the journal directory', /mode 700/],
165 ['the DAIMOND_HAND_ROOT trap', /DAIMOND_HAND_ROOT/],
166 ['the workspace.id token', /workspace\.id/],
167 ['unix sockets are always refused', /Named local sockets are refused/],
168 ['the Landlock ABI 9 reason', /ABI 9/],
169 ['ssh does not work, for two reasons', /ssh.{0,20}does not work|Two independent reasons/],
170 ['a toolchain needs a grant', /credentials\.toml/],
171 ['the network rule', /own output counts as content from outside/],
172 ['32-bit binaries are killed', /32-bit binaries are killed/],
173 ['the three rungs', /Ask every time/],
174 ['a rung never changes what is possible', /never changes what is possible/],
175 ['NixOS and Guix will not work', /nix\/store/],
176 ['Linux 5.13 to 6.6 is partial', /5\.13 to 6\.6/],
177 ['version managers other than two', /sdkman/],
178 ['BusyBox is fine', /BusyBox/],
179 ['the Terminal panel', /Terminal/],
180 ['the journal is hash-chained', /hash-chained/],
181 ['what is not protected: stat', /Asking about a file is not opening it/],
182 ['what is not protected: the deny-list', /The filter is a deny-list/],
183 ['what is not protected: timestamps', /Timestamps are not protected anywhere/],
184 ['taking it back, three ways', /uninstall\.sh/],
185];
186
187const browser = await chromium.launch({ executablePath: CHROME });
188const ctx = await browser.newContext({ viewport: { width: 900, height: 1000 } });
189const page = await ctx.newPage();
190
191/// The rendered page, with the prove-run's damage applied to the text.
192let damage = null;
193async function load(url) {
194 await page.goto(url, { waitUntil: 'networkidle' });
195 // EVERY match, not the first. Removing one of two callouts leaves the other
196 // carrying half the sentences, and the check passes on damage -- which is how
197 // three of these proofs first reported success.
198 if (damage) await page.evaluate((d) => {
199 document.querySelectorAll(d.sel).forEach((el) => el.remove());
200 }, damage);
201 return page.evaluate(() => document.body.innerText);
202}
203
204const mo = await load(`${BASE}/machine-operations.html`);
205
206/// A required fact is a regular expression or a predicate over the page's text.
207const said = (t, m) => (typeof m === 'function' ? m(t) : m.test(t));
208
209for (const [what, m] of STOPPERS) say(said(mo, m), `machine-operations says: ${what}`);
210for (const [what, m] of NEW_FACTS) say(said(mo, m), `machine-operations says: ${what}`);
211for (const [what, m] of KEPT) say(said(mo, m), `still says: ${what}`);
212
213// ── Order ────────────────────────────────────────────────────────────
214//
215// A warning below the instructions it should have prevented is decoration.
216{
217 const stop = mo.search(/snap or flatpak browser cannot/i);
218 const acct = mo.search(ACCOUNT_AT);
219 const inst = mo.search(/^Installing it$/m);
220 const steps = mo.search(/cargo build --release/);
221 say(stop >= 0 && inst > stop, 'the snap warning comes before the installation steps', `${stop} vs ${inst}`);
222 say(acct >= 0 && inst > acct, 'the account warning comes before them too', `${acct} vs ${inst}`);
223 say(steps > inst, 'and the commands come after the heading that introduces them');
224 // The very first thing after the lede, because it is the first thing that
225 // stops anyone.
226 say(stop < mo.length * 0.12, 'the snap warning is in the first eighth of the page',
227 `at ${((stop / mo.length) * 100).toFixed(1)}%`);
228}
229
230// ── The callout, in every palette ────────────────────────────────────
231//
232// `.note.stop` is a new colour, and a colour that works in two of eleven
233// palettes is a colour that is wrong in nine.
234{
235 const problems = [];
236 for (const [name, [tone, ink]] of Object.entries(PALETTES)) {
237 const found = await page.evaluate(({ name, tone, ink }) => {
238 const r = document.documentElement;
239 r.setAttribute('data-theme', name);
240 r.setAttribute('data-tone', tone);
241 r.setAttribute('data-ink', ink);
242 const rgb = (s) => {
243 const n = (s.match(/-?[\d.]+(?:e-?\d+)?/g) || []).map(Number);
244 const k = /^color\(/.test(s) ? 255 : 1;
245 return [n[0] * k, n[1] * k, n[2] * k, n.length > 3 ? n[3] : 1];
246 };
247 const bgOf = (el) => {
248 for (let n = el; n; n = n.parentElement) {
249 const c = rgb(getComputedStyle(n).backgroundColor);
250 if (c[3] > 0) return c.slice(0, 3);
251 }
252 return [255, 255, 255];
253 };
254 const lum = (c) => {
255 const f = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); });
256 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
257 };
258 const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
259 const out = [];
260 for (const box of document.querySelectorAll('.note.stop')) {
261 const bg = bgOf(box);
262 for (const el of [box, ...box.querySelectorAll('strong, a, li, code, em')]) {
263 const own = [...el.childNodes].filter((n) => n.nodeType === 3)
264 .map((n) => n.textContent).join('').trim();
265 if (!own) continue;
266 const fg = rgb(getComputedStyle(el).color).slice(0, 3);
267 const r = ratio(fg, bgOf(el.parentElement || el));
268 if (r < 4.5) out.push(`${r.toFixed(2)}:1 ${JSON.stringify(own.slice(0, 30))}`);
269 }
270 // The box's own edge, which is what makes it read as a warning
271 // rather than as a paragraph.
272 const edge = rgb(getComputedStyle(box).borderLeftColor).slice(0, 3);
273 if (ratio(edge, bg) < 3) out.push(`edge ${ratio(edge, bg).toFixed(2)}:1`);
274 }
275 return out;
276 }, { name, tone, ink });
277 if (found.length) problems.push(`${name}: ${found.join('; ')}`);
278 }
279 say(problems.length === 0, 'the stop callouts clear 4.5:1 in all eleven palettes', problems.join(' | '));
280}
281
282// ── The correction ───────────────────────────────────────────────────
283//
284// The translations are the half that rots quietly: a corrected English page
285// keeps telling seven other languages the old thing, and nobody who reads
286// English ever sees it.
287//
288// Their oracle is the BANK, not a list of foreign phrases. The locale pages are
289// generated from dev/guide-i18n/<loc>.json by dev/guide_i18n.mjs, so "this
290// locale carries the corrected sentence" means exactly "the locale page renders
291// the bank's translation of the English run that carries it". Naming the
292// phrases instead fails twice over: it called Japanese wrong for writing
293// 作り直すのではなく where the list held 作り直しません, and it called an
294// untranslated page RIGHT, because a fallback page carries the English source
295// and the English source was one of the alternatives.
296const BANK = path.join(ROOT, 'dev', 'guide-i18n');
297
298/// Every locale's rendering of the English runs on `page` that match `carries`.
299/// Returns what is stale, what is missing, and why.
300async function translated(page, carries) {
301 const src = JSON.parse(fs.readFileSync(path.join(BANK, '_source.json'), 'utf8'));
302 const runs = (src[page] || []).filter((s) => carries.test(s));
303 const stale = [], missing = [];
304 // The English page having no such sentence is itself the failure, and a
305 // louder one than any locale's: there is nothing left to translate.
306 if (!runs.length) missing.push(`the English ${page} has no run matching ${carries}`);
307 for (const loc of LOCS) {
308 const text = flat(await load(`${BASE}/${loc}/${page}`));
309 if (/Anyone who imports it becomes you/i.test(text)) stale.push(loc);
310 const raw = JSON.parse(fs.readFileSync(path.join(BANK, `${loc}.json`), 'utf8'));
311 const bank = Object.assign({}, raw._common || {}, raw[page] || {});
312 for (const run of runs) {
313 const t = bank[run];
314 if (t === undefined) missing.push(`${loc}: no entry for "${run.slice(0, 48)}…"`);
315 else if (!text.includes(flat(t))) missing.push(`${loc}: the page does not render its entry`);
316 }
317 }
318 return { stale, missing };
319}
320
321{
322 const acc = await load(`${BASE}/accounts.html`);
323 // Not "a backup contains your identity", which is TRUE -- the export writes
324 // the wrapped bundle. The claim to keep out is that holding the file is
325 // enough, which is what the passphrase stops.
326 say(!/Anyone who imports it becomes you/i.test(acc),
327 'accounts.html does not say that importing a backup makes anyone you');
328 say(ACCOUNT_FACT(acc),
329 'and says the passphrase alone starts a different account, and what carries the key');
330 say(BACKUP_FACT(acc),
331 'and says the key is in a backup, wrapped under the passphrase');
332
333 const sync = await load(`${BASE}/sync.html`);
334 say(/cannot travel on its own|nothing for it to open/.test(sync),
335 'sync.html says the passphrase cannot bring the account over by itself');
336
337 const tr = await translated('accounts.html', /passphrase does not recreate/i);
338 say(tr.stale.length === 0, 'no translation still carries the old claim', tr.stale.join(', '));
339 say(tr.missing.length === 0, 'every translation carries the corrected one', tr.missing.join(' | '));
340}
341
342// ── Shots ────────────────────────────────────────────────────────────
343if (!PROVE) {
344 for (const [name] of [['light'], ['dark'], ['linen'], ['amber']].map((x) => x)) {
345 const [tone, ink] = PALETTES[name];
346 await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' });
347 await page.evaluate(({ name, tone, ink }) => {
348 const r = document.documentElement;
349 r.setAttribute('data-theme', name);
350 r.setAttribute('data-tone', tone);
351 r.setAttribute('data-ink', ink);
352 }, { name, tone, ink });
353 // The top of the page, where the two stop boxes are.
354 await page.screenshot({ path: path.join(OUT, `facts-top-${name}.png`) });
355 const box = await page.$$('.note.stop');
356 if (box[1]) await box[1].screenshot({ path: path.join(OUT, `facts-account-${name}.png`) });
357 }
358 // And the narrow width, where a callout with a list in it is likeliest to
359 // break out of its box.
360 await page.setViewportSize({ width: 360, height: 900 });
361 await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' });
362 const narrow = await page.evaluate(() => {
363 const out = [];
364 if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1) out.push('page scrolls sideways');
365 for (const b of document.querySelectorAll('.note.stop')) {
366 const r = b.getBoundingClientRect();
367 if (r.right > document.documentElement.clientWidth + 1 || r.left < -1) out.push('callout out of the column');
368 if (b.scrollWidth > b.clientWidth + 2) out.push('callout clips its own text');
369 }
370 return out;
371 });
372 say(narrow.length === 0, '360px wide: the stop callouts stay in the column', narrow.join('; '));
373 const b2 = await page.$$('.note.stop');
374 if (b2[1]) await b2[1].screenshot({ path: path.join(OUT, 'facts-account-narrow.png') });
375}
376
377// ── Proving the checks ───────────────────────────────────────────────
378//
379// Every check above reads text off a rendered page, so one breakage shape --
380// removing the element that carries the sentence -- exercises all of them. Each
381// group gets its own, aimed at the element that group is about.
382if (PROVE) {
383 const CASES = [
384 ['the stop callouts', '.note.stop', async () => {
385 const t = await load(`${BASE}/machine-operations.html`);
386 return !/snap or flatpak browser cannot/i.test(t) && !ACCOUNT_FACT(t) && t.search(ACCOUNT_AT) < 0;
387 }],
388 ['the callouts that carry the kept facts', '.note', async () => {
389 const t = await load(`${BASE}/machine-operations.html`);
390 return !KEPT.every(([, m]) => said(t, m));
391 }],
392 ['the troubleshooting section', '#trouble', async () => {
393 // Removing only the heading leaves the commands, so the check that
394 // would notice is the ordering one; the heading is what anchors it.
395 const t = await load(`${BASE}/machine-operations.html`);
396 return !/When it does not work/.test(t);
397 }],
398 ['the accounts correction', 'main p', async () => {
399 const t = await load(`${BASE}/accounts.html`);
400 return !ACCOUNT_FACT(t);
401 }],
402 ['the translated sentence', 'main p', async () => {
403 // The same removal, on the seven generated pages: the bank still holds
404 // the entry, and the page no longer renders it. A check that only asked
405 // whether the bank had an entry would pass here.
406 const t = await translated('accounts.html', /passphrase does not recreate/i);
407 return t.missing.length === LOCS.length;
408 }],
409 ];
410 for (const [what, sel, run] of CASES) {
411 damage = { sel };
412 const caught = await run();
413 say(caught, `broken on purpose: removing ${what} is caught`, 'the check still passed');
414 damage = null;
415 }
416 // BACKUP_FACT, against the page as it actually read until this was corrected.
417 // Removing an element cannot prove this one -- three separate sentences carry
418 // the fact, and any of them satisfies the predicate -- and the failure was
419 // never a missing sentence. It was a page that said something, confidently,
420 // and said the opposite of what doExport does. So it is given those words
421 // back, with the neighbours that make a page-wide match pass: "encrypted"
422 // and "passphrase" both appear, a few rows up, about something else.
423 {
424 const was = [
425 'Change passphrase… — set a new one; your encrypted data is re-wrapped under it.',
426 'Export a backup — write your chats, Diamonds and workspace files to a file you keep.',
427 'It restores your work and not your account: your key is not in it.',
428 'Import a backup… — bring an exported identity into this browser.',
429 'Your account is a signing key held in this browser. The passphrase does not recreate it, only decrypts the copy already stored here, so the same passphrase in a fresh browser starts a separate account with its own credits and no Pro.',
430 'Two things carry the key across and nothing else does: Link another device, which shows a pairing code to type into the new browser, and a passkey, which stands a new device up in one gesture.',
431 'Take an Export a backup as well, for the chats, Diamonds and workspace files the key does not carry.',
432 'A backup holds everything you have written.',
433 'Not the key — importing it does not make anyone you — but every chat, Diamond and workspace file is in it as plain text, so keep it as private as the work itself.',
434 ].join('\n');
435 say(!BACKUP_FACT(was), 'broken on purpose: "your key is not in it" is caught', 'the check still passed');
436 // And the same text must still satisfy the OTHER property, or the case
437 // above would be proving nothing more than that some words changed.
438 say(ACCOUNT_FACT(was), 'and that page still says the account fact, so the two are independent');
439 }
440 // The palette check, against a colour that does not clear the floor.
441 {
442 await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' });
443 const worst = await page.evaluate(() => {
444 const s = document.createElement('style');
445 s.textContent = '.note.stop { color: color-mix(in srgb, currentColor 20%, var(--warn-bg)); }';
446 document.head.appendChild(s);
447 const rgb = (x) => { const n = (x.match(/-?[\d.]+(?:e-?\d+)?/g) || []).map(Number);
448 const k = /^color\(/.test(x) ? 255 : 1; return [n[0] * k, n[1] * k, n[2] * k]; };
449 const lum = (c) => { const f = c.map((v) => { const t = v / 255; return t <= 0.03928 ? t / 12.92 : Math.pow((t + 0.055) / 1.055, 2.4); });
450 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; };
451 const box = document.querySelector('.note.stop');
452 const li = box.querySelector('li') || box;
453 const [x, y] = [lum(rgb(getComputedStyle(li).color)), lum(rgb(getComputedStyle(box).backgroundColor))]
454 .sort((a, b) => b - a);
455 return (x + 0.05) / (y + 0.05);
456 });
457 say(worst < 4.5, 'broken on purpose: a washed-out callout colour is caught', `${worst.toFixed(2)}:1`);
458 }
459 // The translation check, against a page that kept the old claim.
460 {
461 const stale = 'Anyone who imports it becomes you';
462 const t = `nonsense ${stale} nonsense`;
463 say(/Anyone who imports it becomes you/i.test(t),
464 'broken on purpose: the old claim in a translation is caught');
465 }
466}
467
468await browser.close();
469console.log(`\n${ran} checks, ${bad ? bad + ' FAILED' : 'all good'} — shots in ${OUT}`);
470process.exit(bad ? 1 : 0);