Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_raildialogs.mjs

22.5 KiB, 1 run

created by r2519314175:621, 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_raildialogs.mjs — photograph and MEASURE every dialog the rail opens.
2//
3// The user's complaint, verbatim: *"I saw obvious button and chip misalignment
4// and spacing problems in dialogs in the rail, I can't remember which ones, so
5// examine all visually and quality control."* A defect nobody can name cannot
6// be asserted on, so it has to be swept for.
7//
8// `dev/sweep.mjs` sweeps panels and three pop-ups. It does not open a single
9// dialog, because a dialog needs something to be about — a Diamond, a chat, a
10// mailbox — and the sweep runs on an empty account. So this seeds a rail and
11// then walks every dialog it can reach, at both skins, both themes and three
12// widths.
13//
14// What it MEASURES, chosen for the defect the user described rather than for
15// completeness:
16//
17// [escapes] an element whose box leaves the card it is in
18// [clipped] an element whose content is wider or taller than its own box,
19// with nothing on screen admitting it — a sideways cut that ends
20// in an ellipsis is the app saying so, and is not counted
21// [wrapped] a flex ROW whose children landed on more than one line —
22// which is what "the refresh button dropped below the title"
23// looks like from the outside, and what geometry alone can see
24// [offcentre] children of a centred row whose INK centres differ by more
25// than 1.5px — the misalignment, measured rather than eyeballed
26// [overlap] two controls whose boxes intersect
27//
28// It asserts nothing about beauty. It fails only on [escapes], [clipped] and
29// [overlap], which are always wrong; [wrapped] and [offcentre] are reported to
30// be LOOKED at, because a row that wraps on a phone is often correct.
31//
32// node dev/verify_raildialogs.mjs
33// node dev/verify_raildialogs.mjs --out ~/.cache/daimond/raildlg
34// node dev/verify_raildialogs.mjs --only 'Tile dialog (Diamond)'
35//
36// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway; nothing spends.
37import fs from 'node:fs';
38import path from 'node:path';
39import os from 'node:os';
40import { open, scratch } from './harness.mjs';
41
42const argv = process.argv.slice(2);
43const val = (n, d) => { const i = argv.indexOf(n); return (i >= 0 && argv[i + 1]) ? argv[i + 1] : d; };
44const OUT = val('--out', path.join(os.homedir(), '.cache/daimond/raildlg'));
45const ONLY = val('--only', '');
46fs.mkdirSync(OUT, { recursive: true });
47
48let hard = 0, soft = 0, shots = 0;
49const findings = [];
50
51const CELLS = [
52 { skin: 'sharp', theme: 'dark', w: 1500, h: 950 },
53 { skin: 'warm', theme: 'light', w: 1500, h: 950 },
54 // 1400, not 900: below 1280 the rail FOLDS ITSELF AWAY (daimond.js:3112), so
55 // at 900 the dialogs it opens are genuinely unreachable and a sweep there
56 // photographs nothing. 1400 is the narrowest at which the rail is still a rail.
57 { skin: 'sharp', theme: 'light', w: 1400, h: 820 },
58 { skin: 'warm', theme: 'dark', w: 380, h: 780 },
59];
60
61// ── The measuring pass, run inside the page ────────────────────────────
62//
63// One evaluate, not one per element: a layout read per element over a whole
64// dialog is hundreds of round trips and the page relayouts between them.
65const MEASURE = function (rootSel) {
66 const root = document.querySelector(rootSel);
67 if (!root) return { missing: true, defects: [] };
68 const out = [];
69 const vis = (el) => {
70 if (el.getClientRects().length === 0) return false;
71 const cs = getComputedStyle(el);
72 if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
73 if (el.classList.contains('vh')) return false;
74 return true;
75 };
76 const name = (el) => {
77 let s = el.tagName.toLowerCase();
78 if (el.id) s += '#' + el.id;
79 else if (typeof el.className === 'string' && el.className.trim()) {
80 s += '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.');
81 }
82 const t = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 24);
83 return t ? `${s} "${t}"` : s;
84 };
85 const at = (r) => ({ x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) });
86 const all = [...root.querySelectorAll('*')].filter(vis);
87 const rr = root.getBoundingClientRect();
88 // A container that SCROLLS is meant to hold things outside its visible box —
89 // that is what scrolling is — so an escape is measured only on the axes the
90 // root does not scroll. Without this the Appearance menu, a tall scroller on
91 // a phone, reported every control below its fold as escaping.
92 const rcs = getComputedStyle(root);
93 const scrollY = /auto|scroll/.test(rcs.overflowY) || /auto|scroll/.test(rcs.overflow);
94 const scrollX = /auto|scroll/.test(rcs.overflowX) || /auto|scroll/.test(rcs.overflow);
95 // Once per chain: a path inside an svg inside a button that escapes is ONE
96 // defect written up three times, which is how eight findings become twenty.
97 const escaped = [];
98
99 for (const el of all) {
100 const r = el.getBoundingClientRect();
101 // [escapes] — beyond the card, by more than a rounding error. Half a pixel
102 // is subpixel layout; two is somebody's box being too big.
103 const overX = scrollX ? -1 : Math.max(rr.left - r.left, r.right - rr.right);
104 const overY = scrollY ? -1 : Math.max(rr.top - r.top, r.bottom - rr.bottom);
105 const over = Math.max(overX, overY);
106 if (over > 2 && !escaped.some((a) => a.contains(el))) {
107 escaped.push(el);
108 out.push({ kind: 'escapes', by: Math.round(over), el: name(el), rect: at(r), weight: 3 });
109 }
110 // [clipped] — its own content does not fit, and it is not a scroller.
111 //
112 // SIDEWAYS, AN ELLIPSIS IS NOT A DEFECT. A one-line box with
113 // `text-overflow: ellipsis` is the app admitting the cut, and the reader
114 // sees the "…" and knows to open the thing — the trigger gist in the
115 // Diamond tile dialog is drawn that way on purpose, and this flagged it
116 // as always-wrong at the one skin where the words happen to be too long.
117 // dev/verify_sweep_desktop.mjs has always drawn the line here ("an
118 // admitted cut"), and two sweeps disagreeing about what clipping means
119 // is how one of them gets ignored.
120 //
121 // Downwards it still counts: `text-overflow` says nothing about a box
122 // too short for its own lines, so text running out of the bottom of a
123 // card leaves no sign at all. That is the case this was written for and
124 // it is untouched. The probe at the foot of this file plants both.
125 const cs = getComputedStyle(el);
126 const scrolls = /auto|scroll/.test(cs.overflow + cs.overflowX + cs.overflowY);
127 if (!scrolls) {
128 const dx = cs.textOverflow === 'ellipsis' ? 0 : el.scrollWidth - el.clientWidth;
129 const dy = el.scrollHeight - el.clientHeight;
130 if (el.clientWidth > 0 && (dx > 2 || dy > 2)) {
131 out.push({ kind: 'clipped', by: Math.max(dx, dy), el: name(el), rect: at(r), weight: 3 });
132 }
133 }
134 }
135
136 // [wrapped] and [offcentre] — properties of a ROW, so they are measured on
137 // the containers rather than on the elements.
138 for (const el of all) {
139 const cs = getComputedStyle(el);
140 if (cs.display !== 'flex' && cs.display !== 'inline-flex') continue;
141 if (cs.flexDirection !== 'row') continue;
142 const kids = [...el.children].filter(vis);
143 if (kids.length < 2) continue;
144 const boxes = kids.map((k) => k.getBoundingClientRect());
145 // The CENTRES, against the tallest child. A 30px button beside a 19px
146 // label sits on one line and its box top is 5px higher; bucketing by top
147 // called that a wrap, which is a false positive on every mixed row in the
148 // app. A row has genuinely wrapped only when a child's centre is most of a
149 // child-height away from another's.
150 const mids = boxes.map((b) => b.top + b.height / 2);
151 const tall = Math.max(...boxes.map((b) => b.height), 1);
152 const spread = Math.max(...mids) - Math.min(...mids);
153 if (spread > tall * 0.6) {
154 out.push({ kind: 'wrapped', by: +spread.toFixed(1), el: name(el),
155 rect: at(el.getBoundingClientRect()), weight: 1, note: kids.map(name).join(' | ') });
156 continue; // a wrapped row's centres are not comparable
157 }
158 if (cs.alignItems !== 'center') continue;
159 if (spread > 1.5) {
160 out.push({ kind: 'offcentre', by: +spread.toFixed(2), el: name(el),
161 rect: at(el.getBoundingClientRect()), weight: 2, note: kids.map(name).join(' | ') });
162 }
163 }
164
165 // [overlap] — two controls sharing pixels. Only controls: a label lying over
166 // its own field's background is how a field is drawn.
167 const ctl = all.filter((e) => e.matches('button, a[href], input, select, textarea, [role="button"]'));
168 for (let i = 0; i < ctl.length; i++) {
169 for (let j = i + 1; j < ctl.length; j++) {
170 if (ctl[i].contains(ctl[j]) || ctl[j].contains(ctl[i])) continue;
171 const a = ctl[i].getBoundingClientRect(), b = ctl[j].getBoundingClientRect();
172 const ox = Math.min(a.right, b.right) - Math.max(a.left, b.left);
173 const oy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
174 if (ox > 2 && oy > 2) {
175 out.push({ kind: 'overlap', by: Math.round(Math.min(ox, oy)), el: name(ctl[i]),
176 other: name(ctl[j]), rect: at(a), weight: 3 });
177 }
178 }
179 }
180 out.sort((p, q) => (q.weight - p.weight) || ((q.by || 0) - (p.by || 0)));
181 return { missing: false, defects: out };
182};
183
184// ── Getting each dialog on screen ──────────────────────────────────────
185/// Make sure the rail is actually on screen, opening the drawer if it is not.
186/// Returns whether it managed it, so a skip says "no rail" rather than "no
187/// button".
188async function railOpen(page) {
189 const there = () => page.evaluate(() => {
190 const r = document.getElementById('panel-rail');
191 return !!r && r.getClientRects().length > 0 && r.getBoundingClientRect().width > 40;
192 });
193 if (await there()) return true;
194 // Two ways, because the rail is hidden two different ways: below 1280px it
195 // is a panel the dock has closed, and on a phone it is a drawer behind the
196 // hamburger. Asking the panel registry first is the one that works at 900.
197 await page.evaluate(() => { try { DaimondPanels.show('rail'); } catch (e) { /* not up */ } });
198 await page.waitForTimeout(400);
199 if (await there()) return true;
200 await page.evaluate(() => { const b = document.getElementById('drawer-btn'); if (b) b.click(); });
201 await page.waitForTimeout(450);
202 return there();
203}
204
205const press = async (page, sel) => {
206 await page.waitForSelector(sel, { timeout: 10000 });
207 await page.evaluate((s) => { const e = document.querySelector(s); if (e) e.click(); }, sel);
208 await page.waitForTimeout(320);
209};
210
211/// Press the control inside `rootSel` whose words are exactly `text`. Exact,
212/// because Playwright's `:has-text` is a case-insensitive substring and would
213/// press "Change name…" when asked for "Change passphrase…".
214const pressLabel = async (page, rootSel, text) => {
215 await page.waitForSelector(rootSel, { timeout: 10000 });
216 const hit = await page.evaluate(({ rootSel, text }) => {
217 const root = document.querySelector(rootSel);
218 if (!root) return false;
219 const b = [...root.querySelectorAll('button')].find((x) => (x.textContent || '').trim() === text);
220 if (!b) return false;
221 b.click();
222 return true;
223 }, { rootSel, text });
224 if (!hit) throw new Error(`no control labelled "${text}"`);
225 await page.waitForTimeout(400);
226};
227
228const DIALOGS = [
229 {
230 name: 'New Diamond', sel: '.modal.dlg .dlg-card',
231 reach: async (p) => { await press(p, '#new-diamond-btn'); },
232 },
233 {
234 name: 'Tile dialog (Diamond)', sel: '.tile-dlg-card',
235 reach: async (p) => { await press(p, '#diamond-list .tile-cog'); },
236 },
237 {
238 name: 'Tile dialog (chat)', sel: '.tile-dlg-card',
239 reach: async (p) => { await press(p, '#session-list .tile-cog'); },
240 },
241 // The two confirms that used to be here — "Delete a Diamond" and "Delete a
242 // chat" — are gone: since the trash, deleting is reversible and asks nothing.
243 // The QUESTION moved rather than disappeared, so the coverage moves with it.
244 // Both of these are reached by deleting something first, which is now the
245 // step that opens no dialog at all.
246 {
247 name: 'Delete permanently (confirm)', sel: '.modal.dlg .dlg-card',
248 reach: async (p) => {
249 await press(p, '#diamond-list .tile-cog');
250 await press(p, '.tile-dlg-delete');
251 await p.waitForTimeout(1200);
252 await p.evaluate(() => { try { DaimondPanels.show('trash'); } catch (e) { /* not up */ } });
253 await p.waitForTimeout(700);
254 await press(p, '#trash-list .trash-purge');
255 },
256 },
257 {
258 name: 'Empty trash (confirm)', sel: '.modal.dlg .dlg-card',
259 reach: async (p) => {
260 await press(p, '#session-list .tile-cog');
261 await press(p, '.tile-dlg-delete');
262 await p.waitForTimeout(1200);
263 await p.evaluate(() => { try { DaimondPanels.show('trash'); } catch (e) { /* not up */ } });
264 await p.waitForTimeout(700);
265 await press(p, '#trash-empty');
266 },
267 },
268 {
269 name: 'Rename a Diamond', sel: '.modal.dlg .dlg-card',
270 reach: async (p) => {
271 await p.evaluate(() => {
272 const n = document.querySelector('#diamond-list .session-box-name');
273 n.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
274 });
275 await p.waitForTimeout(400);
276 },
277 },
278 {
279 name: 'Fold this chat into…', sel: '.fold-menu',
280 reach: async (p) => { await press(p, '#session-list .tile-fold'); },
281 },
282 {
283 name: 'Admin home', sel: '#admin-home',
284 reach: async (p) => { await press(p, '#user-row'); },
285 },
286 {
287 name: 'Models', sel: '#admin-models',
288 reach: async (p) => { await press(p, '#astat-model'); },
289 },
290 {
291 name: 'Credits', sel: '#admin-credits',
292 reach: async (p) => { await press(p, '#astat-account'); },
293 },
294 {
295 name: 'Change name', sel: '.modal.dlg .dlg-card',
296 reach: async (p) => {
297 await press(p, '#user-row');
298 await pressLabel(p, '#admin-home', 'Change name…');
299 },
300 },
301 {
302 name: 'Forget this identity (confirm)', sel: '.modal.dlg .dlg-card',
303 reach: async (p) => {
304 await press(p, '#user-row');
305 await pressLabel(p, '#admin-home', 'Forget this identity…');
306 },
307 },
308 {
309 name: 'Add a mailbox', sel: '#admin-form',
310 reach: async (p) => {
311 await p.evaluate(() => window.DaimondPanels && DaimondPanels.show('mail'));
312 await p.waitForTimeout(320);
313 await press(p, '#panel-mail [data-act="mail-add"]');
314 await p.waitForSelector('#admin-form .dlg-input', { timeout: 8000 });
315 },
316 },
317 {
318 name: 'Link another device', sel: '.pair-scrim',
319 reach: async (p) => {
320 await p.evaluate(() => window.DaimondPairing && DaimondPairing.showLink());
321 await p.waitForSelector('.pair-scrim', { timeout: 8000 });
322 },
323 },
324 {
325 name: 'Appearance menu', sel: '#settings-menu',
326 reach: async (p) => { await press(p, '#settings-menu-btn'); },
327 },
328 {
329 name: 'Panel gallery', sel: '#panel-gallery',
330 reach: async (p) => {
331 await p.evaluate(() => {
332 ['doc', 'msg', 'compose'].forEach((x) => { try { DaimondPanels.markUsed(x); } catch (e) {} });
333 try { DaimondPanels.reflow(); } catch (e) {}
334 });
335 await p.waitForTimeout(400);
336 await press(p, '#panel-more');
337 },
338 },
339];
340
341/// Screenshot, and PROVE it landed. The suite's own `shot()` swallows a failed
342/// capture, and capture on this box has silently failed for an hour at a time
343/// under load — so a clean run is not by itself evidence of a picture.
344async function snap(page, file, sel) {
345 const p = path.join(OUT, file + '.png');
346 try {
347 const el = sel ? await page.$(sel) : null;
348 if (el) await el.screenshot({ path: p, timeout: 8000 });
349 else await page.screenshot({ path: p, timeout: 8000 });
350 } catch (e) { return null; }
351 if (!fs.existsSync(p) || fs.statSync(p).size < 400) return null;
352 shots++;
353 return p;
354}
355
356const s = await open({ name: 'raildlg', profile: scratch('pw', 'raildlg-' + process.pid) });
357const { page } = s;
358try {
359 // A rail with something in it. Every dialog below is about an object, and an
360 // empty account has none — the whole reason dev/sweep.mjs never opened one.
361 await page.evaluate(() => { const b = document.getElementById('admin-close'); if (b) b.click(); });
362 await page.waitForTimeout(250);
363 await press(page, '#new-diamond-btn');
364 await page.waitForSelector('.dlg-card', { timeout: 8000 });
365 await page.evaluate(() => {
366 const card = [...document.querySelectorAll('.dlg-card')].find((c) => c.getClientRects().length);
367 const i = card.querySelector('input.dlg-input');
368 i.value = 'A Diamond with a fairly long name';
369 i.dispatchEvent(new Event('input', { bubbles: true }));
370 card.querySelector('.dlg-ok').click();
371 });
372 await page.waitForTimeout(1600);
373 await press(page, '#new-session-btn');
374 await page.evaluate(() => { const b = [...document.querySelectorAll('.tile-start')].pop(); if (b) b.click(); });
375 await page.waitForTimeout(1500);
376 // One turn, so the chat has something in it. The Fold picker refuses an empty
377 // chat with a notice, so without this the picker is never photographed and
378 // the skip reads as "unreachable" rather than "there was nothing to fold".
379 if (await page.isVisible('#chat-input')) {
380 await page.fill('#chat-input', '@text a first answer');
381 await page.click('#chat-send');
382 await page.waitForTimeout(2500);
383 }
384
385 const seeded = await page.evaluate(() => ({
386 d: document.querySelectorAll('#diamond-list .session-box').length,
387 c: document.querySelectorAll('#session-list .session-box').length,
388 // An ACTIVE chat, not a pending one: Fold is only on an active tile, and
389 // a pending one would make that dialog silently unreachable.
390 fold: document.querySelectorAll('#session-list .tile-fold').length,
391 }));
392 console.log(`seeded: ${seeded.d} Diamond(s), ${seeded.c} chat(s), ${seeded.fold} foldable`);
393 if (!seeded.fold) console.log('note the chat did not start — the Fold picker will be skipped');
394 if (!seeded.d || !seeded.c) {
395 console.log('FAIL nothing to open a dialog about — refusing to sweep an empty rail.');
396 await s.close();
397 process.exit(1);
398 }
399
400 for (const cell of CELLS) {
401 const tag = `${cell.skin}-${cell.theme}-${cell.w}`;
402 console.log(`\n── ${tag}`);
403 await page.setViewportSize({ width: cell.w, height: cell.h });
404 await page.evaluate((c) => {
405 document.documentElement.setAttribute('data-skin', c.skin);
406 document.documentElement.setAttribute('data-theme', c.theme);
407 document.documentElement.setAttribute('data-tone', c.theme === 'light' ? 'light' : 'dark');
408 document.documentElement.setAttribute('data-ink', c.theme === 'light' ? 'dark' : 'light');
409 }, cell);
410 await page.waitForTimeout(250);
411
412 // The rail folds itself away below 1280px, and on a phone it is a
413 // drawer. Photographing without opening it photographs the chat and
414 // concludes the work is missing — so it is opened whenever it is not
415 // actually on screen, whatever the width.
416 await railOpen(page);
417
418 for (const d of DIALOGS) {
419 if (ONLY && d.name !== ONLY) continue;
420 // Quieten first, so the previous dialog cannot be measured as this one.
421 for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(90); }
422 await railOpen(page);
423 let up = false;
424 try { await d.reach(page); up = true; } catch (e) { /* reported below */ }
425 await page.waitForTimeout(280);
426 const there = up && await page.evaluate((sel) => {
427 const e = document.querySelector(sel);
428 return !!e && e.getClientRects().length > 0 && e.getBoundingClientRect().height > 4;
429 }, d.sel);
430 if (!there) { console.log(` skip ${d.name} — did not open here`); continue; }
431
432 const file = `${tag}--${d.name.replace(/\W+/g, '-').toLowerCase()}`;
433 const shot = await snap(page, file, d.sel);
434 const m = await page.evaluate(MEASURE, d.sel);
435 const worst = m.defects.length ? `${m.defects.length}: ${m.defects[0].kind} ${m.defects[0].el}` : '';
436 console.log(` ${m.defects.length ? 'LOOK' : ' ok '} ${d.name}${worst ? ' ' + worst : ''}`
437 + (shot ? '' : ' [NO SHOT ON DISK]'));
438 for (const f of m.defects) {
439 findings.push({ cell: tag, dialog: d.name, shot, ...f });
440 if (f.kind === 'escapes' || f.kind === 'clipped' || f.kind === 'overlap') hard++; else soft++;
441 }
442 }
443 }
444
445 // ── The clipped rule, shown catching and shown letting go ───────────
446 //
447 // A sweep that reports nothing is not evidence that there was nothing to
448 // report, and the exemption above is the kind of edit that quietly turns a
449 // check off. So both halves are planted in the live page and MEASURE — the
450 // same function, not a copy of its rule — is run over them: a cut with an
451 // ellipsis must be let through, a cut without one must be caught, and text
452 // running out of the bottom of a box must be caught whatever its
453 // `text-overflow` says, because that is the one nothing on screen admits to.
454 {
455 await page.evaluate(() => {
456 const box = document.createElement('div');
457 box.id = 'raildlg-probe';
458 box.style.cssText = 'position:fixed; left:20px; top:20px; width:300px; '
459 + 'z-index:99999; background:#fff;';
460 box.innerHTML = `
461 <div id="probe-ellipsis" style="width:60px; overflow:hidden; white-space:nowrap;
462 text-overflow:ellipsis;">a line far longer than sixty pixels of room</div>
463 <div id="probe-clip" style="width:60px; overflow:hidden; white-space:nowrap;
464 text-overflow:clip;">a line far longer than sixty pixels of room</div>
465 <div id="probe-short" style="width:200px; height:16px; overflow:hidden;
466 text-overflow:ellipsis;">three lines of words in a box with room for one of
467 them, and nothing on screen to say the other two are there</div>`;
468 document.body.appendChild(box);
469 });
470 const m = await page.evaluate(MEASURE, '#raildlg-probe');
471 const flagged = (id) => m.defects.some((f) => f.kind === 'clipped' && f.el.indexOf('#' + id) !== -1);
472 const proofs = [
473 ['a cut with an ellipsis is let through', !flagged('probe-ellipsis')],
474 ['a cut with no ellipsis is caught', flagged('probe-clip')],
475 ['lines with no room and no sign of it are caught', flagged('probe-short')],
476 ];
477 for (const [what, good] of proofs) {
478 console.log(` ${good ? 'ok ' : 'FAIL'} [proof] ${what}`);
479 if (!good) hard++;
480 }
481 await page.evaluate(() => {
482 const e = document.getElementById('raildlg-probe'); if (e) e.remove();
483 });
484 }
485} finally {
486 await s.close();
487}
488
489findings.sort((a, b) => (b.weight - a.weight) || ((b.by || 0) - (a.by || 0)));
490console.log(`\n${shots} shots in ${OUT}`);
491if (!findings.length) console.log('nothing measured — the images are still the other half. Read them.');
492else {
493 console.log(`\n${findings.length} finding(s), worst first:\n`);
494 for (const f of findings.slice(0, 40)) {
495 console.log(` [${f.kind}] by ${f.by} ${f.cell} ${f.dialog}\n ${f.el}`
496 + (f.other ? ` ↔ ${f.other}` : '') + (f.note ? `\n children: ${f.note}` : '')
497 + ` @${f.rect.x},${f.rect.y} ${f.rect.w}x${f.rect.h}`);
498 }
499 if (findings.length > 40) console.log(` … and ${findings.length - 40} more, in the json.`);
500}
501fs.writeFileSync(path.join(OUT, 'raildialogs.json'), JSON.stringify(findings, null, '\t'));
502console.log(`\n${hard} always-wrong, ${soft} to look at. ${path.join(OUT, 'raildialogs.json')}`);
503process.exit(hard === 0 ? 0 : 1);