oxedyne/daimond/dev/shot_permchip.mjs
11.7 KiB, 1 run
created by r2519314175:167, 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_permchip.mjs — the ink-level check the permission chip's CSS asks for. |
| 2 | // |
| 3 | // The dot's nudge carries this comment in www/css/app.css: |
| 4 | // |
| 5 | // /* CHECK THIS AT 8x before shipping — it is a nudge, not a measurement. */ |
| 6 | // |
| 7 | // House rule (`feedback_ink_level_visual_qc`): centre the INK, not the box. A 6px |
| 8 | // dot beside 11px text sits visibly high if it is centred on the line box, because |
| 9 | // the line box carries the descender space and the word "Guarded" has no descender |
| 10 | // in it. So this measures where the ink actually is — from the font's own metrics, |
| 11 | // via TextMetrics — and says what the nudge should be, rather than eyeballing it. |
| 12 | // |
| 13 | // It also takes the 8x crop, faithfully: the element is screenshotted at the size |
| 14 | // it really rendered, then blown up nearest-neighbour, so what is magnified is the |
| 15 | // pixels the browser produced and not a re-layout at another zoom. |
| 16 | // |
| 17 | // And it checks the two contrast floors on a light and a dark palette: 4.5:1 for |
| 18 | // the word (it is text) and 3:1 for the dot (it is a non-text indicator). |
| 19 | // |
| 20 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 21 | // (DAIMOND_MOCK_PORT, default 9099). |
| 22 | import fs from 'node:fs'; |
| 23 | import path from 'node:path'; |
| 24 | import { fileURLToPath } from 'node:url'; |
| 25 | import { open, chat } from './harness.mjs'; |
| 26 | |
| 27 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 28 | const SHOTS = path.join(HERE, 'shots'); |
| 29 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 30 | |
| 31 | const ok = [], bad = []; |
| 32 | const check = (name, pass, detail) => { |
| 33 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 34 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 35 | }; |
| 36 | |
| 37 | const s = await open({ name: 'permchip' }); |
| 38 | const p = s.page; |
| 39 | await chat(s, '@text hello'); // the chat header is only drawn with a chat open |
| 40 | |
| 41 | const visible = await p.evaluate(() => { |
| 42 | const c = document.getElementById('hand-mode-chip'); |
| 43 | if (!c) return null; |
| 44 | const r = c.getBoundingClientRect(); |
| 45 | return { w: r.width, h: r.height, text: c.innerText.trim(), aria: c.getAttribute('aria-label') }; |
| 46 | }); |
| 47 | check('the chip is on screen, in the chat header', !!(visible && visible.w > 0), visible ? JSON.stringify(visible) : 'absent'); |
| 48 | check('the WORD carries the state, so nothing rests on the colour', |
| 49 | !!(visible && /guarded/i.test(visible.text)), visible ? visible.text : ''); |
| 50 | check('and the accessible name contains the visible word (WCAG 2.5.3)', |
| 51 | !!(visible && visible.aria && visible.aria.includes(visible.text.replace(/\s+/g, ' '))), |
| 52 | visible ? visible.aria : ''); |
| 53 | |
| 54 | // ── Where the ink actually is ────────────────────────────────────────────── |
| 55 | |
| 56 | const ink = await p.evaluate(() => { |
| 57 | const chip = document.getElementById('hand-mode-chip'); |
| 58 | const txt = document.getElementById('hand-mode-chip-txt'); |
| 59 | const dot = chip.querySelector('.mode-chip-dot'); |
| 60 | const cs = getComputedStyle(txt); |
| 61 | const cv = document.createElement('canvas').getContext('2d'); |
| 62 | cv.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; |
| 63 | const m = cv.measureText(txt.textContent); |
| 64 | const tr = txt.getBoundingClientRect(); |
| 65 | const dr = dot.getBoundingClientRect(); |
| 66 | // The inline box centres the font's content area (ascent+descent) inside the |
| 67 | // line box, so the baseline is that offset plus the ascent. |
| 68 | const fA = m.fontBoundingBoxAscent, fD = m.fontBoundingBoxDescent; |
| 69 | const baseline = tr.top + (tr.height - (fA + fD)) / 2 + fA; |
| 70 | const inkTop = baseline - m.actualBoundingBoxAscent; |
| 71 | const inkBottom = baseline + m.actualBoundingBoxDescent; |
| 72 | const inkMid = (inkTop + inkBottom) / 2; |
| 73 | const dotMid = dr.top + dr.height / 2; |
| 74 | const applied = getComputedStyle(dot).transform; // the nudge, as matrix(…) |
| 75 | return { |
| 76 | font: cv.font, |
| 77 | lineBox: { top: tr.top, height: tr.height }, |
| 78 | fontMetrics: { ascent: fA, descent: fD }, |
| 79 | inkAscent: m.actualBoundingBoxAscent, inkDescent: m.actualBoundingBoxDescent, |
| 80 | inkTop, inkBottom, inkMid, |
| 81 | dot: { top: dr.top, height: dr.height, mid: dotMid }, |
| 82 | delta: dotMid - inkMid, // + means the dot sits BELOW the ink centre |
| 83 | applied, |
| 84 | }; |
| 85 | }); |
| 86 | |
| 87 | console.log('\n measured:'); |
| 88 | console.log(' font ' + ink.font); |
| 89 | console.log(' line box top ' + ink.lineBox.top.toFixed(2) + ', height ' + ink.lineBox.height.toFixed(2)); |
| 90 | console.log(' font ascent/descent ' + ink.fontMetrics.ascent.toFixed(2) + ' / ' + ink.fontMetrics.descent.toFixed(2)); |
| 91 | console.log(' ink ascent/descent ' + ink.inkAscent.toFixed(2) + ' / ' + ink.inkDescent.toFixed(2)); |
| 92 | console.log(' ink top..bottom ' + ink.inkTop.toFixed(2) + ' .. ' + ink.inkBottom.toFixed(2) |
| 93 | + ' (centre ' + ink.inkMid.toFixed(2) + ')'); |
| 94 | console.log(' dot top..bottom ' + ink.dot.top.toFixed(2) + ' .. ' + (ink.dot.top + ink.dot.height).toFixed(2) |
| 95 | + ' (centre ' + ink.dot.mid.toFixed(2) + ')'); |
| 96 | console.log(' applied nudge ' + ink.applied); |
| 97 | console.log(' dot centre − ink centre = ' + ink.delta.toFixed(2) + 'px' |
| 98 | + (ink.delta > 0 ? ' (dot sits low)' : ink.delta < 0 ? ' (dot sits high)' : '')); |
| 99 | const appliedY = parseFloat((/matrix\([^)]*,\s*([-\d.]+)\)/.exec(ink.applied) || [0, '0'])[1]) || 0; |
| 100 | // The nudge that WOULD centre it: what is applied now, less however far off it is. |
| 101 | console.log(' nudge applied ' + appliedY.toFixed(2) + 'px; nudge that centres it: translateY(' |
| 102 | + (appliedY - ink.delta).toFixed(2) + 'px)'); |
| 103 | |
| 104 | |
| 105 | // ── The other two labels ─────────────────────────────────────────────────── |
| 106 | // |
| 107 | // One nudge serves three words, and they do not have the same ink. "Guarded" has |
| 108 | // an ascender and no descender; "Bypass" has both. A nudge measured against one |
| 109 | // word only is a nudge that is wrong for the other two, which is exactly the kind |
| 110 | // of measurement this check exists to stop being taken. |
| 111 | const perLabel = await p.evaluate(() => { |
| 112 | const txt = document.getElementById('hand-mode-chip-txt'); |
| 113 | const cs = getComputedStyle(txt); |
| 114 | const cv = document.createElement('canvas').getContext('2d'); |
| 115 | cv.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; |
| 116 | const out = {}; |
| 117 | for (const w of (window.DaimondHandMode ? DaimondHandMode.list().map(m => m.label) : ['Guarded'])) { |
| 118 | const m = cv.measureText(w); |
| 119 | // Relative to the baseline: negative is above it. |
| 120 | out[w] = { |
| 121 | asc: m.actualBoundingBoxAscent, |
| 122 | desc: m.actualBoundingBoxDescent, |
| 123 | mid: -(m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2, |
| 124 | }; |
| 125 | } |
| 126 | const x = cv.measureText('x'); |
| 127 | out.__xheight = { asc: x.actualBoundingBoxAscent, desc: x.actualBoundingBoxDescent, |
| 128 | mid: -(x.actualBoundingBoxAscent - x.actualBoundingBoxDescent) / 2 }; |
| 129 | return out; |
| 130 | }); |
| 131 | console.log('\n the ink of each label, relative to the baseline:'); |
| 132 | const mids = []; |
| 133 | for (const [w, m] of Object.entries(perLabel)) { |
| 134 | console.log(` ${w.padEnd(16)} ascent ${m.asc.toFixed(2)}, descent ${m.desc.toFixed(2)}, ink centre ${m.mid.toFixed(2)}`); |
| 135 | if (w !== '__xheight') mids.push(m.mid); |
| 136 | } |
| 137 | const spread = Math.max(...mids) - Math.min(...mids); |
| 138 | console.log(` spread across the three labels: ${spread.toFixed(2)}px`); |
| 139 | // Reported, not asserted: that the three are 2px apart is a fact about the words, |
| 140 | // not a defect. It is WHY the target below is a band rather than a word. |
| 141 | console.log(` (no single offset can centre the dot on all three — hence the band)`); |
| 142 | |
| 143 | // The target is the MIDPOINT of the labels' ink centres, not any one label's. |
| 144 | // |
| 145 | // Measuring against one word is how a nudge goes wrong, and the numbers above are |
| 146 | // why: the three ink centres are 2px apart, so an offset dead-on for "Guarded" is |
| 147 | // 2px out on "Bypass". The midpoint of the extremes is the offset whose worst case |
| 148 | // is smallest — 1px out on either, never 2px on one. The 8x crops in |
| 149 | // dev/shot_permchip_ladder.mjs are what confirmed it reads level on all three; the |
| 150 | // arithmetic alone cannot say that. |
| 151 | // |
| 152 | // Half a pixel is the smallest move a screen at DPR 1 can show, so that is the |
| 153 | // tolerance: inside it, nothing is gained by moving. |
| 154 | const baseline = ink.inkBottom; // no descender in "Guarded" |
| 155 | const target = baseline + (Math.max(...mids) + Math.min(...mids)) / 2; |
| 156 | const offBand = ink.dot.mid - target; |
| 157 | console.log('\n midpoint of the labels ' + target.toFixed(2) |
| 158 | + ' dot centre ' + ink.dot.mid.toFixed(2) |
| 159 | + ' off by ' + offBand.toFixed(2) + 'px'); |
| 160 | console.log(' residual per label: ' + Object.keys(perLabel).filter(k => k !== '__xheight') |
| 161 | .map((w, i) => `${w} ${(target - (baseline + mids[i])).toFixed(2)}`).join(', ')); |
| 162 | check('the dot sits at the midpoint of the three labels, so its worst case is smallest', |
| 163 | Math.abs(offBand) <= 0.5, |
| 164 | `${offBand.toFixed(2)}px off; nudge that lands it: translateY(${(appliedY - offBand).toFixed(2)}px)`); |
| 165 | |
| 166 | // ── The 8x crop, faithful to what was rendered ───────────────────────────── |
| 167 | |
| 168 | const chipPng = await p.locator('#hand-mode-chip').screenshot(); |
| 169 | const crop8 = await p.evaluate(async (b64) => { |
| 170 | const img = new Image(); |
| 171 | await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = 'data:image/png;base64,' + b64; }); |
| 172 | const c = document.createElement('canvas'); |
| 173 | c.width = img.width * 8; c.height = img.height * 8; |
| 174 | c.id = '__crop8'; |
| 175 | const g = c.getContext('2d'); |
| 176 | g.imageSmoothingEnabled = false; // nearest neighbour: no invented pixels |
| 177 | g.drawImage(img, 0, 0, c.width, c.height); |
| 178 | c.style.cssText = 'position:fixed;left:0;top:0;z-index:99999;background:#808080'; |
| 179 | document.body.appendChild(c); |
| 180 | return { w: c.width, h: c.height }; |
| 181 | }, chipPng.toString('base64')); |
| 182 | await p.locator('#__crop8').screenshot({ path: path.join(SHOTS, 'permchip-8x.png') }); |
| 183 | await p.evaluate(() => { const c = document.getElementById('__crop8'); if (c) c.remove(); }); |
| 184 | console.log(`\n 8x crop written to dev/shots/permchip-8x.png (${crop8.w}×${crop8.h})`); |
| 185 | |
| 186 | // ── Contrast, on a light palette and a dark one ──────────────────────────── |
| 187 | |
| 188 | const ratios = await p.evaluate(async () => { |
| 189 | const lum = (c) => { |
| 190 | const [r, g, b] = c.map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); |
| 191 | return 0.2126 * r + 0.7152 * g + 0.0722 * b; |
| 192 | }; |
| 193 | const parse = (s) => (s.match(/[\d.]+/g) || []).slice(0, 3).map(Number); |
| 194 | // The background actually behind the chip, walking up past anything transparent. |
| 195 | const behind = (el) => { |
| 196 | for (let n = el; n; n = n.parentElement) { |
| 197 | const bg = getComputedStyle(n).backgroundColor; |
| 198 | const a = (bg.match(/[\d.]+/g) || [])[3]; |
| 199 | if (bg && bg !== 'transparent' && a !== '0') return parse(bg); |
| 200 | } |
| 201 | return [0, 0, 0]; |
| 202 | }; |
| 203 | const ratio = (a, b) => { const [l, d] = [lum(a), lum(b)].sort((x, y) => y - x); return (l + 0.05) / (d + 0.05); }; |
| 204 | const out = {}; |
| 205 | for (const theme of ['light', 'dark']) { |
| 206 | window.DaimondTheme.set(theme); |
| 207 | await new Promise(r => setTimeout(r, 250)); |
| 208 | const chip = document.getElementById('hand-mode-chip'); |
| 209 | const txt = document.getElementById('hand-mode-chip-txt'); |
| 210 | const dot = chip.querySelector('.mode-chip-dot'); |
| 211 | const bg = behind(chip); |
| 212 | out[theme] = { |
| 213 | word: ratio(parse(getComputedStyle(txt).color), bg), |
| 214 | dot: ratio(parse(getComputedStyle(dot).backgroundColor), bg), |
| 215 | }; |
| 216 | } |
| 217 | window.DaimondTheme.set('dark'); |
| 218 | return out; |
| 219 | }); |
| 220 | for (const theme of ['light', 'dark']) { |
| 221 | check(`the word clears the text floor on the ${theme} palette`, ratios[theme].word >= 4.5, |
| 222 | `${ratios[theme].word.toFixed(2)}:1 (4.5 needed)`); |
| 223 | check(`the dot clears the non-text floor on the ${theme} palette`, ratios[theme].dot >= 3, |
| 224 | `${ratios[theme].dot.toFixed(2)}:1 (3.0 needed)`); |
| 225 | } |
| 226 | |
| 227 | await s.close(); |
| 228 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 229 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |