Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_sweep_mobile.mjs

61.6 KiB, 1 run

created by r2519314175:709, 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_sweep_mobile.mjs — the phone/narrow sweep.
2//
3// Eleven palettes and a second spacing ("Breathe") went in without anyone
4// holding the app at 320px with a thumb. This drives the real page at seven
5// widths, in both spacings, across every palette, through the states a phone
6// actually reaches — the chat floor, the rail drawer, the sheet at half and at
7// full, and each bottom-bar destination — and measures nine things mechanically:
8//
9// off-right nothing visible past the right edge
10// h-scroll the document never scrolls horizontally
11// off-top no drawer control sits above a drawer that cannot scroll to it
12// overlap no two controls' boxes intersect
13// squeezed no nowrap control is narrower than its own text
14// obscured no control's centre belongs to something that is not an overlay
15// shell drawer and sheet open, cover what they should, and close clean;
16// the drawer's two lists keep more than a row between them
17// target size ≥24×24 CSS px (WCAG 2.2 AA 2.5.8); <44 is reported separately
18// contrast the bottom nav, the tag chips and a link in a reply, per palette
19//
20// plus: the tag filter folds to one disclosure row at every width and spacing,
21// and env(safe-area-inset-*) is spent once and only where it is owed.
22//
23// A browser cannot render a home-indicator inset, so the safe-area pass EMULATES
24// one: the inset is injected as literal pixels into exactly the three
25// declarations that name env(), which is what the device resolves them to, and
26// the geometry is measured again.
27//
28// The context is built here rather than through harness.open() because a phone
29// is `pointer: coarse` and `hover: none`, and responsive.css has a whole block
30// keyed on that — testing it from a mouse context tests the wrong stylesheet.
31//
32// node dev/verify_sweep_mobile.mjs # the sweep
33// node dev/verify_sweep_mobile.mjs --quick # three palettes, no shots
34// node dev/verify_sweep_mobile.mjs --selftest # prove the checks can go red
35// node dev/verify_sweep_mobile.mjs --break drawer # expected to FAIL
36//
37// Needs `node dev/serve.mjs` and `node dev/mockllm.mjs` already running.
38//
39// ── WHAT MAKES THIS FILE FAIL, AND WHAT DOES NOT ────────────────────────────
40//
41// This is a REPORT GENERATOR, and the findings it collects are a backlog rather
42// than a verdict: at the time of writing it names nine controls under the WCAG
43// 2.5.8 floor, and a file that exited 1 on those would be red every run until
44// somebody resized nine buttons. Inventing a threshold on the contents of a
45// report to make it look like a test would be worse than leaving it green.
46//
47// So what it exits on is the INSTRUMENT, not the findings:
48//
49// * every width × spacing × state × palette in the matrix was probed;
50// * every state it names was actually REACHED -- the drawer really opened, the
51// sheet really came up, the bottom bar really changed destination. A state
52// that silently failed to open still produced a full set of clean
53// measurements, of the chat floor, under another state's name;
54// * the evidence files were written;
55// * nothing threw in the page while it swept (the errors were collected all
56// along and then dropped on the floor);
57// * and under `--selftest`, every check proved it can go red.
58//
59// The findings themselves are printed and written to dev/results/. If the
60// backlog is ever meant to gate, the figure to gate on is zero STRUCTURAL
61// findings (h-scroll, off-right, off-top, overlap, squeezed, obscured, shell),
62// and that is a decision for whoever owns the backlog, not for this file.
63
64import fs from 'node:fs';
65import os from 'node:os';
66import path from 'node:path';
67import { fileURLToPath, pathToFileURL } from 'node:url';
68import { APP, CHROME, signInAs, connectMock, scratch } from './harness.mjs';
69
70const PW = process.env.DAIMOND_PW
71 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
72const { chromium } = await import(pathToFileURL(PW).href);
73
74const HERE = path.dirname(fileURLToPath(import.meta.url));
75const SHOTS = path.join(HERE, 'shots', 'sweepm');
76const REPORT = path.join(HERE, 'sweep_mobile_report.md');
77
78const QUICK = process.argv.includes('--quick');
79const SELFTEST = process.argv.includes('--selftest');
80const sleep = ms => new Promise(r => setTimeout(r, ms));
81
82const ok = [], bad = [];
83const check = (name, pass, detail) => {
84 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
85 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
86};
87
88// `drawer` makes setState stop opening the drawer, which is what a moved button
89// or a renamed method looks like from here: every measurement still arrives, and
90// every one of them is of the chat floor under the drawer's name.
91const BREAK = (() => {
92 const i = process.argv.indexOf('--break');
93 return i > 0 ? String(process.argv[i + 1] || '') : '';
94})();
95if (BREAK && BREAK !== 'drawer') {
96 console.error(`unknown break '${BREAK}'; known: drawer`);
97 process.exit(2);
98}
99
100// ── The matrix ───────────────────────────────────────────────────────────────
101// 760px is the one structural cliff (responsive.css:11, mobile.css:24), so the
102// pair either side of it is in the list: layout bugs live exactly there.
103const BREAKPOINT = 760;
104const SIZES = [
105 { w: 320, h: 568, tag: '320x568', note: 'smallest phone still in use (iPhone SE)' },
106 { w: 390, h: 844, tag: '390x844', note: 'iPhone 12/13/14' },
107 { w: 414, h: 896, tag: '414x896', note: 'iPhone 11 / XR' },
108 { w: 740, h: 360, tag: '740x360', note: 'phone landscape (exercises the 560+landscape rule)' },
109 { w: BREAKPOINT - 1, h: 1024, tag: '759x1024', note: 'breakpoint − 1 (last phone width)' },
110 { w: BREAKPOINT + 1, h: 1024, tag: '761x1024', note: 'breakpoint + 1 (first desktop width)' },
111 { w: 768, h: 1024, tag: '768x1024', note: 'tablet portrait — desktop layout, coarse pointer' },
112];
113const PALETTES = ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum'];
114const SAMPLE = ['light', 'dark', 'lollypop']; // one light, one dark, one loud
115const SKINS = [['sharp', 'Compact'], ['warm', 'Breathe']];
116// The states each side of the breakpoint, named once: the sweep walks these and
117// the coverage check counts them, and two lists would drift into a false red.
118const PHONE_STATES = ['chat', 'drawer-folded', 'drawer', 'sheet-half', 'sheet-full', 'files', 'mail', 'agents'];
119const WIDE_STATES = ['desktop'];
120
121/// Which palettes a width is swept in: the two smallest get every one, the rest
122/// get the sample. The colour work is the expensive half of a probe and does not
123/// change with the width once the layout has been measured at 320 and 390.
124const palsFor = (size) => (size.w === 390 || size.w === 320) ? palettes : SAMPLE;
125
126// ── Findings ────────────────────────────────────────────────────────────────
127// One row per (check, selector, width, skin, state); the palettes that showed
128// it are collected, so "every palette" and "only lollypop" read differently.
129const found = new Map();
130function report(o) {
131 const key = [o.check, o.sel, o.width, o.skin, o.state].join('|');
132 const e = found.get(key) || { ...o, palettes: new Set(), n: 0 };
133 e.palettes.add(o.palette);
134 e.n++;
135 if (o.detail) { e.detail = o.detail; (e.byPalette = e.byPalette || {})[o.palette] = o.detail; }
136 found.set(key, e);
137}
138let probes = 0;
139
140// ── The in-page probe ───────────────────────────────────────────────────────
141// Everything below runs in the page. It is one function so a state costs one
142// round trip, and it takes its own geometry rather than trusting a stylesheet.
143function PROBE(arg) {
144 const W = window.innerWidth, H = window.innerHeight;
145
146 const nm = (el) => {
147 if (!el || !el.tagName) return String(el);
148 let s = el.tagName.toLowerCase();
149 if (el.id) s += '#' + el.id;
150 const c = (typeof el.className === 'string' ? el.className : '').trim();
151 if (c) s += '.' + c.split(/\s+/).slice(0, 3).join('.');
152 return s.slice(0, 90);
153 };
154 // getComputedStyle is the cost of this whole probe: the off-screen scan reads
155 // every element and clipRect reads its whole ancestry, so an uncached call
156 // per node per lookup put a single state at several seconds. One read per
157 // element per probe.
158 const _cs = new WeakMap();
159 const cs = (el) => { let v = _cs.get(el); if (!v) { v = getComputedStyle(el); _cs.set(el, v); } return v; };
160 const _rect = new WeakMap();
161 const rect = (el) => { let v = _rect.get(el); if (!v) { v = el.getBoundingClientRect(); _rect.set(el, v); } return v; };
162 const shown = (el) => {
163 const s = cs(el);
164 return !(s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0);
165 };
166 /// The rect a user can actually reach: the element's box, cut down by every
167 /// ancestor that clips. A 900px <pre> inside an `overflow-x: auto` box is
168 /// not off-screen; a 900px toolbar in a visible box is.
169 // The clip an element inherits from its ancestry, memoised down the tree so a
170 // deep DOM is one pass rather than one walk per node.
171 const _clip = new WeakMap();
172 const ancestorClip = (el) => {
173 let v = _clip.get(el);
174 if (v) return v;
175 const p = el.parentElement;
176 const up = p ? ancestorClip(p) : { x1: -Infinity, y1: -Infinity, x2: Infinity, y2: Infinity };
177 v = up;
178 if (p) {
179 const s = cs(p);
180 const clipsX = s.overflowX !== 'visible', clipsY = s.overflowY !== 'visible';
181 if (clipsX || clipsY) {
182 const pr = rect(p);
183 v = { x1: clipsX ? Math.max(up.x1, pr.left) : up.x1,
184 y1: clipsY ? Math.max(up.y1, pr.top) : up.y1,
185 x2: clipsX ? Math.min(up.x2, pr.right) : up.x2,
186 y2: clipsY ? Math.min(up.y2, pr.bottom) : up.y2 };
187 }
188 }
189 _clip.set(el, v);
190 return v;
191 };
192 const clipRect = (el) => {
193 const r = rect(el);
194 // A fixed element escapes every ancestor's overflow (barring a transformed
195 // containing block, which nothing here has), so the walk must not clip it —
196 // the drawer and the sheet are both fixed inside an `overflow: hidden` #app.
197 const c = cs(el).position === 'fixed'
198 ? { x1: -Infinity, y1: -Infinity, x2: Infinity, y2: Infinity }
199 : ancestorClip(el);
200 const x1 = Math.max(r.left, c.x1), y1 = Math.max(r.top, c.y1);
201 const x2 = Math.min(r.right, c.x2), y2 = Math.min(r.bottom, c.y2);
202 return { left: x1, top: y1, right: x2, bottom: y2, width: x2 - x1, height: y2 - y1 };
203 };
204 const onScreen = (r) => r.width > 0.5 && r.height > 0.5
205 && r.right > 0 && r.left < W && r.bottom > 0 && r.top < H;
206
207 // ── 1. Off the right edge ───────────────────────────────────
208 const offscreen = [];
209 document.querySelectorAll('*').forEach(el => {
210 if (!shown(el)) return;
211 // If an ancestor already clips this subtree to within the screen, nothing
212 // inside it can reach past the edge — and #app is `overflow: hidden` at
213 // exactly the viewport width, so this retires almost the whole document
214 // without ever measuring it.
215 const fixed = cs(el).position === 'fixed';
216 if (!fixed && ancestorClip(el).x2 <= W + 1) return;
217 const r = clipRect(el);
218 if (r.width <= 0.5 || r.height <= 0.5) return;
219 if (r.bottom <= 0 || r.top >= H) return;
220 if (r.right > W + 1) offscreen.push({ sel: nm(el), left: Math.round(r.left), right: Math.round(r.right) });
221 });
222 // One row per distinct selector; the report groups them anyway, and a burst
223 // component drags forty descendants over the edge with it.
224 const seenOff = new Set();
225 const trimmed = offscreen.filter(o => !seenOff.has(o.sel) && seenOff.add(o.sel));
226
227 // ── 2. Touch targets ────────────────────────────────────────
228 const SEL = 'button, a[href], input:not([type=hidden]), select, textarea,'
229 + '[role="button"], [role="tab"], [role="switch"], [role="checkbox"], summary,'
230 + 'label[for], [tabindex]:not([tabindex="-1"])';
231 const targets = [];
232 document.querySelectorAll(SEL).forEach(el => {
233 if (el.disabled) return;
234 if (el.getAttribute('aria-hidden') === 'true') return;
235 if (!shown(el)) return;
236 if (cs(el).pointerEvents === 'none') return;
237 const c = clipRect(el);
238 if (!onScreen(c)) return;
239 // The TARGET is the control's own box. A list row half-scrolled out of a
240 // scroller measures 4px tall through the clip, and reporting that as an
241 // unreachable target is measuring the scroll position, not the design.
242 const r = rect(el);
243 targets.push({
244 sel: nm(el),
245 w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10,
246 txt: (el.textContent || '').trim().slice(0, 24),
247 });
248 });
249
250 // ── 4. Obscured ─────────────────────────────────────────────
251 // A control whose own centre belongs to something else is unreachable.
252 // An overlay that is SUPPOSED to be over the page (the scrim, the drawer,
253 // the sheet) is named by the caller and does not count.
254 const allow = arg.overlays || [];
255 const obscured = [];
256 document.querySelectorAll(SEL).forEach(el => {
257 if (el.disabled || !shown(el)) return;
258 const r = clipRect(el);
259 if (!onScreen(r)) return;
260 const cx = Math.min(W - 1, Math.max(1, r.left + r.width / 2));
261 const cy = Math.min(H - 1, Math.max(1, r.top + r.height / 2));
262 const hit = document.elementFromPoint(cx, cy);
263 if (!hit) return;
264 if (hit === el || el.contains(hit) || hit.contains(el)) return;
265 const byOverlay = allow.some(o => {
266 const root = document.querySelector(o);
267 return root && root.contains(hit) && !root.contains(el);
268 });
269 if (byOverlay) return;
270 obscured.push({ sel: nm(el), by: nm(hit) });
271 });
272
273 // ── 4b. Controls overlapping each other, and controls squeezed
274 // narrower than their own text ─────────────────────────
275 // A flex row of chips with no `flex: none` does not clip when it runs out of
276 // room — it SHRINKS the chips below their content, and the text then spills
277 // across the neighbour. Both halves of that are measured: the boxes that
278 // intersect, and the boxes whose content no longer fits them.
279 const ctrls = [];
280 document.querySelectorAll(SEL).forEach(el => {
281 if (el.disabled || !shown(el)) return;
282 if (cs(el).pointerEvents === 'none') return;
283 const r = clipRect(el);
284 if (!onScreen(r)) return;
285 ctrls.push({ el, r });
286 });
287 const overlayRoots = (arg.overlays || []).map(o => document.querySelector(o)).filter(Boolean);
288 const inOverlay = (el) => overlayRoots.some(o => o.contains(el));
289 const overlap = [], squeezed = [];
290 // A palette cannot move a box, so the O(n²) pass runs once per state rather
291 // than once per palette — otherwise eleven palettes cost eleven identical
292 // sweeps of every pair of controls on screen.
293 for (let i = 0; arg.deep && i < ctrls.length; i++) {
294 const a = ctrls[i];
295 // A nowrap control narrower than its own text has been shrunk past its
296 // content; the text is outside the box the user can see and click.
297 const st = cs(a.el);
298 if (st.whiteSpace === 'nowrap' && st.overflowX === 'visible'
299 && a.el.scrollWidth > a.el.clientWidth + 1 && a.el.clientWidth > 0) {
300 squeezed.push({ sel: nm(a.el), box: Math.round(a.el.clientWidth), content: Math.round(a.el.scrollWidth),
301 txt: (a.el.textContent || '').trim().slice(0, 20) });
302 }
303 for (let j = i + 1; j < ctrls.length; j++) {
304 const b = ctrls[j];
305 if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
306 if (inOverlay(a.el) !== inOverlay(b.el)) continue; // an overlay is meant to be over things
307 const ox = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
308 const oy = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
309 if (ox > 2 && oy > 2) overlap.push({ a: nm(a.el), b: nm(b.el), ox: Math.round(ox), oy: Math.round(oy) });
310 }
311 }
312
313 // ── 3b. The notch strip ─────────────────────────────────────
314 // `viewport-fit=cover` puts the page UNDER the notch, so in landscape the
315 // leftmost (or rightmost) ~44px belong to the hardware unless something
316 // spends safe-area-inset-left/right. These are the controls that would sit
317 // under it.
318 const NOTCH = 44;
319 const inNotch = [];
320 if (W > H) {
321 document.querySelectorAll(SEL).forEach(el => {
322 if (el.disabled || !shown(el)) return;
323 const r = clipRect(el);
324 if (!onScreen(r)) return;
325 if (r.left < NOTCH) inNotch.push({ sel: nm(el), left: Math.round(r.left), side: 'left' });
326 if (r.right > W - NOTCH) inNotch.push({ sel: nm(el), left: Math.round(r.left), side: 'right' });
327 });
328 }
329
330 // ── 5. The tag filter ───────────────────────────────────────
331 const filt = document.getElementById('diamond-filter');
332 const tog = filt && filt.querySelector('.tagf-toggle');
333 const pool = filt && filt.querySelector('.tagf-pool');
334 const tagf = (!filt || !shown(filt)) ? null : {
335 present: true,
336 hasToggle: !!tog,
337 expanded: tog ? tog.getAttribute('aria-expanded') === 'true' : null,
338 toggleH: tog ? Math.round(tog.getBoundingClientRect().height) : null,
339 filtH: Math.round(filt.getBoundingClientRect().height),
340 lineH: tog ? parseFloat(cs(tog).lineHeight) || 0 : 0,
341 poolH: pool ? Math.round(pool.getBoundingClientRect().height) : null,
342 poolCapPx: pool ? Math.round(parseFloat(cs(pool).maxHeight)) : null,
343 chips: pool ? [...pool.querySelectorAll('.tag-chip')].map(c => {
344 const b = c.getBoundingClientRect();
345 return { w: Math.round(b.width * 10) / 10, h: Math.round(b.height * 10) / 10 };
346 }) : [],
347 };
348
349 // ── 6. Bar geometry, and what mobile.js believes about it ───
350 const rectOf = (sel) => {
351 const el = typeof sel === 'string' ? document.querySelector(sel) : sel;
352 if (!el || !shown(el)) return null;
353 const b = el.getBoundingClientRect();
354 return { t: Math.round(b.top), b: Math.round(b.bottom), l: Math.round(b.left),
355 r: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height) };
356 };
357 const geo = {
358 W, H,
359 phone: window.matchMedia('(max-width: ' + arg.breakpoint + 'px)').matches,
360 coarse: window.matchMedia('(pointer: coarse)').matches,
361 docSW: document.documentElement.scrollWidth,
362 bodySW: document.body.scrollWidth,
363 topbar: rectOf('.topbar'),
364 mnav: rectOf('#mnav'),
365 // The bar's buttons are the panel chips since 2026-08-28; `#mnav button`
366 // still names them, which is why the target-size and contrast checks below
367 // needed no change when the four destinations went.
368 mnavBtns: [...document.querySelectorAll('#mnav button')].filter(b => shown(b)).map(b => {
369 const r = b.getBoundingClientRect();
370 return { t: (b.textContent || '').trim(), w: Math.round(r.width), h: Math.round(r.height) };
371 }),
372 sheet: rectOf('#msheet'),
373 sheetOpen: !!(window.DaimondSheet && DaimondSheet.isOpen()),
374 sheetGuest: window.DaimondSheet ? DaimondSheet.guest() : null,
375 ask: rectOf('#msheet-ask'),
376 askPadBottom: (() => { const e = document.getElementById('msheet-ask'); return e ? cs(e).paddingBottom : null; })(),
377 grab: rectOf('#msheet-grab'),
378 rail: rectOf('#panel-rail'),
379 railOpaque: (() => {
380 const e = document.getElementById('panel-rail'); if (!e) return null;
381 const bg = cs(e).backgroundColor;
382 const m = bg.match(/rgba?\(([^)]+)\)/); if (!m) return null;
383 const p = m[1].split(',').map(x => parseFloat(x));
384 return p.length < 4 || p[3] >= 0.999;
385 })(),
386 scrim: (() => {
387 const e = document.getElementById('scrim'); if (!e) return null;
388 const s = cs(e); const b = e.getBoundingClientRect();
389 return { pe: s.pointerEvents, op: +s.opacity, bg: s.backgroundColor,
390 covers: b.left <= 0 && b.top <= 0 && b.right >= W && b.bottom >= H };
391 })(),
392 drawerOpen: document.body.classList.contains('drawer-open'),
393 // A drawer taller than the screen scrolls; a drawer whose content sits ABOVE
394 // its own top edge while it is scrolled to the top does not. Those controls
395 // cannot be reached at all.
396 railClipped: (() => {
397 const rl = document.getElementById('panel-rail');
398 if (!rl || !document.body.classList.contains('drawer-open')) return null;
399 const rr = rect(rl);
400 const out = [];
401 rl.querySelectorAll('button, input, a[href], select, textarea').forEach(el => {
402 if (!shown(el)) return;
403 const r = rect(el);
404 if (r.height > 0 && r.bottom <= rr.top + 1) out.push(nm(el));
405 else if (r.height > 0 && r.top < rr.top - 1) out.push(nm(el) + ' (part)');
406 });
407 return { scrollTop: Math.round(rl.scrollTop), scrollH: Math.round(rl.scrollHeight),
408 clientH: Math.round(rl.clientHeight), above: out.slice(0, 12) };
409 })(),
410 // What the drawer's two lists are left with once the search box, the tag
411 // filter and the admin block have taken their cut.
412 lists: (() => {
413 const g = (id) => { const e = document.getElementById(id); return e ? Math.round(rect(e).height) : null; };
414 return { diamonds: g('diamond-list'), chats: g('session-list') };
415 })(),
416 mpanel: document.body.dataset.mpanel,
417 modalUp: [...document.querySelectorAll('.modal, .dlg')]
418 .filter(e => cs(e).display !== 'none' && e.getBoundingClientRect().width > 0)
419 .map(e => nm(e)),
420 };
421
422 // ── Colour: the palette-sensitive bits ──────────────────────
423 const parse = (c) => { const m = c.match(/[\d.]+/g); return m ? m.map(Number) : [0, 0, 0, 1]; };
424 const lum = (c) => {
425 const [r, g, b] = parse(c).slice(0, 3).map(v => {
426 v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
427 });
428 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
429 };
430 const bgOf = (el) => {
431 for (let p = el; p; p = p.parentElement) {
432 const c = cs(p).backgroundColor, a = parse(c)[3];
433 if (a === undefined || a >= 0.999) return c;
434 }
435 return cs(document.body).backgroundColor;
436 };
437 const ratio = (fg, bg) => {
438 const a = lum(fg), b = lum(bg);
439 return +(((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2));
440 };
441 const contrast = [];
442 const measure = (el, what) => {
443 if (!el || !shown(el)) return;
444 const r = clipRect(el); if (!onScreen(r)) return;
445 const st = cs(el);
446 contrast.push({ what, sel: nm(el), ratio: ratio(st.color, bgOf(el)),
447 size: Math.round(parseFloat(st.fontSize) * 10) / 10,
448 weight: st.fontWeight, fg: st.color, bg: bgOf(el) });
449 };
450 document.querySelectorAll('#mnav button').forEach(b => {
451 const lbl = b.querySelector('span') || b;
452 measure(lbl, b.classList.contains('on') ? 'bottom nav (active)' : 'bottom nav (idle)');
453 });
454 if (pool) [...pool.querySelectorAll('.tag-chip')].slice(0, 4).forEach(c => measure(c, 'tag chip in the pool'));
455 measure(document.querySelector('.tagf-toggle'), 'tag filter disclosure row');
456 // A model's reply is full of links, and nothing in the app styles one — they
457 // arrive as the browser's own #0000EE, which no palette knows about.
458 // The FIRST link in a long transcript is scrolled far above the fold, and
459 // measure() skips anything off screen — take the first one actually in view.
460 measure([...document.querySelectorAll('#chat-output .chat-msg-content a')]
461 .find(a => { const r = clipRect(a); return onScreen(r); }), 'link in a chat message');
462 measure(document.querySelector('#chat-output .chat-msg-assistant .chat-msg-content'), 'assistant message body');
463 measure(document.querySelector('#msheet-title'), 'sheet title');
464 measure(document.querySelector('.rail-tag-hint'), 'rail tag hint');
465
466 return { offscreen: trimmed, targets, obscured, overlap, squeezed, tagf, geo, contrast, inNotch };
467}
468
469// ── Driving ─────────────────────────────────────────────────────────────────
470async function boot() {
471 const dir = scratch('pw', 'sweepm');
472 fs.mkdirSync(dir, { recursive: true });
473 const browser = await chromium.launchPersistentContext(dir, {
474 executablePath: CHROME,
475 headless: false,
476 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'],
477 viewport: { width: 390, height: 844 },
478 // A phone is coarse and cannot hover. responsive.css keys a whole block
479 // on that, so without it the sweep reads a stylesheet no phone loads.
480 hasTouch: true,
481 deviceScaleFactor: 1,
482 });
483 const page = browser.pages()[0] || await browser.newPage();
484 const errs = [];
485 page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); });
486 page.on('pageerror', e => errs.push('pageerror: ' + e.message));
487 await page.goto(APP, { waitUntil: 'domcontentloaded' });
488 const s = { browser, page, errs, name: 'sweepm' };
489 await page.setViewportSize({ width: 1400, height: 900 }); // seed on a desktop
490 await signInAs(s, 'sweepm');
491 await connectMock(s);
492 await page.evaluate(() => { try { DaimondAdmin.closeModal(); } catch (e) {} });
493 // Headless does not advance transitions reliably; a mid-flight measurement is
494 // a measurement of nothing.
495 await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
496 await sleep(400);
497 return s;
498}
499
500/// Give the rail something to be: Diamonds, a vocabulary of tags, and a chat
501/// whose content is hostile to a narrow column.
502async function seed(s) {
503 const { page } = s;
504 const have = await page.$$eval('.diamond-box', els => els.length);
505 if (have < 3) {
506 for (const n of ['Ship a CSV parser', 'Mum birthday plan and the long tail of it', 'Rust compiler notes']) {
507 await page.click('#new-diamond-btn', { force: true });
508 await page.waitForSelector('.dlg-input', { timeout: 10000 });
509 await page.fill('.dlg-input', n);
510 await page.click('.dlg-ok', { force: true });
511 await page.waitForTimeout(600);
512 }
513 }
514 // Tags straight through the wasm: the editor is nine clicks a tag and this
515 // sweep is not testing the editor.
516 await page.evaluate(async () => {
517 const m = await import('/pkg/oxedyne_daimond.js');
518 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
519 const rows = JSON.parse(await app.list_diamonds());
520 const bank = ['person', 'work', 'idea', 'rust', 'admin', 'errands', 'reading',
521 '2026', 'urgent', 'someday', 'money', 'travel', 'a-rather-long-tag-name'];
522 for (let i = 0; i < rows.length; i++) {
523 const tags = bank.filter((_, j) => (j + i) % 3 !== 2).slice(0, 9);
524 await app.set_tags(rows[i].id, JSON.stringify(tags));
525 }
526 });
527 // The rail paints its tag pool at load, so a reload is the honest way to see
528 // the tags land. The identity is held in memory only, so the reload drops
529 // back to the passphrase gate and has to be answered again.
530 await page.reload({ waitUntil: 'domcontentloaded' });
531 await signInAs(s, 'sweepm');
532 await sleep(600);
533 await page.evaluate(() => { try { DaimondAdmin.closeModal(); } catch (e) {} });
534 await page.waitForSelector('#diamond-list', { timeout: 15000 });
535 await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
536 await sleep(600);
537
538 // A chat with a long unbroken token in it — the thing that bursts a column.
539 // Best effort: a chat gives the floor something to be, but a sweep of the
540 // SHELL is still worth running on an empty one.
541 try {
542 if (!(await page.isVisible('#chat-input'))) {
543 // The Admin drawer opens over the rail on a fresh profile and swallows
544 // the + button's click (see harness.newChat).
545 const dc = page.locator('#admin-close');
546 if (await dc.isVisible().catch(() => false)) { await dc.click({ force: true }); await sleep(200); }
547 await page.click('#new-session-btn', { force: true });
548 await sleep(700);
549 const start = page.locator('.tile-start').first();
550 if (await start.count()) { await start.click({ force: true }); await sleep(700); }
551 await page.waitForSelector('#chat-input', { state: 'visible', timeout: 8000 });
552 }
553 await page.fill('#chat-input', 'read https://example.com/a/very/long/unbroken/path/that/no/narrow/column/can/possibly/wrap/nicely?q=aaaaaaaaaaaaaaaaaaaaaaaaaaaa');
554 await page.click('#chat-send', { force: true });
555 await sleep(2500);
556 } catch (e) {
557 console.log(' (no chat seeded: ' + e.message.split('\n')[0] + ')');
558 }
559 // Agents is hidden until first reached; the bottom bar's fourth slot only
560 // exists once it is, so reveal it or the bar is never tested full.
561 await page.evaluate(() => {
562 try { localStorage.setItem('daimond-agents-revealed', '1'); } catch (e) {}
563 document.body.classList.remove('agents-hidden');
564 });
565}
566
567/// Put the app into a named state and return the overlays that are meant to be
568/// covering things there.
569/// Put the page back to bare: no modal, no dialog, no sheet, no drawer.
570///
571/// On a phone the Admin drawer is hosted in `#settings-modal`, which is a
572/// full-screen overlay — leave one standing and every later measurement is a
573/// measurement of the overlay.
574async function clearOverlays(page) {
575 return page.evaluate(() => {
576 const up = () => [...document.querySelectorAll('.modal, .dlg')]
577 .filter(e => getComputedStyle(e).display !== 'none' && e.getBoundingClientRect().width > 0);
578 const was = up().map(e => e.id || e.className);
579 for (let i = 0; i < 4 && up().length; i++) {
580 try { if (window.DaimondAdmin && DaimondAdmin.closeModal) DaimondAdmin.closeModal(); } catch (e) {}
581 up().forEach(e => {
582 const x = e.querySelector('.admin-back, .modal-close, .dlg-cancel, .dlg-ok');
583 if (x) x.click(); else e.style.display = 'none';
584 });
585 }
586 return was;
587 });
588}
589
590async function setState(page, state) {
591 await clearOverlays(page);
592 await page.evaluate(() => {
593 try { if (window.DaimondSheet && DaimondSheet.isOpen()) DaimondSheet.close(); } catch (e) {}
594 try { if (window.DaimondShell) DaimondShell.closeDrawer(); } catch (e) {}
595 });
596 await sleep(150);
597 switch (state) {
598 case 'chat':
599 await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="ai"]'); if (b) b.click(); });
600 break;
601 case 'drawer':
602 if (BREAK !== 'drawer') {
603 await page.evaluate(() => { const b = document.getElementById('drawer-btn'); if (b) b.click(); else DaimondShell.openDrawer(); });
604 }
605 await sleep(250);
606 // Open the tag pool, which is where the small controls live.
607 await page.evaluate(() => {
608 const t = document.querySelector('#diamond-filter .tagf-toggle');
609 if (t && t.getAttribute('aria-expanded') !== 'true') t.click();
610 });
611 break;
612 case 'drawer-folded':
613 await page.evaluate(() => {
614 const b = document.getElementById('drawer-btn'); if (b) b.click(); else DaimondShell.openDrawer();
615 });
616 await sleep(250);
617 await page.evaluate(() => {
618 const t = document.querySelector('#diamond-filter .tagf-toggle');
619 if (t && t.getAttribute('aria-expanded') === 'true') t.click();
620 });
621 break;
622 case 'sheet-half':
623 await page.evaluate(() => { DaimondPanels.hide('web'); DaimondPanels.show('web'); });
624 break;
625 case 'sheet-full':
626 await page.evaluate(() => { DaimondPanels.hide('tools'); DaimondPanels.show('tools'); });
627 break;
628 case 'files':
629 await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="work"]'); if (b) b.click(); });
630 break;
631 case 'mail':
632 await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="mail"]'); if (b) b.click(); });
633 break;
634 case 'agents':
635 await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="agents"]'); if (b) b.click(); });
636 break;
637 case 'desktop':
638 break;
639 }
640 await sleep(420);
641 const overlays = { drawer: ['#scrim', '#panel-rail'], 'drawer-folded': ['#scrim', '#panel-rail'],
642 'sheet-half': ['#msheet'], 'sheet-full': ['#msheet'] }[state] || [];
643 return overlays;
644}
645
646// ── Judging one probe ───────────────────────────────────────────────────────
647// The floors. 24 is the WCAG 2.2 AA minimum (2.5.8 Target Size, Minimum); 44
648// is the size Apple and the AAA level ask for and is reported separately.
649const MIN = 24, WANT = 44;
650// Controls whose size is set by the reading scale and which sit in a row of
651// their own are still worth knowing about but are not "unreachable".
652function judge(r, ctx) {
653 const { width, palette, skin, state } = ctx;
654
655 // 1. off-screen / horizontal scroll
656 if (r.geo.docSW > r.geo.W + 1) {
657 report({ check: 'h-scroll', sel: 'document', width, palette, skin, state,
658 detail: `scrollWidth ${r.geo.docSW} > innerWidth ${r.geo.W}` });
659 }
660 for (const o of r.offscreen) {
661 report({ check: 'off-right', sel: o.sel, width, palette, skin, state,
662 detail: `right edge at ${o.right}px, viewport is ${r.geo.W}px` });
663 }
664
665 // 2. touch targets
666 const seen = new Map();
667 for (const t of r.targets) {
668 const prev = seen.get(t.sel);
669 if (!prev || t.w * t.h < prev.w * prev.h) seen.set(t.sel, t);
670 }
671 for (const t of seen.values()) {
672 if (t.w < MIN || t.h < MIN) {
673 report({ check: 'target<24', sel: t.sel, width, palette, skin, state,
674 detail: `${t.w}×${t.h} px${t.txt ? ' ("' + t.txt + '")' : ''}` });
675 } else if (t.w < WANT || t.h < WANT) {
676 report({ check: 'target<44', sel: t.sel, width, palette, skin, state,
677 detail: `${t.w}×${t.h} px` });
678 }
679 }
680
681 // 4. obscured. A standing modal covers everything by design, so it is
682 // reported once rather than as forty unreachable controls.
683 if (r.geo.modalUp && r.geo.modalUp.length) {
684 report({ check: 'overlay', sel: r.geo.modalUp[0], width, palette, skin, state,
685 detail: 'a modal was standing during this state — obscured check skipped' });
686 } else {
687 for (const o of r.obscured) {
688 report({ check: 'obscured', sel: o.sel, width, palette, skin, state,
689 detail: `centre belongs to ${o.by}` });
690 }
691 }
692
693 // 4b. controls on top of each other, and controls squeezed past their text
694 for (const o of (r.overlap || [])) {
695 report({ check: 'overlap', sel: `${o.a} × ${o.b}`, width, palette, skin, state,
696 detail: `their boxes intersect by ${o.ox}×${o.oy} px` });
697 }
698 for (const q of (r.squeezed || [])) {
699 report({ check: 'squeezed', sel: q.sel, width, palette, skin, state,
700 detail: `box ${q.box}px, its own text needs ${q.content}px${q.txt ? ' ("' + q.txt + '")' : ''}` });
701 }
702
703 // 5. the tag filter
704 if (r.tagf && r.tagf.present) {
705 if (!r.tagf.hasToggle) {
706 report({ check: 'tagfilter', sel: '#diamond-filter', width, palette, skin, state,
707 detail: 'no disclosure row — the pool is standing' });
708 } else if (state === 'drawer-folded') {
709 // Folded, the whole filter must be ONE row.
710 const oneRow = Math.max(r.tagf.lineH * 2, 34);
711 if (r.tagf.filtH > oneRow) {
712 report({ check: 'tagfilter', sel: '#diamond-filter', width, palette, skin, state,
713 detail: `folded height ${r.tagf.filtH}px, one row is ≤${Math.round(oneRow)}px` });
714 }
715 } else if (state === 'drawer') {
716 if (r.tagf.poolH === null) {
717 report({ check: 'tagfilter', sel: '.tagf-pool', width, palette, skin, state,
718 detail: 'disclosure open but no pool drawn' });
719 } else if (r.tagf.poolCapPx && r.tagf.poolH > r.tagf.poolCapPx + 2) {
720 report({ check: 'tagfilter', sel: '.tagf-pool', width, palette, skin, state,
721 detail: `pool ${r.tagf.poolH}px over its ${r.tagf.poolCapPx}px cap` });
722 }
723 }
724 }
725
726 // 3/6. the shells
727 const g = r.geo;
728 if (g.phone) {
729 for (const b of (g.mnavBtns || [])) {
730 if (b.h < MIN || b.w < MIN) {
731 report({ check: 'target<24', sel: `#mnav button ("${b.t}")`, width, palette, skin, state,
732 detail: `${b.w}×${b.h} px` });
733 }
734 }
735 if (!g.mnav || g.mnav.h < 1) {
736 report({ check: 'shell', sel: '#mnav', width, palette, skin, state, detail: 'bottom bar not drawn on a phone width' });
737 }
738 if (state === 'drawer' || state === 'drawer-folded') {
739 if (!g.drawerOpen) report({ check: 'shell', sel: 'body.drawer-open', width, palette, skin, state, detail: 'the hamburger did not open the drawer' });
740 if (g.rail && g.rail.l < -1) report({ check: 'shell', sel: '#panel-rail', width, palette, skin, state, detail: `drawer left edge at ${g.rail.l}px — it did not slide in` });
741 if (g.rail && g.rail.r > g.W + 1) report({ check: 'shell', sel: '#panel-rail', width, palette, skin, state, detail: `drawer runs to ${g.rail.r}px past a ${g.W}px screen` });
742 if (g.railOpaque === false) report({ check: 'shell', sel: '#panel-rail', width, palette, skin, state, detail: 'drawer background is not opaque — the page shows through' });
743 const rc = g.railClipped;
744 if (rc && rc.above.length && rc.scrollTop === 0) {
745 report({ check: 'off-top', sel: '#panel-rail', width, palette, skin, state,
746 detail: `scrolled to the top, ${rc.above.length} control(s) sit above the drawer's own top edge `
747 + `and cannot be reached: ${rc.above.join(', ')} (scrollHeight ${rc.scrollH}, clientHeight ${rc.clientH})` });
748 }
749 // A drawer whose lists have been squeezed out by their own furniture is
750 // a drawer that shows the filing system and none of the files.
751 for (const [what, h] of Object.entries(g.lists || {})) {
752 if (h !== null && h < 60) {
753 report({ check: 'shell', sel: `#${what === 'diamonds' ? 'diamond' : 'session'}-list`, width, palette, skin, state,
754 detail: `the drawer's ${what} list is ${h}px tall — under two rows` });
755 }
756 }
757 if (g.scrim && (g.scrim.pe !== 'auto' || !g.scrim.covers)) {
758 report({ check: 'shell', sel: '#scrim', width, palette, skin, state,
759 detail: `scrim pointer-events=${g.scrim.pe}, covers=${g.scrim.covers}` });
760 }
761 }
762 if (state.startsWith('sheet')) {
763 if (!g.sheetOpen) report({ check: 'shell', sel: '#msheet', width, palette, skin, state, detail: 'the sheet did not rise' });
764 if (g.sheet && g.mnav && g.sheet.b > g.mnav.t + 1) {
765 report({ check: 'shell', sel: '#msheet', width, palette, skin, state,
766 detail: `sheet foot at ${g.sheet.b} overlaps the bar at ${g.mnav.t}` });
767 }
768 if (g.sheet && g.topbar && g.sheet.t < g.topbar.b) {
769 report({ check: 'shell', sel: '#msheet', width, palette, skin, state,
770 detail: `sheet top at ${g.sheet.t} rides under the top bar (bottom ${g.topbar.b})` });
771 }
772 if (g.ask && g.ask.b > g.H + 1) {
773 report({ check: 'shell', sel: '#msheet-ask', width, palette, skin, state, detail: `ask pill foot at ${g.ask.b}, viewport ${g.H}` });
774 }
775 // Room left for the thing itself, between the grabber and the pill.
776 if (g.sheet && g.grab && g.ask) {
777 const body = g.ask.t - g.grab.b;
778 if (body < 60) {
779 report({ check: 'shell', sel: '#msheet-body', width, palette, skin, state,
780 detail: `only ${body}px between the grabber and the ask pill` });
781 }
782 }
783 }
784 } else {
785 if (g.mnav && g.mnav.h > 0) {
786 report({ check: 'shell', sel: '#mnav', width, palette, skin, state, detail: 'phone bottom bar still drawn above the breakpoint' });
787 }
788 }
789
790 // contrast — small text needs 4.5:1, ≥18.66px (or ≥24px) needs 3:1.
791 for (const c of r.contrast) {
792 const large = c.size >= 24 || (c.size >= 18.66 && +c.weight >= 700);
793 const need = large ? 3 : 4.5;
794 if (c.ratio < need) {
795 report({ check: 'contrast', sel: c.sel, width, palette, skin, state,
796 detail: `${c.what}: ${c.ratio}:1 at ${c.size}px, needs ${need}:1 (${c.fg} on ${c.bg})` });
797 }
798 }
799 probes++;
800}
801
802// ── Safe-area emulation ─────────────────────────────────────────────────────
803// Chromium renders env(safe-area-inset-*) as 0, so the three declarations that
804// spend it are re-stated with a literal inset — exactly what a notched phone
805// resolves them to — and the geometry is measured again.
806const INSET = 34; // iPhone portrait home-indicator inset
807const INSET_CSS = `
808@media (max-width: ${BREAKPOINT}px) {
809 .mnav { padding-bottom: calc(6px + ${INSET}px) !important; }
810 #msheet { bottom: calc(58px + ${INSET}px) !important; }
811 .msheet-ask { padding-bottom: calc(8px + ${INSET}px) !important; }
812}`;
813
814async function safeAreaPass(page, ctx) {
815 const handle = await page.addStyleTag({ content: INSET_CSS });
816 await sleep(300);
817 const before = await page.evaluate(() => {
818 const r = (s) => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect();
819 return { t: Math.round(b.top), b: Math.round(b.bottom), h: Math.round(b.height) }; };
820 return { sheet: r('#msheet'), mnav: r('#mnav'), topbar: r('.topbar'), ask: r('#msheet-ask'),
821 grab: r('#msheet-grab'), H: window.innerHeight };
822 });
823 await handle.evaluate(el => el.remove());
824 await sleep(200);
825 return before;
826}
827
828/// What the stylesheets actually spend the safe area on, read out of the CSSOM.
829///
830/// `viewport-fit=cover` is what MAKES the insets non-zero: it hands the app the
831/// whole screen including the strips the hardware owns. A page that asks for
832/// that and then only ever pays back `-bottom` is under the notch on every other
833/// side, and there is no way to see that in a browser that renders every inset
834/// as zero.
835async function staticAudit(page) {
836 return page.evaluate(() => {
837 const sides = { top: [], right: [], bottom: [], left: [] };
838 // `r.cssRules` is TRUTHY on a plain CSSStyleRule in current Chromium — every
839 // style rule is a grouping rule since CSS nesting landed, and its list is
840 // merely empty. Recursing on truthiness therefore walks past every rule in
841 // the document and finds nothing. Test the LENGTH, and read declarations off
842 // `r.style` rather than off a grouping rule's whole cssText.
843 const walk = (rules, href) => {
844 for (const r of rules) {
845 if (r.style) {
846 // r.style's INDEXED properties are longhands, and a shorthand carrying
847 // env() cannot be expanded — it is held as a pending substitution and
848 // never appears there. `padding: 6px 4px calc(6px + env(...))` is
849 // exactly that shape, so read the declaration block as text.
850 const decls = (r.style.cssText || '').split(';').map(x => x.trim()).filter(Boolean);
851 for (const d of decls) {
852 ['top', 'right', 'bottom', 'left'].forEach(side => {
853 if (d.includes('safe-area-inset-' + side)) {
854 sides[side].push(`${(href || 'inline').split('/').pop()} — ${r.selectorText || '?'} { ${d} }`);
855 }
856 });
857 }
858 }
859 if (r.cssRules && r.cssRules.length) walk(r.cssRules, href);
860 }
861 };
862 for (const sh of document.styleSheets) {
863 try { walk(sh.cssRules, sh.href); } catch (e) { /* cross-origin */ }
864 }
865 const meta = document.querySelector('meta[name="viewport"]');
866 return { sides, viewport: meta ? meta.content : null };
867 });
868}
869
870// ── The self-test ───────────────────────────────────────────────────────────
871// A check never seen red is not evidence. Each of these breaks the page in a
872// specific way and asserts the corresponding check reports it.
873async function selftest(page) {
874 const results = [];
875 let asState = 'chat', asOverlays = [];
876 const run = async (name, breakIt, fixIt, wanted) => {
877 found.clear();
878 await breakIt();
879 await sleep(250);
880 const r = await page.evaluate(PROBE, { overlays: asOverlays, breakpoint: BREAKPOINT, deep: true });
881 judge(r, { width: 'selftest', palette: 'dark', skin: 'sharp', state: asState });
882 const hits = [...found.values()].filter(f => f.check === wanted.check
883 && (!wanted.sel || f.sel.includes(wanted.sel))
884 && (!wanted.detail || (f.detail || '').includes(wanted.detail)));
885 await fixIt();
886 await sleep(200);
887 results.push({ name, red: hits.length > 0, saw: hits.slice(0, 2).map(h => `${h.sel} — ${h.detail}`) });
888 console.log(` ${hits.length ? 'RED ' : 'MISS'} ${name}` + (hits.length ? ` → ${hits[0].sel}: ${hits[0].detail}` : ''));
889 };
890
891 await page.setViewportSize({ width: 390, height: 844 });
892 asOverlays = await setState(page, 'chat');
893 asState = 'chat';
894
895 await run('touch target: shrink #drawer-btn to 20×20',
896 () => page.evaluate(() => { const b = document.getElementById('drawer-btn');
897 b.dataset.old = b.style.cssText; b.style.cssText += ';width:20px!important;height:20px!important;min-width:20px!important;min-height:20px!important'; }),
898 () => page.evaluate(() => { const b = document.getElementById('drawer-btn'); b.style.cssText = b.dataset.old || ''; }),
899 { check: 'target<24', sel: 'drawer-btn' });
900
901 // A fixed bar, because #app is `overflow: hidden` and would legitimately clip
902 // an in-flow one — which is what clipRect is for, and is not the bug class.
903 await run('off-screen: a fixed toolbar 200px past the right edge',
904 () => page.evaluate(() => { const d = document.createElement('div'); d.id = 'selftest-wide-bar';
905 d.style.cssText = 'position:fixed;left:100px;top:200px;width:' + (window.innerWidth + 100) + 'px;height:30px;background:#0f0;z-index:5';
906 document.body.appendChild(d); }),
907 () => page.evaluate(() => { const d = document.getElementById('selftest-wide-bar'); if (d) d.remove(); }),
908 { check: 'off-right', sel: 'selftest-wide-bar' });
909
910 await run('obscured: drop a fixed pane over the top bar',
911 () => page.evaluate(() => { const d = document.createElement('div'); d.id = 'selftest-cover';
912 d.style.cssText = 'position:fixed;left:0;right:0;top:0;height:70px;z-index:999;background:#f00';
913 document.body.appendChild(d); }),
914 () => page.evaluate(() => { const d = document.getElementById('selftest-cover'); if (d) d.remove(); }),
915 { check: 'obscured' });
916
917 await run('h-scroll: force the document wider than the screen',
918 () => page.evaluate(() => { const d = document.createElement('div'); d.id = 'selftest-wide';
919 d.style.cssText = 'position:absolute;left:0;top:0;width:1200px;height:4px';
920 document.documentElement.style.overflowX = 'auto'; document.body.style.overflowX = 'auto';
921 document.body.appendChild(d); }),
922 () => page.evaluate(() => { const d = document.getElementById('selftest-wide'); if (d) d.remove();
923 document.documentElement.style.overflowX = ''; document.body.style.overflowX = ''; }),
924 { check: 'h-scroll' });
925
926 await run('overlap: slide one bottom-nav button over its neighbour',
927 () => page.evaluate(() => { const b = document.querySelectorAll('#mnav button')[1];
928 b.dataset.old = b.style.cssText; b.style.cssText += ';position:relative;left:-40px'; }),
929 () => page.evaluate(() => { const b = document.querySelectorAll('#mnav button')[1]; b.style.cssText = b.dataset.old || ''; }),
930 { check: 'overlap' });
931
932 await run('squeezed: shrink a bottom-nav label below its own text',
933 () => page.evaluate(() => { const b = document.querySelectorAll('#mnav button')[2];
934 b.dataset.old2 = b.style.cssText;
935 b.style.cssText += ';white-space:nowrap;overflow:visible;width:8px;max-width:8px;flex:none'; }),
936 () => page.evaluate(() => { const b = document.querySelectorAll('#mnav button')[2]; b.style.cssText = b.dataset.old2 || ''; }),
937 { check: 'squeezed' });
938
939 asOverlays = await setState(page, 'drawer-folded');
940 asState = 'drawer-folded';
941 await run('tag filter: unfold the pool behind the disclosure row',
942 () => page.evaluate(() => { const p = document.querySelector('#diamond-filter .tagf-pool');
943 const t = document.querySelector('#diamond-filter .tagf-toggle');
944 if (t && t.getAttribute('aria-expanded') !== 'true') t.click(); }),
945 () => page.evaluate(() => { const t = document.querySelector('#diamond-filter .tagf-toggle');
946 if (t && t.getAttribute('aria-expanded') === 'true') t.click(); }),
947 { check: 'tagfilter' });
948
949 asOverlays = await setState(page, 'sheet-half');
950 asState = 'sheet-half';
951 console.log(' (sheet up for the shell self-test: '
952 + await page.evaluate(() => !!(window.DaimondSheet && DaimondSheet.isOpen())) + ')');
953 await run('shell: shove the sheet foot down over the bottom bar',
954 () => page.evaluate(() => { const m = document.getElementById('msheet');
955 m.dataset.old = m.style.bottom; m.style.setProperty('bottom', '0px', 'important'); }),
956 () => page.evaluate(() => { const m = document.getElementById('msheet'); m.style.bottom = m.dataset.old || ''; }),
957 { check: 'shell', sel: 'msheet', detail: 'overlaps the bar' });
958
959 found.clear();
960 return results;
961}
962
963// ── Main ────────────────────────────────────────────────────────────────────
964const s = await boot();
965const { page } = s;
966await seed(s);
967fs.mkdirSync(SHOTS, { recursive: true });
968
969let selftestRows = [];
970if (SELFTEST) {
971 console.log('\nSELF-TEST — proving each check can go red\n');
972 selftestRows = await selftest(page);
973}
974
975const palettes = QUICK ? SAMPLE : PALETTES;
976const shots = [];
977const safeArea = [];
978const notch = new Map();
979// One probe per (width, skin, state, palette), and the page's own state at the
980// first probe of each (width, skin, state).
981let swept = 0;
982const reached = [];
983const cssSafe = await staticAudit(page);
984
985console.log('\nSWEEP\n');
986for (const size of SIZES) {
987 await page.setViewportSize({ width: size.w, height: size.h });
988 await page.evaluate(() => window.dispatchEvent(new Event('resize')));
989 await sleep(500);
990 const phone = size.w <= BREAKPOINT;
991 const states = phone ? PHONE_STATES : WIDE_STATES;
992
993 for (const [skin, skinName] of SKINS) {
994 await page.evaluate(k => window.DaimondSkin.set(k), skin);
995 await sleep(200);
996 for (const state of states) {
997 const overlays = await setState(page, state);
998 let first = true;
999 for (const palette of palsFor(size)) {
1000 await page.evaluate(p => window.DaimondTheme.set(p), palette);
1001 await sleep(90);
1002 const deep = palette === (QUICK ? SAMPLE : PALETTES)[0];
1003 const r = await page.evaluate(PROBE, { overlays, breakpoint: BREAKPOINT, deep });
1004 judge(r, { width: size.tag, palette, skin, state });
1005 swept++;
1006 // What the page was actually in when the first measurement of this
1007 // state was taken. A state that never opened is measured just as
1008 // happily as one that did, and reads as clean.
1009 if (first) { first = false; reached.push({ width: size.tag, skin, state, geo: r.geo }); }
1010 for (const n of (r.inNotch || [])) notch.set(size.tag + '|' + state + '|' + n.side + '|' + n.sel, n);
1011 }
1012 // One screenshot per (width, skin, state) in a dark and a light palette.
1013 if (!QUICK && ['chat', 'drawer', 'sheet-half', 'sheet-full'].includes(state) || state === 'desktop') {
1014 for (const p of ['dark', 'lollypop']) {
1015 await page.evaluate(x => window.DaimondTheme.set(x), p);
1016 await sleep(120);
1017 const f = `${size.tag}-${skin}-${p}-${state}.png`;
1018 await page.screenshot({ path: path.join(SHOTS, f), timeout: 8000 }).catch(() => {});
1019 shots.push(f);
1020 }
1021 }
1022 // Safe-area: only where a fixed foot exists and only once per width/skin.
1023 if (phone && (state === 'sheet-full' || state === 'sheet-half')) {
1024 const g = await safeAreaPass(page, {});
1025 safeArea.push({ width: size.tag, skin, state, ...g });
1026 }
1027 }
1028 // The drawer must close and hand the page back.
1029 if (phone) {
1030 await setState(page, 'drawer');
1031 await page.evaluate(() => { const sc = document.getElementById('scrim'); if (sc) sc.click(); });
1032 await sleep(400);
1033 const after = await page.evaluate(() => ({
1034 open: document.body.classList.contains('drawer-open'),
1035 railL: Math.round(document.getElementById('panel-rail').getBoundingClientRect().left),
1036 scrimPE: getComputedStyle(document.getElementById('scrim')).pointerEvents,
1037 composer: (() => { const c = document.getElementById('chat-input'); if (!c) return 'absent';
1038 const st = getComputedStyle(c); return st.visibility + '/' + st.display; })(),
1039 }));
1040 if (after.open || after.railL > -10 || after.scrimPE !== 'none') {
1041 report({ check: 'shell', sel: '#scrim (close)', width: size.tag, palette: '(all)', skin, state: 'drawer-close',
1042 detail: `after a scrim tap: open=${after.open} railLeft=${after.railL} scrimPE=${after.scrimPE}` });
1043 }
1044 // And the sheet must hand the guest back to the stage.
1045 await setState(page, 'sheet-half');
1046 await page.evaluate(() => document.getElementById('msheet-close').click());
1047 await sleep(400);
1048 const back = await page.evaluate(() => {
1049 const el = document.getElementById('panel-web');
1050 return { inStage: !!(el && el.closest('#stage')), sheetOpen: !!(window.DaimondSheet && DaimondSheet.isOpen()),
1051 bodyClass: document.body.classList.contains('sheet-open'),
1052 composer: (() => { const b = document.querySelector('.ai .chat-input-bar'); return b ? getComputedStyle(b).visibility : 'absent'; })() };
1053 });
1054 if (!back.inStage || back.sheetOpen || back.bodyClass || back.composer !== 'visible') {
1055 report({ check: 'shell', sel: '#msheet-close', width: size.tag, palette: '(all)', skin, state: 'sheet-close',
1056 detail: `after closing: guestInStage=${back.inStage} sheetOpen=${back.sheetOpen} bodyClass=${back.bodyClass} composer=${back.composer}` });
1057 }
1058 }
1059 console.log(` swept ${size.tag} ${skinName}`);
1060 }
1061}
1062
1063// ── Safe-area judgement ─────────────────────────────────────────────────────
1064const safeFindings = [];
1065for (const a of safeArea) {
1066 if (!a.sheet || !a.mnav || !a.topbar) continue;
1067 if (a.sheet.t < a.topbar.b) {
1068 safeFindings.push(`${a.width} ${a.skin}: with a ${INSET}px bottom inset the FULL sheet's top is ${a.sheet.t}px, `
1069 + `under the top bar (bottom ${a.topbar.b}px) — mobile.js maxH() should be spending ${a.mnav.h}px for the bar, which is what it measures.`);
1070 }
1071 if (a.sheet.b > a.mnav.t + 1) {
1072 safeFindings.push(`${a.width} ${a.skin}: with a ${INSET}px inset the sheet foot (${a.sheet.b}) overlaps the bar top (${a.mnav.t}).`);
1073 }
1074 if (a.ask && a.state === 'sheet-half') {
1075 const h = a.ask.b - a.ask.t;
1076 if (h >= 61 + INSET - 1) {
1077 safeFindings.push(`${a.width} ${a.skin}: the ask pill grows from 61px to ${h}px with a ${INSET}px inset — `
1078 + `.msheet-ask (mobile.css:218) spends safe-area-inset-bottom a SECOND time, although the sheet already `
1079 + `stands ${INSET}px clear of the screen edge on top of the bar. ${INSET}px of dead space inside the sheet.`);
1080 }
1081 }
1082}
1083
1084// ── Write the evidence ──────────────────────────────────────────────────────
1085// The prose report (dev/sweep_mobile_report.md) is written by hand from this:
1086// a suggested fix is a judgement, and a generator that invents one would be
1087// padding. Everything here is measurement, and re-running overwrites only
1088// measurement.
1089const rows = [...found.values()];
1090const SEV = { 'h-scroll': 0, 'off-right': 1, 'off-top': 2, 'overlap': 3, 'squeezed': 4, 'obscured': 5,
1091 'overlay': 6, 'shell': 7, 'target<24': 8, 'tagfilter': 9, 'contrast': 10, 'target<44': 11 };
1092const roll = new Map();
1093for (const r of rows) {
1094 const k = r.check + '|' + r.sel;
1095 const e = roll.get(k) || { check: r.check, sel: r.sel, widths: new Set(), skins: new Set(),
1096 states: new Set(), palettes: new Set(), details: new Set(), byPalette: {} };
1097 e.widths.add(r.width); e.skins.add(r.skin); e.states.add(r.state);
1098 r.palettes.forEach(p => e.palettes.add(p));
1099 if (r.detail) e.details.add(r.detail);
1100 Object.assign(e.byPalette, r.byPalette || {});
1101 roll.set(k, e);
1102}
1103const rolled = [...roll.values()].sort((a, b) => SEV[a.check] - SEV[b.check] || a.sel.localeCompare(b.sel));
1104const hard = rolled.filter(r => r.check !== 'target<44');
1105const soft = rolled.filter(r => r.check === 'target<44');
1106
1107const spacings = (set) => [...set].map(x => x === 'sharp' ? 'Compact' : 'Breathe').join(', ');
1108const pal = (set) => set.size >= palettes.length ? 'all ' + palettes.length : [...set].join(', ');
1109
1110const L = [];
1111L.push('# Sweep evidence — phone and narrow widths');
1112L.push('');
1113L.push('Generated by `dev/verify_sweep_mobile.mjs`. The read-and-judge report is');
1114L.push('`dev/sweep_mobile_report.md`; this file is the raw measurement behind it.');
1115L.push('');
1116L.push(`Run ${new Date().toISOString().slice(0, 16).replace('T', ' ')} against \`${APP}\`.`);
1117L.push(`${probes} probes over ${SIZES.length} widths × ${SKINS.length} spacings × ${palettes.length} palettes,`);
1118L.push('in a `hasTouch: true` context so `(pointer: coarse)` and `(hover: none)` match.');
1119L.push('');
1120L.push('Widths: ' + SIZES.map(z => `${z.tag} (${z.note})`).join('; ') + '.');
1121L.push('');
1122if (selftestRows.length) {
1123 L.push('## Checks proved red');
1124 L.push('');
1125 L.push('| broken deliberately | check fired |');
1126 L.push('| --- | --- |');
1127 for (const t of selftestRows) L.push(`| ${t.name} | ${t.red ? 'yes — ' + (t.saw[0] || '') : '**NO — the check is blind**'} |`);
1128 L.push('');
1129}
1130L.push('## Findings');
1131L.push('');
1132if (!hard.length) L.push('_None._');
1133for (const r of hard) {
1134 L.push(`### \`${r.check}\` — \`${r.sel}\``);
1135 L.push('');
1136 L.push(`- widths: ${[...r.widths].join(', ')}`);
1137 L.push(`- spacings: ${spacings(r.skins)}`);
1138 L.push(`- states: ${[...r.states].join(', ')}`);
1139 L.push(`- palettes: ${pal(r.palettes)}`);
1140 if (Object.keys(r.byPalette).length > 1 && r.check === 'contrast') {
1141 for (const [k, v] of Object.entries(r.byPalette)) L.push(`- ${k}: ${v}`);
1142 } else {
1143 for (const d of [...r.details].slice(0, 4)) L.push(`- ${d}`);
1144 }
1145 L.push('');
1146}
1147L.push('## Under 44×44 (advisory, WCAG AAA / platform guidance)');
1148L.push('');
1149L.push('| control | smallest seen | widths |');
1150L.push('| --- | --- | --- |');
1151for (const r of soft) {
1152 const sizes = [...r.details].map(d => d.replace(' px', ''));
1153 L.push(`| \`${r.sel}\` | ${sizes.sort()[0] || ''} | ${[...r.widths].join(' ')} |`);
1154}
1155L.push('');
1156L.push('## Safe-area insets');
1157L.push('');
1158L.push('`' + (cssSafe.viewport || '(no viewport meta)') + '`');
1159L.push('');
1160for (const side of ['top', 'right', 'bottom', 'left']) {
1161 const u = cssSafe.sides[side];
1162 L.push(`- \`safe-area-inset-${side}\`: ${u.length ? u.length + ' declaration(s) — ' + u.join(' | ') : '**never used**'}`);
1163}
1164L.push('');
1165L.push(`Geometry re-measured with a ${INSET}px bottom inset injected into exactly those declarations:`);
1166L.push('');
1167if (!safeFindings.length) L.push('- nothing moved.');
1168for (const f of safeFindings) L.push('- ' + f);
1169L.push('');
1170if (notch.size) {
1171 L.push('## Controls in the landscape notch strip (left/right 44px)');
1172 L.push('');
1173 const byWidth = new Map();
1174 for (const [k, v] of notch) {
1175 const w = k.split('|')[0];
1176 (byWidth.get(w) || byWidth.set(w, new Set()).get(w)).add(v.side + ' ' + v.sel + ' @x=' + v.left);
1177 }
1178 for (const [w, set] of byWidth) L.push(`- ${w}: ${[...set].join('; ')}`);
1179 L.push('');
1180}
1181L.push('## Screenshots');
1182L.push('');
1183L.push(`\`dev/shots/sweepm/\` — ${shots.length} files, \`<width>-<skin>-<palette>-<state>.png\`.`);
1184L.push('');
1185fs.mkdirSync(path.join(HERE, 'results'), { recursive: true });
1186fs.writeFileSync(path.join(HERE, 'results', 'sweep_mobile_raw.md'), L.join('\n'));
1187fs.writeFileSync(path.join(HERE, 'results', 'sweep_mobile.json'),
1188 JSON.stringify({ rolled: rolled.map(r => ({ ...r, widths: [...r.widths], skins: [...r.skins],
1189 states: [...r.states], palettes: [...r.palettes], details: [...r.details] })),
1190 safeFindings, cssSafe, notch: [...notch.values()], shots }, null, 1));
1191
1192console.log(`\n${hard.length} findings + ${soft.length} advisory, over ${probes} probes; ${shots.length} screenshots.`);
1193for (const r of hard) console.log(` ${r.check.padEnd(11)} ${r.sel} [${[...r.widths].join(' ')}]`);
1194console.log(`\nevidence: ${path.join(HERE, 'results', 'sweep_mobile_raw.md')}\n`);
1195
1196// ── The instrument, judged ──────────────────────────────────────────────────
1197// None of this is a threshold on the findings: see the header. It is the set of
1198// ways this file can produce a clean-looking report of nothing.
1199
1200// 1. The matrix was walked. Computed the same way the loop walks it, so a size
1201// or a state quietly dropped from either list shows up as a shortfall here.
1202const want = SIZES.reduce((n, size) => {
1203 const states = (size.w <= BREAKPOINT ? PHONE_STATES : WIDE_STATES).length;
1204 return n + SKINS.length * states * palsFor(size).length;
1205}, 0);
1206check(`THE WHOLE MATRIX WAS PROBED — ${SIZES.length} widths × ${SKINS.length} spacings × states × palettes`,
1207 swept === want, `${swept} probes, ${want} expected`);
1208
1209// 2. Every state was really entered. A drawer that did not open is measured as
1210// happily as one that did, and every measurement is then of the chat floor.
1211const IS = {
1212 'chat': (g) => g.mpanel === 'ai',
1213 'drawer': (g) => g.drawerOpen === true,
1214 'drawer-folded': (g) => g.drawerOpen === true,
1215 'sheet-half': (g) => g.sheetOpen === true,
1216 'sheet-full': (g) => g.sheetOpen === true,
1217 'files': (g) => g.mpanel === 'work',
1218 'mail': (g) => g.mpanel === 'mail',
1219 'agents': (g) => g.mpanel === 'agents',
1220 'desktop': () => true,
1221};
1222const unreached = reached.filter(x => !IS[x.state](x.geo))
1223 .map(x => `${x.width} ${x.skin} ${x.state} (mpanel=${x.geo.mpanel}, drawerOpen=${x.geo.drawerOpen}, sheetOpen=${x.geo.sheetOpen})`);
1224check('EVERY SURFACE IT WAS ASKED FOR WAS REACHED',
1225 unreached.length === 0 && reached.length > 0,
1226 unreached.length ? `${unreached.length} of ${reached.length} never opened: ${unreached.slice(0, 4).join('; ')}`
1227 : `${reached.length} (width, spacing, state) cells, all open`);
1228
1229// 3. The evidence exists to be read.
1230const RAW = path.join(HERE, 'results', 'sweep_mobile_raw.md');
1231const bytes = (p) => { try { return fs.statSync(p).size; } catch (e) { return 0; } };
1232const JSN = path.join(HERE, 'results', 'sweep_mobile.json');
1233check('the evidence was written', bytes(RAW) > 500 && bytes(JSN) > 200,
1234 `${bytes(RAW)} bytes of markdown, ${bytes(JSN)} of JSON`);
1235
1236// 4. Nothing threw. These were collected from the first line of boot() and then
1237// dropped on the floor; 502 is a world with no gateway behind it.
1238const errs = s.errs.filter(e => !/favicon|502|Bad Gateway/i.test(e));
1239check('nothing threw in the page while it swept', errs.length === 0,
1240 `${errs.length}: ${errs.slice(0, 2).join(' | ')}`);
1241
1242// 5. And, when asked, that the checks above the report can go red at all.
1243if (SELFTEST) {
1244 const blind = selftestRows.filter(t => !t.red).map(t => t.name);
1245 check('every check proved it can go red', blind.length === 0,
1246 blind.length ? `blind: ${blind.join('; ')}` : `${selftestRows.length} proved`);
1247}
1248
1249await s.browser.close();
1250console.log(`\n${ok.length} passed, ${bad.length} failed`);
1251if (bad.length) { bad.forEach(x => console.log(' FAILED: ' + x)); process.exit(1); }