Scenes lab: three animated views of the live feed on /scenes (Forge, Lattice, Pulse), unlinked, with captures and ten-second recordings

the project lead, 6 October 2026, item 3: three alternative scenes for the same live data, side by side at 1440, each a self-contained
module on one shared feed, the ledger's words in the legend (pending, included, excluded, proven, locked).

site/scenes/feed.js polls /api/live every 2 s and pushes the reply to every scene; it carries the site's state words
(connecting, live devnet, observer offline with the age, live feed unavailable) and the shared helpers (theme tokens from
CSS, the word for a block, DPR-sharp sizing, a frame loop that stops when hidden or off screen and draws a still frame under
reduced motion). Every mark in every scene is a block the observer stored; nothing is invented.

A, Forge (site/scenes/forge.js): blocks rise from the hearth as embers, one column per miner, at the height of their header
age over a 120 s window; a block cools as its word changes (pending molten, included ember, proven bone, excluded ash);
a new arrival glows for a second; a lock is a dark band that sweeps up the scene at the newest locked checkpoint, and
everything under it is final and settles to ash.

B, Lattice (site/scenes/lattice.js): a slow isometric DAG, time left to right, the selected chain as the lit spine in the
front lane, the other miners one shallow step deeper each (ranked by blocks, the shear capped to a third of the width so a
narrow card keeps its time axis), parent threads reaching forward, chain edges heavy with a soft halo, checkpoints as rings
that close around the spine when they lock with the lock weight beside them, new blocks glowing in; the camera drifts at
chain speed.

C, Pulse (site/scenes/pulse.js): the tip at the centre breathing, time outward with a ring every 30 s, each miner a ray,
each block a bead on its ray drifting outward, filling when proven, hollow while pending; the selected chain as short arcs
winding between consecutive beads (never a chord through the centre); a checkpoint is a ring that hardens from dashed to
solid when it locks.

site/scenes.html: the three cards side by side at 1440 and stacked under 1000 px, the shared legend, a note on the window
and reduced motion; ?only=a|b|c shows one scene full width (the card filter copies the children before removing, so the
right card survives). noindex, not linked from any page; registered in the build's PAGES so it takes the shared chrome.

Captures (docs/plans/site-ui-3-shots/scenes/, one niced headless Chromium): scenes-1440-dark.jpg (the three side by side),
scene-{a,b,c}-1440-dark.jpg and scene-{a,b,c}-390-dark.jpg, and scene-{a,b,c}-1440-dark.webm, ten seconds each at 1440 by
900 (VP8, recorded by Playwright and cut with its ffmpeg, which has no mp4 muxer). No console errors on any scene at either
width; no horizontal overflow at 390. Tonight's chain shows in all three: finality paused, so no band, closed ring or hardened
ring appears, and the checkpoint marks read pending.

Checks: identity grep 0 hits over 232 export files and 33 served site files, link check 931 links across 16 pages 0 broken,
ledger text 43 of 43, contrast 32 pairs 0 under 4.5:1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 20:02:57 +00:00
parent 2a12fb5143
commit 95ee0efe29
16 changed files with 453 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View file

@ -345,7 +345,9 @@ function renderEvidence(html) {
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''],
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']];
['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}`);

212
site/scenes.html Normal file
View file

@ -0,0 +1,212 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Igneum scenes, a lab</title>
<meta name="description" content="Three animated views of the same live devnet feed: Forge, Lattice and Pulse. An unlinked lab page.">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#0C0C0E" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F4F1EC" media="(prefers-color-scheme: light)">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<!-- head:start -->
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/site.css">
<style>
/* Shared by every page: the head partial, injected by the build between the head markers. Edit the partial, not the page. */
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. The tokens, type and components are in /site.css (docs/plans/site-ui-3.md). */
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
</style>
<script>
/* theme before first paint: the footer control stores "light" or "dark" under igneum-theme; nothing stored means the system setting */
(function(){try{var t=localStorage.getItem("igneum-theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();
</script>
<!-- head:end -->
<style>
/* scenes lab block; tokens, type and components are in /site.css */
.lab{padding:calc(var(--sec) * .5) 0 var(--sec)}
.lab-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:var(--s-3);margin-bottom:var(--s-5)}
.scenes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4)}
.scenes.only{grid-template-columns:1fr}
.scene-card{display:flex;flex-direction:column;gap:var(--s-3)}
.scene-card .head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3)}
.scene-card .head .eyebrow{margin:0}
.scene-card canvas{width:100%;height:420px;border-radius:var(--r-row);background:var(--row);border:1px solid var(--line);display:block}
.scenes.only .scene-card canvas{height:560px}
.scene-card p{margin:0;color:var(--ink-2);font-size:var(--t-base)}
@media (max-width:1000px){.scenes{grid-template-columns:1fr}.scene-card canvas{height:320px}.scenes.only .scene-card canvas{height:420px}}
</style>
</head>
<body>
<!-- nav:start -->
<a class="skip" href="#main">Skip to content</a>
<nav class="nav" aria-label="Main">
<div class="wrap">
<a href="/" class="brand" aria-label="Igneum home">
<svg class="mark" viewBox="0 0 1024 1024" width="28" height="28" aria-hidden="true"><rect width="1024" height="1024" rx="160" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
<span class="word">IGNEUM</span>
</a>
<div class="links" id="nav-links">
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
<a href="/live" data-nav="live">Live devnet</a>
<a href="/bench" data-nav="bench">Engineering log</a>
<a href="/miner" data-nav="miner">Miner</a>
<a href="/wallet" data-nav="wallet">Wallet</a>
<a href="/evidence" data-nav="evidence">Evidence</a>
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
</div>
<a href="/miner#get" class="btn primary small cta">Download the miner</a>
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-sheet" aria-label="Menu"><span class="bars"></span></button>
<div class="nav-sheet" id="nav-sheet">
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
<a href="/live" data-nav="live">Live devnet</a>
<a href="/bench" data-nav="bench">Engineering log</a>
<a href="/miner" data-nav="miner">Miner</a>
<a href="/wallet" data-nav="wallet">Wallet</a>
<a href="/evidence" data-nav="evidence">Evidence</a>
<a href="/explorer">Explorer</a>
<a href="/ledger">Ledger</a>
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
<a href="/miner#get" class="btn primary">Download the miner</a>
</div>
</div>
</nav>
<script>
(function(){
// the menu: open on the button, closed by any link, Escape (focus back on the button) or a resize past 900 px
var n=document.querySelector('.nav'),b=document.getElementById('nav-burger'),s=document.getElementById('nav-sheet');if(!n||!b||!s)return;
function set(o){n.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');document.documentElement.style.overflow=o?'hidden':'';}
b.addEventListener('click',function(){set(!n.classList.contains('open'));});
s.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&n.classList.contains('open')){set(false);b.focus();}});
var mq=window.matchMedia('(min-width:901px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
})();
</script>
<!-- nav:end -->
<main id="main">
<section class="lab">
<div class="wrap">
<div class="lab-head">
<div>
<div class="eyebrow">A lab, not linked</div>
<h1>Three scenes, one feed</h1>
<p class="lead" style="margin-top:var(--s-3)">The same live devnet reply, drawn three ways. Every mark is a block the observer stored; the words are the ledger's.</p>
</div>
<div class="state" id="feed-state"><span class="dot off"></span>connecting to the devnet observer</div>
</div>
<div class="scenes" id="scenes">
<div class="scene-card card" data-scene="a">
<div class="head"><h3>A · Forge</h3><div class="eyebrow">embers rise</div></div>
<canvas id="sc-a" aria-label="Forge: blocks rise as embers from the bottom and cool from molten to bone as they go from pending to included to proven; a dark band sweeps up at the lock"></canvas>
<p>Blocks arrive as embers from the hearth, one column per miner, and cool as they go: pending molten, included ember, proven bone. A lock is the dark band sweeping up; everything under it is final.</p>
</div>
<div class="scene-card card" data-scene="b">
<div class="head"><h3>B · Lattice</h3><div class="eyebrow">a slow isometric DAG</div></div>
<canvas id="sc-b" aria-label="Lattice: an isometric DAG with parents behind children, the selected chain a lit spine, checkpoints as rings closing around it"></canvas>
<p>Time runs left to right, each miner one step deeper, so parents stand behind their children. The selected chain is the lit spine; a checkpoint is a ring that closes around it when it locks. The camera drifts at chain speed.</p>
</div>
<div class="scene-card card" data-scene="c">
<div class="head"><h3>C · Pulse</h3><div class="eyebrow">the tip at the centre</div></div>
<canvas id="sc-c" aria-label="Pulse: the tip at the centre, time outward in rings, each block a bead on its miner's ray, proofs filling beads, locks as a ring hardening"></canvas>
<p>The tip is the centre and time runs outward, a ring every 30 seconds. Each miner is a ray; a block is a bead on it that drifts outward, filling when proven. A lock is a ring that hardens from dashed to solid.</p>
</div>
</div>
<div class="legend" style="margin-top:var(--s-5)">
<span><i class="sw" style="border:2px solid var(--molten);border-radius:50%"></i>pending</span>
<span><i class="sw" style="background:var(--ember);border-radius:50%"></i>included</span>
<span><i class="sw" style="border:2px solid var(--line-2);border-radius:50%;opacity:.45"></i>excluded</span>
<span><i class="sw" style="background:var(--bone);border-radius:50%"></i>proven</span>
<span><i class="sw" style="border:3px solid var(--ember);border-radius:50%;background:transparent"></i>locked checkpoint</span>
</div>
<p class="note">One node is read every 2 s; the window is the last 120 s. Under reduced motion each scene draws a still frame on every reply. Add <code>?only=a</code>, <code>b</code> or <code>c</code> to the address for one scene full width.</p>
</div>
</section>
</main>
<!-- footer:start -->
<footer class="foot">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="26" height="26" aria-hidden="true"><rect width="1024" height="1024" rx="160" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
<p>Mined by GPUs. Proven by fire.</p>
</div>
<nav class="foot-col" aria-label="Read">
<div class="eyebrow">Read</div>
<a href="/litepaper">Litepaper</a>
<a href="/litepaper#limits">What Igneum does not claim</a>
<a href="/litepaper#randomx">Igneum vs RandomX</a>
<a href="/litepaper#shoulders">Built on the shoulders</a>
<a href="/bench">Engineering log</a>
<a href="/evidence">Evidence</a>
<a href="/ledger">Ledger: every criticism</a>
</nav>
<nav class="foot-col" aria-label="Run">
<div class="eyebrow">Run</div>
<a href="/miner">The miner</a>
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
<a href="/faucet">Testnet faucet</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/explorer">Explorer</a>
<a href="/#journey">Journey</a>
<a href="/metamask">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Miner downloads: devnet build</a>
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
</nav>
</div>
<div class="foot-base">
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
<span class="theme" role="group" aria-label="Theme"><button type="button" data-theme-set="system" aria-pressed="true">System</button><button type="button" data-theme-set="light" aria-pressed="false">Light</button><button type="button" data-theme-set="dark" aria-pressed="false">Dark</button></span>
<span class="mono">igneum.network</span>
</div>
</div>
</footer>
<script>
(function(){
// theme control: stores light or dark under igneum-theme; system clears it. The head applied the stored value before paint.
var bs=document.querySelectorAll('[data-theme-set]');if(!bs.length)return;
function cur(){try{var t=localStorage.getItem('igneum-theme');return t==='light'||t==='dark'?t:'system';}catch(e){return 'system';}}
function paint(){var c=cur();bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===c?'true':'false');});}
bs.forEach(function(b){b.addEventListener('click',function(){var v=b.getAttribute('data-theme-set');try{if(v==='system')localStorage.removeItem('igneum-theme');else localStorage.setItem('igneum-theme',v);}catch(e){}
if(v==='system')document.documentElement.removeAttribute('data-theme');else document.documentElement.setAttribute('data-theme',v);paint();});});
paint();
})();
</script>
<!-- footer:end -->
<script src="/scenes/feed.js"></script>
<script src="/scenes/forge.js"></script>
<script src="/scenes/lattice.js"></script>
<script src="/scenes/pulse.js"></script>
<script>
(function(){
var only=new URLSearchParams(location.search).get('only'),grid=document.getElementById('scenes');
if(only){grid.classList.add('only');Array.prototype.slice.call(grid.children).forEach(function(c){if(c.getAttribute('data-scene')!==only)c.remove();});}
var subs=[];
if(document.getElementById('sc-a'))subs.push(IgneumForge.mount(document.getElementById('sc-a'),{}));
if(document.getElementById('sc-b'))subs.push(IgneumLattice.mount(document.getElementById('sc-b'),{window:120,compact:window.innerWidth<700}));
if(document.getElementById('sc-c'))subs.push(IgneumPulse.mount(document.getElementById('sc-c'),{window:120}));
var st=document.getElementById('feed-state');
IgneumFeed.start({window:120,subs:subs,onState:function(s,r){st.className='state '+(s==='live'?'live':s==='failed'?'bad':'');st.innerHTML='<span class="dot '+(s==='live'?'live':s==='failed'?'bad':'off')+'"></span>'+(s==='live'?'live devnet':s==='connecting'?'connecting to the devnet observer':(r||'live feed unavailable'));}});
})();
</script>
</body>
</html>

40
site/scenes/feed.js Normal file
View file

@ -0,0 +1,40 @@
/* One poll of /api/live every 2 s, pushed to every scene on the page (docs/plans/site-ui-3.md, the scenes lab, 6 Oct 2026).
IgneumFeed.start({ url, window, onState, subs: [scene, ...] }); each scene has push(reply). The state words are the site's:
connecting, live, stale (observer offline, last update N ago), failed (live feed unavailable). */
(function (root) {
'use strict';
function fmtAge(sec) { sec = Math.round(sec); return sec < 90 ? sec + ' s' : sec < 5400 ? Math.round(sec / 60) + ' min' : Math.round(sec / 3600) + ' h'; }
function start(opts) {
var subs = opts.subs || [], failures = 0, state = 'connecting', reason = 'connecting';
function set(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); }
function poll() {
if (document.hidden) return;
fetch((opts.url || '/api/live') + '?window=' + (opts.window || 120), { cache: 'no-store' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
if (!d || !d.ok || !d.state || !d.blocks) throw new Error('bad reply');
failures = 0;
if (d.state.stale) set('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); else set('live', null);
subs.forEach(function (s) { s.push(d); });
}).catch(function () { failures++; if (failures >= 2) set('failed', 'live feed unavailable'); });
}
poll(); var t = setInterval(poll, 2000);
document.addEventListener('visibilitychange', function () { if (!document.hidden) poll(); });
return { stop: function () { clearInterval(t); }, state: function () { return { state: state, reason: reason }; } };
}
// shared helpers for the three scenes
function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } }
function theme() { return { ember: cssVar('--ember', '#F2541B'), molten: cssVar('--molten', '#FFB35C'), bone: cssVar('--bone', '#F4F1EC'), ash: cssVar('--ash', '#9A9A9E'), line: cssVar('--line', '#2A2A30'), row: cssVar('--row', '#111114'), ink2: cssVar('--ink-2', '#C9C7C2') }; }
function rgba(hex, a) { var h = hex.replace('#', ''); if (h.length !== 6) return hex; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ',' + parseInt(h.slice(2, 4), 16) + ',' + parseInt(h.slice(4, 6), 16) + ',' + a + ')'; }
// the ledger's word for a block
function word(b) { return b.locked ? 'locked' : b.proven ? 'proven' : (b.chain || b.color === 'blue') ? 'included' : b.color === 'red' ? 'excluded' : 'pending'; }
function hash32(s) { var h = 2166136261; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967296; }
function sizer(canvas, ctx) { var dpr = Math.min(root.devicePixelRatio || 1, 2); return function () { var W = canvas.clientWidth, H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return { W: W, H: H }; }; }
function loop(draw, canvas) {
var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches, running = false, inView = true;
function frame(t) { if (!running) return; draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); }
function kick() { if (!running) { running = true; requestAnimationFrame(frame); } }
if ('IntersectionObserver' in root) new IntersectionObserver(function (es) { es.forEach(function (e) { inView = e.isIntersecting; if (inView) kick(); }); }, { threshold: 0.05 }).observe(canvas);
document.addEventListener('visibilitychange', function () { if (!document.hidden) kick(); });
return { kick: kick, reduce: reduce };
}
root.IgneumFeed = { start: start, theme: theme, rgba: rgba, word: word, hash32: hash32, sizer: sizer, loop: loop };
})(window);

61
site/scenes/forge.js Normal file
View file

@ -0,0 +1,61 @@
/* Scene A, Forge. Blocks arrive as embers rising from the bottom, one column per miner. A block cools as its word changes:
pending is molten, included is ember, proven is bone. A lock is a dark band that sweeps up the scene at the newest locked
checkpoint; everything below it is final and settles to ash. Every ember is a block the observer stored. */
(function (root) {
'use strict';
var F = root.IgneumFeed;
function mount(canvas, opts) {
opts = opts || {};
var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme();
var blocks = [], byHash = {}, lanes = {}, laneN = 0, lockBlue = null, lockY = null, lockTarget = null, lastT = 0;
var WIN = opts.window || 120; // seconds from the hearth to the top; a block's height is its header age, its glow is its arrival
function vnow() { return Date.now() - 2500; }
root.addEventListener('resize', function () { dims = size(); L.kick(); });
function laneX(id) { if (!(id in lanes)) lanes[id] = laneN++; var n = Math.max(1, laneN); var i = lanes[id]; return 24 + ((i * 0.618034) % 1) * (dims.W - 48); }
function push(d) {
var now = performance.now();
d.blocks.forEach(function (b) {
var e = byHash[b.hash];
if (e) { e.src = b; e.word = F.word(b); return; }
e = { hash: b.hash, src: b, word: F.word(b), x: laneX(b.miner || '?') + (F.hash32(b.hash) - 0.5) * 10, born: now, ts: b.ts, glow: 1, settle: 0 };
byHash[b.hash] = e; blocks.push(e);
});
var cps = (d.finality && d.finality.checkpoints) || [], locked = cps.filter(function (c) { return c.state === 'locked'; });
lockBlue = locked.length ? locked[locked.length - 1].blue_score : null;
blocks.forEach(function (e) { e.final = lockBlue !== null && e.src.blue_score !== null && e.src.blue_score <= lockBlue; });
var cut = vnow() - WIN * 1000 - 8000;
blocks = blocks.filter(function (e) { if (e.ts < cut) { delete byHash[e.hash]; return false; } return true; });
L.kick();
}
function yOf(e) { return dims.H - 16 - ((vnow() - e.ts) / 1000) * ((dims.H - 32) / WIN); }
function draw(t) {
var W = dims.W, H = dims.H; T = F.theme();
ctx.clearRect(0, 0, W, H);
// the hearth: a faint warm floor
var g = ctx.createLinearGradient(0, H, 0, H - 90); g.addColorStop(0, F.rgba(T.ember, 0.16)); g.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = g; ctx.fillRect(0, H - 90, W, 90);
// the lock band: the lowest final block's height, eased
var target = null; blocks.forEach(function (e) { if (e.final) { var y = yOf(e); if (target === null || y > target) target = y; } });
if (target !== null) { lockY = lockY === null ? target : lockY + (target - lockY) * 0.08; }
if (lockY !== null) { var band = ctx.createLinearGradient(0, lockY - 70, 0, lockY); band.addColorStop(0, F.rgba(T.row, 0)); band.addColorStop(1, F.rgba(T.row, 0.9)); ctx.fillStyle = band; ctx.fillRect(0, lockY - 70, W, 70); ctx.fillStyle = F.rgba(T.row, 0.9); ctx.fillRect(0, 0, W, Math.max(0, lockY - 70));
ctx.strokeStyle = F.rgba(T.ember, 0.6); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, lockY); ctx.lineTo(W, lockY); ctx.stroke();
ctx.fillStyle = T.ash; ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('locked', W - 10, lockY - 4); }
blocks.forEach(function (e) {
var y = yOf(e); if (y < -20) return;
var age = (t - e.born) / 1000, w = e.word;
var col = w === 'proven' ? T.bone : w === 'included' || w === 'locked' ? T.ember : w === 'excluded' ? T.ash : T.molten;
var r = w === 'excluded' ? 2.2 : w === 'proven' ? 3.6 : 3;
var a = e.final ? 0.45 : 1;
if (age < 1.2) { var gl = 1 - age / 1.2; var rg = ctx.createRadialGradient(e.x, y, 0, e.x, y, 18); rg.addColorStop(0, F.rgba(T.molten, 0.5 * gl)); rg.addColorStop(1, F.rgba(T.molten, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(e.x, y, 18, 0, Math.PI * 2); ctx.fill(); }
var flicker = e.final ? 0 : Math.sin(t / 420 + e.x) * 0.6;
ctx.globalAlpha = a; ctx.fillStyle = col; ctx.beginPath(); ctx.arc(e.x + flicker, y, r, 0, Math.PI * 2); ctx.fill();
if (w === 'locked') { ctx.strokeStyle = F.rgba(T.ember, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(e.x, y, r + 4, 0, Math.PI * 2); ctx.stroke(); }
// a thin trail: the ember's rise
ctx.strokeStyle = F.rgba(col, 0.12); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(e.x, y + r); ctx.lineTo(e.x, Math.min(H - 16, y + 14)); ctx.stroke();
ctx.globalAlpha = 1;
});
}
var L = F.loop(draw, canvas); L.kick();
return { push: push, redraw: L.kick };
}
root.IgneumForge = { mount: mount };
})(window);

77
site/scenes/lattice.js Normal file
View file

@ -0,0 +1,77 @@
/* Scene B, Lattice. A slow isometric DAG: time runs left to right, each miner lane sits one step deeper, so parents stand
behind their children. The selected chain is a lit spine; every other edge is a thin thread. Checkpoints are rings that
close around the spine where they lock. The camera drifts at chain speed. */
(function (root) {
'use strict';
var F = root.IgneumFeed;
function mount(canvas, opts) {
opts = opts || {};
var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme();
var blocks = [], byHash = {}, lanes = {}, laneN = 0, cps = [], windowS = opts.window || 120, rank = {};
var DEPTH = 8; // up to DEPTH lanes deep; the isometric step scales with the canvas (about 4.5% of its height per lane)
// the step scales with the canvas; the whole shear never takes more than a third of the width, so a narrow card keeps its time axis
function stepY() { return Math.max(7, Math.min(dims.H * 0.045, (dims.W * 0.33) / (DEPTH * 1.4))); } function stepX() { return stepY() * 1.4; }
root.addEventListener('resize', function () { dims = size(); L.kick(); });
function lane(id) { if (!(id in lanes)) lanes[id] = laneN++; return lanes[id]; }
function vnow() { return Date.now() - 2500; }
function push(d) {
var now = performance.now();
d.blocks.forEach(function (b) { var e = byHash[b.hash]; if (e) { e.src = b; e.word = F.word(b); return; } e = { hash: b.hash, src: b, word: F.word(b), lane: lane(b.miner || '?'), born: now }; byHash[b.hash] = e; blocks.push(e); });
var cut = vnow() - windowS * 1000 - 20000;
blocks = blocks.filter(function (e) { if (e.src.ts < cut) { delete byHash[e.hash]; return false; } return true; });
blocks.sort(function (a, b) { return a.src.ts - b.src.ts; });
// lanes ranked: the miners of the selected chain nearest the front, then by blocks in the window
var score = {}; blocks.forEach(function (e) { var m = e.src.miner || '?'; score[m] = (score[m] || 0) + 1 + (e.src.chain ? 3 : 0); });
var ids = Object.keys(score).sort(function (a, b) { return score[b] - score[a] || (a < b ? -1 : 1); }); rank = {}; ids.forEach(function (id, i) { rank[id] = i; });
cps = (d.finality && d.finality.checkpoints) || [];
L.kick();
}
function project(e) {
// the selected chain is the front lane; the other blocks sit one step deeper per miner rank, the many small miners sharing the back
var W = dims.W, H = dims.H, r = rank[e.src.miner || '?'] || 0, z = e.src.chain ? 0 : (1 + (r % (DEPTH - 1))) / DEPTH;
var SX = stepX(), SY = stepY();
var x = 40 + (W - 80 - DEPTH * SX) * (1 - (vnow() - e.src.ts) / (windowS * 1000)) + z * DEPTH * SX;
var y = H * 0.74 - z * DEPTH * SY;
return { x: x, y: y, z: z };
}
function draw(t) {
var W = dims.W, H = dims.H; T = F.theme();
ctx.clearRect(0, 0, W, H);
// the ground plane: three faint isometric rails
ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1;
for (var k = 0; k <= DEPTH; k += 2) { var yy = H * 0.74 - k * stepY(), xx = k * stepX(); ctx.beginPath(); ctx.moveTo(xx, yy); ctx.lineTo(W, yy); ctx.stroke(); }
var pos = {}; blocks.forEach(function (e) { pos[e.hash] = project(e); });
// threads, back to front
var ordered = blocks.slice().sort(function (a, b) { return pos[b.hash].z - pos[a.hash].z; });
ordered.forEach(function (e) {
var p = pos[e.hash]; if (p.x < -20) return;
(e.src.parents || []).forEach(function (h) { var q = byHash[h]; if (!q) return; var pq = pos[h]; var spine = e.src.chain && q.src.chain;
ctx.strokeStyle = spine ? F.rgba(T.ember, 0.9) : F.rgba(T.ash, e.word === 'excluded' ? 0.1 : 0.2); ctx.lineWidth = spine ? 2.2 : 1;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pq.x, pq.y); ctx.stroke();
if (spine) { ctx.strokeStyle = F.rgba(T.ember, 0.16); ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pq.x, pq.y); ctx.stroke(); } });
});
// checkpoint rings around the spine
cps.forEach(function (c) {
var ref = null, best = Infinity; blocks.forEach(function (e) { if (!e.src.chain || e.src.blue_score === null) return; var dd = Math.abs(e.src.blue_score - c.blue_score); if (dd < best) { best = dd; ref = e; } });
if (!ref || best > 3) return; var p = pos[ref.hash]; if (p.x < 0 || p.x > W) return;
var locked = c.state === 'locked', age = (performance.now() - ref.born) / 1000, close = locked ? Math.min(1, age / 1.5) : 0.35;
ctx.strokeStyle = F.rgba(T.ember, locked ? 0.85 : 0.35); ctx.lineWidth = locked ? 1.8 : 1; ctx.setLineDash(locked ? [] : [3, 5]);
ctx.beginPath(); ctx.ellipse(p.x, p.y, 22 - 8 * close, 9 - 3 * close, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
if (locked && !opts.compact) { ctx.fillStyle = T.ash; ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText(Math.round((c.fraction_total || 0) * 100) + '%', p.x, p.y - 16); }
});
// blocks: small isometric tiles, lit by word
ordered.forEach(function (e) {
var p = pos[e.hash]; if (p.x < -20 || p.x > W + 20) return; var w = e.word, age = (t - e.born) / 1000;
var col = w === 'proven' ? T.bone : (w === 'included' || w === 'locked') ? T.ember : w === 'excluded' ? T.ash : T.molten;
var s = e.src.chain ? 6 : 4.5, a = w === 'excluded' ? 0.45 : 1 - p.z * 0.35;
if (age < 1.4) { var gl = 1 - age / 1.4; var rg = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 20); rg.addColorStop(0, F.rgba(T.ember, 0.45 * gl)); rg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = a; ctx.fillStyle = w === 'pending' ? T.row : col; ctx.strokeStyle = col; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(p.x, p.y - s); ctx.lineTo(p.x + s * 1.3, p.y); ctx.lineTo(p.x, p.y + s); ctx.lineTo(p.x - s * 1.3, p.y); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.globalAlpha = 1;
});
}
var L = F.loop(draw, canvas); L.kick();
return { push: push, redraw: L.kick };
}
root.IgneumLattice = { mount: mount };
})(window);

60
site/scenes/pulse.js Normal file
View file

@ -0,0 +1,60 @@
/* Scene C, Pulse. A radial view: the tip at the centre, time outward in rings, each miner a ray. A block is a bead on its
miner's ray at the radius of its age; a proven bead fills; a locked checkpoint is a ring that hardens from dashed to
solid at the radius where it locked. Beads drift outward as time passes. */
(function (root) {
'use strict';
var F = root.IgneumFeed;
function mount(canvas, opts) {
opts = opts || {};
var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme();
var blocks = [], byHash = {}, rays = {}, rayN = 0, cps = [], windowS = opts.window || 120;
root.addEventListener('resize', function () { dims = size(); L.kick(); });
function vnow() { return Date.now() - 2500; }
function ray(id) { if (!(id in rays)) rays[id] = rayN++; return rays[id]; }
function push(d) {
var now = performance.now();
d.blocks.forEach(function (b) { var e = byHash[b.hash]; if (e) { e.src = b; e.word = F.word(b); return; } e = { hash: b.hash, src: b, word: F.word(b), ray: ray(b.miner || '?'), born: now }; byHash[b.hash] = e; blocks.push(e); });
var cut = vnow() - windowS * 1000 - 20000;
blocks = blocks.filter(function (e) { if (e.src.ts < cut) { delete byHash[e.hash]; return false; } return true; });
cps = (d.finality && d.finality.checkpoints) || [];
L.kick();
}
function polar(e) { var R = Math.min(dims.W, dims.H) / 2 - 14, cx = dims.W / 2, cy = dims.H / 2; var r = 10 + (R - 10) * ((vnow() - e.src.ts) / (windowS * 1000)); var ang = ((e.ray * 0.618034) % 1) * Math.PI * 2 - Math.PI / 2; return { x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, r: r, ang: ang }; }
function draw(t) {
var W = dims.W, H = dims.H; T = F.theme(); var cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 14;
ctx.clearRect(0, 0, W, H);
// time rings every 30 s
ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1;
for (var s = 30; s < windowS; s += 30) { var rr = 10 + (R - 10) * (s / windowS); ctx.beginPath(); ctx.arc(cx, cy, rr, 0, Math.PI * 2); ctx.stroke(); }
// the tip
var pulse = 0.5 + 0.5 * Math.sin(t / 900); var tg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 26); tg.addColorStop(0, F.rgba(T.ember, 0.35 + 0.2 * pulse)); tg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = tg; ctx.beginPath(); ctx.arc(cx, cy, 26, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = T.ember; ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); ctx.fill();
// checkpoint rings, hardening when locked
cps.forEach(function (c) {
var ref = null, best = Infinity; blocks.forEach(function (e) { if (e.src.blue_score === null) return; var dd = Math.abs(e.src.blue_score - c.blue_score); if (dd < best) { best = dd; ref = e; } });
if (!ref || best > 3) return; var p = polar(ref); if (p.r > R) return; var locked = c.state === 'locked', age = (performance.now() - ref.born) / 1000, hard = locked ? Math.min(1, age / 2) : 0;
ctx.strokeStyle = F.rgba(T.ember, locked ? 0.35 + 0.5 * hard : 0.3); ctx.lineWidth = locked ? 1 + hard : 1; ctx.setLineDash(locked && hard > 0.9 ? [] : [3, 6]);
ctx.beginPath(); ctx.arc(cx, cy, p.r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
});
// the selected chain as a soft spiral thread through its beads
var chain = blocks.filter(function (e) { return e.src.chain; }).sort(function (a, b) { return a.src.ts - b.src.ts; });
// the selected chain as short arcs between consecutive chain beads (the arc follows the ring between the two radii, never a chord through the centre)
if (chain.length > 1) { ctx.strokeStyle = F.rgba(T.ember, 0.28); ctx.lineWidth = 1; for (var i = 1; i < chain.length; i++) { var p0 = polar(chain[i - 1]), p1 = polar(chain[i]); if (p0.r > R || p1.r > R) continue; var a0 = p0.ang, a1 = p1.ang, da = ((a1 - a0 + Math.PI * 3) % (Math.PI * 2)) - Math.PI; var steps = 10; ctx.beginPath(); for (var k = 0; k <= steps; k++) { var f = k / steps, rr = p0.r + (p1.r - p0.r) * f, aa = a0 + da * f; var x = cx + Math.cos(aa) * rr, y = cy + Math.sin(aa) * rr; if (k) ctx.lineTo(x, y); else ctx.moveTo(x, y); } ctx.stroke(); } }
// beads
blocks.forEach(function (e) {
var p = polar(e); if (p.r > R + 6) return; var w = e.word, age = (t - e.born) / 1000;
var col = w === 'proven' ? T.bone : (w === 'included' || w === 'locked') ? T.ember : w === 'excluded' ? T.ash : T.molten;
var rad = e.src.chain ? 4 : 3;
if (age < 1.2) { var gl = 1 - age / 1.2; var rg = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16); rg.addColorStop(0, F.rgba(T.ember, 0.5 * gl)); rg.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = w === 'excluded' ? 0.45 : 1;
ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, Math.PI * 2);
if (w === 'proven' || w === 'included' || w === 'locked') { ctx.fillStyle = col; ctx.fill(); } else { ctx.fillStyle = T.row; ctx.fill(); ctx.strokeStyle = col; ctx.lineWidth = 1.2; ctx.stroke(); }
if (w === 'locked') { ctx.strokeStyle = F.rgba(T.ember, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, rad + 4, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
});
}
var L = F.loop(draw, canvas); L.kick();
return { push: push, redraw: L.kick };
}
root.IgneumPulse = { mount: mount };
})(window);