igneum/tools/ci/padding-check.mjs
igneum-labs 7eed16a29a Pre-public scrub, the text pass (7 October 2026, 19:5x UK): no founder name, personal login, earlier business or personal address in any tracked text file, and a gate check that keeps it so
The sweep (main's item 1): 199 tracked text files, 783 lines. The founder's full name, first name and possessive become "the founder" (sentence starts capitalised); the lowercase operating-system user name in WSL paths and commands becomes <user>; the second owner login becomes "the second owner login"; the three earlier businesses and the two other brands become "the other business", "the earlier entity", "the earlier business" and "another brand"; the Chrome profile rule names the igneum.network profile, not the profile's label. The standing commit login igneum-labs is not a founder term here: the fresh-repository step renames it in the history (docs/plans/history-rewrite.md, tools/repo/fresh-repo.sh).

The patterns never appear in plain text in the tree (a plaintext list would be the hit): tools/ci/founder-strings.b64 (perl regex, tab, a sample per row) is read by tools/ci/founder-strings-check.sh (every tracked text file, perl, known-failed first: the self-test plants each row's sample in a fixture and the hit must name the file), by tools/community/discord-hooks.mjs (the guard's founder and business rows; the test takes its fixtures from the samples) and by tools/repo/fresh-repo.sh (the business names of the rewrite rules). site/forbidden-strings.txt carries the same patterns as b64: lines, decoded case-insensitive by site/scrub.mjs and tools/ci/launch-gates-check.mjs (whose fixture now plants an encoded made-up name). The check runs in the gate's tree checks on every merge.

Not in this commit, by main's word: the 105 commit messages and 40 personal-identity commits that need the history rewrite (listed, not run), and the secrets found by gitleaks over the history (reported with owners).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 18:39:50 +00:00

170 lines
16 KiB
JavaScript

#!/usr/bin/env node
// The padding and visuals sweep (site-ui-5, 7 October 2026; the founder'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(); }