igneum/tools/ci/site-nav-check.mjs
igneum-labs 517320aad2 site-ui-5, deploy 1: the site in the apps' look (7 October 2026, 15:0x UK): one design layer on the app's tokens, the slim bar, the home fold on the shared scene, /download, OS and GPU marks
the project lead: "a site now what is on par with our current miner and wallet app look and feel with the new animation ... simple and
beautiful ... apple and windows icons everywhere needed, GPU brand icons ... easy access to all of our pages without a header
that is crazy".

- site.css section 10: the miner and wallet apps' treatments (app.css miner-ui-5, wallet-0.1.5) over the package: buttons
  (Plex Sans 600, radius 10), the status pill, graphite cards at 16 and rows at 12 on the hairline, the wallet lock screen's
  ember glow, the mark wells; the fourteen palette tokens stay in the scene-tokens block (scene/tokens.css); light and dark.
- Navigation: the seven-item header is gone. The slim bar carries the mark, a DEVNET pill with the live dot, Mine / Network /
  Learn (each opens a panel listing every route of its group with one line), the theme toggle and Download; on phones one
  button opens a full-screen sheet with the same groups. Keyboard: arrows between groups, Escape closes and returns focus,
  Tab walks a panel, the sheet is a dialog with its focus kept. The gate's header check is the new rule
  (tools/ci/site-nav-check.mjs: the bar, the three groups with their aria, 18 routes in the panels and the sheet with their
  descriptions, Download, the burger; self-test known-failed first).
- Home: one fold. The mark on the ember glow, one line, Download for Windows and Download for macOS with the OS glyphs and the
  stamped version (the visitor's platform first), "Linux and HiveOS" to /download, IgneumDag 2.0.3 painting underneath from
  /api/live through IgneumFeed (window 60, fps 60, poll false, no fork). Below: three lines on what a miner gets (E5 and X35
  verbatim), the live network in three tiles (hash rate, blocks a second, locked checkpoint; X2 and X31 verbatim), the
  community row (Discord, GitHub, Reddit), the footer (X7). The scene scripts are deferred; fonts preload with swap.
- /download: a new route. Windows, macOS, Linux and HiveOS cards with their marks, the stamped version, size and sha256, the
  HiveOS flight sheet, the three steps, the card picker with the vendor badge, the wallet for macOS; Windows wallet named as
  next with no file.
- Marks: brand/marks/vendor-marks.mjs (gpu-logos lane, a94dd192) copied byte for byte to site/lib/marks.mjs (the build and a
  gate line refuse drift); site/lib/os-marks.mjs adds Linux and HiveOS in the same treatment. The build stamps
  <span data-os> and <span data-gpu> and puts the vendor's mini badge in front of every table cell that names a card
  (/miner, /miners, the bench table), writes the --mark-* tokens into site.css between markers, and hands yourcard.js the
  badges as JSON for the card picker. The vendor well is scoped to [data-mark] so the ledger's and the journey's own
  .badge keep their look.
- The footer carries the four OS download chips; the nav's group wrappers are .nav-group (the wallet page owns .group).
- tools/site/serve.mjs and capture.sh: the site as Vercel serves it with /api passed to the live observer, captured on
  igneum-build-2 at 390 and 1440, dark and light (docs/plans/site-ui-5-shots, the home set in this commit). The overlap
  lane's detector ran over every page on build-2: 0 overlaps after the two fixes here (the explorer's hash titles wrap, the
  ledger's status badges wrap at 390).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:46:23 +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', '/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`);
}