Oregami
Repositories/oxedyne/daimond

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).
22import fs from 'node:fs';
23import path from 'node:path';
24import { fileURLToPath } from 'node:url';
25import { open, chat } from './harness.mjs';
26
27const HERE = path.dirname(fileURLToPath(import.meta.url));
28const SHOTS = path.join(HERE, 'shots');
29fs.mkdirSync(SHOTS, { recursive: true });
30
31const ok = [], bad = [];
32const check = (name, pass, detail) => {
33 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
34 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
35};
36
37const s = await open({ name: 'permchip' });
38const p = s.page;
39await chat(s, '@text hello'); // the chat header is only drawn with a chat open
40
41const 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});
47check('the chip is on screen, in the chat header', !!(visible && visible.w > 0), visible ? JSON.stringify(visible) : 'absent');
48check('the WORD carries the state, so nothing rests on the colour',
49 !!(visible && /guarded/i.test(visible.text)), visible ? visible.text : '');
50check('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
56const 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
87console.log('\n measured:');
88console.log(' font ' + ink.font);
89console.log(' line box top ' + ink.lineBox.top.toFixed(2) + ', height ' + ink.lineBox.height.toFixed(2));
90console.log(' font ascent/descent ' + ink.fontMetrics.ascent.toFixed(2) + ' / ' + ink.fontMetrics.descent.toFixed(2));
91console.log(' ink ascent/descent ' + ink.inkAscent.toFixed(2) + ' / ' + ink.inkDescent.toFixed(2));
92console.log(' ink top..bottom ' + ink.inkTop.toFixed(2) + ' .. ' + ink.inkBottom.toFixed(2)
93 + ' (centre ' + ink.inkMid.toFixed(2) + ')');
94console.log(' dot top..bottom ' + ink.dot.top.toFixed(2) + ' .. ' + (ink.dot.top + ink.dot.height).toFixed(2)
95 + ' (centre ' + ink.dot.mid.toFixed(2) + ')');
96console.log(' applied nudge ' + ink.applied);
97console.log(' dot centre − ink centre = ' + ink.delta.toFixed(2) + 'px'
98 + (ink.delta > 0 ? ' (dot sits low)' : ink.delta < 0 ? ' (dot sits high)' : ''));
99const 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.
101console.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.
111const 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});
131console.log('\n the ink of each label, relative to the baseline:');
132const mids = [];
133for (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}
137const spread = Math.max(...mids) - Math.min(...mids);
138console.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.
141console.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.
154const baseline = ink.inkBottom; // no descender in "Guarded"
155const target = baseline + (Math.max(...mids) + Math.min(...mids)) / 2;
156const offBand = ink.dot.mid - target;
157console.log('\n midpoint of the labels ' + target.toFixed(2)
158 + ' dot centre ' + ink.dot.mid.toFixed(2)
159 + ' off by ' + offBand.toFixed(2) + 'px');
160console.log(' residual per label: ' + Object.keys(perLabel).filter(k => k !== '__xheight')
161 .map((w, i) => `${w} ${(target - (baseline + mids[i])).toFixed(2)}`).join(', '));
162check('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
168const chipPng = await p.locator('#hand-mode-chip').screenshot();
169const 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'));
182await p.locator('#__crop8').screenshot({ path: path.join(SHOTS, 'permchip-8x.png') });
183await p.evaluate(() => { const c = document.getElementById('__crop8'); if (c) c.remove(); });
184console.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
188const 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});
220for (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
227await s.close();
228console.log(`\n${ok.length} passed, ${bad.length} failed`);
229if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }