Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_style.mjs

9.6 KiB, 1 run

created by r2519314175:705, 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_style.mjs — the objective half of style coherence: the things a machine
2// can judge without an eye. Run per cell (engine × device × theme) and per view.
3//
4// 1. The page body never scrolls horizontally (a cardinal responsive rule).
5// 2. No visible element spills past the viewport's right edge.
6// 3. Every visible run of text clears a contrast floor against its background —
7// which doubles as the theme-leak check: a colour hardcoded for the dark
8// theme goes near-invisible on the light one and is caught here.
9// 4. On a touch device, interactive targets are not too small to hit.
10//
11// The subjective half — spacing rhythm, alignment, "does it read as one system" —
12// is not here; that is the screenshot + vision pass (shots_matrix.mjs).
13import { openCell, defaultCells, VIEWS, cellLabel, enginesAvailable, resetView } from './matrix.mjs';
14
15const CONTRAST_HARD = 3.0; // below this, text is effectively unreadable — a fail
16const TAP_MIN = 40; // px; a touch target smaller than this is flagged
17
18// The in-page audit: returns { hScroll, overflow[], contrast[], tap[] } for the
19// current state. Pure DOM + getComputedStyle, so it runs on any engine. All
20// thresholds arrive as arguments — this function is serialised into the page and
21// cannot close over the module's constants.
22const AUDIT = (opts) => {
23 const tapMin = opts.tapMin, contrastHard = opts.contrastHard;
24 const vw = window.innerWidth;
25 const de = document.documentElement;
26
27 // 1. Horizontal body scroll.
28 const hScroll = Math.max(0, de.scrollWidth - de.clientWidth);
29
30 const tag = (el) => {
31 const id = el.id ? '#' + el.id : '';
32 const cls = (typeof el.className === 'string' && el.className) ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '';
33 return el.tagName.toLowerCase() + id + cls;
34 };
35 const visible = (el) => {
36 const s = getComputedStyle(el);
37 if (s.visibility === 'hidden' || s.display === 'none' || parseFloat(s.opacity) === 0) return false;
38 const r = el.getBoundingClientRect();
39 return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < window.innerHeight;
40 };
41
42 // 2. Elements spilling past the right edge.
43 const overflow = [];
44 for (const el of document.querySelectorAll('body *')) {
45 if (!visible(el)) continue;
46 const r = el.getBoundingClientRect();
47 if (r.right > vw + 2 && r.width <= vw + 40) { // ignore full-bleed scroll containers
48 const s = getComputedStyle(el);
49 if (s.overflowX === 'auto' || s.overflowX === 'scroll') continue; // a scroller is allowed to be wide inside
50 overflow.push({ el: tag(el), right: Math.round(r.right), vw });
51 if (overflow.length >= 8) break;
52 }
53 }
54
55 // 3. Contrast of visible text against its effective background.
56 const parse = (c) => { const m = c && c.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(parseFloat); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
57 const lum = (r, g, b) => { const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; };
58 const ratio = (fg, bg) => { const L1 = lum(fg[0], fg[1], fg[2]), L2 = lum(bg[0], bg[1], bg[2]); const hi = Math.max(L1, L2), lo = Math.min(L1, L2); return (hi + 0.05) / (lo + 0.05); };
59 const effBg = (el) => { let e = el; while (e) { const p = parse(getComputedStyle(e).backgroundColor); if (p && p[3] > 0.5) return p; e = e.parentElement; } const rootLight = de.getAttribute('data-theme') !== 'dark'; return rootLight ? [255, 255, 255] : [21, 18, 15]; };
60 const hasDirectText = (el) => { for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim().length > 1) return true; return false; };
61
62 const contrast = [];
63 for (const el of document.querySelectorAll('body *')) {
64 if (contrast.length >= 12) break;
65 if (!hasDirectText(el) || !visible(el)) continue;
66 const s = getComputedStyle(el);
67 const fg = parse(s.color); if (!fg || fg[3] < 0.5) continue;
68 const r = ratio(fg, effBg(el));
69 if (r < contrastHard) contrast.push({ el: tag(el), ratio: Math.round(r * 100) / 100, text: el.textContent.trim().slice(0, 24) });
70 }
71
72 // 4. Tap targets (touch devices only — the caller decides whether to read this).
73 const tap = [];
74 for (const el of document.querySelectorAll('button, a[href], input, [role="button"], select')) {
75 if (!visible(el)) continue;
76 const r = el.getBoundingClientRect();
77 if ((r.width < tapMin || r.height < tapMin) && r.width > 0) {
78 tap.push({ el: tag(el), w: Math.round(r.width), h: Math.round(r.height) });
79 if (tap.length >= 10) break;
80 }
81 }
82
83 // 5. A centred dialog card that does not fill its modal, because a stray flex
84 // sibling is splitting the row. This is exactly the identity-screen bug: a
85 // mis-targeted injected button became a second flex child and squeezed the
86 // card until its content clipped. A shown modal whose card is under 70% of
87 // the modal width while a sibling exists is broken.
88 const squeezed = [];
89 for (const modal of document.querySelectorAll('.modal, .pair-scrim')) {
90 if (!visible(modal) || getComputedStyle(modal).display !== 'flex') continue;
91 const card = modal.querySelector('.modal-card, .pair-box');
92 if (!card || !visible(card)) continue;
93 const mw = modal.clientWidth, cw = card.getBoundingClientRect().width;
94 if (mw > 0 && cw > 0 && cw < mw * 0.7 && modal.children.length > 1) {
95 squeezed.push({ el: tag(card), cardW: Math.round(cw), modalW: mw, siblings: modal.children.length });
96 }
97 }
98
99 return { hScroll: Math.round(hScroll), overflow, contrast, tap, squeezed };
100};
101
102// Findings are deduped per cell: the persistent top bar and rail repeat in every
103// view, so the same element is reported once, with the views it showed up in.
104// HARD faults (body scroll, invisible text) gate the run; REVIEW items (viewport
105// overflow, borderline contrast, small tap targets) are judgement calls surfaced
106// for the eye, and are printed but do not fail the run unless --strict is passed.
107const STRICT = process.argv.includes('--strict');
108const CONTRAST_INVISIBLE = 2.0; // below this, text is effectively unreadable
109
110const avail = await enginesAvailable();
111console.log('engines: ' + Object.entries(avail).map(([k, v]) => `${k}=${v ? 'yes' : 'no'}`).join(' '));
112
113const cells = defaultCells().filter(c => avail[c.engine]);
114const skipped = defaultCells().filter(c => !avail[c.engine]);
115if (skipped.length) console.log('skipped (engine unavailable): ' + [...new Set(skipped.map(c => c.engine))].join(', ') + ' (install to widen coverage)');
116
117let hardTotal = 0, reviewTotal = 0;
118
119for (const cell of cells) {
120 const label = cellLabel(cell);
121 const isMobile = ['pixel', 'iphone', 'iphone-se', 'ipad'].includes(cell.device);
122 const hard = new Map(), review = new Map(); // key → { msg, views:Set }
123 const add = (bag, key, msg, view) => {
124 if (!bag.has(key)) bag.set(key, { msg, views: new Set() });
125 bag.get(key).views.add(view);
126 };
127
128 // The first screen a new user sees is the signed-OUT create-account modal,
129 // which the signed-in views below never show. Audit it on its own pass — a
130 // squeezed card here is a HARD fault, it is literally the first impression.
131 try {
132 const ls = await openCell(cell, { signIn: false, connect: false });
133 try {
134 const a = await ls.page.evaluate(AUDIT, { tapMin: TAP_MIN, contrastHard: CONTRAST_HARD });
135 for (const q of a.squeezed) add(hard, 'sqz:' + q.el, `first screen: ${q.el} fills only ${q.cardW}/${q.modalW}px of its modal (${q.siblings} flex children — a stray sibling is splitting the row)`, 'locked-out');
136 if (a.hScroll > 2) add(hard, 'hscroll-out', `first screen: body scrolls horizontally by ${a.hScroll}px`, 'locked-out');
137 for (const o of a.overflow) add(review, 'ovf-out:' + o.el, `first screen: ${o.el} spills past the right edge`, 'locked-out');
138 } finally { await ls.close(); }
139 } catch (e) { add(hard, 'locked-open', `could not open signed-out first screen — ${e.message}`, 'locked-out'); }
140
141 let s;
142 try { s = await openCell(cell, { signIn: true, connect: true }); }
143 catch (e) { console.log(`\n■ ${label}\n HARD could not open cell — ${e.message}`); hardTotal += hard.size; continue; }
144 try {
145 for (const view of VIEWS) {
146 if (view.needsAuth === false && view.name !== 'locked') continue;
147 await resetView(s.page);
148 await view.setup(s.page).catch(() => {});
149 let a;
150 try { a = await s.page.evaluate(AUDIT, { tapMin: TAP_MIN, contrastHard: CONTRAST_HARD }); }
151 catch (e) { add(hard, 'audit-threw:' + view.name, `audit threw in ${view.name} — ${e.message}`, view.name); continue; }
152
153 if (a.hScroll > 2) add(hard, 'hscroll', `body scrolls horizontally by ${a.hScroll}px`, view.name);
154 for (const o of a.overflow) add(review, 'ovf:' + o.el, `${o.el} spills past the right edge (right ${o.right} > vw ${o.vw})`, view.name);
155 for (const c of a.contrast) {
156 const bag = c.ratio < CONTRAST_INVISIBLE ? hard : review;
157 add(bag, 'con:' + c.el + ':' + c.text, `${c.el} "${c.text}" at ${c.ratio}:1`, view.name);
158 }
159 if (isMobile) for (const t of a.tap) add(review, 'tap:' + t.el, `${t.el} is ${t.w}×${t.h}px (< ${TAP_MIN})`, view.name);
160 }
161 } finally { await s.close(); }
162
163 hardTotal += hard.size; reviewTotal += review.size;
164 if (!hard.size && !review.size) { console.log(`\n■ ${label} — clean`); continue; }
165 console.log(`\n■ ${label}`);
166 const show = (bag, kind) => { for (const { msg, views } of bag.values()) console.log(` ${kind} ${msg} [${[...views].join(',')}]`); };
167 show(hard, 'HARD ');
168 show(review, 'review');
169}
170
171console.log(`\n${hardTotal} hard, ${reviewTotal} review across ${cells.length} cells` + (skipped.length ? `; ${[...new Set(skipped.map(c => c.engine))].join('/')} not installed` : ''));
172process.exit((hardTotal || (STRICT && reviewTotal)) ? 1 : 0);