#!/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 --json --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) => `fixture${head.replace(/href="\/fonts\//g, 'href="/nofonts/').replace(/url\(\/fonts\//g, 'url(/nofonts/')} ${nav}

Fixture.

A page for the padding check.

The miner pays this wallet.

Install the miner, press Start, and the address it makes is the one this wallet imports with one click.

The miner
`; writeFileSync(join(fx, 'broken.html'), page(true)); writeFileSync(join(fx, 'clean.html'), page(false)); writeFileSync(join(fx, '404.html'), '404'); 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(); }