oxedyne/daimond/dev/shot_qc_geom.mjs
12.2 KiB, 1 run
created by r2519314175:179, 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 | // shot_qc_geom.mjs — the questions a screenshot raises and only a measurement |
| 2 | // can answer. |
| 3 | // |
| 4 | // Reading images finds the gross faults. It cannot tell 10px of inset from |
| 5 | // 14px, and the difference between those two is the whole of "the left edges |
| 6 | // wander" — the complaint that produced this pass. So each check below is a |
| 7 | // specific claim with a number attached, printed as pass/fail against a stated |
| 8 | // rule rather than as a rectangle for someone to compare by eye. |
| 9 | // |
| 10 | // node dev/shot_qc_geom.mjs |
| 11 | import { open, errors } from './harness.mjs'; |
| 12 | |
| 13 | const s = await open({ name: 'qcgeom', connect: true }); |
| 14 | const { page } = s; |
| 15 | const pause = (ms) => page.waitForTimeout(ms); |
| 16 | const rows = []; |
| 17 | const r2 = (n) => Math.round(n * 100) / 100; |
| 18 | |
| 19 | /// One claim, its measured value, and whether it holds. |
| 20 | function claim(what, ok, detail) { |
| 21 | rows.push({ ok, what, detail }); |
| 22 | console.log(`${ok ? 'ok ' : 'LOOK'} ${what}\n ${detail}`); |
| 23 | } |
| 24 | |
| 25 | await page.waitForFunction(() => !!(window.DaimondPanels && DaimondPanels.panels), null, { timeout: 20000 }); |
| 26 | await pause(800); |
| 27 | await page.evaluate(() => { const c = document.getElementById('admin-close'); if (c && c.getClientRects().length) c.click(); }); |
| 28 | await page.evaluate(() => DaimondPanels.show('rail')); |
| 29 | await pause(400); |
| 30 | |
| 31 | // ── The rail's two lists ─────────────────────────────────────────────── |
| 32 | // |
| 33 | // A Diamond tile and a chat tile are the same component in two lists. If the |
| 34 | // lists inset them differently the rail has a visible step down its middle. |
| 35 | const lists = await page.evaluate(() => { |
| 36 | const g = (id) => { |
| 37 | const el = document.getElementById(id); |
| 38 | if (!el) return null; |
| 39 | const cs = getComputedStyle(el); |
| 40 | const r = el.getBoundingClientRect(); |
| 41 | return { |
| 42 | padLeft: parseFloat(cs.paddingLeft), padRight: parseFloat(cs.paddingRight), |
| 43 | padTop: parseFloat(cs.paddingTop), padBottom: parseFloat(cs.paddingBottom), |
| 44 | contentLeft: r.left + parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth), |
| 45 | contentRight: r.right - parseFloat(cs.paddingRight) - parseFloat(cs.borderRightWidth), |
| 46 | }; |
| 47 | }; |
| 48 | return { diamond: g('diamond-list'), session: g('session-list') }; |
| 49 | }); |
| 50 | claim('the two rail lists inset their tiles by the same amount', |
| 51 | lists.diamond && lists.session |
| 52 | && lists.diamond.padLeft === lists.session.padLeft |
| 53 | && lists.diamond.padRight === lists.session.padRight, |
| 54 | `#diamond-list padding ${lists.diamond?.padLeft}/${lists.diamond?.padRight}px, ` |
| 55 | + `#session-list padding ${lists.session?.padLeft}/${lists.session?.padRight}px; ` |
| 56 | + `content starts at x=${r2(lists.diamond?.contentLeft)} vs x=${r2(lists.session?.contentLeft)}`); |
| 57 | |
| 58 | // ── The rail's furniture, left edge by left edge ──────────────────────── |
| 59 | // |
| 60 | // Everything stacked in one column should start on one line. Each of these is |
| 61 | // a different component written at a different time, which is how a column |
| 62 | // acquires four different left edges. |
| 63 | const railLefts = await page.evaluate(() => { |
| 64 | const pick = [ |
| 65 | ['pptw label', '.pptw-head-label'], |
| 66 | ['DIAMONDS head', '.railhead span'], |
| 67 | ['tag pool', '#diamond-filter'], |
| 68 | ['diamond list', '#diamond-list'], |
| 69 | ['session list', '#session-list'], |
| 70 | ['status rows', '.astat-row'], |
| 71 | ['admin note', '.admin-note'], |
| 72 | ]; |
| 73 | const rail = document.getElementById('panel-rail').getBoundingClientRect(); |
| 74 | const out = []; |
| 75 | for (const [name, sel] of pick) { |
| 76 | const el = document.querySelector(sel); |
| 77 | if (!el || !el.getClientRects().length) continue; |
| 78 | const cs = getComputedStyle(el); |
| 79 | const r = el.getBoundingClientRect(); |
| 80 | out.push({ name, left: Math.round((r.left + parseFloat(cs.paddingLeft) - rail.left) * 100) / 100 }); |
| 81 | } |
| 82 | return out; |
| 83 | }); |
| 84 | { |
| 85 | const xs = railLefts.map((x) => x.left); |
| 86 | const spread = Math.max(...xs) - Math.min(...xs); |
| 87 | claim('the rail column starts its rows on one left edge', spread <= 1, |
| 88 | railLefts.map((x) => `${x.name}=${x.left}`).join(' ') + ` → spread ${r2(spread)}px`); |
| 89 | } |
| 90 | |
| 91 | // ── The two rail heads' buttons end on one right edge ────────────────── |
| 92 | const heads = await page.evaluate(() => { |
| 93 | const rail = document.getElementById('panel-rail').getBoundingClientRect(); |
| 94 | // Scoped to the rail: `.railhead` is also the class the four dock panels use |
| 95 | // for their own headers, and measuring those against the RAIL's rectangle |
| 96 | // reported a button "-1152px from the edge" — a number with no meaning. |
| 97 | return [...document.querySelectorAll('#panel-rail .railhead')].map((h) => { |
| 98 | const bs = [...h.querySelectorAll('button')].filter((b) => b.getClientRects().length); |
| 99 | return { |
| 100 | label: (h.querySelector('span') || {}).textContent, |
| 101 | lastRight: bs.length ? Math.round((rail.right - bs[bs.length - 1].getBoundingClientRect().right) * 100) / 100 : null, |
| 102 | sizes: bs.map((b) => { const r = b.getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)}`; }), |
| 103 | }; |
| 104 | }); |
| 105 | }); |
| 106 | { |
| 107 | const insets = heads.filter((h) => h.lastRight !== null).map((h) => h.lastRight); |
| 108 | claim('both rail heads end their button rows at the same inset', |
| 109 | insets.length > 1 && Math.max(...insets) - Math.min(...insets) <= 0.5, |
| 110 | heads.map((h) => `${h.label}: last button ${h.lastRight}px from the rail edge, sizes ${h.sizes.join(',')}`).join('; ')); |
| 111 | const allSizes = heads.flatMap((h) => h.sizes); |
| 112 | claim('every rail-head button is the same size', new Set(allSizes).size === 1, |
| 113 | allSizes.join(' ')); |
| 114 | } |
| 115 | |
| 116 | // ── The appearance menu's segmented rows ─────────────────────────────── |
| 117 | await page.click('#settings-menu-btn', { force: true }); |
| 118 | await pause(400); |
| 119 | const segs = await page.evaluate(() => [...document.querySelectorAll('#settings-menu .seg')].map((row) => { |
| 120 | const bs = [...row.children].filter((b) => b.getClientRects().length); |
| 121 | const ws = bs.map((b) => Math.round(b.getBoundingClientRect().width * 100) / 100); |
| 122 | const tops = new Set(bs.map((b) => Math.round(b.getBoundingClientRect().top))); |
| 123 | return { |
| 124 | labels: bs.map((b) => b.textContent.trim().replace(/\s+/g, '')), |
| 125 | widths: ws, lines: tops.size, |
| 126 | spread: Math.round((Math.max(...ws) - Math.min(...ws)) * 100) / 100, |
| 127 | flex: getComputedStyle(bs[0]).flex, |
| 128 | }; |
| 129 | })); |
| 130 | for (const seg of segs) { |
| 131 | claim(`segmented row [${seg.labels.join('|')}] is evenly divided`, |
| 132 | seg.lines > 1 || seg.spread <= 1, |
| 133 | `widths ${seg.widths.join(', ')}px on ${seg.lines} line(s), spread ${seg.spread}px, flex ${seg.flex}`); |
| 134 | } |
| 135 | |
| 136 | // The percentage readout at the end of the text-size row: right-aligned inside |
| 137 | // a 38px min-width box, which is what decides whether "100%" touches the edge. |
| 138 | const pct = await page.evaluate(() => { |
| 139 | const el = document.querySelector('#settings-menu .size-row .pct'); |
| 140 | if (!el) return null; |
| 141 | const pop = document.getElementById('settings-menu').getBoundingClientRect(); |
| 142 | const r = el.getBoundingClientRect(); |
| 143 | const up = el.previousElementSibling.getBoundingClientRect(); |
| 144 | return { |
| 145 | text: el.textContent, right: Math.round((pop.right - r.right) * 100) / 100, |
| 146 | gapToButton: Math.round((r.left - up.right) * 100) / 100, |
| 147 | width: Math.round(r.width * 100) / 100, |
| 148 | }; |
| 149 | }); |
| 150 | if (pct) { |
| 151 | claim('the text-size percentage keeps the popover\'s own padding', |
| 152 | pct.right >= 5, |
| 153 | `"${pct.text}" is ${pct.right}px from the popover edge (rows above use 6px), ` |
| 154 | + `${pct.gapToButton}px from the A button, ${pct.width}px wide`); |
| 155 | } |
| 156 | await page.keyboard.press('Escape'); |
| 157 | await pause(200); |
| 158 | |
| 159 | // ── The dock panels' headers ─────────────────────────────────────────── |
| 160 | // |
| 161 | // Five panels, five headers built separately. They are the most-seen row in |
| 162 | // the app after the top bar. |
| 163 | const dock = await page.evaluate(() => { |
| 164 | ['agents', 'mail', 'work', 'spend'].forEach((id) => { try { DaimondPanels.show(id); } catch (e) {} }); |
| 165 | return true; |
| 166 | }); |
| 167 | await pause(600); |
| 168 | const dockHeads = await page.evaluate(() => { |
| 169 | const out = []; |
| 170 | for (const id of ['agents', 'mail', 'work', 'spend']) { |
| 171 | const p = document.getElementById('panel-' + id); |
| 172 | if (!p || !p.getClientRects().length) continue; |
| 173 | const head = p.querySelector('.phead, .panel-head, .chead, [class*="head"]'); |
| 174 | if (!head) continue; |
| 175 | const pr = p.getBoundingClientRect(); |
| 176 | const title = head.querySelector('span, h2, .ptitle'); |
| 177 | const bs = [...head.querySelectorAll('button')].filter((b) => b.getClientRects().length); |
| 178 | const hr = head.getBoundingClientRect(); |
| 179 | out.push({ |
| 180 | id, headClass: head.className, |
| 181 | height: Math.round(hr.height * 100) / 100, |
| 182 | titleLeft: title ? Math.round((title.getBoundingClientRect().left - pr.left) * 100) / 100 : null, |
| 183 | lastRight: bs.length ? Math.round((pr.right - bs[bs.length - 1].getBoundingClientRect().right) * 100) / 100 : null, |
| 184 | btn: bs.length ? (() => { const r = bs[0].getBoundingClientRect(); return `${Math.round(r.width)}x${Math.round(r.height)}`; })() : null, |
| 185 | // A title whose ink centre is off the button row's centre is the |
| 186 | // classic "the label sits high" — measured, not guessed. |
| 187 | titleMid: title ? Math.round((title.getBoundingClientRect().top + title.getBoundingClientRect().bottom) / 2 * 100) / 100 : null, |
| 188 | btnMid: bs.length ? Math.round((bs[0].getBoundingClientRect().top + bs[0].getBoundingClientRect().bottom) / 2 * 100) / 100 : null, |
| 189 | }); |
| 190 | } |
| 191 | return out; |
| 192 | }); |
| 193 | { |
| 194 | const lefts = dockHeads.map((h) => h.titleLeft).filter((x) => x !== null); |
| 195 | const rights = dockHeads.map((h) => h.lastRight).filter((x) => x !== null); |
| 196 | claim('every dock panel titles at the same left inset', |
| 197 | lefts.length > 1 && Math.max(...lefts) - Math.min(...lefts) <= 0.5, |
| 198 | dockHeads.map((h) => `${h.id}=${h.titleLeft}`).join(' ')); |
| 199 | claim('every dock panel ends its button row at the same right inset', |
| 200 | rights.length > 1 && Math.max(...rights) - Math.min(...rights) <= 0.5, |
| 201 | dockHeads.map((h) => `${h.id}=${h.lastRight}`).join(' ')); |
| 202 | claim('every dock panel header is the same height', |
| 203 | new Set(dockHeads.map((h) => h.height)).size === 1, |
| 204 | dockHeads.map((h) => `${h.id}=${h.height}px`).join(' ')); |
| 205 | const off = dockHeads.filter((h) => h.titleMid !== null && h.btnMid !== null |
| 206 | && Math.abs(h.titleMid - h.btnMid) > 0.75); |
| 207 | claim('every dock title sits on its button row\'s centre line', off.length === 0, |
| 208 | dockHeads.map((h) => `${h.id}: title mid ${h.titleMid} vs buttons ${h.btnMid}`).join('; ')); |
| 209 | } |
| 210 | |
| 211 | // ── Left insets inside a panel body ──────────────────────────────────── |
| 212 | // |
| 213 | // A panel's own content, top to bottom. The header sets an inset; everything |
| 214 | // under it should keep it. |
| 215 | const insets = await page.evaluate(() => { |
| 216 | const out = []; |
| 217 | for (const id of ['work', 'mail', 'spend', 'agents']) { |
| 218 | const p = document.getElementById('panel-' + id); |
| 219 | if (!p || !p.getClientRects().length) continue; |
| 220 | const pr = p.getBoundingClientRect(); |
| 221 | const kids = [...p.querySelectorAll('*')] |
| 222 | .filter((e) => e.getClientRects().length && (e.textContent || '').trim() |
| 223 | && e.children.length === 0 && e.getBoundingClientRect().height > 6); |
| 224 | const seen = new Map(); |
| 225 | for (const k of kids) { |
| 226 | // The INK, not the box: a full-width block with 10px of padding has |
| 227 | // its left edge at 0 and its text at 10, and it is the text a reader |
| 228 | // lines up against its neighbour. |
| 229 | const rng = document.createRange(); |
| 230 | rng.selectNodeContents(k); |
| 231 | const r = rng.getBoundingClientRect(); |
| 232 | if (r.width < 1) continue; |
| 233 | const x = Math.round((r.left - pr.left) * 10) / 10; |
| 234 | if (!seen.has(x)) seen.set(x, (k.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 22)); |
| 235 | } |
| 236 | out.push({ id, edges: [...seen.entries()].sort((a, b) => a[0] - b[0]) }); |
| 237 | } |
| 238 | return out; |
| 239 | }); |
| 240 | for (const p of insets) { |
| 241 | // Text nested inside a card legitimately starts further in; what matters is |
| 242 | // how many DISTINCT top-level edges the eye has to reconcile below 24px. |
| 243 | const shallow = p.edges.filter(([x]) => x <= 24); |
| 244 | claim(`the ${p.id} panel uses one left inset for its own top-level text`, |
| 245 | shallow.length <= 1, |
| 246 | shallow.map(([x, t]) => `${x}px "${t}"`).join(' | ') || '(none under 24px)'); |
| 247 | } |
| 248 | |
| 249 | console.log(`\n${rows.filter((r) => !r.ok).length} of ${rows.length} claims want a look.`); |
| 250 | const errs = errors(s).filter((e) => !/502|Bad Gateway/.test(e)); |
| 251 | if (errs.length) console.log(`\n${errs.length} console error(s): ` + errs.slice(0, 5).join(' | ')); |
| 252 | await s.close(); |