igneum/tools/ci/site-nav-check.mjs
igneum-labs 7cf223fa2b Site, 7 October 2026 evening (the project lead's four asks through main, one landing): (1) the bar's nav to the right: the mark and the devnet pill on the left, Mine, Network, Learn and the controls right-aligned with the same gaps and sizes, each open panel anchored under its button (the script sets --panel-left) and a panel that would run past the container aligned to its button's right edge, the phone sheet unchanged; (2) no lateral scroll: tools/ci/scroll-width-check.mjs on the gate (scrollWidth equals clientWidth on every route at 390, 768, 1024, 1280 and 1440 in both themes; only a table, code or diagram scrolls inside its own overflow-x box; self-test with a 100vw plus 20 px fixture known-failed first); (3) LG-5, the launch text: the front page leads with the three wants (hardware that stays useful, income without a cliff, a fair supply) and the live network with finality is the second screen; the litepaper's regulatory note reads "Not legal advice." alone over its eight sentences from future.md 8.3 (the project lead: counsel not yet; the string "counsel is engaged" is in both forbidden-strings lists and gone from the research heading), the pay sentence stays; the journey's phase line reads Devnet 3 live, testnet next; (4) LG-1, /income: docs/analysis/income-tiers.md published as a page by tools/launch/income-page.mjs (checked on the gate against the document), listed under Learn, labels on every number, no price of IGN anywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 19:12:04 +00:00

78 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
// Every served page carries the slim bar (site-ui-5, the owner's rule of 7 October 2026: "easy access to all of our pages
// without a header that is crazy"): the mark and wordmark, the three groups (Mine, Network, Learn) as buttons with their
// aria, the Download button, and every route of the site reachable from the group panels AND from the phone sheet, each with
// its one-line description. A page with a shorter nav, a panel missing a route, or a route the panels do not name fails the
// build and the gate. The route list below is the contract: a new page adds its line here and in site/partials/nav.html in
// the same commit.
// node tools/ci/site-nav-check.mjs [site-dir]
// node tools/ci/site-nav-check.mjs --self-test a page with a route dropped from the sheet is caught; the tree passes
import { readdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const GROUPS = {
mine: ['/miner', '/download', '/app', '/wallet', '/miners', '/dev-fee', '/faucet', '/metamask'],
network: ['/live', '/explorer', '/journey', '/bench', '/evidence'],
learn: ['/litepaper', '/income', '/ledger', '/claims', '/randomx', '/provenance'],
};
const ROUTES = Object.values(GROUPS).flat();
// pages served without their own panel entry: the explorer's two rewrites, the 404 and the unlinked scenes lab
const UNLISTED = new Set(['block.html', 'address.html', '404.html', 'scenes.html']);
export function check(site) {
const pages = readdirSync(site).filter(f => f.endsWith('.html'));
const bad = [];
for (const page of pages) {
const html = readFileSync(join(site, page), 'utf8');
const nav = html.match(/<nav class="nav"[\s\S]*?<\/nav>/); if (!nav) { bad.push(`${page}: no <nav class="nav">`); continue; }
const n = nav[0];
if (!/<span class="word">IGNEUM<\/span>/.test(n)) bad.push(`${page}: no wordmark`);
if (!/<svg class="brand-mark"/.test(n)) bad.push(`${page}: no mark`);
for (const g of Object.keys(GROUPS)) {
if (!new RegExp(`<button type="button" class="group-btn" id="nav-btn-${g}" data-group="${g}"(?: data-active)? aria-expanded="(true|false)" aria-controls="nav-panel-${g}" aria-haspopup="true"`).test(n)) bad.push(`${page}: the ${g} group button or its aria is missing`);
const panel = n.match(new RegExp(`<div class="nav-panel" id="nav-panel-${g}"[\\s\\S]*?<\\/div>\\s*<\\/div>`));
if (!panel) { bad.push(`${page}: no panel for ${g}`); continue; }
for (const r of GROUPS[g]) if (!new RegExp(`<a href="${r}"[^>]*><b>[^<]+</b><span>[^<]+</span></a>`).test(panel[0])) bad.push(`${page}: the ${g} panel lacks ${r} with its description`);
}
const sheet = n.match(/<div class="nav-sheet" id="nav-sheet"[\s\S]*?<\/nav>/);
if (!sheet) { bad.push(`${page}: no phone sheet`); continue; }
for (const r of ROUTES) if (!new RegExp(`<a href="${r}"[^>]*><b>[^<]+</b><span>[^<]+</span></a>`).test(sheet[0])) bad.push(`${page}: the sheet lacks ${r} with its description`);
if (!/<a href="\/download" class="btn primary small cta" data-nav="download">Download<\/a>/.test(n)) bad.push(`${page}: no Download button in the bar`);
if (!/<a href="\/download" class="btn primary big" data-nav="download">Download<\/a>/.test(sheet[0])) bad.push(`${page}: no Download in the sheet`);
if (!/<button class="burger" id="nav-burger" type="button" aria-expanded="(true|false)" aria-controls="nav-sheet"/.test(n)) bad.push(`${page}: the burger lacks its aria attributes`);
if (!/role="dialog" aria-modal="true" aria-label="Menu"/.test(sheet[0])) bad.push(`${page}: the sheet is not a dialog`);
if (/\.(group-btn|panel-list a|sheet-group a|nav \.cta)[^{]*\{[^}]*display:\s*none/.test(html.replace(/@media \(max-width:860px\)\{[\s\S]*?\}\}/g, ''))) bad.push(`${page}: a page style hides the bar's items`);
// the page's own route is marked in the bar (aria-current) unless it is one of the unlisted views
const route = '/' + page.replace(/\.html$/, '').replace(/^index$/, '');
if (route !== '/' && !UNLISTED.has(page) && !ROUTES.includes(route)) bad.push(`${page}: served but named in no panel (add it to a group in nav.html and here)`);
}
// every panel route is a served page
for (const r of ROUTES) if (!pages.includes(r.slice(1) + '.html')) bad.push(`${r} is in a panel but has no page`);
return { pages: pages.length, bad };
}
const args = process.argv.slice(2);
if (args[0] === '--self-test') {
const site = join(here, '..', '..', 'site');
const fx = mkdtempSync(join(tmpdir(), 'nav-check-'));
try {
for (const f of readdirSync(site).filter(f => f.endsWith('.html'))) writeFileSync(join(fx, f), readFileSync(join(site, f), 'utf8'));
const clean = check(fx); if (clean.bad.length) { console.error('self-test: the tree copy should pass\n' + clean.bad.join('\n')); process.exit(1); }
// known failed: /ledger dropped from the sheet of one page
const p = join(fx, 'miner.html'); const h = readFileSync(p, 'utf8');
const sheetAt = h.indexOf('<div class="nav-sheet"'); const broken = h.slice(0, sheetAt) + h.slice(sheetAt).replace(/<a href="\/ledger"[^>]*>[\s\S]*?<\/a>/, '');
writeFileSync(p, broken);
const r = check(fx); if (!r.bad.some(b => /miner\.html: the sheet lacks \/ledger/.test(b))) { console.error('self-test: a route dropped from the sheet was not caught'); process.exit(1); }
// known failed: a group button without its aria
writeFileSync(p, h.replace('id="nav-btn-learn" data-group="learn" aria-expanded="false"', 'id="nav-btn-learn" data-group="learn"'));
const r2 = check(fx); if (!r2.bad.some(b => /miner\.html: the learn group button/.test(b))) { console.error('self-test: a group button without aria was not caught'); process.exit(1); }
console.log('self-test passed: a route dropped from the sheet and a group button without its aria are caught; the tree passes');
} finally { rmSync(fx, { recursive: true, force: true }); }
} else {
const site = args[0] || join(here, '..', '..', 'site');
const { pages, bad } = check(site);
if (bad.length) { console.error('site nav check: ' + bad.length + ' problems\n' + bad.join('\n')); process.exit(1); }
console.log(`site nav check: ${pages} pages carry the slim bar: the mark, three groups with ${ROUTES.length} routes in the panels and the sheet, Download, the burger`);
}