${esc(heading)}
${note}
Generated from the repository at build time. Times are UTC. Machine names are model names.
// Igneum site build: renders the engineering log from the repository's markdown into a branded
// page and merges the log's dated entries into the journey feed. The FUD ledger is internal and
// is never rendered here.
// Runs on every deploy (Vercel build command) and locally with `node build.mjs`.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { scrubBench } from './scrub.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const docs = join(repo, 'docs');
function esc(s) { return s.replace(/&/g, '&').replace(//g, '>'); }
function inline(s) {
s = esc(s);
s = s.replace(/`([^`]+)`/g, '$1');
s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
s = s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '$1');
return s;
}
function md(src) {
const lines = src.split('\n'); const out = []; let i = 0; let toc = [];
const slug = t => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
while (i < lines.length) {
const l = lines[i];
if (/^```/.test(l)) { const buf = []; i++; while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]); i++; out.push('
' + esc(buf.join('\n')) + ''); continue; }
const h = /^(#{1,4})\s+(.*)$/.exec(l);
if (h) { const lvl = h[1].length; const t = h[2].trim(); const id = slug(t); if (lvl <= 2) toc.push({ lvl, t, id }); out.push(`| ${inline(c)} | `).join('') + '
|---|
| ${inline(c)} | `).join('') + '
' + inline(buf.join(' ')) + '
'); } return { html: out.join('\n'), toc }; } // Shared chrome: fonts and tokens (head), the nav bar and the footer live once in site/partials/ and are injected into every // page between HTML comment markers ( ... , nav, footer; journey on the homepage). // The committed pages carry the injected copy, so they are right with or without a build; the build keeps them in step. const partial = name => readFileSync(join(here, 'partials', name), 'utf8').trim(); const HEAD = partial('head.html'), NAV = partial('nav.html'), FOOT = partial('footer.html'); function navFor(active) { if (!active) return NAV; const re = new RegExp(`([\\s\\S]*?`); if (!re.test(html)) throw new Error(`${file}: missing ... markers`); return html.replace(re, () => `\n${content}\n`); } const ORIGIN = 'https://igneum.network'; const svgIcon = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E"; // Search and share metadata. Same set as the hand-written pages (index, litepaper, live); keep them in step. function meta(title, desc, path) { const url = ORIGIN + path; const t = esc(title); const d = esc(desc); return `${note}
Generated from the repository at build time. Times are UTC. Machine names are model names.
(the versioned URL, for the HiveOS Flight Sheet) and .
// TESTNET_OPEN: the one flag of the go. While false every download section keeps its "Public testnet: not yet open" line
// (elements marked data-testnet-notice); true removes them.
const TESTNET_OPEN = false;
const DL_HOST = 'https://dl.igneum.network';
const DL_SNAPSHOT = join(here, 'downloads.json');
// The snapshot is a tracked file: the build rewrites it only on an explicit refresh (SITE_DOWNLOADS_REFRESH=1 or
// --refresh-downloads, the ship pipeline's step, committed with the release), or in CI (a throwaway checkout). A plain
// local build, and the pre-push hook, read live but never write: on 6 October 2026 the hook's build stamped 0.3.14's rows
// into five worktrees that had nothing to do with the release, as uncommitted edits to tracked files.
const DL_REFRESH = process.env.SITE_DOWNLOADS_REFRESH === '1' || process.argv.includes('--refresh-downloads') || !!process.env.CI;
async function loadDownloads() {
const snapshot = existsSync(DL_SNAPSHOT) ? JSON.parse(readFileSync(DL_SNAPSHOT, 'utf8')) : { files: {} };
if (process.env.SITE_DOWNLOADS_OFFLINE) return { ...snapshot, source: 'snapshot (offline)' };
try {
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(`${DL_HOST}/dl/public/igneum-downloads.json`, { signal: ctl.signal, headers: { 'Cache-Control': 'no-cache' } });
clearTimeout(t);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const live = await r.json();
if (!live || typeof live.files !== 'object') throw new Error('no files object');
for (const [k, f] of Object.entries(live.files)) if (!/^\/public\/[a-z0-9.-]+$/.test(f.alias) || !/^\d+\.\d+\.\d+$/.test(String(f.version)) || !(f.size > 0)) throw new Error(`bad entry ${k}`);
if (DL_REFRESH) writeFileSync(DL_SNAPSHOT, JSON.stringify(live, null, 2) + '\n');
else if (JSON.stringify(live) !== JSON.stringify(snapshot)) console.warn('downloads: the live index differs from site/downloads.json; the release step refreshes it (SITE_DOWNLOADS_REFRESH=1 node site/build.mjs)');
return { ...live, source: 'live' };
} catch (e) {
console.warn(`downloads: using the snapshot (${e.message})`);
return { ...snapshot, source: 'snapshot' };
}
}
const fmtMB = n => (n / 1e6).toLocaleString('en-GB', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' MB';
function stampDownloads(html, file, dl) {
const f = dl.files || {};
html = html.replace(/]*)\sdata-dl="([a-z-]+)"([^>]*)>/g, (m, pre, key, post) => {
const attrs = (pre + post).replace(/\shref="[^"]*"/, '').replace(/\sdata-dl-missing(="[^"]*")?/, '').replace(/\saria-disabled="[^"]*"/, '');
if (!f[key]) return ``;
return ``;
});
// the stamped elements keep their other attributes (class, style); only the text between the tags is replaced
html = html.replace(/]*\sdata-dl-meta="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `${f[key] ? `v${esc(String(f[key].version))} · ${fmtMB(f[key].size)}` : 'not published yet'}`);
html = html.replace(/]*\sdata-dl-url="([a-z-]+)"[^>]*)>[^<]*<\/code>/g, (m, attrs, key) => `${f[key] ? `${DL_HOST}${esc(f[key].path)}` : '(not published yet)'}`);
html = html.replace(/]*\sdata-dl-sha="([a-z-]+)"[^>]*)>[^<]*<\/span>/g, (m, attrs, key) => `${f[key] ? esc(String(f[key].sha256)) : 'not published yet'}`);
if (TESTNET_OPEN) html = html.replace(/\n?[ \t]*]*>[\s\S]*?<\/p>/g, '');
for (const key of ['miner-windows', 'miner-mac', 'miner-hive', 'wallet-mac']) if (html.includes(`data-dl="${key}"`) && !f[key]) console.warn(`${file}: ${key} has no published file yet; its button points at #get`);
return html;
}
const downloads = await loadDownloads();
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
// the evidence page (/evidence): the claims table, the counts and the date are rendered from docs/evidence.md (6 October 2026);
// the page's prose stays in site/evidence.html, the rows are judgement edited in the markdown and follow from there.
function renderEvidence(html) {
const mdp = join(docs, 'evidence.md');
if (!existsSync(mdp)) return html;
const src = readFileSync(mdp, 'utf8');
const start = src.indexOf('\n| # | Claim |'); const end = src.indexOf('\n## Count by status');
if (start < 0 || end < 0) throw new Error('evidence.md: claims table not found');
const rows = src.slice(start, end).split('\n').filter(l => /^\| \d+ \|/.test(l));
const LABELS = ['designed', 'implemented', 'tested by the team', 'reproduced externally', 'reviewed independently'];
const inl = t => esc(t.trim()).replace(/`([^`]+)`/g, (m, c) => `${c}`);
const cells = l => l.replace(/^\| /, '').replace(/ \|$/, '').split(' | ');
const counts = Object.fromEntries(LABELS.map(k => [k, 0]));
const tr = rows.map(l => {
const c = cells(l); if (c.length !== 8) throw new Error(`evidence.md: row ${c[0]} has ${c.length} cells`);
const [n, claim, where, status, ver, test, result, iv] = c;
const idx = LABELS.findIndex(k => status.toLowerCase().includes(k)); if (idx < 0) throw new Error(`evidence.md: row ${n} status "${status}"`);
counts[LABELS[idx]]++;
return `
${n} ${inl(claim)}${inl(where)} ${inl(status)} ${inl(ver)} ${inl(test)} ${inl(result)} ${inl(iv)} `;
}).join('\n');
// the same scrub as /bench: local-time stamps to UTC and the private-string check (the build fails on any hit)
const trs = scrubBench(tr);
html = html.replace(/([\s\S]*?)[\s\S]*?(<\/tbody>)/, (m, a, b) => `${a}\n${trs}\n${b}`);
for (const k of LABELS) {
html = html.replace(new RegExp(`(${k})\\d+()`), `$1${counts[k]}$2`);
html = html.replace(new RegExp(`(data-filter="${k}">)\\d+()`), `$1${counts[k]}$2`);
}
html = html.replace(/(data-filter="">)\d+(<\/b>)/, `$1${rows.length}$2`);
const d = new Date(); const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const day = `${d.getUTCDate()} ${MON[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
const dayLong = `${d.getUTCDate()} ${['January','February','March','April','May','June','July','August','September','October','November','December'][d.getUTCMonth()]} ${d.getUTCFullYear()}`;
html = html.replace(/(?:<\/span>)?\d+ claims · 5 labels · [^<]*<\/div>/, `${rows.length} claims · 5 labels · ${day}`);
html = html.replace(/Statuses are honest as of [^<]*<\/p>/, `
Statuses are honest as of ${dayLong}, the day this page was generated from docs/evidence.md, and change only through that file.
`);
return html;
}
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['app.html', 'app'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''],
// the DAG explorer (5 Oct 2026): /explorer, /block/ and /address/ (vercel.json rewrites the last two)
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live'],
// the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex
['scenes.html', '']];
for (const [file, active] of PAGES) {
const p = join(here, file);
if (!existsSync(p)) throw new Error(`missing page ${file}`);
let html = readFileSync(p, 'utf8');
html = inject(html, 'head', HEAD, file);
html = inject(html, 'nav', navFor(active), file);
html = inject(html, 'footer', FOOT, file);
html = stampProduct(html, file);
html = stampDownloads(html, file, downloads);
if (file === 'evidence.html') html = renderEvidence(html);
if (file === 'index.html') html = inject(html, 'journey', ``, file);
writeFileSync(p, html);
built.push(file);
}
// the public bench table (/miners): one row per card, generator version and miner version, from site/miner-bench.json
// (the bench-log numbers that exist today, and rows later jobs append); rendered through the same scrub as /bench
{
const bj = JSON.parse(readFileSync(join(here, 'miner-bench.json'), 'utf8'));
const rows = bj.rows.slice().sort((a, b) => (a.card < b.card ? -1 : a.card > b.card ? 1 : a.generator < b.generator ? -1 : a.generator > b.generator ? 1 : b.mh_s - a.mh_s));
const fmt = (n) => Number(n).toLocaleString('en-GB', { maximumFractionDigits: 1 });
const cell = (r) => [
r.card, r.generator, fmt(r.mh_s), r.mh_per_w == null ? 'not measured' : fmt(r.mh_per_w), r.miner, r.date, r.source, r.by + (r.note ? '. ' + r.note : ''),
];
const table = '' + ['Card', 'Generator', 'Best MH/s', 'MH per watt', 'Miner', 'Date', 'Source', 'Who measured it'].map(h => `${h} `).join('') + ' ' +
rows.map(r => '' + cell(r).map(c => `${esc(String(c))} `).join('') + ' ').join('') + '
';
// Ember Tune's fleet priors (site/miner-priors.json, tools/tuning.mjs --priors --site): one row per card model,
// driver major and program class; a row under the sample floor shows its count and no point
const pj = JSON.parse(readFileSync(join(here, 'miner-priors.json'), 'utf8'));
const prows = (pj.rows || []).slice().sort((a, b) => (b.samples - a.samples) || (a.card < b.card ? -1 : 1));
const pcell = r => [
r.card, r.driver_major, r.class, r.samples + (r.machines ? ' from ' + r.machines + ' machine' + (r.machines === 1 ? '' : 's') : ''),
r.prior ? (r.clock_mhz ? fmt(r.clock_mhz) + ' MHz at ' + r.power_pct + '%' : r.power_pct + '%, clock unlocked') : 'under the floor (' + pj.min_samples + ' needed)',
r.mh_per_w == null ? 'not yet' : Number(r.mh_per_w).toFixed(3) + (r.spread_pct != null ? ' (spread ' + r.spread_pct + '%)' : ''),
r.mh_s == null ? '' : fmt(r.mh_s) + ' MH/s at ' + fmt(r.watts) + ' W',
r.untuned_mh_per_w == null ? 'not measured' : Number(r.untuned_mh_per_w).toFixed(3) + (r.gain_pct != null ? ' (' + (r.gain_pct >= 0 ? '+' : '') + r.gain_pct + '%)' : ''),
];
const ptable = prows.length
? '' + ['Card', 'Driver', 'Program class', 'Samples', 'Tuned point', 'MH per watt', 'Rate and draw', 'Untuned MH per watt (gain)'].map(h => `${h} `).join('') + ' ' +
prows.map(r => '' + pcell(r).map(c => `${esc(String(c))} `).join('') + ' ').join('') + '
'
: 'No tune reports yet. The first rows appear once five machines with the same card model have reported.
';
const body = scrubBench([
'The table
',
'One row per card, generator version and miner version. The rate is the best one measured. Integrated GPUs are not listed. Prototype rows are bench numbers from before the devnet and say so in the miner column.
',
table,
'How a row gets here
',
'Every row names the engineering log entry or the job it came from. "Measured by the team" means our own hardware and our own log. "Reported by the fleet" means a machine we do not own, read from the status lines its miner uploads.
',
'MH per watt needs the card\'s power draw during the run. The app reads it on NVIDIA cards through the driver. Rows get the figure when a run records it.
',
'There is no other Igneum miner to compare with yet, so this table compares cards, not miners. The app that produces these rows: the miner page.
',
`Rows: ${rows.length}. Each row names the engineering log entry it came from.
`,
'Fleet tuning priors
',
'Ember Tune runs on every card the app mines with: the power limit and the core clock are stepped on the live program and the card keeps the point with the best MH per watt within 1% of its top rate. Every finished tune is reported back without anything that identifies the owner, and the fleet\'s median point per card model, driver major and program class comes back down inside the signed update manifest as the starting point for the next card of that model. A model needs ' + pj.min_samples + ' reports before its prior is used.
',
ptable,
// the team's own measured rows (site/miner-priors.json "team"): the per-user sentence per card, with its job
...((pj.team || []).length ? ['Measured by the team
', '' + pj.team.map(t => `- ${esc(t.card)} (driver ${esc(t.driver_major)}, class ${esc(t.class)}, ${esc(t.date)}, job
${esc(t.job)}): ${esc(t.point)}. ${esc(t.sentence)}${t.note ? ' ' + esc(t.note) : ''} `).join('') + '
'] : []),
`Rows: ${prows.length}${pj.generated ? ', generated ' + pj.generated : ''}. Written from the fleet's tune records at build time.
`,
].join('\n'));
const toc = [{ lvl: 2, t: 'The table', id: 'table' }, { lvl: 2, t: 'How a row gets here', id: 'how' }, { lvl: 2, t: 'Fleet tuning priors', id: 'priors' }];
writeFileSync(join(here, 'miners.html'), page('Igneum GPU bench table', 'Measured Igneum hash rates per GPU: card, generator version, best MH/s, MH per watt where measured, miner version, date and the log entry each number came from.', body, toc,
'Measured hash rates per card on the Igneum lottery hash, with the generator version, the miner version, the date and the log entry behind each number.',
{ path: '/miners', heading: 'GPU bench table', active: 'miner', eyebrow: `${rows.length} measured rows, ${prows.length} fleet tuning models`, crumb: 'GPU bench table' }));
built.push('miners.html (' + rows.length + ' rows)');
}
// The secondary pages (7 Oct 2026, the redesign): each one is rendered from ONE source so no sentence is kept twice by hand.
// /claims and /randomx are the litepaper's own sections (their ledger sentences stay on the litepaper, which is what the
// ledger text check reads); /dev-fee is the miner page's fee section; /provenance is docs/provenance.md through the same
// scrub as /bench; /journey is journey.json (the phases and the log the bench entries feed). A page with no source is not written.
function shell({ title, desc, path, crumb, eyebrow, heading, lead, body, active = '', style = '', after = '' }) {
return `
${meta(title, desc, path)}
${HEAD}
${navFor(active)}
${eyebrow}
${heading}
${lead}
${body}
${after}
${FOOT}
`;
}
// a section of a built page, its own h2 and section head removed, its in-page anchors pointed back at the source page
function sectionOf(file, id, base) {
const html = readFileSync(join(here, file), 'utf8');
const m = new RegExp(`]*>([\\s\\S]*?) `).exec(html);
if (!m) throw new Error(`${file}: no `);
let inner = m[1];
// the section head (eyebrow, h2, an optional lead) is removed by a balanced walk over its divs, never a lazy regex
// (7 Oct 2026: a lazy match ran on to the first pair inside the fee card and emptied /dev-fee)
const hs = inner.indexOf('');
let lead = '';
if (hs >= 0) {
let depth = 0, i = hs; const re = //g; re.lastIndex = hs; let m;
while ((m = re.exec(inner))) { depth += m[0] === '' ? -1 : 1; if (depth === 0) { i = m.index + m[0].length; break; } }
if (depth !== 0) throw new Error(`${file}#${id}: unbalanced section head`);
const head = inner.slice(hs, i); const pm = /([\s\S]*?)<\/p>\s*<\/div>\s*$/.exec(head); lead = pm ? pm[1] : '';
inner = inner.slice(0, hs) + inner.slice(i);
}
inner = inner.replace(/^\s*
[\s\S]*?<\/h2>/, '');
const ids = new Set([...inner.matchAll(/\sid="([^"]+)"/g)].map(x => x[1]));
inner = inner.replace(/href="#([^"]+)"/g, (all, frag) => ids.has(frag) ? all : `href="${base}#${frag}"`);
return { html: inner.trim(), lead };
}
function onward(links) { return `${links.map(([href, label]) => `${esc(label)}`).join('')}`; }
{
const lp = sectionOf('litepaper.html', 'limits', '/litepaper');
writeFileSync(join(here, 'claims.html'), shell({
title: 'What Igneum does not claim', desc: 'The limits of Igneum, stated first: proof times, chips, income, finality in the first month, the review it has not had yet. From the litepaper, with the ledger behind it.',
path: '/claims', crumb: 'What Igneum does not claim', eyebrow: 'The limits, stated first', heading: 'What Igneum does not claim.',
lead: 'Every limit we know of, written down here before anyone else writes it. The same text as the litepaper’s last section; the ledger carries every criticism in full.',
body: `${lp.html}`, active: 'litepaper',
after: onward([['/ledger', 'The ledger: every criticism'], ['/evidence', 'The evidence page'], ['/litepaper#limits', 'Read it in the litepaper']]),
}));
built.push('claims.html (from litepaper#limits)');
const rx = sectionOf('litepaper.html', 'randomx', '/litepaper');
writeFileSync(join(here, 'randomx.html'), shell({
title: 'Igneum and RandomX', desc: 'Monero’s random-program idea, rebuilt for graphics cards: a program per hour instead of a program per hash, a dataset that grows, and what RandomX’s record does and does not say about Igneum.',
path: '/randomx', crumb: 'Igneum vs RandomX', eyebrow: 'Monero’s idea, finished for GPUs', heading: 'RandomX, rebuilt for GPUs.',
lead: 'Random-program mining is the idea Igneum borrowed. Here is what changed when it was rebuilt for graphics cards, and what RandomX’s seven years say and do not say about this chain. The same text as the litepaper’s section.',
body: `${rx.html}`, active: 'litepaper',
after: onward([['/litepaper#mining', 'How mining works'], ['/litepaper#chip-model', 'The chip model'], ['/provenance', 'Built on the shoulders']]),
}));
built.push('randomx.html (from litepaper#randomx)');
const fee = sectionOf('miner.html', 'fee', '/miner');
writeFileSync(join(here, 'dev-fee.html'), shell({
title: 'The Ember dev fee, in full view', desc: 'The protocol carries no fee. The Ember software takes an optional 1% dev fee, the norm for GPU miners, visible in the app and off with one flag. Where it is, how it was measured, how to turn it off.',
path: '/dev-fee', crumb: 'The dev fee', eyebrow: 'The dev fee', heading: 'The fee, in full view.',
lead: fee.lead || 'The protocol carries no fee. The software fee is the miner app’s, like every other GPU miner’s, and one flag turns it off.',
body: fee.html, active: 'miner',
style: '.feecard{display:grid;grid-template-columns:1fr;gap:var(--s-4)}@media (min-width:900px){.feecard{grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);align-items:start}}pre b{color:var(--molten-text);font-weight:500}',
after: onward([['/miner#get', 'Get the miner'], ['/litepaper#economics', 'The economics'], ['/bench', 'The engineering log']]),
}));
built.push('dev-fee.html (from miner#fee)');
}
if (existsSync(join(docs, 'provenance.md'))) {
const src = scrubBench(readFileSync(join(docs, 'provenance.md'), 'utf8'));
const { html, toc } = md(src);
writeFileSync(join(here, 'provenance.html'), page('Igneum provenance: built on the shoulders', 'Every borrowed component of Igneum credited in public: the origin and licence, what changed, why the design needed it, and how the change is measured. What is new in Igneum, and what is adopted from upstream.', html, toc,
'Igneum credits every borrowed component in public, replaces only what its own design requires, and measures every change. This table is the record: the origin and licence, what changed, why, and the measurement behind it.',
{ path: '/provenance', heading: 'Built on the shoulders', active: 'litepaper', eyebrow: 'Provenance · the decision of 3 October 2026', crumb: 'Built on the shoulders', filter: false }));
built.push('provenance.html (from docs/provenance.md)');
}
{
const j = JSON.parse(readFileSync(join(here, 'journey.json'), 'utf8'));
const STATUS = { active: ['current', 'Under way'], next: ['next', 'Next'], done: ['done', 'Done'] };
const phases = (j.phases || []).map((p, i) => { const [cls, word] = STATUS[p.status] || ['', p.status]; return `${esc(p.when)}${word}${i + 1}. ${esc(p.name)}
${esc(p.line)}
${p.gate ? `Gate: ${esc(p.gate)}
` : ''}`; }).join('\n');
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const day = d => { const [y, m, dd] = d.split('-'); return `${Number(dd)} ${MON[Number(m) - 1]} ${y}`; };
const log = (j.log || []).map(e => `log${esc(e.short || e.text)}
${e.short && e.short !== e.text ? `${esc(e.text)}
` : ''} `).join('\n');
writeFileSync(join(here, 'journey.html'), shell({
title: 'The Igneum journey: six phases, four gates', desc: 'Where Igneum is: six phases from the specification to a fair launch, each closing at a published gate, and the engineering log’s latest entries. No calendar dates.',
path: '/journey', crumb: 'The journey', eyebrow: `Six phases · updated ${day(j.updated)}`, heading: 'The journey.',
lead: 'Six phases from the specification to a fair launch. Each closes at its gate, a measurement published whether it passes or fails, so there is no date to slip. Below the phases, the latest entries of the engineering log.',
body: `${phases}
The log, newest firstLately, in the log.
${log}
Nothing in the log matches that.
Every entry is a dated heading of the engineering log, where the commands and the hardware are. The phases and their gates are the litepaper’s roadmap.
`,
active: '',
after: onward([['/bench', 'The engineering log'], ['/evidence', 'Every claim and its status'], ['/live', 'The live devnet']]),
}));
built.push('journey.html (' + (j.phases || []).length + ' phases, ' + (j.log || []).length + ' entries)');
}
console.log('built: ' + built.join(', '));
// the header rule: every served page carries the full nav (tools/ci/site-nav-check.mjs); the build fails on a shorter one
// (the gate builds a copy of site/ alone and runs the check as its own line, so a missing tools/ is not a failure here)
{ const check = join(here, '..', 'tools', 'ci', 'site-nav-check.mjs'); if (existsSync(check)) { const { spawnSync } = await import('node:child_process'); const r = spawnSync(process.execPath, [check, here], { stdio: 'inherit' }); if (r.status !== 0) process.exit(r.status || 1); } }