igneum/tools/ci/padding-check.mjs
igneum-josh 693a0e0ada site-ui-5: the padding and visuals sweep (7 October 2026, 17:xx UK): the /wallet band's padding, tools/ci/padding-check.mjs in the gate, every finding fixed in site.css, the eye pass
Josh: the ember band on /wallet had no vertical padding (the heading on the band's top edge, the button on its bottom), then
"run a full site sweep for padding and visuals".

- The band: .band .wrap carried the padding and the three bands use .container; both selectors now, 56 px (40 on phones), and
  24 px at the sides on phones.
- tools/ci/padding-check.mjs: renders every served page at 390, 768, 1024, 1440 and 1600 in both themes and flags TIGHT (text or
  a control under 24 px from the edge of the filled band, card or section that holds it; rows of radius 12 and under are the
  app's compact rows and keep their own padding; small filled things such as buttons, pills, badges, tabs, cells and the bar are
  never containers), SCALE (a section's vertical padding not 0 and not a multiple of 4 from 24), TOUCH (controls under 6 px
  apart outside segmented groups), OVERRUN (an image or canvas past its frame) and UNDER-BAR (a heading under the sticky bar on
  load, an anchor the bar would cover). --self-test builds the fixture from the site's own stylesheet and bar with the exact
  /wallet band with its padding lost, a touching pair, an image over its frame and a heading under the bar; each must fail,
  the clean twin must pass. A gate line runs it (self-test, then the built site); where no browser exists it says so.
- Findings fixed (first run 2,740, then 1,040, then 48, now 0 at 230 renders): page heroes 64/48 (were 65/45), the
  observatory head and the 404 the same, the footer's bottom 24, doc sections and the litepaper's 40 (were 42), .sec 72 at
  tablet (was 70), the 404 card 36, /live's motion block 24 (was 25), the scenes lab 48 (was 50); every card at 24 inside
  (download cards, home tiles, the live tiles and stats, the evidence labels, the wallet state cards, the journey phases, the
  live inspector and toolbar, the download card on phones).
- The eye pass over every page at 1440 and 390 dark: the journey's phases are cards now, so the old timeline rail and marker
  (drawn for an unboxed list, sitting on the card's top border) go and the when column keeps clear of the body; tables keep
  words whole (break-word, not anywhere: "regenerat es" on the litepaper) and give the label column 14ch (one word per line on
  /randomx); the litepaper's body paragraphs share one measure; the ledger's count-table chips stay on one line; orphan words
  go through text-wrap: pretty on body text (copy law 18, "measurement." alone on /claims); the dev-fee page lost its doubled
  gutter (a lifted section keeps its own .container) and its table clears the text above; /faucet and /metamask keep a gap
  between headline and lead, line their two cards up at the top and end 72 px above the footer; a heading after a card grid
  gets 40 px.
- The overlap sweep at the same five widths and both themes: 0 overlaps at 290 renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:30:58 +01:00

170 lines
16 KiB
JavaScript

#!/usr/bin/env node
// The padding and visuals sweep (site-ui-5, 7 October 2026; Josh's /wallet band whose heading touched the band's top edge and
// whose button touched its bottom). Renders every served page at five widths in both themes and flags what a reader sees as
// cramped or broken, five classes:
// TIGHT text or a control closer than 24 px to the edge of the filled band, card or section that holds it
// SCALE a section whose vertical padding is off the design system's scale (0, or a multiple of 4 from 24 up)
// TOUCH two controls (buttons, links styled as buttons, inputs, selects) that touch or nearly touch (under 6 px apart)
// OVERRUN an image or canvas that runs out of its frame (the nearest .frame, .app-window, .scene, .panel or figure)
// UNDER-BAR a heading that renders under the sticky bar on load, or an anchor target the bar would cover on arrival
// The things that are small and filled by design (buttons, pills, badges, chips, tabs, table cells, the bar, tooltips) are
// containers whose own padding is theirs, so they are never the container of a TIGHT finding; segmented controls (.tabs,
// .theme-group, .zoom, [role=tablist]) are exempt from TOUCH.
//
// node tools/ci/padding-check.mjs --self-test the fixture (the exact /wallet band with its padding lost, a
// touching pair, an image over its frame, a heading under the bar)
// must fail with one finding of each class; the clean fixture passes
// node tools/ci/padding-check.mjs --site [dir] every *.html of the site (default site/)
// options: --widths 390,768,1024,1440,1600 --themes dark,light --only <substring> --json <file> --jobs 3
//
// Needs a browser: Playwright from $IGNEUM_PLAYWRIGHT_DIR, /srv/builds/_bin/overlap (the box) or this tree's node_modules.
// Without one it says so and exits 0 (CI installs Playwright; the box has it; the Mac renders nothing). Pages are served by
// tools/site/serve.mjs with /api answering 502, so every page renders its rest state; ?theme= and ?motion=off are passed.
import { readdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import http from 'node:http';
const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..');
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
const WIDTHS = opt('--widths', '390,768,1024,1440,1600').split(',').map(Number);
const THEMES = opt('--themes', 'dark,light').split(',');
const ONLY = opt('--only', ''), JSON_OUT = opt('--json', ''), JOBS = Number(opt('--jobs', '3'));
function playwright() {
for (const root of [process.env.IGNEUM_PLAYWRIGHT_DIR, '/srv/builds/_bin/overlap', repo].filter(Boolean)) {
try { return createRequire(join(root, 'package.json'))('playwright'); } catch (e) { /* next */ }
}
return null;
}
const pw = playwright();
if (!pw) { console.log('padding-check: no Playwright here (the box and CI have it); nothing checked'); process.exit(0); }
// what runs inside the page: returns the findings for this render
const INSPECT = `(() => {
const MIN = 24, GAP = 6, out = [];
const vis = el => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0.05; };
const txt = el => (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 60);
const path = el => { const parts = []; for (let e = el; e && e !== document.body && parts.length < 4; e = e.parentElement) parts.unshift(e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (e.classList.length ? '.' + [...e.classList].slice(0, 2).join('.') : '')); return parts.join(' > '); };
const alpha = c => { const m = /rgba?\\(([^)]+)\\)/.exec(c); if (!m) return c === 'transparent' ? 0 : 1; const p = m[1].split(',').map(s => parseFloat(s)); return p.length > 3 ? p[3] : 1; };
// a filled container has a background, or a border on every side (a framed box); a rule above or below a block is not a box
const filled = el => { const cs = getComputedStyle(el); const bordered = ['Top','Right','Bottom','Left'].every(s => parseFloat(cs['border' + s + 'Width']) > 0 && alpha(cs['border' + s + 'Color']) > 0.02); return alpha(cs.backgroundColor) > 0.02 || cs.backgroundImage !== 'none' || bordered; };
const SMALL = el => el.matches('a,button,input,select,textarea,label,td,th,.pill,.badge,.osmark,.chip,.tag,.tab,.tb,.state,.kind,.st,.sw,.lgi,.dot,code,kbd,summary,[role=tab],.nav,nav,.tip,.chip-zoom,.gen,.step-index,.n,.number,.num,.dev-label,.devnet,.hash-btn,.linkish,.switch,.track,.record-top .badge,.mini');
// 1. TIGHT: the nearest filled, sizeable container of each text or control, and the distance to its edges
const containerOf = el => { for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) { if (SMALL(e)) return null; if (filled(e)) { const r = e.getBoundingClientRect(); const radius = parseFloat(getComputedStyle(e).borderTopLeftRadius) || 0; /* a row (radius 12 or under) is the app's compact row, its own padding is its own; a card is 14 and up */ if (r.height >= 72 && r.width >= 200 && !(radius > 0 && radius <= 12)) return e; return null; } } return null; };
const seen = new Set();
for (const el of document.querySelectorAll('h1,h2,h3,h4,p,li,a.btn,button,input,select,.eyebrow,.lead,figcaption,dt,dd,.k,.v,.d,.mk,.mv,.mf,.key,strong.v')) {
if (!vis(el) || !txt(el) && !el.matches('a,button,input,select')) continue;
if (el.closest('.nav,.tip,.chip-zoom,table,.tabs,.theme-group,.zoom,.mode,.nav-sheet,.panels,figcaption,.frame .bar,.window-top,.hive-sheet,.footer-dl,.sheet-social,.social')) continue;
if (el.matches('figcaption')) continue;
const c = containerOf(el); if (!c) continue;
const r = el.getBoundingClientRect(), cr = c.getBoundingClientRect(), cs = getComputedStyle(c);
const inset = { top: r.top - cr.top, bottom: cr.bottom - r.bottom, left: r.left - cr.left, right: cr.right - r.right };
// a container that scrolls or clips (a table frame, a window) is judged by what is inside its padding box only
const bad = Object.entries(inset).filter(([k, v]) => v >= -1 && v < MIN).map(([k, v]) => k + ' ' + Math.round(v) + 'px');
if (bad.length) { const key = path(c) + '|' + bad.join(','); if (!seen.has(key)) { seen.add(key); out.push({ kind: 'TIGHT', what: txt(el) || el.tagName.toLowerCase(), where: path(el), container: path(c), detail: bad.join(', ') }); } }
}
// 2. SCALE: section paddings on the scale
for (const el of document.querySelectorAll('section,.sec,.band,.page-hero,.fold,.site-footer,.cta-band,main > .obs-head')) {
if (!vis(el)) continue; const cs = getComputedStyle(el);
for (const side of ['paddingTop', 'paddingBottom']) { const v = Math.round(parseFloat(cs[side])); if (v !== 0 && (v < 24 || v % 4 !== 0)) out.push({ kind: 'SCALE', what: side + ' ' + v + 'px', where: path(el), detail: 'not 0 and not a multiple of 4 from 24 up' }); }
}
// 3. TOUCH: controls that touch
const ctrls = [...document.querySelectorAll('a.btn,button,input,select,.community a,.dl-list a,.dl-row a,.footer-dl a')].filter(e => vis(e) && !e.closest('.tabs,.theme-group,.zoom,.mode,[role=tablist],.nav,.nav-sheet,.panels,table'));
for (let i = 0; i < ctrls.length; i++) for (let j = i + 1; j < ctrls.length; j++) {
const a = ctrls[i].getBoundingClientRect(), b = ctrls[j].getBoundingClientRect(); if (ctrls[i].contains(ctrls[j]) || ctrls[j].contains(ctrls[i])) continue;
const dx = Math.max(a.left, b.left) - Math.min(a.right, b.right), dy = Math.max(a.top, b.top) - Math.min(a.bottom, b.bottom);
if ((dx < GAP && dy < 0) || (dy < GAP && dx < 0)) out.push({ kind: 'TOUCH', what: (txt(ctrls[i]) || ctrls[i].tagName) + ' / ' + (txt(ctrls[j]) || ctrls[j].tagName), where: path(ctrls[i]), detail: 'gap ' + Math.round(Math.max(dx, dy)) + 'px' });
}
// 4. OVERRUN: media out of its frame
for (const el of document.querySelectorAll('img,canvas,video')) {
if (!vis(el)) continue; const f = el.closest('.frame,.app-window,.scene,.panel,.fold-scene,.proof-scene,figure,.wallet-card'); if (!f) continue;
const r = el.getBoundingClientRect(), fr = f.getBoundingClientRect(), over = Math.max(r.right - fr.right, r.bottom - fr.bottom, fr.left - r.left, fr.top - r.top);
if (over > 2 && getComputedStyle(f).overflow === 'visible') out.push({ kind: 'OVERRUN', what: el.tagName.toLowerCase() + ' ' + (el.getAttribute('src') || el.id || '').split('/').pop(), where: path(f), detail: Math.round(over) + 'px past the frame' });
}
// 5. UNDER-BAR: the bar covers a heading on load, or an anchor the bar would cover on arrival
const nav = document.querySelector('.nav'); const navH = nav ? nav.getBoundingClientRect().height : 0;
const h1 = [...document.querySelectorAll('h1')].find(vis);
if (h1 && navH && h1.getBoundingClientRect().top < navH - 1) out.push({ kind: 'UNDER-BAR', what: txt(h1), where: path(h1), detail: 'top ' + Math.round(h1.getBoundingClientRect().top) + 'px under a ' + Math.round(navH) + 'px bar' });
const sp = parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
for (const el of document.querySelectorAll('section[id],h2[id],h3[id],article[id],div[id].doc-section')) { const sm = parseFloat(getComputedStyle(el).scrollMarginTop) || 0; if (navH && Math.max(sp, sm) < navH) { out.push({ kind: 'UNDER-BAR', what: '#' + el.id, where: path(el), detail: 'scroll padding ' + Math.round(Math.max(sp, sm)) + 'px under a ' + Math.round(navH) + 'px bar' }); break; } }
return out;
})()`;
async function serve(dir) {
const port = 4800 + Math.floor(Math.random() * 200);
const child = spawn(process.execPath, [join(repo, 'tools', 'site', 'serve.mjs'), String(port), dir, 'http://127.0.0.1:1'], { stdio: 'ignore' });
await new Promise((res, rej) => { let n = 0; const t = setInterval(() => { http.get(`http://127.0.0.1:${port}/`, () => { clearInterval(t); res(); }).on('error', () => { if (++n > 80) { clearInterval(t); rej(new Error('serve.mjs did not answer')); } }); }, 100); });
return { port, stop: () => child.kill() };
}
async function sweep(dir, pages, browser) {
const { port, stop } = await serve(dir);
const findings = []; let renders = 0;
const jobs = []; for (const p of pages) for (const w of WIDTHS) for (const t of THEMES) jobs.push({ p, w, t });
async function one({ p, w, t }) {
const ctx = await browser.newContext({ viewport: { width: w, height: w < 500 ? 844 : 900 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const route = p === 'index' ? '/' : '/' + p;
try {
await page.goto(`http://127.0.0.1:${port}${route}?theme=${t}&motion=off`, { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(250);
const found = await page.evaluate(INSPECT);
renders++;
for (const f of found) findings.push({ page: p, width: w, theme: t, ...f });
} catch (e) { findings.push({ page: p, width: w, theme: t, kind: 'RENDER', what: e.message.split('\n')[0].slice(0, 120), where: route, detail: '' }); }
await ctx.close();
}
let i = 0; await Promise.all(Array.from({ length: Math.max(1, JOBS) }, async () => { while (i < jobs.length) await one(jobs[i++]); }));
stop();
return { renders, findings };
}
const fmt = f => ` FAIL ${f.page} ${f.width}px ${f.theme}: ${f.kind} "${f.what}" in ${f.where}${f.container ? ' (container ' + f.container + ')' : ''}${f.detail ? ': ' + f.detail : ''}`;
const browser = await pw.chromium.launch({ headless: true, args: ['--no-sandbox'] });
try {
if (args.includes('--self-test')) {
// the fixture: the site's own stylesheet and bar, the exact /wallet band with its padding lost, a touching pair, an image over
// its frame, a heading under the bar; then the same page with each fixed
const site = join(repo, 'site');
const fx = mkdtempSync(join(tmpdir(), 'padding-check-'));
const nav = readFileSync(join(site, 'partials', 'nav.html'), 'utf8'), head = readFileSync(join(site, 'partials', 'head.html'), 'utf8');
const css = readFileSync(join(site, 'site.css'), 'utf8');
writeFileSync(join(fx, 'site.css'), css);
const page = (broken) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>fixture</title>${head.replace(/href="\/fonts\//g, 'href="/nofonts/').replace(/url\(\/fonts\//g, 'url(/nofonts/')}
<style>${broken ? '.band .container{padding-top:0!important;padding-bottom:0!important}.pair .btn+.btn{margin-left:0!important}.pair{gap:0!important}.frame img{width:120%!important;max-width:none!important}.page-hero{margin-top:-120px}' : ''}</style></head><body>
${nav}
<main id="main"><section class="page-hero"><div class="container"><h1>Fixture.</h1><p class="lead">A page for the padding check.</p></div></section>
<section class="section"><div class="container"><div class="actions pair"><a class="btn primary" href="#a">One</a><a class="btn" href="#b">Two</a></div>
<figure class="frame" style="max-width:400px;overflow:visible"><div class="bar"><i></i><i></i><i></i></div><img src="/icon-192.png" width="192" height="192" alt=""></figure></div></section></main>
<div class="band"><div class="container"><div><h2>The miner pays this wallet.</h2><p>Install the miner, press Start, and the address it makes is the one this wallet imports with one click.</p></div><a href="/miner" class="btn dark">The miner</a></div></div>
</body></html>`;
writeFileSync(join(fx, 'broken.html'), page(true)); writeFileSync(join(fx, 'clean.html'), page(false));
writeFileSync(join(fx, '404.html'), '<!doctype html><title>404</title>'); writeFileSync(join(fx, 'icon-192.png'), readFileSync(join(site, 'icon-192.png')));
const saveW = WIDTHS.splice(0, WIDTHS.length, 1440), saveT = THEMES.splice(0, THEMES.length, 'dark');
const r = await sweep(fx, ['broken', 'clean'], browser);
rmSync(fx, { recursive: true, force: true });
const broken = r.findings.filter(f => f.page === 'broken'), clean = r.findings.filter(f => f.page === 'clean');
const kinds = new Set(broken.map(f => f.kind));
const band = broken.find(f => f.kind === 'TIGHT' && /band/.test(f.container || ''));
let ok = true;
for (const k of ['TIGHT', 'TOUCH', 'OVERRUN', 'UNDER-BAR']) if (!kinds.has(k)) { console.error('self-test: the broken fixture did not raise ' + k); ok = false; }
if (!band) { console.error('self-test: the /wallet band with its padding lost was not flagged TIGHT'); ok = false; }
if (clean.length) { console.error('self-test: the clean fixture should pass\n' + clean.map(fmt).join('\n')); ok = false; }
if (!ok) process.exit(1);
console.log(`self-test passed: the band with no padding (${band.detail}), a touching pair, an image over its frame and a heading under the bar are caught; the clean fixture passes`);
WIDTHS.push(...saveW); THEMES.push(...saveT);
}
if (args.includes('--site')) {
const dir = opt('--site', '').startsWith('--') || !opt('--site', '') ? join(repo, 'site') : opt('--site');
const pages = readdirSync(dir).filter(f => f.endsWith('.html')).map(f => f.replace(/\.html$/, '')).filter(p => !ONLY || p.includes(ONLY));
const t0 = Date.now();
const r = await sweep(dir, pages, browser);
if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(r.findings, null, 1));
for (const f of r.findings) console.log(fmt(f));
const byKind = {}; for (const f of r.findings) byKind[f.kind] = (byKind[f.kind] || 0) + 1;
console.log(`padding-check (site): ${r.renders} renders, ${r.findings.length} findings${r.findings.length ? ' (' + Object.entries(byKind).map(([k, n]) => k + ' ' + n).join(', ') + ')' : ''} in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
if (r.findings.length) process.exitCode = 1;
}
} finally { await browser.close(); }