diff --git a/infra/build-server/overlap-browser.sh b/infra/build-server/overlap-browser.sh new file mode 100644 index 00000000..cc968eeb --- /dev/null +++ b/infra/build-server/overlap-browser.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# A headless Chromium for the text-overlap sweep (tools/ci/overlap-check.mjs) on a build box, without root. The build user +# has no sudo, so Playwright's `install --with-deps` cannot add the X and GTK libraries Chromium links; this script downloads +# the same Ubuntu packages with apt-get download (no root needed), unpacks them into a sysroot and points LD_LIBRARY_PATH +# at it, plus two font packages behind a private fontconfig file (the box ships no fonts at all: fc-list is empty, and +# text with no font has no width). Idempotent; re-run after a box re-provision. +# +# infra/build-server/overlap-browser.sh on the box (run-from-mac.sh copies and runs it; or ssh and run by hand) +# . /srv/builds/_bin/overlap/env.sh what a caller sources before `node tools/ci/overlap-check.mjs` +# +# Installed 7 October 2026 on igneum-build-2 (Ubuntu 24.04.5, node 22): playwright 1.56 with chromium 1194 (headless shell +# and full), 36 packages in the sysroot, Liberation and DejaVu fonts. A page with 20 px sans-serif text measured 23 px high +# through it, so glyphs have real metrics there. +set -euo pipefail +ROOT="${OVERLAP_ROOT:-/srv/builds/_bin/overlap}" +PW_VERSION="${PW_VERSION:-1.56}" +mkdir -p "$ROOT/debs" "$ROOT/sysroot" "$ROOT/fonts" "$ROOT/fc-cache" +cd "$ROOT" +[ -f package.json ] || npm init -y >/dev/null +if ! node -e "require('playwright')" 2>/dev/null; then npm i --no-audit --no-fund "playwright@$PW_VERSION" | tail -1; fi +# the browser download (Playwright keeps it under ~/.cache/ms-playwright; a second run finds it and does nothing) +PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1 npx playwright install chromium 2>&1 | tail -1 || true +PKGS="libatk1.0-0t64 libatk-bridge2.0-0t64 libatspi2.0-0t64 libx11-6 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libxcb1 + libasound2t64 libxrender1 libxau6 libxdmcp6 libwayland-server0 libwayland-client0 libcups2t64 libcairo2 libpango-1.0-0 libpangocairo-1.0-0 + libpangoft2-1.0-0 libharfbuzz0b libfribidi0 libthai0 libdatrie1 libpixman-1-0 libxcb-render0 libxcb-shm0 libavahi-client3 libavahi-common3 + libxi6 libfontconfig1 libgraphite2-3 fonts-liberation fonts-dejavu-core" +cd "$ROOT/debs" +# shellcheck disable=SC2086 +apt-get download $PKGS 2>&1 | grep -v '^Get:' | tail -1 || true +for d in ./*.deb; do dpkg -x "$d" "$ROOT/sysroot"; done +find "$ROOT/sysroot" -name '*.ttf' -exec cp -n {} "$ROOT/fonts/" \; +cat >"$ROOT/fonts.conf" < +$ROOT/fonts$ROOT/fc-cache +sans-serifLiberation Sans +serifLiberation Serif +monospaceLiberation Mono +EOF +cat >"$ROOT/env.sh" <"$ROOT/smoke.cjs" <<'EOF2' +const { chromium } = require("playwright"); +(async () => { const b = await chromium.launch({ args: ["--no-sandbox"] }); const p = await b.newPage(); + await p.setContent('

Hello overlap world

'); + const h = await p.evaluate(() => document.getElementById("a").getBoundingClientRect().height); await b.close(); + if (!(h > 15 && h < 40)) { console.error("overlap-browser: text has no metrics (height " + h + ")"); process.exit(1); } + console.log("overlap-browser: ready at " + __dirname + " (20 px text measures " + h + " px; source " + __dirname + "/env.sh)"); })(); +EOF2 +cd "$ROOT" && node smoke.cjs diff --git a/site/address.html b/site/address.html index c54a1e24..26a6c118 100644 --- a/site/address.html +++ b/site/address.html @@ -59,7 +59,7 @@ a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} .mono{font-family:var(--f-mono)} h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance} -.head{padding:65px 0 45px;display:flex;flex-direction:column;gap:14px} +.head{padding:65px 0 45px;display:flex;flex-direction:column;gap:14px}.head h1{overflow-wrap:anywhere;min-width:0} .head p{color:var(--ink-2);max-width:67ch;font-size:18px;line-height:1.65} .search{display:flex;gap:10px;max-width:760px;margin-top:4px;align-items:center;padding:12px 16px;border:1px solid var(--line-2);border-radius:8px;background:var(--row)} .search input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:var(--bone);font-size:14px} diff --git a/site/ledger.html b/site/ledger.html index 410aa9ba..80c3f587 100644 --- a/site/ledger.html +++ b/site/ledger.html @@ -53,7 +53,7 @@ +

Igneum light client, version zero: genuine and tampered

diff --git a/tools/ci/overlap-check.mjs b/tools/ci/overlap-check.mjs new file mode 100644 index 00000000..0f0f38ef --- /dev/null +++ b/tools/ci/overlap-check.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node +// The text-overlap sweep: renders every served page of the site, and the miner and wallet UIs through their mocks, in a +// headless Chromium at five widths in light and dark (the home hero at rest and at each of its three steps), reads every +// visible run of text and flags what a reader cannot read: text COVERED by another element (a pill over a caption, a label +// over a value, a card over its neighbour, text under the header), text CLIPPED by an overflow-hidden ancestor, and text +// running OUTSIDE the viewport. The owner found the first one on 7 October 2026: the home hero's step pill sat on the +// caption's second line ("Two thirds of the weight sign. The checkpoint locks.") at every desktop width. +// +// node tools/ci/overlap-check.mjs --self-test a fixture with one deliberate overlap of each kind is flagged, a +// clean one passes (the known-failed-first rule; runs before any sweep) +// node tools/ci/overlap-check.mjs --site [dir] the site tree (default site/), every *.html, the rewrites, the hero steps +// node tools/ci/overlap-check.mjs --apps [tree] the miner UI (tools/ui-mock/server.mjs) and the wallet UI +// (tools/ui-mock/wallet.mjs) from a repository tree (default this one); +// a UI directory absent from the tree is reported and skipped +// node tools/ci/overlap-check.mjs --previews the owner's preview set: twelve app screens, dark, 1280 and 900 wide, PNG +// options: --widths 390,768,1024,1280,1600 --themes light,dark --shots (a PNG per render) --json (the findings) +// --jobs 4 (parallel pages) --only (page names) --local (never go to the box) --box N (which box) +// +// Where it runs: a browser is needed. The script resolves Playwright from this tree's node_modules, $IGNEUM_PLAYWRIGHT_DIR +// or /srv/builds/_bin/overlap (the box install, infra/build-server/overlap-browser.sh). Without one, and unless --local, +// it ships its inputs to a build box (rsync into /srv/builds/_overlap//, nothing heavy on the Mac) and runs +// there under nice 10, shots and JSON coming back; CI installs Playwright in the workflow and runs it in place. +// +// How a cover is decided: for every run of text, five points along its visible part are hit-tested (elementFromPoint, +// with pointer-events forced on so decorative captions are hit too); a point whose top element is neither the text's +// element, nor inside it, nor one of its ancestors is covered when something between that element and the common +// ancestor paints (a background, a border, an image, a canvas, or text of its own). A transparent wrapper over text is +// not a cover. A dialog or a toast that is MEANT to sit over the page (role=dialog, aria-modal, the update card, a toast) +// is a cover by design and is not flagged; the ALLOW table below carries each with its reason. Animations and +// transitions are frozen at their end state and smooth scrolling is off before measuring, so a reveal in flight is not a +// finding. Clipping by an ancestor with text-overflow: ellipsis is reported as "truncated", a note, not a failure. +import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, dirname, resolve, extname, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..', '..'); +const args = process.argv.slice(2); +const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : dflt; }; +const has = (name) => args.includes(name); +const WIDTHS = String(opt('--widths', '390,768,1024,1280,1600')).split(',').map(Number); +const THEMES = String(opt('--themes', 'light,dark')).split(','); +const JOBS = Number(opt('--jobs', 4)); +const ONLY = opt('--only', ''); +const SHOTS = opt('--shots', ''); +const JSON_OUT = opt('--json', ''); +const HEIGHT_FOR = (w, app) => app ? (w <= 900 ? 700 : 800) : (w <= 390 ? 844 : w <= 768 ? 1024 : w <= 1024 ? 768 : w <= 1280 ? 800 : 900); + +// covers that are meant to sit over the page: a match on the cover's selector path, with the reason +const ALLOW = [ + { re: /\[role="?dialog"?\]|\[aria-modal|#upd\b|\.upd-card|\.update-card/, why: 'the update card is one centred card over everything (miner and wallet, by design)' }, + { re: /\.toast\b|#toast\b/, why: 'a toast sits over the page for a few seconds' }, + { re: /\.nav-sheet\b/, why: 'the open menu sheet covers the page on purpose' }, + { re: /\.tip\b/, why: 'a chart tooltip follows the pointer over the chart' }, +]; + +// ---------- the in-page measurer (serialised into the page; no closure over this file) ---------- +async function measureInPage(opts) { + const tol = opts.tol || 2, vw = innerWidth, vh = innerHeight; + const st = document.createElement('style'); st.id = '__ov_style'; + st.textContent = '*{pointer-events:auto!important}html{scroll-behavior:auto!important}*,*::before,*::after{transition:none!important;animation-duration:0s!important;animation-delay:0s!important}'; + const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE', 'OPTION', 'SELECT', 'TEXTAREA', 'HEAD', 'META', 'LINK']); + const alpha = (c) => { if (!c || c === 'transparent') return 0; const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return 1; const p = m[1].split(/[\s,\/]+/).map(parseFloat); return p.length >= 4 ? p[3] : 1; }; + const ownText = (el) => { for (const n of el.childNodes) if (n.nodeType === 3 && n.data.trim()) return true; return false; }; + const paints = (el) => { + if (['IMG', 'CANVAS', 'VIDEO', 'PICTURE', 'IFRAME', 'INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return true; + if (el.tagName === 'svg') return false; // an root hit with no shape under the point is an empty area; a filled shape is hit as itself + const cs = getComputedStyle(el); + if (cs.backgroundImage !== 'none' || alpha(cs.backgroundColor) > 0.02) return true; + for (const side of ['Top', 'Bottom', 'Left', 'Right']) if (parseFloat(cs['border' + side + 'Width']) > 0 && alpha(cs['border' + side + 'Color']) > 0.02) return true; + return ownText(el); + }; + const visible = (el) => { + if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true })) return false; // closed
, display none, content-visibility + for (let e = el; e && e.nodeType === 1; e = e.parentElement) { if (e.hidden) return false; const cs = getComputedStyle(e); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0 || cs.contentVisibility === 'hidden') return false; if (e.tagName === 'DETAILS' && !e.open && !(e.firstElementChild && e.firstElementChild.tagName === 'SUMMARY' && e.firstElementChild.contains(el))) return false; } + return true; + }; + // the author's pointer-events, read before the override below: a cover with pointer-events none and no text of its own is + // decorative (a glow, a gradient, a vignette) and never hides text on purpose + const noPointer = new WeakSet(); for (const e of document.querySelectorAll('*')) if (getComputedStyle(e).pointerEvents === 'none') noPointer.add(e); + document.head.appendChild(st); + const sel = (el) => { const parts = []; for (let e = el, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) { let s = e.tagName.toLowerCase(); if (e.id) s += '#' + e.id; else if (e.classList.length) s += '.' + [...e.classList].slice(0, 2).join('.'); parts.unshift(s); } return parts.join(' > '); }; + const inter = (a, b) => ({ l: Math.max(a.l, b.l), t: Math.max(a.t, b.t), r: Math.min(a.r, b.r), b: Math.min(a.b, b.b) }); + const findings = [], keys = new Set(); + const add = (kind, el, by, text, q, how) => { const k = kind + '|' + sel(el) + '|' + (by ? sel(by) : '') + '|' + text.slice(0, 30); if (keys.has(k)) return; keys.add(k); findings.push({ kind, text, el: sel(el), by: by ? sel(by) : '', how, box: [Math.round(q.left), Math.round(q.top + scrollY), Math.round(q.width), Math.round(q.height)] }); }; + const docH = Math.min(document.documentElement.scrollHeight, 30000); + let runs = 0; + for (let y0 = 0; y0 < docH; y0 += vh) { + const lastWindow = y0 + vh >= docH; + scrollTo(0, y0); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const visCache = new Map(), clipCache = new Map(); + const clipChain = (el) => { // the ancestors (and the element) that clip or scroll, once per element per window + let c = clipCache.get(el); if (c) return c; c = []; + for (let a = el; a && a !== document.documentElement; a = a.parentElement) { + const acs = getComputedStyle(a); const ox = acs.overflowX, oy = acs.overflowY; + const cx = ox === 'hidden' || ox === 'clip', cy = oy === 'hidden' || oy === 'clip', scrolls = /auto|scroll/.test(ox + ' ' + oy); + if (!cx && !cy && !scrolls) continue; + if (a.clientWidth === 0 && a.clientHeight === 0) continue; + c.push({ a, cx, cy, scrolls, srOnly: a.clientWidth <= 1 || a.clientHeight <= 1 }); + } + clipCache.set(el, c); return c; + }; + let n; + while ((n = walker.nextNode())) { + if (!n.data.trim()) continue; + const el = n.parentElement; if (!el || skip.has(el.tagName)) continue; + let v = visCache.get(el); if (v === undefined) { v = !el.closest('select,textarea') && visible(el); if (v) { const cs = getComputedStyle(el); if (parseFloat(cs.fontSize) < 1 || alpha(cs.color) < 0.05) v = false; } visCache.set(el, v); } + if (!v) continue; + const cs = getComputedStyle(el); + const range = document.createRange(); range.selectNodeContents(n); + const text = n.data.replace(/\s+/g, ' ').trim().slice(0, 70); + for (const q of range.getClientRects()) { + if (q.width <= 1 || q.height <= 1 || q.bottom < 0 || q.top > vh) continue; + runs++; + let vis = { l: q.left, t: q.top, r: q.right, b: q.bottom }, clipper = null, cut = 0, srOnly = false; + for (const { a, cx, cy, scrolls, srOnly: sr } of clipChain(el)) { + if (sr) { srOnly = true; break; } // the visually-hidden pattern: a 1 px box for screen readers + const ab = a.getBoundingClientRect(); + const box = { l: ab.left + a.clientLeft, t: ab.top + a.clientTop, r: ab.left + a.clientLeft + a.clientWidth, b: ab.top + a.clientTop + a.clientHeight }; + if (scrolls && !cx && !cy) { + // a table wrapper or a
 scrolls sideways by design (no vertical scrolling of its own); a pane that scrolls
+            // vertically AND is wider than its box (the wallet's main, a settings page) hides text sideways
+            if (a.scrollHeight > a.clientHeight + tol && a.scrollWidth > a.clientWidth + tol && (vis.r > box.r + tol || vis.l < box.l - tol)) add('outside', el, a, text, q, Math.round(vis.r > box.r + tol ? vis.r - box.r : box.l - vis.l) + ' px past the edge of ' + sel(a) + ', a pane that scrolls both ways');
+            vis = inter(vis, box); continue;
+          }
+          const cutHere = Math.max(cx ? Math.max(box.l - vis.l, vis.r - box.r) : 0, cy ? Math.max(box.t - vis.t, vis.b - box.b) : 0);
+          if (cutHere > tol && cutHere > cut) { cut = cutHere; clipper = a; }
+          vis = inter(vis, { l: cx ? box.l : -1e9, t: cy ? box.t : -1e9, r: cx ? box.r : 1e9, b: cy ? box.b : 1e9 });
+        }
+        if (srOnly) continue;
+        if (vis.r - vis.l > 2 && (vis.l < -tol || vis.r > vw + tol)) add('outside', el, null, text, q, (vis.l < -tol ? Math.round(-vis.l) + ' px past the left edge' : Math.round(vis.r - vw) + ' px past the right edge') + ' at ' + vw + ' px');
+        if (clipper) {
+          const ccs = getComputedStyle(clipper);
+          const ellipsis = (ccs.textOverflow === 'ellipsis' || cs.textOverflow === 'ellipsis') && (ccs.whiteSpace === 'nowrap' || cs.whiteSpace === 'nowrap');
+          add(ellipsis ? 'truncated' : 'clipped', el, clipper, text, q, Math.round(cut) + ' px cut by ' + sel(clipper));
+        }
+        if (vis.r - vis.l < 2 || vis.b - vis.t < 2) continue;
+        const ym = (vis.t + vis.b) / 2; if (ym < 0 || ym > vh) continue;
+        const w = vis.r - vis.l; let covered = 0, by = null, pts = 0;
+        for (const f of [0.08, 0.3, 0.5, 0.7, 0.92]) {
+          const x = vis.l + w * f; if (x < 0 || x > vw) continue; pts++;
+          const top = document.elementFromPoint(x, ym);
+          if (!top || top === el || el.contains(top) || top.contains(el)) continue;
+          let p = top, painted = null, fixed = null;
+          while (p && !p.contains(el)) { const pcs = getComputedStyle(p); if (!fixed && (pcs.position === 'fixed' || pcs.position === 'sticky')) fixed = p; if (!painted && paints(p) && !(noPointer.has(p) && !ownText(p) && !/^(IMG|CANVAS|VIDEO)$/.test(p.tagName))) painted = p; p = p.parentElement; }
+          if (!painted) continue;
+          if (fixed) { // a bar that travels with the viewport: content scrolling under it is by design; what sits under it at rest (the top, first window) or at the end (the bottom, last window) is not
+            const fr = fixed.getBoundingClientRect(); const atTop = fr.top <= 2 && y0 === 0, atBottom = fr.bottom >= vh - 2 && lastWindow;
+            if (!atTop && !atBottom) continue;
+          }
+          covered++; if (!by) by = painted;
+        }
+        if (covered) add('covered', el, by, text, q, covered + ' of ' + pts + ' points under ' + sel(by));
+      }
+    }
+  }
+  scrollTo(0, 0);
+  const scrollW = document.documentElement.scrollWidth, wide = [];
+  if (scrollW > vw + tol) { // name the outermost elements that reach past the viewport (the cause of a sideways page)
+    for (const e of document.body.querySelectorAll('*')) { if (!visible(e)) continue; const r = e.getBoundingClientRect(); if (r.right > vw + tol && r.width > 2 && !(e.parentElement && e.parentElement.getBoundingClientRect().right > vw + tol && e.parentElement !== document.body)) wide.push({ el: sel(e), right: Math.round(r.right), width: Math.round(r.width) }); }
+    wide.sort((a, b) => b.right - a.right);
+  }
+  st.remove();
+  const status = document.getElementById('scene-status');
+  return { findings, runs, vw, vh, docH, scrollW, wide: wide.slice(0, 4), status: status ? status.textContent : '' };
+}
+
+// ---------- servers ----------
+function freePort() { return new Promise((res) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); }
+const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain', '.xml': 'application/xml' };
+function serveSite(dir) {
+  // the site as Vercel serves it: cleanUrls, the two rewrites, /api/* answering nothing (the pages show their rest state)
+  const rewrites = [[/^\/block\/[^/]+$/, '/block.html'], [/^\/address\/[^/]+$/, '/address.html'], [/^\/benchmarks$/, '/miners.html']];
+  return new Promise((res) => {
+    const srv = http.createServer((req, rs) => {
+      let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
+      if (p.startsWith('/api/')) { rs.writeHead(503, { 'Content-Type': 'application/json' }); return rs.end('{}'); }
+      for (const [re, to] of rewrites) if (re.test(p)) p = to;
+      if (p === '/') p = '/index.html';
+      let f = join(dir, p);
+      if (!extname(f) && existsSync(f + '.html')) f += '.html';
+      if (existsSync(f) && statSync(f).isDirectory()) f = join(f, 'index.html');
+      if (!f.startsWith(dir) || !existsSync(f) || statSync(f).isDirectory()) { const nf = join(dir, '404.html'); rs.writeHead(404, { 'Content-Type': 'text/html' }); return rs.end(existsSync(nf) ? readFileSync(nf) : 'not found'); }
+      rs.writeHead(200, { 'Content-Type': MIME[extname(f)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); rs.end(readFileSync(f));
+    });
+    srv.listen(0, '127.0.0.1', () => res({ port: srv.address().port, close: () => srv.close() }));
+  });
+}
+async function spawnMock(script, uiDir) {
+  const port = await freePort();
+  const child = spawn(process.execPath, [script, String(port)].concat(uiDir ? [uiDir] : []), { stdio: ['ignore', 'ignore', 'inherit'] });
+  for (let i = 0; i < 50; i++) { await new Promise((r) => setTimeout(r, 100)); const ok = await new Promise((r) => { const s = net.connect(port, '127.0.0.1'); s.on('connect', () => { s.destroy(); r(true); }); s.on('error', () => r(false)); }); if (ok) break; }
+  return { port, close: () => child.kill() };
+}
+
+// ---------- targets ----------
+function siteTargets(dir, port) {
+  const base = `http://127.0.0.1:${port}`;
+  const pages = readdirSync(dir).filter((f) => f.endsWith('.html')).sort();
+  const out = [];
+  for (const f of pages) {
+    const name = f.replace(/\.html$/, '');
+    const path = name === 'index' ? '/' : name === '404' ? '/no-such-page' : '/' + name;
+    out.push({ surface: 'site', name: name === 'index' ? 'home' : name, url: base + path, app: false });
+  }
+  out.push({ surface: 'site', name: 'block-id', url: base + '/block/1234', app: false }, { surface: 'site', name: 'address-id', url: base + '/address/0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', app: false });
+  if (existsSync(join(dir, 'verify', 'test.html'))) out.push({ surface: 'site', name: 'verify-test', url: base + '/verify/test.html', app: false });
+  // the home hero at rest and at each step: the step buttons pin the stage (and its caption) before the sweep
+  const home = out.find((t) => t.name === 'home'); if (home) home.steps = [0, 1, 2];
+  return out;
+}
+function minerTargets(port) {
+  const u = (q) => `http://127.0.0.1:${port}/t/mock/?${q}`;
+  const screens = [['setup-welcome', 'scenario=fresh&screen=welcome'], ['setup-cards', 'scenario=fresh&screen=cards'], ['setup-region', 'scenario=fresh&screen=region'], ['setup-address', 'scenario=fresh&screen=address'], ['cards-none', 'scenario=nocards&screen=cards']];
+  const pages = ['overview', 'cards', 'earnings', 'prove', 'settings'];
+  const scenarios = ['live', 'nvidia', 'syncing', 'nodedown', 'nocards', 'integrated', 'clock', 'paused', 'heat', 'intel', 'firstwait', 'firstblock', 'ladder', 'job', 'biglog'];
+  const out = screens.map(([n, q]) => ({ surface: 'miner', name: n, url: u(q), app: true }));
+  for (const s of scenarios) for (const p of pages) { if (s !== 'live' && s !== 'nvidia' && p !== 'overview' && p !== 'cards' && !(s === 'ladder' && p === 'earnings') && !(s === 'job' && p === 'settings')) continue; out.push({ surface: 'miner', name: `${s}-${p}`, url: u(`scenario=${s}&page=${p}`), app: true }); }
+  for (const k of ['available', 'downloading', 'ready', 'waiting', 'applying', 'urgent', 'manual', 'error', 'updated']) out.push({ surface: 'miner', name: `update-${k}`, url: u(`scenario=live&page=overview&update=${k}&card=1`), app: true });
+  out.push({ surface: 'miner', name: 'job-running', url: u('scenario=live&page=overview&job=running'), app: true }, { surface: 'miner', name: 'job-failed', url: u('scenario=live&page=overview&job=failed'), app: true });
+  return out;
+}
+function walletTargets(port) {
+  const u = (q) => `http://127.0.0.1:${port}/t/mock/?${q}`;
+  // the scenarios of tools/ui-mock/wallet.mjs (wallet-0.1.5): welcome, unlock, home, public, scanning, nonode, plain, empty, update
+  const out = [['lock', 'scenario=unlock'], ['lock-touch', 'scenario=unlock&bio=touch'], ['welcome', 'scenario=welcome'], ['create', 'scenario=welcome&screen=create'], ['import', 'scenario=welcome&screen=import'],
+    ['home', 'scenario=home&page=home'], ['send', 'scenario=home&page=send'], ['receive', 'scenario=home&page=receive'], ['history', 'scenario=home&page=history'], ['settings', 'scenario=home&page=settings'],
+    ['public-home', 'scenario=public&page=home'], ['public-settings', 'scenario=public&page=settings'], ['scanning-home', 'scenario=scanning&page=home'], ['nonode-home', 'scenario=nonode&page=home'], ['nonode-send', 'scenario=nonode&page=send'],
+    ['plain-send', 'scenario=plain&page=send'], ['empty-home', 'scenario=empty&page=home'], ['empty-history', 'scenario=empty&page=history'], ['update-card', 'scenario=update&page=home'], ['update-downloading', 'scenario=home&page=home&update=downloading&card=1']];
+  return out.map(([n, q]) => ({ surface: 'wallet', name: n, url: u(q), app: true }));
+}
+
+// ---------- the sweep ----------
+async function loadPlaywright() {
+  const dirs = [root, process.env.IGNEUM_PLAYWRIGHT_DIR, '/srv/builds/_bin/overlap'].filter(Boolean);
+  for (const d of dirs) { try { const req = createRequire(join(d, 'package.json')); return req('playwright'); } catch (e) { /* next */ } }
+  return null;
+}
+async function renderAll(pw, targets, label) {
+  const browser = await pw.chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'] });
+  const jobs = [];
+  for (const t of targets) for (const w of WIDTHS) for (const theme of THEMES) {
+    if (t.app && w < 720) continue; // the apps are desktop windows; 720 px is their smallest layout
+    jobs.push({ t, w, theme });
+  }
+  const results = []; let next = 0, done = 0; const t0 = Date.now();
+  async function worker() {
+    const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' });
+    const page = await ctx.newPage();
+    page.on('pageerror', () => {});
+    let curTheme = '';
+    while (next < jobs.length) {
+      const j = jobs[next++]; const { t, w, theme } = j;
+      if (theme !== curTheme) { await page.emulateMedia({ colorScheme: theme }); curTheme = theme; }
+      await page.setViewportSize({ width: w, height: HEIGHT_FOR(w, t.app) });
+      const url = t.app ? t.url + '&theme=' + theme : t.url;
+      try {
+        await page.goto(url, { waitUntil: 'load', timeout: 20000 });
+        await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
+        await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
+        await page.waitForTimeout(t.app ? 700 : 300);
+        const variants = [{ step: 'rest', act: null }].concat((t.steps || []).map((s) => ({ step: 'step' + (s + 1), act: s })));
+        for (const v of variants) {
+          if (v.act !== null) { const b = page.locator(`#scene-steps [data-stage="${v.act}"]`); if (await b.count()) { await b.first().click({ force: true }); await page.waitForTimeout(80); } }
+          const m = await page.evaluate(measureInPage, { tol: 2 });
+          const key = `${t.surface}/${t.name} ${w}px ${theme}${v.step === 'rest' ? '' : ' ' + v.step}`;
+          if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: join(SHOTS, `${t.surface}__${t.name}__${w}__${theme}${v.step === 'rest' ? '' : '__' + v.step}.png`), fullPage: !t.app }).catch(() => {}); }
+          results.push({ key, surface: t.surface, name: t.name, width: w, theme, step: v.step, status: m.status, runs: m.runs, scrollW: m.scrollW, findings: m.findings });
+        }
+      } catch (e) { results.push({ key: `${t.surface}/${t.name} ${w}px ${theme}`, surface: t.surface, name: t.name, width: w, theme, step: 'rest', error: String(e.message || e).split('\n')[0], findings: [] }); }
+      done++;
+    }
+    await ctx.close();
+  }
+  await Promise.all(Array.from({ length: Math.min(JOBS, jobs.length) }, worker));
+  await browser.close();
+  return { results, secs: (Date.now() - t0) / 1000, renders: jobs.length, label };
+}
+function allowed(f) { return ALLOW.find((a) => a.re.test(f.by || '')); }
+function report(run) {
+  const fails = [], notes = [], errors = [];
+  for (const r of run.results) {
+    if (r.error) { errors.push(`${r.key}: ${r.error}`); continue; }
+    if (r.scrollW > r.width + 2) fails.push(`${r.key}: the page scrolls sideways (${r.scrollW} px wide in a ${r.width} px viewport; past the edge: ${(r.wide || []).map((w) => w.el + ' to ' + w.right + ' px').join('; ') || 'no visible element, an overflowing pseudo-element or margin'})`);
+    for (const f of r.findings) {
+      const a = allowed(f);
+      const line = `${r.key}${r.status ? ' [' + r.status + ']' : ''}: ${f.kind.toUpperCase()} "${f.text}" in ${f.el} (${f.how})`;
+      if (a) notes.push(line + ' [allowed: ' + a.why + ']'); else if (f.kind === 'truncated') notes.push(line); else fails.push(line);
+    }
+  }
+  return { fails, notes, errors };
+}
+
+// ---------- the self-test: known failed first ----------
+const FIXTURE_BAD = `
+
03 / Locking
Two thirds of the weight sign. The checkpoint locks. The caption runs on under the pill.
01 Mine 02 Prove 03 Lock
+

A first line that fits

A second line cut off by overflow hidden

+
Hash rate124 MH/s
+

Text pushed past the right edge of the viewport

`; +const FIXTURE_GOOD = ` +
03 / Locking
Two thirds of the weight sign. The checkpoint locks.
01 Mine 02 Prove 03 Lock
+

A card

Some words with a link and bold inline.

A long single line that ends in an ellipsis by design

+ beside text`; +async function selfTest(pw) { + const srv = http.createServer((req, rs) => { rs.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); rs.end(req.url.includes('good') ? FIXTURE_GOOD : FIXTURE_BAD); }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; + const savedW = WIDTHS.splice(0, WIDTHS.length, 390), savedT = THEMES.splice(0, THEMES.length, 'dark'); + const run = await renderAll(pw, [{ surface: 'fixture', name: 'bad', url: `http://127.0.0.1:${port}/bad`, app: false }, { surface: 'fixture', name: 'good', url: `http://127.0.0.1:${port}/good`, app: false }], 'self-test'); + WIDTHS.splice(0, WIDTHS.length, ...savedW); THEMES.splice(0, THEMES.length, ...savedT); + srv.close(); + const bad = run.results.find((r) => r.name === 'bad'), good = run.results.find((r) => r.name === 'good'); + const kinds = (r) => new Set(r.findings.filter((f) => !allowed(f)).map((f) => f.kind)); + const problems = []; + if (!bad || bad.error) problems.push('the bad fixture did not render: ' + (bad && bad.error)); + else { + const k = kinds(bad); + if (!bad.findings.some((f) => f.kind === 'covered' && /Two thirds/.test(f.text) && /pill/.test(f.by))) problems.push('the pill over the caption was not flagged (covered)'); + if (!bad.findings.some((f) => f.kind === 'clipped' && /second line/.test(f.text))) problems.push('the line under overflow hidden was not flagged (clipped)'); + if (!bad.findings.some((f) => f.kind === 'outside' && /past the right/.test(f.text))) problems.push('the text past the viewport was not flagged (outside)'); + if (!bad.findings.some((f) => f.kind === 'covered' && /Hash rate|124 MH/.test(f.text))) problems.push('the label over the value was not flagged (covered, text over text)'); + if (!k.has('covered') || !k.has('clipped') || !k.has('outside')) problems.push('kinds found on the bad fixture: ' + [...k].join(',')); + } + if (!good || good.error) problems.push('the good fixture did not render: ' + (good && good.error)); + else { const f = good.findings.filter((x) => !allowed(x) && x.kind !== 'truncated'); if (f.length) problems.push('the clean fixture was flagged: ' + f.map((x) => x.kind + ' ' + JSON.stringify(x.text) + ' by ' + x.by).join('; ')); if (!good.findings.some((x) => x.kind === 'truncated')) problems.push('the ellipsis line was not noted as truncated'); } + if (problems.length) { console.error('overlap-check self-test FAILED:\n ' + problems.join('\n ')); if (bad) console.error(JSON.stringify(bad.findings, null, 1)); return false; } + console.log(`overlap-check self-test passed: the pill over the caption, the clipped line, the label over the value and the text past the edge are flagged; the clean page passes, its ellipsis noted (${run.secs.toFixed(1)} s)`); + return true; +} + +// ---------- the previews (the owner's set) ---------- +async function previews(pw, dir, miner, wallet) { + mkdirSync(dir, { recursive: true }); + const set = []; + if (miner) { const u = (q) => `http://127.0.0.1:${miner.port}/t/mock/?${q}`; set.push(['miner-setup', u('scenario=fresh&screen=welcome')], ['miner-mine-live', u('scenario=live&page=overview')], ['miner-earnings', u('scenario=live&page=earnings')], ['miner-prove', u('scenario=live&page=prove')], ['miner-settings', u('scenario=live&page=settings')], ['miner-update-card', u('scenario=live&page=overview&update=ready&card=1')]); } + if (wallet) { const u = (q) => `http://127.0.0.1:${wallet.port}/t/mock/?${q}`; set.push(['wallet-lock', u('scenario=unlock&bio=touch')], ['wallet-home', u('scenario=home&page=home')], ['wallet-send', u('scenario=home&page=send')], ['wallet-receive', u('scenario=home&page=receive')], ['wallet-settings', u('scenario=home&page=settings')], ['wallet-update-card', u('scenario=update&page=home')]); } + const browser = await pw.chromium.launch({ args: ['--no-sandbox', '--disable-gpu'] }); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark' }); + const page = await ctx.newPage(); const files = []; + for (const [name, url] of set) for (const [w, h] of [[1280, 800], [900, 700]]) { + await page.setViewportSize({ width: w, height: h }); + await page.goto(url + '&theme=dark', { waitUntil: 'load', timeout: 20000 }); + await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {}); await page.waitForTimeout(900); + const f = join(dir, `${name}__${w}.png`); await page.screenshot({ path: f }); files.push(f); + } + await browser.close(); + console.log('previews: ' + files.length + ' files in ' + dir); return files; +} + +// ---------- remote: ship the inputs to a box and run there ---------- +function hostFile(box) { const b = join(os.homedir(), '.config', 'igneum', 'build-server'); return box === '1' ? b : b + '-' + box; } +async function runRemote() { + const box = String(opt('--box', '2')); + const hf = hostFile(box); if (!existsSync(hf)) { console.error(`overlap-check: no browser here and no box host file at ${hf}; install Playwright (npm i playwright && npx playwright install chromium) or run infra/build-server/run-from-mac.sh --box ${box} `); return 2; } + const host = readFileSync(hf, 'utf8').trim(); const key = join(os.homedir(), '.ssh', 'igneum_ed25519'); + const wt = basename(root); const remote = `/srv/builds/_overlap/${wt}`; + const ssh = ['-i', key, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new']; + const rs = (src, dst) => { const r = spawnSync('rsync', ['-a', '--delete', '-e', `ssh ${ssh.join(' ')}`, src.endsWith('/') ? src : src + '/', `${host}:${dst}/`], { stdio: 'inherit' }); if (r.status !== 0) throw new Error('rsync failed for ' + src); }; + spawnSync('ssh', ssh.concat([host, `mkdir -p ${remote}/tools/ci ${remote}/tools/ui-mock ${remote}/app ${remote}/brand`]), { stdio: 'inherit' }); + const files = ['tools/ci/overlap-check.mjs']; for (const f of files) spawnSync('scp', ['-q', '-i', key, join(root, f), `${host}:${remote}/${f}`], { stdio: 'inherit' }); + const rargs = []; + if (has('--self-test')) rargs.push('--self-test'); + const siteDir = opt('--site', ''); if (siteDir) { const d = resolve(siteDir === true ? join(root, 'site') : siteDir); rs(d, `${remote}/site`); rargs.push('--site', 'site'); } + const appsTree = opt('--apps', ''); const prev = opt('--previews', ''); + if (appsTree || prev) { + const tree = resolve(appsTree && appsTree !== true ? appsTree : root); + rs(existsSync(join(tree, 'tools', 'ui-mock', 'server.mjs')) ? join(tree, 'tools', 'ui-mock') : join(root, 'tools', 'ui-mock'), `${remote}/tools/ui-mock`); + for (const d of ['app/igneum-app/ui', 'app/igneum-wallet/ui']) if (existsSync(join(tree, d))) { spawnSync('ssh', ssh.concat([host, `mkdir -p ${remote}/${d}`]), { stdio: 'inherit' }); rs(join(tree, d), `${remote}/${d}`); } + const coin = join(tree, 'brand', 'igneum-coin-1024.png'); if (existsSync(coin)) spawnSync('scp', ['-q', '-i', key, coin, `${host}:${remote}/brand/`], { stdio: 'inherit' }); + if (appsTree) rargs.push('--apps', '.'); if (prev) rargs.push('--previews', 'previews'); + } + for (const k of ['--widths', '--themes', '--jobs', '--only']) if (opt(k, '')) rargs.push(k, String(opt(k))); + if (SHOTS) rargs.push('--shots', 'shots'); if (JSON_OUT) rargs.push('--json', 'findings.json'); + const cmd = `cd ${remote} && . /srv/builds/_bin/overlap/env.sh && nice -n 10 node tools/ci/overlap-check.mjs --local ${rargs.map((a) => `'${a}'`).join(' ')}`; + console.log(`overlap-check: no browser on this machine; running on box ${box} (${host}:${remote})`); + const r = spawnSync('ssh', ssh.concat([host, cmd]), { stdio: 'inherit' }); + const back = (what, to) => spawnSync('rsync', ['-a', '-e', `ssh ${ssh.join(' ')}`, `${host}:${remote}/${what}`, to], { stdio: 'inherit' }); + if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); back('shots/', SHOTS); } + if (JSON_OUT) back('findings.json', JSON_OUT); + if (prev) { mkdirSync(prev, { recursive: true }); back('previews/', prev); console.log('previews copied to ' + resolve(prev)); } + return r.status === null ? 1 : r.status; +} + +// ---------- main ---------- +(async () => { + if (!has('--self-test') && !opt('--site', '') && !opt('--apps', '') && !opt('--previews', '')) { console.error('usage: overlap-check.mjs --self-test | --site [dir] | --apps [tree] | --previews [--widths ..] [--themes ..] [--shots dir] [--json file] [--local|--box N]'); process.exit(2); } + const pw = await loadPlaywright(); + if (!pw) { if (has('--local')) { console.error('overlap-check: Playwright is not installed here (npm i playwright; npx playwright install chromium) and --local forbids the box'); process.exit(2); } process.exit(await runRemote()); } + let code = 0; const all = []; + if (has('--self-test')) { if (!(await selfTest(pw))) { process.exitCode = 1; return; } } + const closers = []; + try { + const appsTree = opt('--apps', ''); const prev = opt('--previews', ''); + let miner = null, wallet = null; + if (appsTree || prev) { + const tree = resolve(appsTree && appsTree !== true ? appsTree : root); + // both mocks travel with the app tree (their scenarios match that UI): tools/ui-mock/server.mjs and wallet.mjs + const mockDir = existsSync(join(tree, 'tools', 'ui-mock', 'server.mjs')) ? join(tree, 'tools', 'ui-mock') : join(root, 'tools', 'ui-mock'); + if (existsSync(join(tree, 'app', 'igneum-app', 'ui', 'index.html'))) { miner = await spawnMock(join(mockDir, 'server.mjs'), null); closers.push(miner.close); } else console.log('overlap-check: no app/igneum-app/ui in ' + tree + '; the miner UI is skipped'); + if (existsSync(join(tree, 'app', 'igneum-wallet', 'ui', 'index.html')) && existsSync(join(mockDir, 'wallet.mjs'))) { wallet = await spawnMock(join(mockDir, 'wallet.mjs'), null); closers.push(wallet.close); } else console.log('overlap-check: no app/igneum-wallet/ui in ' + tree + '; the wallet UI is skipped'); + if (prev) await previews(pw, resolve(prev), miner, wallet); + if (appsTree) { const targets = (miner ? minerTargets(miner.port) : []).concat(wallet ? walletTargets(wallet.port) : []).filter((t) => !ONLY || t.name.includes(ONLY)); if (targets.length) all.push(await renderAll(pw, targets, 'apps')); } + } + const siteDir = opt('--site', ''); + if (siteDir) { + const dir = resolve(siteDir === true ? join(root, 'site') : siteDir); + const srv = await serveSite(dir); closers.push(srv.close); + const targets = siteTargets(dir, srv.port).filter((t) => !ONLY || t.name.includes(ONLY)); + all.push(await renderAll(pw, targets, 'site')); + } + } finally { for (const c of closers) try { c(); } catch (e) { /* closed */ } } + if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(all, null, 1)); + for (const run of all) { + const { fails, notes, errors } = report(run); + const renders = run.results.length, runs = run.results.reduce((a, r) => a + (r.runs || 0), 0); + console.log(`overlap-check (${run.label}): ${renders} renders, ${runs} text runs read, ${fails.length} overlaps, ${notes.length} notes, ${errors.length} render errors in ${run.secs.toFixed(1)} s`); + for (const n of notes) console.log(' note ' + n); + for (const e of errors) { console.log(' ERROR ' + e); code = 1; } + for (const f of fails) { console.log(' FAIL ' + f); code = 1; } + } + process.exitCode = code; // not process.exit: stdout to a pipe (ssh, the gate's log) is asynchronous and exit would drop the tail +})().catch((e) => { console.error('overlap-check: ' + (e.stack || e)); process.exitCode = 1; });