#!/usr/bin/env node // Lateral scroll (the project lead, 7 October 2026: "the website big container also has some lateral scroll movement that needs fixing"). // Renders every route at five widths in both themes and fails when document.documentElement.scrollWidth exceeds clientWidth: // the page itself must never scroll sideways; only a table, code or diagram may scroll inside its own overflow-x container. // On a failure it names the widest elements that run past the viewport, so the cause is fixed rather than the body clipped. // node tools/ci/scroll-width-check.mjs --self-test a fixture with a 100vw band plus 20 px must fail; the clean one passes // node tools/ci/scroll-width-check.mjs --site [dir] every *.html of the site (default site/) // options: --widths 390,768,1024,1280,1440 --themes dark,light --only // 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 (the box and CI have it; the Mac renders nothing). Pages are served by tools/site/serve.mjs. import { readdirSync, writeFileSync, mkdtempSync, rmSync } 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,1280,1440').split(',').map(Number), THEMES = opt('--themes', 'dark,light').split(','), ONLY = opt('--only', ''); 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) { } } return null; } const pw = playwright(); if (!pw) { console.log('scroll-width-check: no Playwright here (the box and CI have it); nothing checked'); process.exit(0); } const INSPECT = `(() => { const de = document.documentElement, sw = de.scrollWidth, cw = de.clientWidth; const wide = []; if (sw > cw) { for (const el of document.querySelectorAll('body *')) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > cw + 1 && !el.closest('[style*="overflow-x"],.tbl,pre,.diagram,.scroll-x')) { const cs = getComputedStyle(el); if (cs.position === 'fixed') continue; wide.push({ right: Math.round(r.right), width: Math.round(r.width), what: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : '') }); } } wide.sort((a, b) => b.right - a.right); } return { sw, cw, wide: wide.slice(0, 4) }; })()`; async function serve(dir) { const port = 4700 + 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, child }; } async function sweep(dir, routes) { const { port, child } = await serve(dir); const browser = await pw.chromium.launch(); const bad = []; let n = 0; try { for (const w of WIDTHS) { const ctx = await browser.newContext({ viewport: { width: w, height: w < 500 ? 844 : 900 }, deviceScaleFactor: 1, reducedMotion: 'reduce' }); const page = await ctx.newPage(); for (const t of THEMES) for (const route of routes) { await page.goto(`http://127.0.0.1:${port}${route}?theme=${t}&motion=off`, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(150); const r = await page.evaluate(INSPECT); n++; if (r.sw > r.cw) bad.push(`${route} at ${w} ${t}: scrollWidth ${r.sw} > clientWidth ${r.cw}; widest: ${r.wide.map((x) => x.what + ' right ' + x.right).join(', ') || 'none found below body'}`); } await ctx.close(); } } finally { await browser.close(); child.kill(); } return { bad, n }; } const routes = (dir) => readdirSync(dir).filter((f) => f.endsWith('.html') && f !== '404.html' && (!ONLY || f.includes(ONLY))).map((f) => '/' + f.replace(/\.html$/, '').replace(/^index$/, '')); if (args.includes('--self-test')) { const dir = mkdtempSync(join(tmpdir(), 'scroll-fixture-')); const page = (band) => `fixture
a table may scroll inside its own box

text

`; writeFileSync(join(dir, 'broken.html'), page('width:calc(100vw + 20px);')); writeFileSync(join(dir, 'clean.html'), page('width:100%;')); writeFileSync(join(dir, 'index.html'), page('width:100%;')); // serve.mjs answers / from index.html const r = await sweep(dir, ['/broken', '/clean']); rmSync(dir, { recursive: true, force: true }); const brokenHit = r.bad.filter((b) => b.startsWith('/broken')).length, cleanHit = r.bad.filter((b) => b.startsWith('/clean')).length; if (brokenHit !== WIDTHS.length * THEMES.length || cleanHit !== 0) { console.error('scroll-width-check self-test: the fixture did not behave', r.bad); process.exit(1); } console.log(`scroll-width-check self-test: the 100vw + 20 px band fails at every width (${brokenHit}), the clean page and its scrolling table pass`); process.exit(0); } const dir = opt('--site', join(repo, 'site')); const r = await sweep(dir, routes(dir)); if (r.bad.length) { console.error(`scroll-width-check: ${r.bad.length} of ${r.n} renders scroll sideways:`); for (const b of r.bad) console.error(' ' + b); process.exit(1); } console.log(`scroll-width-check: ${r.n} renders, none scrolls sideways (${WIDTHS.join(', ')} wide, ${THEMES.join(' and ')})`);