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). |
| 13 | import { openCell, defaultCells, VIEWS, cellLabel, enginesAvailable, resetView } from './matrix.mjs'; |
| 14 | |
| 15 | const CONTRAST_HARD = 3.0; // below this, text is effectively unreadable — a fail |
| 16 | const 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. |
| 22 | const 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. |
| 107 | const STRICT = process.argv.includes('--strict'); |
| 108 | const CONTRAST_INVISIBLE = 2.0; // below this, text is effectively unreadable |
| 109 | |
| 110 | const avail = await enginesAvailable(); |
| 111 | console.log('engines: ' + Object.entries(avail).map(([k, v]) => `${k}=${v ? 'yes' : 'no'}`).join(' ')); |
| 112 | |
| 113 | const cells = defaultCells().filter(c => avail[c.engine]); |
| 114 | const skipped = defaultCells().filter(c => !avail[c.engine]); |
| 115 | if (skipped.length) console.log('skipped (engine unavailable): ' + [...new Set(skipped.map(c => c.engine))].join(', ') + ' (install to widen coverage)'); |
| 116 | |
| 117 | let hardTotal = 0, reviewTotal = 0; |
| 118 | |
| 119 | for (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 | |
| 171 | console.log(`\n${hardTotal} hard, ${reviewTotal} review across ${cells.length} cells` + (skipped.length ? `; ${[...new Set(skipped.map(c => c.engine))].join('/')} not installed` : '')); |
| 172 | process.exit((hardTotal || (STRICT && reviewTotal)) ? 1 : 0); |