Merge remote-tracking branch 'origin/scene-parity-21' into release-0.3.21

This commit is contained in:
igneum-labs 2026-10-07 13:54:45 +00:00
commit f3c7c11c00
20 changed files with 1366 additions and 65 deletions

View file

@ -2,14 +2,17 @@
//! numbers. Two sources, one contract:
//!
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
//! window, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents,
//! colours, the selected chain, checkpoints, proof state). `state.source` = "observer".
//! window, passed through untouched (`shape` adds `state.you_blocks` and `state.source` = "observer"): the scene's full
//! feed (lanes, parents, colours, the selected chain, checkpoints, proof state). This machine's blocks are NOT rewritten:
//! the UI marks them through the scene's `mine` option (scene/feed-contract.md: `miner` is always the 8-hex key id).
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
//! is unreachable the node's rows stand in for the scene (`state.source` = "node", `partial: true`: no parents, no
//! proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the node source rests 10 minutes.
//! is unreachable the node's rows stand in for the scene in the SAME JSON shape as the observer's reply
//! (`node_only_reply`, every key of scene/feed-contract.json, null where the node cannot know; `state.source` = "node",
//! `partial: true`: no parents, no proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the
//! node source rests 10 minutes. The test `the_node_only_reply_has_the_contract_shape` reads the contract file itself.
//!
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
use crate::engine::Shared;
@ -31,21 +34,14 @@ struct NodeCache {
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
const NODE_TTL: Duration = Duration::from_secs(5);
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
/// The observer's reply with this machine's blocks counted. `ids` are the 8-character vote key ids of this machine's
/// cards; `state.you_blocks` counts the blocks whose `miner` is one of them and `state.source` names where the data came
/// from. Every row passes through untouched (until 0.3.20 the field was rewritten to "you", which the feed contract refuses:
/// the UI's `mine` function marks the lane from the card ids instead), so the contract holds on both surfaces.
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mut yours = 0u64;
if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) {
for b in blocks.iter_mut() {
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
if mine {
yours += 1;
if let Some(o) = b.as_object_mut() {
o.insert("miner".into(), json!("you"));
}
}
}
if let Some(blocks) = reply.get("blocks").and_then(|b| b.as_array()) {
yours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false)).count() as u64;
}
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("you_blocks".into(), json!(yours));
@ -108,7 +104,8 @@ fn short(h: &str) -> String {
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
/// scene's row shape as far as the node knows it: no parents) and `miners` arrays. `now_ms` is the reference time.
/// contract's row shape as far as the node knows it: no parents, no number, no proof state) and `miners` arrays
/// (the contract's `{id, blocks, share, last_seen, engine}`). `now_ms` is the reference time.
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
let ten = now_ms.saturating_sub(600_000);
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
@ -128,12 +125,12 @@ pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) ->
.iter()
.filter(|b| b.timestamp_ms >= win)
.map(|b| {
let id = lane(&b.vote_key_hash);
json!({
"hash": short(&b.hash), "ts": b.timestamp_ms, "timestamp_ms": b.timestamp_ms, "blue_score": b.blue_score, "daa": b.daa_score,
"parents": [], "chain": b.is_chain_block, "is_chain_block": b.is_chain_block,
"hash": short(&b.hash), "number": Value::Null, "blue_score": b.blue_score, "daa": b.daa_score,
"ts": b.timestamp_ms, "rx": b.timestamp_ms, "parents": [], "chain": b.is_chain_block,
"miner": if id.is_empty() { Value::Null } else { json!(id) },
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
"miner": lane(&b.vote_key_hash), "vote_key_hash": short(&b.vote_key_hash),
"timestamp_source": if b.timestamp_source.is_empty() { "header" } else { b.timestamp_source.as_str() },
"locked": false, "final": false, "shards": [], "proven": false
})
})
@ -143,10 +140,57 @@ pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) ->
"blocks_10m": recent.len(),
"blocks_per_minute": per_min,
"blocks": rows,
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "blocks": n, "share": if recent.is_empty() { 0.0 } else { (*n as f64 * 1000.0 / recent.len() as f64).round() / 10.0 }, "last_seen": Value::Null, "engine": Value::Null })).collect::<Vec<_>>(),
})
}
/// The node-only reply, in the contract's shape: every key of scene/feed-contract.json present, null where the node cannot
/// know (the observer's lag, the mempool, the proving layer), `partial: true` and `state.source` = "node" as the documented
/// extensions, `state.node` carrying the node's own numbers as on an observer reply. `now_iso` is the clock as ISO 8601.
pub fn node_only_reply(blocks: &[RecentBlock], now_ms: u64, now_iso: &str, window_s: u64, node: Value, ids: &HashSet<String>) -> Value {
let mut v = shape_from_blocks(blocks, now_ms, window_s);
let o = v.as_object_mut().unwrap();
let rows = o.remove("blocks").unwrap_or(Value::Null);
let miners = o.remove("miners").unwrap_or(Value::Null);
let state = json!({
"stale": false, "age_s": 0, "network": Value::Null, "node_version": Value::Null, "height": Value::Null,
"block_count": node.get("block_count").cloned().unwrap_or(Value::Null), "header_count": node.get("header_count").cloned().unwrap_or(Value::Null),
"blue_score": node.get("blue_score").cloned().unwrap_or(Value::Null), "difficulty": node.get("difficulty").cloned().unwrap_or(Value::Null),
"hashes_per_second_estimate": Value::Null, "peers": node.get("peers").cloned().unwrap_or(Value::Null), "mempool": Value::Null,
"blocks_60s": Value::Null, "blocks_per_second_60s": Value::Null, "blocks_per_minute": o.get("blocks_per_minute").cloned().unwrap_or(json!([])),
"miners_10m": o.get("miners_10m").cloned().unwrap_or(json!(0)), "observer_started_at": Value::Null, "observer_lag_s": Value::Null,
"queue_depth": Value::Null, "updated_at": now_iso, "source": "node", "node": node
});
let finality = json!({ "supported": false, "active": false, "next_index": Value::Null, "latest_locked_index": Value::Null, "latest_locked_hash": Value::Null,
"latest_locked_blue_score": Value::Null, "params": Value::Null, "weights": Value::Null, "checkpoints": [] });
let out = json!({ "ok": true, "partial": true, "now": now_iso, "state": state, "blocks": rows, "miners": miners, "events": [], "finality": finality,
"proving": { "supported": false, "reason": "the local node's rows: no proving layer in this view" } });
let mut out = shape(out, ids);
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("source".into(), json!("node")); // shape() names the observer; these rows are the node's
}
out
}
/// The clock as the contract's `now`: ISO 8601 with milliseconds, UTC.
fn iso_now(unix_s: f64) -> String {
let ms = (unix_s * 1000.0) as i64;
let (secs, millis) = (ms.div_euclid(1000), ms.rem_euclid(1000));
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm), no chrono dependency
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
}
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
/// minutes) or answered nothing.
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
@ -236,17 +280,8 @@ pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<
}
(true, None) => reply,
(false, Some(n)) => {
// the node's rows stand in: the scene gets blocks without parents or proof state, and says so
let mut v = shape_from_blocks(&blocks, now_ms, window_s as u64);
let o = v.as_object_mut().unwrap();
let rows = o.remove("blocks").unwrap_or(Value::Null);
let miners = o.remove("miners").unwrap_or(Value::Null);
let mut out = json!({ "ok": true, "partial": true, "now": crate::platform::unix_now_f(), "state": n, "blocks": rows, "miners": miners, "events": [], "finality": { "checkpoints": [] } });
out = shape(out, ids);
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("source".into(), json!("node"));
}
out
// the node's rows stand in, in the contract's shape: blocks without parents or proof state, and the reply says so
node_only_reply(&blocks, now_ms, &iso_now(crate::platform::unix_now_f()), window_s as u64, n, ids)
}
(false, None) => reply,
}
@ -276,13 +311,13 @@ mod tests {
}
#[test]
fn this_machines_blocks_become_the_lane_you_and_nothing_else_moves() {
fn this_machines_blocks_are_counted_and_every_row_passes_through_untouched() {
let ids: HashSet<String> = ["8fafda27".to_string(), "9a8b7c6d".to_string()].into_iter().collect();
let out = shape(fixture(), &ids);
let blocks = out["blocks"].as_array().unwrap();
assert_eq!(blocks.len(), 4);
assert_eq!(blocks[0]["miner"], "you");
assert_eq!(blocks[2]["miner"], "you");
assert_eq!(blocks[0]["miner"], "8fafda27", "the key id stays: the contract refuses a rewritten miner");
assert_eq!(blocks[2]["miner"], "8fafda27");
assert_eq!(blocks[1]["miner"], "1b2c3d4e");
assert_eq!(blocks[3]["miner"], "deadbeef");
// the rest of the contract passes through: parents, chain, colour, proof state, checkpoints
@ -302,7 +337,6 @@ mod tests {
#[test]
fn no_ids_means_no_lane_is_yours_and_a_bare_reply_survives() {
let out = shape(fixture(), &HashSet::new());
assert!(out["blocks"].as_array().unwrap().iter().all(|b| b["miner"] != "you"));
assert_eq!(out["state"]["you_blocks"], 0);
let bare = shape(json!({ "ok": true, "blocks": [] }), &HashSet::new());
assert_eq!(bare["blocks"].as_array().unwrap().len(), 0);
@ -335,21 +369,80 @@ mod tests {
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
assert_eq!(v["blocks"][0]["color"], "blue");
assert_eq!(v["blocks"][0]["timestamp_source"], "header");
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
// the lane "you" marks node rows too
assert_eq!(v["miners"][0]["blocks"], 2, "the contract's miner row: blocks, not blocks_10m");
// this machine's rows are counted, never rewritten
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
assert_eq!(marked["blocks"][0]["miner"], "you");
assert_eq!(marked["blocks"][0]["miner"], "aa");
assert_eq!(marked["state"]["you_blocks"], 2);
assert_eq!(lane("0x0123456789abcdef"), "01234567");
assert_eq!(lane(""), "");
}
/// scene/feed-contract.json, the same file tools/scene/feed-contract.mjs reads: the node-only reply carries exactly the
/// contract's keys (plus the documented extensions) at every level the scene reads.
const CONTRACT: &str = include_str!("../../../scene/feed-contract.json");
fn keys_of(v: &Value) -> Vec<String> {
let mut k: Vec<String> = v.as_object().expect("an object").keys().cloned().collect();
k.sort();
k
}
fn listed(c: &Value, name: &str) -> Vec<String> {
let mut k: Vec<String> = c[name].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
k.sort();
k
}
#[test]
fn the_node_only_reply_has_the_contract_shape() {
let c: Value = serde_json::from_str(CONTRACT).expect("scene/feed-contract.json parses");
let now = 1_791_300_000_000u64;
let blocks = vec![b(now - 5_000, "aa11bb22", true), b(now - 30_000, "bb22cc33", true), b(now - 95_000, "aa11bb22", false), b(now - 40_000, "", true)];
let node = json!({ "block_count": 10, "header_count": 12, "blue_score": 9, "difficulty": 1.5, "peers": 3, "synced": true, "source": "node", "miners_10m": 2, "blocks_10m": 3, "blocks_per_minute": [0,0,0,0,0,0,0,0,1,2] });
let ids: HashSet<String> = ["aa11bb22".to_string()].into_iter().collect();
let out = node_only_reply(&blocks, now, "2026-10-07T13:20:00.000Z", 120, node, &ids);
// top level: the contract's keys plus the documented extensions, nothing else
let mut top = listed(&c, "top"); top.extend(listed(&c, "top_extensions")); top.sort();
assert_eq!(keys_of(&out), top);
assert_eq!(out["partial"], true);
assert_eq!(out["now"], "2026-10-07T13:20:00.000Z", "now is the ISO string, not a float of seconds");
// state: every contract key present, extras only from the extension list
let state = listed(&c, "state");
let ext = listed(&c, "state_extensions");
for k in &state { assert!(out["state"].get(k).is_some(), "state lacks {k}"); }
for k in keys_of(&out["state"]) { assert!(state.contains(&k) || ext.contains(&k), "state carries an unknown key {k}"); }
assert_eq!(out["state"]["source"], "node");
assert_eq!(out["state"]["you_blocks"], 2);
assert_eq!(out["state"]["node"]["peers"], 3);
// rows: exactly the contract's keys
for row in out["blocks"].as_array().unwrap() { assert_eq!(keys_of(row), listed(&c, "block")); }
assert_eq!(out["blocks"][0]["miner"], "aa11bb22");
assert_eq!(out["blocks"][0]["number"], Value::Null);
assert_eq!(out["blocks"][3]["miner"], Value::Null, "a block without a key reads null, never an empty string");
assert_eq!(out["miners"][0]["id"], "aa11bb22");
assert_eq!(out["miners"][0]["share"], 50.0, "two of the four blocks in ten minutes");
for m in out["miners"].as_array().unwrap() { assert_eq!(keys_of(m), listed(&c, "miner")); }
assert_eq!(keys_of(&out["finality"]), listed(&c, "finality"));
assert_eq!(keys_of(&out["proving"]), listed(&c, "proving_unsupported"));
assert_eq!(out["events"], json!([]));
// the miner id is 8 hex or null, never a word
let re_ok = |s: &str| s.len() == 8 && s.chars().all(|ch| ch.is_ascii_hexdigit());
for row in out["blocks"].as_array().unwrap() { if let Some(m) = row["miner"].as_str() { assert!(re_ok(m), "miner {m}"); } }
}
#[test]
fn iso_now_formats_the_clock_as_the_contracts_now() {
assert_eq!(iso_now(1_791_301_670.312), "2026-10-06T15:47:50.312Z");
assert_eq!(iso_now(0.0), "1970-01-01T00:00:00.000Z");
assert_eq!(iso_now(951_782_400.5), "2000-02-29T00:00:00.500Z");
}
#[test]
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();

View file

@ -14,9 +14,14 @@
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
/* scene-tokens:end */
:root{
/* colour, dark */
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--intel:#7CC4FF;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
/* type scale */
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
@ -26,13 +31,13 @@
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px;
/* the chain scene's inclusion colours (EMBER 02 live-dag.js reads them from :root) */
--included:#3B7DD8;--excluded:#B0362B}
}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;--included:#2A62B8;--excluded:#B0362B}}
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}}
:root[data-theme="light"]{
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;--included:#2A62B8;--excluded:#B0362B}
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
@ -792,7 +797,7 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.zoom span{font-size:var(--t-xs);color:var(--ash);padding:0 6px;min-width:44px;text-align:center}
.cf-graph{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:var(--s-4)}
.cf-scene{position:relative;min-width:0}
.cf-scene canvas{display:block;width:100%;height:360px;border-radius:10px;background:var(--row);border:1px solid var(--line)}
.cf-scene canvas{display:block;width:100%;height:420px;border-radius:10px;background:var(--row);border:1px solid var(--line)}
.cf-foot{margin-top:8px;font-size:var(--t-xs);color:var(--ash)}
.inspector{min-width:0;font-size:var(--t-base);border-left:1px solid var(--line);padding-left:var(--s-4)}
.inspector .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}

View file

@ -1828,7 +1828,10 @@ if (typeof document !== 'undefined') (function () {
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); });
requestDraw();
// ---------- the chain scene (EMBER 02: live-dag.js and proof-core.js, the site lane's byte-identical copies) ----------
// ---------- the chain scene (EMBER 02: live-dag.js and proof-core.js, byte-equal copies of scene/ written by tools/scene/sync.mjs) ----------
// Same renderer, same tokens, same feed and the same mount options as the site's /live (window 60, fps 60, poll false; the
// page asks for 300 s so the viewer can pan the full range). The phone rule keys on the viewport inside the renderer; this
// machine's vote keys are the `mine` overlay (own blocks glow, the lane reads YOUR KEY), never a rewritten feed (scene/README.md).
// One owner of the feed: this page reads the engine's api/live every 2 s while the Overview shows and pushes the reply
// into the compact scene on the card and, once opened, the full scene with the block inspector (poll: false on both).
// "mine" is this machine's vote key ids (the 8 hex the feed names as b.miner), never the payout address. The fallback
@ -1844,7 +1847,7 @@ if (typeof document !== 'undefined') (function () {
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
try {
dag = IgneumDag.mount($('dag-live'), { compact: true, window: 60, fps: 60, narrow: window.innerWidth < 720, poll: false, mine: mineFn, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'),
dag = IgneumDag.mount($('dag-live'), { compact: true, window: 60, fps: 60, poll: false, mine: mineFn, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'),
onSelect: function (b, why) { if (b && why !== 'update') openFull(b.hash); },
onState: function (st, why) { if (dagMode === 'failed') return; if (st === 'live' || st === 'stale') { setText('dag-state', st === 'live' ? 'live' : 'behind'); setText('dag-note', st === 'stale' ? (why || '') : (lastLive ? View.liveNote(lastLive) : '')); } } });
pollLive();
@ -1855,7 +1858,7 @@ if (typeof document !== 'undefined') (function () {
if (phase !== 'dashboard' || hiddenNow() || !dag) return;
if (!liveBusy) {
liveBusy = true;
fetch('api/live?window=120', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
fetch('api/live?window=300', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
liveBusy = false;
if (!d || d.ok === false) { liveFails++; if (liveFails >= 3) dagFallback(true, d && d.error ? 'The chain feed says: ' + d.error + '. This is the last 10 minutes of your own blocks.' : ''); return; }
liveFails = 0; lastLive = d;

View file

@ -1,4 +1,9 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026) with the narrow options of 2.0.2 (narrow, narrowBreak 720,
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
@ -82,7 +87,7 @@
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
redraw();
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
@ -90,6 +95,7 @@
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
@ -101,7 +107,7 @@
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&W<narrowBreak);
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
@ -127,7 +133,7 @@
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();redraw();
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
@ -165,7 +171,7 @@
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused)redraw();return true;
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
@ -276,6 +282,8 @@
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
function words(b){
@ -354,5 +362,5 @@
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.2'};
root.IgneumDag={mount:mount,version:'2.0.3'};
})(window);

35
scene/README.md Normal file
View file

@ -0,0 +1,35 @@
# scene: the chain scene, once
The live devnet animation on the site's home fold and `/live`, and the miner app's "The chain, live" card and its Inspect view,
are ONE renderer fed ONE JSON shape. This folder is the source; everything else is a copy or a reader.
| File | What it is | Copies |
|---|---|---|
| `live-dag.js` | EMBER 02 `IgneumDag` 2.0.3: lanes per vote key, parent curves, the selected chain, checkpoint bands, proof glow | `site/live-dag.js`, `app/igneum-app/ui/live-dag.js` |
| `proof-core.js` | `IgneumProof` 2.0.0: the selected block's shards | `site/proof-core.js`, `app/igneum-app/ui/proof-core.js` |
| `tokens.css` | The fourteen palette tokens the two scripts read from `:root`, dark and light, the brand package's values | the `scene-tokens` block in `site/site.css` and `app/igneum-app/ui/app.css` |
| `feed-contract.md`, `feed-contract.json` | The feed shape, in words and as key lists | read by `tools/scene/feed-contract.mjs` and the app's `src/live.rs` test |
| `fixtures/live-2026-10-07.json` | One recorded `/api/live?window=300` reply | the parity, paint and contract tests |
`node tools/scene/sync.mjs` writes the copies; `--check` is the gate line (byte-equal scripts, an equal token block, the token
names defined nowhere else, a print block excepted); `--self-test` proves the check on known-failed cases first. A side (site or
app) takes part once its `live-dag.js` copy exists; a `release-*` branch checks the app's copies only, because the release
branches carry the site tree as it was when they were cut and the site deploys from master alone.
## The one rule for the two surfaces
Same renderer, same tokens, same feed, same mount options: `window: 60`, `fps: 60`, `poll: false`, the page polls
`/api/live?window=300` every 2 s and pushes. What may differ is the box the scene sits in (the home hero is 500 px tall, `/live`
420 px, the app's card 220 px in `compact` mode and its Inspect view the `/live` height), and the app's viewpoint: its `mine`
option names this machine's vote keys, so its own blocks glow and its lane reads YOUR KEY. That is an overlay on the same
picture, never a second renderer, never a rewritten feed. The phone rule (30 s window, four lanes, bigger nodes) keys on the
viewport width under 720 px, not on the canvas width: a 640 px hero on a laptop is not a phone.
## The tests (all in `tools/ci/pre-push.sh`)
| Line | What it proves |
|---|---|
| `tools/scene/sync.mjs --self-test && --check` | the copies are the source, the tokens live once |
| `tools/scene/paint-test.cjs` | a push paints with the document hidden, the observer silent and no animation frame (the blank `/live` of 7 October 2026) |
| `node --test tools/scene/feed-contract.test.mjs` | the fixture validates; a rewritten miner, a float `now`, a stray key are refused |
| `tools/scene/parity-remote.sh` | on build-2: the fixture through the site's `/live` and the app's UI, three frames each, pixel-equal apart from the app's lane label; a changed token fails first |

20
scene/feed-contract.json Normal file
View file

@ -0,0 +1,20 @@
{
"version": 1,
"doc": "scene/feed-contract.md: the one JSON shape the chain scene reads, from the observer's /api/live and from the miner app's api/live alike",
"top": ["ok", "now", "state", "blocks", "miners", "events", "finality", "proving"],
"top_extensions": ["partial"],
"state": ["stale", "age_s", "network", "node_version", "height", "block_count", "header_count", "blue_score", "difficulty", "hashes_per_second_estimate", "peers", "mempool", "blocks_60s", "blocks_per_second_60s", "blocks_per_minute", "miners_10m", "observer_started_at", "observer_lag_s", "queue_depth", "updated_at"],
"state_extensions": ["source", "you_blocks", "node"],
"block": ["hash", "number", "blue_score", "daa", "ts", "rx", "parents", "chain", "miner", "color", "locked", "final", "shards", "proven"],
"miner": ["id", "blocks", "share", "last_seen", "engine"],
"event": ["ts", "kind", "text"],
"finality": ["supported", "active", "next_index", "latest_locked_index", "latest_locked_hash", "latest_locked_blue_score", "params", "weights", "checkpoints"],
"checkpoint": ["index", "hash", "blue_score", "daa", "state", "signed", "active", "total", "fraction_active", "fraction_total", "votes", "voters", "locked_at", "updated_at"],
"shard": ["i", "n", "state", "prover", "lag", "payout", "pgas"],
"proving_unsupported": ["supported", "reason"],
"proving_supported": ["supported", "active", "activation_daa", "tip_daa", "verifier", "pool", "paid_shards_total", "shard_budget_pgas", "blocks_10m", "blocks_fully_proven_10m", "shards_proven_10m", "shards_paid_10m", "median_proof_lag_s", "provers_10m"],
"color": ["blue", "red", "pending"],
"shard_state": ["planned", "proving", "verified", "paid"],
"checkpoint_state": ["pending", "locked"],
"miner_id": "^[0-9a-f]{8}$"
}

78
scene/feed-contract.md Normal file
View file

@ -0,0 +1,78 @@
# The live feed contract
One JSON shape. The chain scene (`scene/live-dag.js`, `scene/proof-core.js`) reads it, and two producers write it:
| Producer | Where | Source of the rows |
|---|---|---|
| The observer | `site/api/live.mjs` (GET `/api/live?window=N`, igneum.network) | Neon tables the observer (`tools/observer/observer.mjs`) fills from node 1 |
| The miner app | `app/igneum-app/src/live.rs` (GET `api/live?window=N` on the engine's port) | The observer's reply, passed through; when the observer is unreachable, the local node's `igneum_getRecentBlocks` shaped into the same rows (`partial: true`) |
The machine-readable key lists live in `scene/feed-contract.json`. `tools/scene/feed-contract.mjs` validates a reply against them
(the gate runs it over the recorded fixture and over known-bad shapes); the app's Rust test reads the same file, so neither side
can add a field or drop one without the other noticing.
## Top level
| Key | Type | Meaning |
|---|---|---|
| `ok` | true | A reply with `ok: false` carries `error` and nothing else |
| `now` | ISO 8601 string | The producer's clock when the reply was built |
| `state` | object | The network state, below |
| `blocks` | array | The window's blocks, oldest first, at most 1,800 |
| `miners` | array | Vote keys seen in the last 10 minutes |
| `events` | array | The last 30 observer events |
| `finality` | object | Checkpoints and weights |
| `proving` | object | The proving layer |
| `partial` | true, app only | The rows came from the local node: no parents, no proof state, no checkpoints |
## `state`
`stale`, `age_s`, `network`, `node_version`, `height` (the chain block number), `block_count`, `header_count`, `blue_score`,
`difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `blocks_60s`, `blocks_per_second_60s`, `blocks_per_minute`
(10 numbers, oldest first), `miners_10m`, `observer_started_at`, `observer_lag_s`, `queue_depth`, `updated_at`. A value the
producer does not know is `null`, never missing.
App extensions, additive and ignored by the scene: `source` (`observer` or `node`), `you_blocks` (this machine's blocks in
the window), `node` (the local node's own numbers for the Cards tab).
## A block
| Key | Type | Meaning |
|---|---|---|
| `hash` | string, the first 16 hex | Both producers cut the hash the same way, so a hash reads the same everywhere |
| `number` | number or null | The chain block number once planned; null off the chain or before the plan |
| `blue_score`, `daa` | number or null | |
| `ts` | number | Header time in milliseconds |
| `rx` | number | When the producer first saw the block, milliseconds |
| `parents` | array of hash strings | Empty on a `partial` reply |
| `chain` | boolean | On the selected chain |
| `miner` | string, 8 hex | The vote key id. NEVER a word: the app marks its own blocks through the scene's `mine` option, not by rewriting this field |
| `color` | `blue`, `red` or `pending` | GHOSTDAG inclusion |
| `locked` | boolean | A locked checkpoint block |
| `final` | boolean | At or before the newest lock |
| `shards` | array of `{i, n, state, prover, lag, payout, pgas}` | `state` is `planned`, `proving`, `verified` or `paid` |
| `proven` | boolean | Every shard verified or paid |
## `miners[]`, `events[]`, `finality`, `proving`
A miner is `{id (8 hex), blocks, share (percent of the 10 minutes), last_seen (ISO or null), engine (string or null)}`.
An event is `{ts, kind, text}`.
`finality` is `{supported, active, next_index, latest_locked_index, latest_locked_hash, latest_locked_blue_score, params, weights,
checkpoints}`; a checkpoint is `{index, hash, blue_score, daa, state (pending or locked), signed, active, total, fraction_active,
fraction_total, votes, voters, locked_at, updated_at}`.
`proving` is `{supported: false, reason}` or the supported object (`active`, `activation_daa`, `tip_daa`, `verifier`, `pool`,
`paid_shards_total`, `shard_budget_pgas`, `blocks_10m`, `blocks_fully_proven_10m`, `shards_proven_10m`, `shards_paid_10m`,
`median_proof_lag_s`, `provers_10m`).
## What the scene does with it
`IgneumDag.mount(canvas, opts).push(reply)` normalises every block (unknown colours become `pending`, unknown shard states
`unknown`, a hash over 256 characters or a non-numeric `ts` drops the row), keeps the newest 340 s, and paints at once.
The window the scene shows (`opts.window`, 30 to 300 s) is independent of the window the page asks the producer for: both the
site and the app ask for 300 s so the viewer can pan and zoom out to the full range (`?window=300`).
## The recorded fixture
`scene/fixtures/live-2026-10-07.json` is one real `/api/live?window=300` reply (7 October 2026, 13:17 UTC, 905 blocks, 15
miners, 40 checkpoints, 14 proven blocks, 50 excluded). The contract test validates it; the parity test renders it through both
surfaces; the paint test pushes it into a hidden document.

File diff suppressed because one or more lines are too long

366
scene/live-dag.js Normal file
View file

@ -0,0 +1,366 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
* Existing mount/push options and public methods are retained; see README.md for additions.
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
*/
(function (root) {
'use strict';
var instances = new WeakMap();
var TAU = Math.PI * 2;
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
function num(v) { return finite(v) ? v : null; }
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
function rgba(c, a) {
var s = String(c).trim(), h = s.replace('#', ''), rgb;
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
}
function rounded(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w/2, h/2)); ctx.beginPath(); ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function cloneBlock(b) {
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
}
function normalize(b) {
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
}
function mount(canvas, opts) {
opts=opts||{};
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
if (instances.has(canvas)) instances.get(canvas).destroy();
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
var doc=canvas.ownerDocument, compact=!!opts.compact;
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
// narrow (phones, under narrowBreak px, or opts.narrow true/false): window narrowWindow s until the viewer sets one, at most
// narrowLanes lanes, nodes no smaller than narrowMinNode, hairlines off except the selected chain and the selected block,
// checkpoint labels as the percent alone. The owner's call of 7 Oct 2026: faster drift, more gap, on a phone.
var narrow=false,narrowBreak=finite(opts.narrowBreak)?opts.narrowBreak:720,narrowWindow=clamp(finite(opts.narrowWindow)?opts.narrowWindow:30,30,300),narrowLanes=clamp(finite(opts.narrowLanes)?opts.narrowLanes:4,2,12),narrowMinNode=finite(opts.narrowMinNode)?opts.narrowMinNode:11,baseWindow=0,windowTouched=false;
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
var reduced=mq?mq.matches:false, manualMotion=true;
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
var tip=opts.tooltip||null,chip=opts.chip||null;
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
function own(b){try{return !!mine(b);}catch(e){return false;}}
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
function theme(){
var s=getComputedStyle(doc.documentElement);
function c(n,f){return s.getPropertyValue(n).trim()||f;}
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
if(!following)return fixedRight;
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
if(state===s && reason===r)return;
if(s!=='live' && state==='live')staleRight=rightTime();
state=s;reason=r||null;
if(s==='live')staleRight=null;
emit('onState',s,reason);redraw();
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
var capacity=Math.min(narrow?narrowLanes:maxLanes,Math.max(2,Math.floor((H-padT-padB)/(compact?26:narrow?40:46)))),ids=Array.from(counts.keys());
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
var keep=lanes.filter(function(id){return chosen.includes(id);});
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
var now=performance.now();
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
});
}
function size(){
if(destroyed)return;
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
next.set(hash,v);
});
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
cps=latestCps.map(function(c){return Object.assign({},c);});
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
layout();
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
if(hover&&!view.has(hover))hover=null;
queuedCount=0;
}
function push(d){
if(destroyed)return false;
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
}
var now=Date.now(), incoming=[], bad=0;
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
// Bounded retention uses the newest observed header, not a local wall-clock guess.
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
omitted=0;
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state};
}
function poll(){
if(destroyed||opts.poll===false||doc.hidden)return;
if(inflight){pollAgain=true;return;}
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
var url;
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
catch(e){finishError(e);return;}
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
}
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
function draw(t){
if(destroyed||!W||!H)return;
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
ctx.lineWidth=1;ctx.setLineDash([2,7]);
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
ctx.setLineDash([]);
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
}
});
var cpLabels=[];
cps.forEach(function(c){
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
if(!ref||ref.ts<left||ref.ts>rt)return;
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
if(!compact && (narrow || W>510))cpLabels.push({x:x,cp:c,locked:locked});
});
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
cpLabels.forEach(function(p){var pct=p.cp.fraction_total===null||p.cp.fraction_total===undefined?'':Math.round(p.cp.fraction_total*100)+'%',label=narrow?(pct||(p.locked?'LOCKED':'PENDING')):(p.locked?'LOCKED':'PENDING')+(pct?' '+pct:''),w=label.length*5.5+(narrow?14:22),x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
});
// Animate lane transitions without changing their underlying key identity.
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
if(!path&&!selectedEdge){if(narrow||pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
});});
var S=compact?6.5:narrow?clamp(lh*.24,narrowMinNode,15):clamp(lh*.22,minNode,13);
blocks.forEach(function(b){if(!b.on)return;
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
// a proven block glows and carries a ring, so the proof reads from across the room
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
}
}
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
ctx.globalAlpha=1;
});ctx.restore();
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
var ticks=W<420?2:4;
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
}
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
if(!blocks.some(function(b){return b.on;})){
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
}
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
lastT=t;
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
function words(b){
if(!b)return {title:'No block selected',lines:[]};
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]};
}
function showTip(b){
if(!tip)return;
if(!b){tip.hidden=true;return;}
var w=words(b),key=w.title+'|'+w.lines.join('|');
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
}
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowTouched=true;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
function setPaused(p){p=!!p;if(paused===p)return;
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
else{paused=false;syncView();}
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
emit('onPause',paused);emit('onStats',stats());redraw();
}
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
canvas.style.touchAction='pan-y';
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
on(canvas,'pointermove',function(ev){
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
});
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
on(canvas,'pointercancel',function(){drag=null;});
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
var touchStart=null;
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
on(canvas,'keydown',function(ev){
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
});
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
on(root,'resize',size);
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
}
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.3'};
})(window);

72
scene/proof-core.js Normal file
View file

@ -0,0 +1,72 @@
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
*/
(function(root){
'use strict';
var mounted=new WeakMap(),TAU=Math.PI*2;
function mount(canvas,opts){
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
var phase=0,lastT=0;
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
function iso(x,y,w,h,depth,fill,stroke){
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
}
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
function draw(t){
if(!W||!H||disposed)return;frames++;
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
// A stationary coordinate grid, not fabricated hashrate or work counters.
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
var count=Math.min(12,shards.length);
for(var i=0;i<count;i++){
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
var midx=(px+x)/2,midy=(py+y)/2;
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
}
// Layered block core. Geometry does not imply three GPUs or three proofs.
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
ctx.lineWidth=1;
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
var topY=y-11*scale-lift;
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
}
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
function visibility(){if(document.hidden)stop();else kick();}
function reduction(e){reduce=e.matches;stop();kick();}
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
}
root.IgneumProof={mount:mount,version:'2.0.0'};
})(window);

6
scene/tokens.css Normal file
View file

@ -0,0 +1,6 @@
/* The chain scene's palette: the full-site redesign package's design system (7 Oct 2026), the brand source of truth for colour.
THE ONE PLACE these fourteen tokens are defined. tools/scene/sync.mjs writes them into site/site.css and app/igneum-app/ui/app.css
between the "scene-tokens" markers (dark; light under [data-theme="light"]; and, for a page without a theme attribute, light under
prefers-color-scheme: light); the gate refuses a copy whose values differ. live-dag.js and proof-core.js read them from :root. */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}

View file

@ -1,4 +1,9 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026) with the narrow options of 2.0.2 (narrow, narrowBreak 720,
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
@ -82,7 +87,7 @@
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
redraw();
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
@ -90,6 +95,7 @@
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
@ -101,7 +107,7 @@
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&W<narrowBreak);
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
@ -127,7 +133,7 @@
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();redraw();
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
@ -165,7 +171,7 @@
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused)redraw();return true;
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
@ -276,6 +282,8 @@
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
function words(b){
@ -354,5 +362,5 @@
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.2'};
root.IgneumDag={mount:mount,version:'2.0.3'};
})(window);

View file

@ -3,7 +3,12 @@
the text tokens --ember-text and --molten-text exist for contrast on light surfaces, and a compatibility layer at the end
restates the vocabulary our data pages still use (.wrap, .sec, .card, .tbl, .tile, .frame, .lines, .band, states, scenes). */
/* ---------- 1. tokens (the package's) ---------- */
:root{color-scheme:dark;--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E;--glass:rgba(12,12,14,.93);--ember-tint:rgba(242,84,27,.09);--shadow:0 24px 80px #0005;--sans:'IBM Plex Sans','Plex Sans Fallback',Arial,sans-serif;--display:'Unbounded','Unbounded Fallback','Arial Black',Arial,sans-serif;--mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,monospace;--max:1280px;
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
/* scene-tokens:end */
:root{color-scheme:dark;--glass:rgba(12,12,14,.93);--ember-tint:rgba(242,84,27,.09);--shadow:0 24px 80px #0005;--sans:'IBM Plex Sans','Plex Sans Fallback',Arial,sans-serif;--display:'Unbounded','Unbounded Fallback','Arial Black',Arial,sans-serif;--mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,monospace;--max:1280px;
--ember-text:#F2541B;--molten-text:#FFB35C;
/* aliases for the page blocks written before the redesign (7 Oct 2026): fonts, type steps, space, radii, chrome */
--f-sans:var(--sans);--f-display:var(--display);--f-mono:var(--mono);
@ -19,7 +24,7 @@
--ui-accent:var(--ember);--ui-accent-ink:var(--ember-ink);--ui-hot:var(--molten);--ui-hover:var(--ember-hi);--ui-tint:var(--ember-tint);
--code:var(--row);--code-ink:var(--ink-2);
}
:root[data-theme="light"]{color-scheme:light;--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--ember-ink:#FFFFFF;--glass:rgba(244,241,236,.95);--shadow:0 24px 70px #30201810;
:root[data-theme="light"]{color-scheme:light;--glass:rgba(244,241,236,.95);--shadow:0 24px 70px #30201810;
--ember-text:#B8390C;--molten-text:#8F5810;
--scrim:rgba(244,241,236,.72);--hover:rgba(0,0,0,.04);--ok:var(--molten);
}

View file

@ -97,6 +97,10 @@ tree_checks() {
run "faucet unit tests" node --test site/api/faucet.test.mjs
run "redesign package data tests (the /api/live contract the pages read)" node tools/site-redesign/tests/data-tests.cjs
run "the home hero's loop never idles in view, stops hidden, resumes without a jump" node tools/site-redesign/tests/hero-loop-test.cjs
run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check'
run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs
run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs
run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
run "ship tool self-test" node tools/ship-app.mjs --self-test
run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test

View file

@ -0,0 +1,56 @@
// The live feed contract, as code: validate(reply) -> [] or a list of one-line problems. The key lists come from
// scene/feed-contract.json (the app's Rust test reads the same file). Extra keys are refused outside the listed extensions, so
// "the same JSON shape" is literal: a producer that adds a field changes the contract file first.
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const CONTRACT = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scene', 'feed-contract.json'), 'utf8'));
const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const minerId = new RegExp(CONTRACT.miner_id);
function keys(problems, where, obj, want, extensions = []) {
if (!isObj(obj)) { problems.push(where + ' is not an object'); return false; }
for (const k of want) if (!(k in obj)) problems.push(where + ' lacks ' + k);
for (const k of Object.keys(obj)) if (!want.includes(k) && !extensions.includes(k)) problems.push(where + ' carries an unknown key ' + k);
return true;
}
const numOrNull = (problems, where, v) => { if (v !== null && typeof v !== 'number') problems.push(where + ' is neither a number nor null'); };
const bool = (problems, where, v) => { if (typeof v !== 'boolean') problems.push(where + ' is not a boolean'); };
export function validate(reply) {
const p = [];
if (!isObj(reply)) return ['reply is not an object'];
if (reply.ok !== true) return ['reply.ok is not true' + (reply.error ? ' (' + reply.error + ')' : '')];
keys(p, 'reply', reply, CONTRACT.top, CONTRACT.top_extensions);
if (typeof reply.now !== 'string' || Number.isNaN(Date.parse(reply.now))) p.push('now is not an ISO 8601 string');
if (keys(p, 'state', reply.state, CONTRACT.state, CONTRACT.state_extensions)) {
bool(p, 'state.stale', reply.state.stale); numOrNull(p, 'state.age_s', reply.state.age_s); numOrNull(p, 'state.height', reply.state.height);
if (!Array.isArray(reply.state.blocks_per_minute)) p.push('state.blocks_per_minute is not an array');
}
if (!Array.isArray(reply.blocks)) p.push('blocks is not an array');
else reply.blocks.forEach((b, i) => {
const w = 'blocks[' + i + ']';
if (!keys(p, w, b, CONTRACT.block)) return;
if (typeof b.hash !== 'string' || !/^[0-9a-f]{1,64}$/.test(b.hash)) p.push(w + '.hash is not a hex string');
if (typeof b.ts !== 'number' || b.ts < 1e12) p.push(w + '.ts is not milliseconds');
numOrNull(p, w + '.number', b.number); numOrNull(p, w + '.blue_score', b.blue_score); numOrNull(p, w + '.daa', b.daa); numOrNull(p, w + '.rx', b.rx);
if (!Array.isArray(b.parents) || b.parents.some(x => typeof x !== 'string')) p.push(w + '.parents is not an array of strings');
bool(p, w + '.chain', b.chain); bool(p, w + '.locked', b.locked); bool(p, w + '.final', b.final); bool(p, w + '.proven', b.proven);
if (b.miner !== null && !(typeof b.miner === 'string' && minerId.test(b.miner))) p.push(w + '.miner is not an 8-hex vote key id (' + JSON.stringify(b.miner) + ')');
if (!CONTRACT.color.includes(b.color)) p.push(w + '.color is not one of ' + CONTRACT.color.join(', '));
if (!Array.isArray(b.shards)) p.push(w + '.shards is not an array');
else b.shards.forEach((s, j) => { if (keys(p, w + '.shards[' + j + ']', s, CONTRACT.shard) && !CONTRACT.shard_state.includes(s.state)) p.push(w + '.shards[' + j + '].state is not one of ' + CONTRACT.shard_state.join(', ')); });
});
if (!Array.isArray(reply.miners)) p.push('miners is not an array');
else reply.miners.forEach((m, i) => { if (keys(p, 'miners[' + i + ']', m, CONTRACT.miner) && !(typeof m.id === 'string' && minerId.test(m.id))) p.push('miners[' + i + '].id is not an 8-hex id'); });
if (!Array.isArray(reply.events)) p.push('events is not an array');
else reply.events.forEach((e, i) => keys(p, 'events[' + i + ']', e, CONTRACT.event));
if (keys(p, 'finality', reply.finality, CONTRACT.finality)) {
if (!Array.isArray(reply.finality.checkpoints)) p.push('finality.checkpoints is not an array');
else reply.finality.checkpoints.forEach((c, i) => { if (keys(p, 'finality.checkpoints[' + i + ']', c, CONTRACT.checkpoint) && !CONTRACT.checkpoint_state.includes(c.state)) p.push('finality.checkpoints[' + i + '].state is not pending or locked'); });
}
if (isObj(reply.proving)) keys(p, 'proving', reply.proving, reply.proving.supported ? CONTRACT.proving_supported : CONTRACT.proving_unsupported);
else p.push('proving is not an object');
return p;
}

View file

@ -0,0 +1,40 @@
// The live feed contract: the recorded observer reply validates; known-bad shapes are refused, each for its own reason.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validate, CONTRACT } from './feed-contract.mjs';
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scene', 'fixtures', 'live-2026-10-07.json'), 'utf8'));
const edit = fn => { const r = JSON.parse(JSON.stringify(fixture)); fn(r); return r; };
test('the recorded /api/live reply validates (905 blocks, 15 miners, 40 checkpoints)', () => {
assert.deepEqual(validate(fixture), []);
assert.equal(fixture.blocks.length, 905); assert.equal(fixture.miners.length, 15); assert.equal(fixture.finality.checkpoints.length, 40);
});
test('the app\'s documented extensions are accepted', () => {
const r = edit(r => { r.partial = true; r.state.source = 'observer'; r.state.you_blocks = 2; r.state.node = { peers: 3 }; });
assert.deepEqual(validate(r), []);
});
test('known-bad: a block whose miner is the word "you" (the 0.3.19 app rewrote the field) is refused', () => {
const p = validate(edit(r => { r.blocks[0].miner = 'you'; }));
assert.match(p.join('\n'), /blocks\[0\]\.miner is not an 8-hex vote key id \("you"\)/);
});
test('known-bad: now as a float of seconds (the 0.3.19 node fallback) is refused', () => {
assert.match(validate(edit(r => { r.now = 1791301670.5; })).join('\n'), /now is not an ISO 8601 string/);
});
test('known-bad: a missing proving object, a missing block key, an unknown key', () => {
assert.match(validate(edit(r => { delete r.proving; })).join('\n'), /reply lacks proving/);
assert.match(validate(edit(r => { delete r.blocks[3].rx; })).join('\n'), /blocks\[3\] lacks rx/);
assert.match(validate(edit(r => { r.blocks[3].vote_key_hash = 'abc'; })).join('\n'), /blocks\[3\] carries an unknown key vote_key_hash/);
assert.match(validate(edit(r => { r.miners[0] = { id: r.miners[0].id, vote_key_hash: 'x', blocks_10m: 1 }; })).join('\n'), /miners\[0\] lacks blocks/);
});
test('known-bad: a colour, a shard state and a checkpoint state outside the enums', () => {
assert.match(validate(edit(r => { r.blocks[0].color = 'winning'; })).join('\n'), /color is not one of blue, red, pending/);
assert.match(validate(edit(r => { r.blocks[0].shards = [{ i: 0, n: 1, state: 'jackpot', prover: null, lag: null, payout: null, pgas: null }]; })).join('\n'), /state is not one of planned, proving, verified, paid/);
assert.match(validate(edit(r => { r.finality.checkpoints[0].state = 'maybe'; })).join('\n'), /is not pending or locked/);
});
test('the contract file lists what the Rust test reads', () => {
for (const k of ['top', 'state', 'block', 'miner', 'finality', 'checkpoint', 'shard', 'proving_unsupported']) assert(Array.isArray(CONTRACT[k]) && CONTRACT[k].length > 0, k);
});

View file

@ -0,0 +1,95 @@
/* The chain scene paints on push whatever the document's visibility says (2.0.3, 7 October 2026).
* Found by the coordinator at 14:3x UK: /live mounted, push(d) true, state live, Pause reading Pause, and a canvas with no frame
* drawn after 3 s, because the only paint path was a requestAnimationFrame loop gated on document.hidden and the
* IntersectionObserver. A page that loads with document.hidden true (the desktop pane; a background tab) and whose embedder
* never fires visibilitychange stayed blank for good. Rule: a mounted scene with a live feed that has no block drawn after one
* push FAILS, with the document hidden, with the observer never intersecting, and with requestAnimationFrame never firing.
*
* Loads scene/live-dag.js in a stub window with a 2D context that counts its calls (a block is an arc or a rounded path that is
* filled), pushes the recorded observer fixture, and reads the count. Known-failed first: the same renderer with its
* paint-on-push line removed draws nothing (the class this test guards), and an empty feed draws no block. */
const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict'), path = require('node:path');
const root = path.join(__dirname, '..', '..');
const src = fs.readFileSync(path.join(root, 'scene', 'live-dag.js'), 'utf8');
const fixture = JSON.parse(fs.readFileSync(path.join(root, 'scene', 'fixtures', 'live-2026-10-07.json'), 'utf8'));
const T0 = new Date(fixture.now).getTime();
function world(opts) {
opts = opts || {};
const calls = { fill: 0, arc: 0, arcTo: 0, fillText: 0, raf: 0 };
const ctx = new Proxy({}, {
get: (t, k) => {
if (k === 'canvas') return canvas;
return (...a) => { if (k === 'fill') calls.fill++; if (k === 'arc') calls.arc++; if (k === 'arcTo') calls.arcTo++; if (k === 'fillText') calls.fillText++; if (k === 'createRadialGradient' || k === 'createLinearGradient') return { addColorStop() { } }; if (k === 'measureText') return { width: 10 }; if (k === 'getImageData') return { data: new Uint8ClampedArray(4) }; };
}, set: () => true,
});
const canvas = { clientWidth: 900, clientHeight: 420, width: 0, height: 0, style: {}, attrs: {}, getContext: () => ctx, getAttribute(n) { return this.attrs[n] === undefined ? null : this.attrs[n]; }, setAttribute(n, v) { this.attrs[n] = String(v); }, removeAttribute(n) { delete this.attrs[n]; }, addEventListener() { }, removeEventListener() { }, classList: { add() { }, remove() { }, toggle() { } }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 420 }), focus() { } };
const doc = { get hidden() { return !!opts.hidden; }, documentElement: { getAttribute: () => null, style: {} }, baseURI: 'https://igneum.network/live', addEventListener() { }, removeEventListener() { }, createElement: () => ({ style: {}, getContext: () => ctx }) };
canvas.ownerDocument = doc;
const window = {
document: doc, devicePixelRatio: 1, innerWidth: 1440,
requestAnimationFrame: () => { calls.raf++; return opts.rafFires ? 1 : ++calls.raf; }, cancelAnimationFrame() { },
performance: { now: () => 1000 }, Date,
matchMedia: q => ({ matches: false, addEventListener() { }, removeEventListener() { } }),
getComputedStyle: () => ({ getPropertyValue: () => '' }),
ResizeObserver: class { constructor(f) { this.f = f; } observe() { } disconnect() { } },
IntersectionObserver: class { constructor(f) { this.f = f; } observe(el) { if (opts.io === 'never') this.f([{ isIntersecting: false, target: el }]); } disconnect() { } },
MutationObserver: class { observe() { } disconnect() { } },
AbortController: class { abort() { } }, URL, setTimeout: () => 0, clearTimeout() { }, setInterval: () => 0, clearInterval() { },
addEventListener() { }, removeEventListener() { }, console,
};
window.window = window;
return { window, canvas, calls };
}
function run(source, opts, reply) {
const w = world(opts);
const origNow = Date.now; Date.now = () => T0 + 1500;
try {
vm.runInNewContext(source + '\n;window.IgneumDag', w.window, { filename: 'live-dag.js' });
const api = w.window.IgneumDag.mount(w.canvas, { poll: false, window: 60, fps: 60 });
const ok = api.push(reply);
return { ok, calls: w.calls, stats: api.getStats(), state: api.getState().state };
} finally { Date.now = origNow; }
}
// a block is two rounded tiles (the outline and the core), four arcTo calls each
const blocksDrawn = r => Math.floor(r.calls.arcTo / 8);
const results = [];
function check(name, fn) { try { fn(); results.push({ name, pass: true }); } catch (e) { results.push({ name, pass: false, error: e.message }); } }
// known-failed first: the renderer without its paint-on-push line (the 2.0.2 behaviour) draws nothing with the document hidden
check('known-failed: 2.0.2 behaviour (no paint on push) draws no block with the document hidden', () => {
assert(src.includes('/*paint-on-push*/paint();'), 'the paint-on-push marker is gone from scene/live-dag.js');
const old = src.replace('/*paint-on-push*/paint();', '');
const r = run(old, { hidden: true }, fixture);
assert.equal(r.ok, true); assert.equal(r.state, 'live'); assert(r.stats.blocks > 0, 'the fixture has blocks in the window');
assert.equal(blocksDrawn(r), 0, 'the old renderer must draw nothing here, or this test guards nothing');
});
check('known-failed: an empty feed draws no block', () => {
const r = run(src, { hidden: true }, { ok: true, state: { stale: false, age_s: 0 }, blocks: [] });
assert.equal(blocksDrawn(r), 0);
});
// the rule
check('document hidden, requestAnimationFrame never fires: blocks are drawn on the first push', () => {
const r = run(src, { hidden: true }, fixture);
assert.equal(r.ok, true); assert.equal(r.state, 'live'); assert(r.stats.blocks > 50, 'blocks in the 60 s window: ' + r.stats.blocks);
assert(blocksDrawn(r) >= r.stats.blocks, 'tiles drawn ' + blocksDrawn(r) + ' for ' + r.stats.blocks + ' blocks in the window');
assert(r.calls.fillText > 0, 'lane labels and the time axis are written');
});
check('the observer never reports the canvas intersecting: blocks are still drawn on push', () => {
const r = run(src, { hidden: false, io: 'never' }, fixture);
assert(blocksDrawn(r) >= r.stats.blocks);
});
check('a visible page still schedules the motion loop after the synchronous paint', () => {
const r = run(src, { hidden: false }, fixture);
assert(r.calls.raf > 0, 'requestAnimationFrame was asked for');
});
check('a hidden page does not spin the motion loop', () => {
const r = run(src, { hidden: true }, fixture);
assert.equal(r.calls.raf, 0);
});
for (const r of results) console.log((r.pass ? ' ok ' : ' FAIL ') + r.name + (r.error ? ': ' + r.error : ''));
const failed = results.filter(r => !r.pass).length;
console.log(failed ? 'paint-test: RED, ' + failed + ' failed' : 'paint-test: ok, ' + results.length + ' checks (two known-failed cases fail as they must)');
process.exit(failed ? 1 : 0);

36
tools/scene/parity-remote.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# The scene parity test on a build box (the Mac runs no headless browser: standing rule, 7 October 2026). Carries the files the
# test needs to the box under this lane's prefix, runs tools/scene/parity.mjs there and prints its report; the box's own
# Playwright build and Chromium (root installed the libraries on 7 October 2026, 14:5x UK) do the rendering.
#
# tools/scene/parity-remote.sh [--box N] [--out <dir>] default box 2; --out fetches the frames and the report back
#
# Where it runs: a box named by ~/.config/igneum/build-server[-N] (build@<ip>); on a machine with no box file but a local
# Playwright (a CI runner that is itself a box) it runs parity.mjs in place; with neither it prints a skip line and exits 0,
# so a plain GitHub runner stays green while every box-side and Mac-side gate renders for real.
set -uo pipefail
cd "$(git rev-parse --show-toplevel)" || exit 1
BOX=2; OUT=""
while [ $# -gt 0 ]; do case "$1" in --box) BOX="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; *) echo "unknown argument $1" >&2; exit 2 ;; esac; done
HOSTFILE="$HOME/.config/igneum/build-server"; [ "$BOX" = 1 ] || HOSTFILE="$HOSTFILE-$BOX"
if [ ! -s "$HOSTFILE" ]; then
if [ -n "${PLAYWRIGHT_CORE:-}" ] || [ -f /srv/builds/_bin/overlap/node_modules/playwright-core/package.json ]; then exec node tools/scene/parity.mjs ${OUT:+--out "$OUT"}; fi
echo "scene-parity: skipped, no build box file at $HOSTFILE and no Playwright here"; exit 0
fi
HOST="$(cat "$HOSTFILE")"; KEY="$HOME/.ssh/igneum_ed25519"
SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST")
WT="$(basename "$(pwd)")"; DEST="/srv/builds/scene-parity/$WT"
"${SSH[@]}" "mkdir -p $DEST/site $DEST/tools/ui-mock $DEST/tools/scene $DEST/scene $DEST/app/igneum-app/ui" || { echo "scene-parity: box $HOST unreachable" >&2; exit 1; }
RS=(rsync -a --delete -e "ssh -i $KEY -o BatchMode=yes")
"${RS[@]}" --exclude img --exclude verify --exclude '*.png' --exclude '*.ico' site/ "$HOST:$DEST/site/"
"${RS[@]}" scene/ "$HOST:$DEST/scene/"
"${RS[@]}" tools/scene/ "$HOST:$DEST/tools/scene/"
"${RS[@]}" tools/ui-mock/ "$HOST:$DEST/tools/ui-mock/"
"${RS[@]}" tools/site-serve.mjs "$HOST:$DEST/tools/site-serve.mjs"
if [ -f app/igneum-app/ui/live-dag.js ]; then "${RS[@]}" --exclude '*.woff2' app/igneum-app/ui/ "$HOST:$DEST/app/igneum-app/ui/"; else "${SSH[@]}" "rm -rf $DEST/app"; fi
[ -f brand/igneum-coin-1024.png ] && "${SSH[@]}" "mkdir -p $DEST/brand" && "${RS[@]}" brand/igneum-coin-1024.png "$HOST:$DEST/brand/igneum-coin-1024.png"
# the copied-sources rule: re-stamped on the far side before anything runs (touch), so no stale mtime decides anything there
"${SSH[@]}" "cd $DEST && find . -type f -exec touch {} + && git init -q . 2>/dev/null; [ -f /srv/builds/_bin/overlap/env.sh ] && . /srv/builds/_bin/overlap/env.sh; cd $DEST && timeout 240 nice -n 10 node tools/scene/parity.mjs --out $DEST/_out"
RC=$?
if [ -n "$OUT" ]; then mkdir -p "$OUT"; rsync -a -e "ssh -i $KEY -o BatchMode=yes" "$HOST:$DEST/_out/" "$OUT/" 2>/dev/null; fi
exit $RC

184
tools/scene/parity.mjs Normal file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env node
// Scene parity: the same recorded feed through the site's home fold, the site's /live and the miner app's Inspect view, three
// frames each at the same three instants of a fake clock, compared pixel for pixel. Runs on a build box (tools/scene/parity-remote.sh
// carries the tree there and runs this; never a headless browser on the Mac).
//
// node tools/scene/parity.mjs [--out <dir>] the report on stdout, one line per comparison; exit 1 on any difference
//
// What it does, in order (the known-failed case first, as the watcher-trust rule asks):
// 1. serves site/ through tools/site-serve.mjs and app/igneum-app/ui through tools/ui-mock/server.mjs on private ports;
// every page's /api/live is answered from scene/fixtures/live-2026-10-07.json by a Playwright route, the clock is frozen
// at the fixture's `now` (Date, timers, requestAnimationFrame), the viewport is 1280 by 900 at DPR 1, dark;
// 2. KNOWN-FAILED: the app page with one palette token changed (--included) must differ from /live at every instant;
// 3. base: no own key anywhere; home fold, /live and the app's Inspect scene byte-equal at T, T+2 s, T+5 s;
// 4. overlay: the same vote key named on every surface (localStorage on the site, the mock's card ids on the app); the
// three surfaces still byte-equal, and the app's overlay frame differs from its base frame (the overlay is drawn);
// 5. the app's compact card (its own mode, no site counterpart) is rendered once and reported, not compared.
// Every scene canvas is forced to 900 by 420 CSS px so the comparison is of the renderer, the tokens, the options and the
// feed, not of the box the page gives the scene. A surface whose files are absent (master carries no app UI scene) is
// reported as skipped and the remaining surfaces are compared.
import { createRequire } from 'node:module';
import { spawn } from 'node:child_process';
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { homedir } from 'node:os';
const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..');
const out = process.argv.includes('--out') ? process.argv[process.argv.indexOf('--out') + 1] : '';
if (out) mkdirSync(out, { recursive: true });
const PW = [process.env.PLAYWRIGHT_CORE, '/srv/builds/_bin/overlap/node_modules/playwright-core/package.json', '/srv/builds/scene-parity/pw/node_modules/playwright-core/package.json', join(homedir(), '.cache/igneum-pw/node_modules/playwright-core/package.json')].filter(Boolean).find(p => existsSync(p));
const CHROME = [process.env.PLAYWRIGHT_CHROMIUM, join(homedir(), '.cache/ms-playwright/chromium_headless_shell-1194/chrome-linux/headless_shell'), join(homedir(), '.cache/ms-playwright/chromium-1194/chrome-linux/chrome')].filter(Boolean).find(p => existsSync(p));
if (!PW || !CHROME) { console.log('scene-parity: skipped, no Playwright Chromium here (PLAYWRIGHT_CORE, PLAYWRIGHT_CHROMIUM)'); process.exit(0); }
const { chromium } = createRequire(PW)('playwright-core');
const fixture = JSON.parse(readFileSync(join(repo, 'scene/fixtures/live-2026-10-07.json'), 'utf8'));
const T0 = new Date(fixture.now).getTime();
const KEY = fixture.miners[2].id; // a real key of the recording with a few blocks: the overlay case names it on every surface
const SIZE = { w: 900, h: 420 }, INSTANTS = [0, 2000, 4000]; // the first push and the next two polls (every surface polls every 2 s)
const hasApp = existsSync(join(repo, 'app/igneum-app/ui/live-dag.js')) && existsSync(join(repo, 'tools/ui-mock/server.mjs'));
const problems = [], lines = [];
const say = s => { lines.push(s); console.log(s); };
// ---- servers ----
function serve(cmd, args, env, ready) {
return new Promise((resolve, reject) => {
const p = spawn(process.execPath, [cmd, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
let log = ''; const t = setTimeout(() => reject(new Error('server did not start: ' + cmd + '\n' + log)), 15000);
const probe = async () => { try { const r = await fetch(ready); if (r.ok || r.status === 404) { clearTimeout(t); resolve(p); return; } } catch { } setTimeout(probe, 200); };
p.stdout.on('data', d => { log += d; }); p.stderr.on('data', d => { log += d; });
p.on('exit', c => { clearTimeout(t); reject(new Error(cmd + ' exited ' + c + '\n' + log)); });
setTimeout(probe, 300);
});
}
const port = base => base + Math.floor(Math.random() * 200);
const sitePort = port(4600), appPort = port(4900);
const servers = [];
servers.push(await serve(join(repo, 'tools/site-serve.mjs'), [], { PORT: String(sitePort), DATABASE_URL: 'postgres://none' }, `http://127.0.0.1:${sitePort}/live`));
if (hasApp) servers.push(await serve(join(repo, 'tools/ui-mock/server.mjs'), [String(appPort)], {}, `http://127.0.0.1:${appPort}/t/mock/api/state`));
const stop = () => { for (const s of servers) try { s.kill(); } catch { } };
process.on('exit', stop);
// ---- the browser ----
const browser = await chromium.launch({ executablePath: CHROME, args: ['--renderer-process-limit=1', '--font-render-hinting=none', '--disable-lcd-text'] });
// the mount shim: every mounted scene is listed with a push counter, so a capture can wait for a push to have landed
const capture = () => { let real; Object.defineProperty(window, 'IgneumDag', { configurable: true, get() { return real; }, set(v) { real = Object.assign({}, v, { mount(c, o) { const api = v.mount(c, o); const entry = { canvas: c, api, opts: o, pushes: 0 }; const push = api.push; api.push = function (d) { const ok = push(d); if (ok) entry.pushes++; return ok; }; (window.__dags = window.__dags || []).push(entry); return api; } }); } }); };
/** One surface: opens the page with the fixture as its feed and the clock frozen at T0; returns frame hashes at the instants. */
async function surface(name, url, { ownKey, mutate, selectCanvas, prepare } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' });
const page = await ctx.newPage();
const errors = []; page.on('pageerror', e => errors.push(String(e.message)));
await ctx.clock.install({ time: T0 });
await ctx.clock.pauseAt(T0 + 1); // frozen: only runFor moves it, so every surface is read at the same three instants
await page.addInitScript(capture);
// the scene's box before any script runs (lane order keeps the history of earlier layouts, so the first layout must already
// be at the compared size): a style appended the moment <head> exists; no border or padding inside the canvas box
await page.addInitScript(({ sel, w, h, extra }) => { const css = sel + '{width:' + w + 'px!important;height:' + h + 'px!important;max-width:none!important;border:0!important;padding:0!important;box-sizing:content-box!important}' + extra; const add = () => { if (!document.head) return false; const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); return true; }; if (!add()) new MutationObserver((m, o) => { if (add()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); }, { sel: selectCanvas, w: SIZE.w, h: SIZE.h, extra: mutate || '' });
if (ownKey) await page.addInitScript(k => { try { localStorage.setItem('igneum-my-key', k); localStorage.setItem('igneum-theme', 'dark'); } catch { } }, ownKey);
else await page.addInitScript(() => { try { localStorage.removeItem('igneum-my-key'); localStorage.setItem('igneum-theme', 'dark'); } catch { } });
await page.route('**/api/live*', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(fixture) }));
// the app's own keys come from its cards: none in the base case (the mock's recorded card is a real devnet key that is in the
// fixture), exactly the named key in the overlay case
if (name.startsWith('app')) await page.route('**/api/state*', async r => { const res = await r.fetch(); const s = await res.json(); if (s.mining && s.mining.cards) s.mining.cards.forEach((c, i) => { c.ids = ownKey && i === 0 ? [ownKey] : []; }); r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(s) }); });
await page.goto(url, { waitUntil: 'load' });
// the mount, then the first push (the page's own poll runs at load; the fetch resolves in real time)
if (prepare) await prepare(page);
await page.waitForFunction(sel => !!(window.__dags || []).find(x => x.canvas.matches(sel)), selectCanvas, { timeout: 15000 }).catch(() => { });
// the canvas in view, so the IntersectionObserver reports it and the motion loop runs on every surface alike (a canvas below
// the fold paints on push alone since 2.0.3, which is right for a viewer but not a comparison of the loop)
await page.evaluate(sel => { const c = document.querySelector(sel); if (c) c.scrollIntoView({ block: 'center' }); }, selectCanvas);
await page.waitForTimeout(200);
// the capture protocol, the same on every surface: advance the frozen clock exactly to the next poll, wait (in real time)
// for that push to land, read the canvas as the push's own synchronous paint left it. No animation frame runs between the
// push and the read, so the frame depends on the fake time alone, never on how long the fetch took.
const pushes = () => page.evaluate(sel => { const d = (window.__dags || []).find(x => x.canvas.matches(sel)); return d ? d.pushes : -1; }, selectCanvas);
const frames = [];
let at = 0, want = 1;
for (const ms of INSTANTS) {
if (ms > at) { await ctx.clock.runFor(ms - at); at = ms; }
const t0 = Date.now(); while (Date.now() - t0 < 8000 && (await pushes()) < want) { await page.waitForTimeout(50); if ((await pushes()) < want && Date.now() - t0 > 1500) { await ctx.clock.runFor(1); at += 1; } }
if ((await pushes()) < want) say(' push ' + want + ' never landed on ' + name + ' (' + (await pushes()) + ' so far)');
want++;
const f = await page.evaluate(sel => {
const d = (window.__dags || []).find(x => x.canvas.matches(sel)); if (!d) return { error: 'no scene on ' + sel };
const c = d.canvas, g = c.getContext('2d'), st = d.api.getStats();
const px = g.getImageData(0, 0, c.width, c.height).data; let h = 0, lit = 0;
for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; }
return { w: c.width, h: c.height, lit, blocks: st.blocks, lanes: st.lanes, window: st.window, narrow: st.narrow, state: st.state, frames: st.frames, png: c.toDataURL('image/png') };
}, selectCanvas);
if (f.error) { frames.push(f); continue; }
f.hash = createHash('sha256').update(f.png).digest('hex').slice(0, 16);
if (out) writeFileSync(join(out, `${name}-${ms}.png`), Buffer.from(f.png.split(',')[1], 'base64'));
f.pngData = f.png; delete f.png; frames.push(f);
}
await ctx.close();
return { name, frames, errors };
}
const describe = s => s.frames.map((f, i) => f.error ? f.error : `T+${INSTANTS[i] / 1000}s ${f.hash} ${f.w}x${f.h} lit ${f.lit} blocks ${f.blocks} lanes ${f.lanes} win ${f.window}${f.narrow ? ' narrow' : ''} ${f.state}`).join('; ');
async function compare(label, a, b, mustDiffer) {
const diffs = [];
for (let i = 0; i < INSTANTS.length; i++) {
const fa = a.frames[i], fb = b.frames[i];
if (fa.error || fb.error) { diffs.push('absent'); continue; }
if (fa.hash === fb.hash) { diffs.push('equal'); continue; }
const d = (fa.w === fb.w && fa.h === fb.h) ? await pixelDiff(fa.pngData, fb.pngData) : { n: -1 };
diffs.push('DIFFERS' + (d.n >= 0 ? ' (' + d.n + ' of ' + d.total + ' px, box ' + d.box.join(',') + ')' : ' (sizes differ)'));
}
const bad = mustDiffer ? diffs.some(d => !d.startsWith('DIFFERS')) : diffs.some(d => d !== 'equal');
say(` ${bad ? 'RED ' : 'ok '} ${label}: ${diffs.map((d, i) => `T+${INSTANTS[i] / 1000}s ${d}`).join(', ')}`);
if (bad) problems.push(label);
}
const dims = (a, b) => a.frames[0].w === b.frames[0].w && a.frames[0].h === b.frames[0].h;
async function pixelDiff(aPng, bPng) {
const ctx = await browser.newContext(); const page = await ctx.newPage();
const r = await page.evaluate(async ([a, b]) => {
const load = src => new Promise(res => { const i = new Image(); i.onload = () => res(i); i.src = src; });
const [ia, ib] = await Promise.all([load(a), load(b)]);
const c = document.createElement('canvas'); c.width = ia.width; c.height = ia.height; const g = c.getContext('2d');
g.drawImage(ia, 0, 0); const pa = g.getImageData(0, 0, c.width, c.height).data; g.clearRect(0, 0, c.width, c.height); g.drawImage(ib, 0, 0); const pb = g.getImageData(0, 0, c.width, c.height).data;
let n = 0, x0 = 1e9, y0 = 1e9, x1 = -1, y1 = -1;
for (let i = 0; i < pa.length; i += 4) { if (pa[i] !== pb[i] || pa[i + 1] !== pb[i + 1] || pa[i + 2] !== pb[i + 2] || pa[i + 3] !== pb[i + 3]) { n++; const p = i / 4, x = p % c.width, y = (p / c.width) | 0; if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; } }
return { n, box: n ? [x0, y0, x1 - x0 + 1, y1 - y0 + 1] : null, total: c.width * c.height };
}, [aPng, bPng]);
await ctx.close(); return r;
}
const SITE_LIVE = `http://127.0.0.1:${sitePort}/live`, SITE_HOME = `http://127.0.0.1:${sitePort}/`, APP = `http://127.0.0.1:${appPort}/t/mock/?scenario=live&theme=dark`;
const openInspect = async page => { await page.waitForSelector('#dag-inspect', { timeout: 15000 }); await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); await page.waitForTimeout(300); await page.click('#dag-inspect'); await page.waitForTimeout(300); };
try {
say(`scene-parity: fixture ${fixture.now}, ${fixture.blocks.length} blocks, key ${KEY} for the overlay case, canvases ${SIZE.w}x${SIZE.h}, instants ${INSTANTS.join('/')} ms`);
const live = await surface('live', SITE_LIVE, { selectCanvas: '#dag' });
say(' /live base: ' + describe(live));
const home = await surface('home', SITE_HOME, { selectCanvas: '#dag' });
say(' home fold base: ' + describe(home));
for (const s of [live, home]) for (const e of s.errors) say(' page error on ' + s.name + ': ' + e);
if (live.frames.some(f => !f.lit)) problems.push('/live drew nothing');
await compare('home fold = /live (same renderer, same feed, same options)', home, live, false);
if (hasApp) {
// known-failed first: one token changed on the app side must fail
const mutated = await surface('app-mutated', APP, { selectCanvas: '#dag-full', prepare: openInspect, mutate: ':root{--included:#3B7DD9!important}' });
say(' app Inspect with --included changed by one unit: ' + describe(mutated));
await compare('KNOWN-FAILED: app with a changed palette token differs from /live', mutated, live, true);
const app = await surface('app', APP, { selectCanvas: '#dag-full', prepare: openInspect });
say(' app Inspect base: ' + describe(app));
for (const e of app.errors) say(' page error on app: ' + e);
if (!dims(app, live)) problems.push('canvas buffers differ in size');
await compare('app Inspect = /live', app, live, false);
// the overlay: the same key on every surface
const liveK = await surface('live-key', SITE_LIVE, { selectCanvas: '#dag', ownKey: KEY });
const appK = await surface('app-key', APP, { selectCanvas: '#dag-full', prepare: openInspect, ownKey: KEY });
say(' /live with the key: ' + describe(liveK));
say(' app Inspect with the key: ' + describe(appK));
await compare('app Inspect with this machine\'s key = /live with the same key (the overlay is the same picture)', appK, liveK, false);
await compare('the app\'s overlay frame differs from its base frame (the overlay is drawn)', appK, app, true);
const compact = await surface('app-compact', APP, { selectCanvas: '#dag-live', prepare: async page => { await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); } });
say(' app compact card (its own mode, reported, not compared): ' + describe(compact));
} else {
say(' app surface: skipped (no app/igneum-app/ui/live-dag.js in this tree; the app lives on the release branches)');
}
} finally { await browser.close(); stop(); }
say(problems.length ? `scene-parity: RED, ${problems.length} problem(s): ${problems.join('; ')}` : 'scene-parity: ok');
if (out) writeFileSync(join(out, 'report.txt'), lines.join('\n') + '\n');
process.exit(problems.length ? 1 : 0);

186
tools/scene/sync.mjs Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env node
// The chain scene has ONE source: scene/ (live-dag.js, proof-core.js, tokens.css). The site and the miner app serve copies,
// because the site is a static folder Vercel builds from site/ and the app embeds ui/ with include_str!. This tool keeps the
// copies honest without symlinks (Windows checkouts, Vercel's root directory, cargo's include paths):
//
// node tools/scene/sync.mjs [--adopt] write the copies that exist (--adopt creates a missing script copy next to an existing
// target, the first time a tree takes the scene): the two scripts byte-for-byte, the palette block into each CSS file
// between the scene-tokens markers (and, on first adoption, strip the same token names
// from the file's other :root blocks, so the block is the only definition)
// node tools/scene/sync.mjs --check the gate: every copy present in this tree is byte-equal to its source, every CSS
// target's block equals the generated one and defines those names nowhere else;
// exit 1 with one line per difference ("which side": the source is always right)
// node tools/scene/sync.mjs --self-test known-failed first: a palette token changed on one side, one byte changed in a
// copy, a stray token definition; then the clean tree passes
//
// Scope: a side (site or app) takes part only once it has adopted the scene, which is when its live-dag.js copy exists
// (master carries no app/igneum-app/ui/live-dag.js; the app lives on the release branches, whose site tree is frozen). On a release-* branch (the branch name, or SCENE_SYNC_SCOPE=app) only the app's copies are checked:
// the release branches carry the site tree as it was when they were cut, and the site deploys from master alone.
import { readFileSync, writeFileSync, existsSync, mkdtempSync, cpSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url));
const repoDefault = join(here, '..', '..');
export const TOKEN_NAMES = ['--obsidian', '--graphite', '--row', '--line', '--line-2', '--ember', '--ember-hi', '--molten', '--bone', '--ink-2', '--ash', '--included', '--excluded', '--ember-ink'];
const START = '/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */';
const END = '/* scene-tokens:end */';
const TARGETS = [
{ from: 'scene/live-dag.js', to: 'site/live-dag.js', scope: 'site', kind: 'copy' },
{ from: 'scene/proof-core.js', to: 'site/proof-core.js', scope: 'site', kind: 'copy' },
{ from: 'scene/tokens.css', to: 'site/site.css', scope: 'site', kind: 'tokens' },
{ from: 'scene/live-dag.js', to: 'app/igneum-app/ui/live-dag.js', scope: 'app', kind: 'copy' },
{ from: 'scene/proof-core.js', to: 'app/igneum-app/ui/proof-core.js', scope: 'app', kind: 'copy' },
{ from: 'scene/tokens.css', to: 'app/igneum-app/ui/app.css', scope: 'app', kind: 'tokens' },
];
// tokens.css -> { dark: {name: value}, light: {name: value} }; every name must be in both, no extras
export function parseTokens(css) {
const block = sel => { const m = css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\{([^}]*)\\}')); if (!m) throw new Error('tokens.css: no ' + sel + ' block'); return m[1]; };
const map = body => Object.fromEntries(body.split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf(':'); return [s.slice(0, i).trim(), s.slice(i + 1).trim()]; }));
const dark = map(block(':root')), light = map(block(':root[data-theme="light"]'));
for (const set of [dark, light]) {
const names = Object.keys(set).sort().join(','), want = TOKEN_NAMES.slice().sort().join(',');
if (names !== want) throw new Error('tokens.css defines ' + names + ', expected exactly ' + want);
}
return { dark, light };
}
const decl = m => TOKEN_NAMES.map(n => n + ':' + m[n]).join(';');
export function tokenBlock(tokens) {
const light = decl(tokens.light);
return [START,
':root{' + decl(tokens.dark) + '}',
':root[data-theme="light"]{' + light + '}',
'@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){' + light + '}}',
END].join('\n');
}
const blockRe = new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
// @media print blocks keep their own overrides (a printed page is white); everything else defines the names only in the block
export function withoutExempt(css) {
let out = css.replace(blockRe, ''), i;
while ((i = out.indexOf('@media print{')) >= 0) {
let depth = 0, j = i + '@media print'.length;
for (; j < out.length; j++) { if (out[j] === '{') depth++; else if (out[j] === '}' && --depth === 0) { j++; break; } }
out = out.slice(0, i) + out.slice(j);
}
return out;
}
// the token names defined outside the block (any "--name:" inside a declaration list; a var(--name) read is not a definition)
export function strayDefinitions(css) {
const outside = withoutExempt(css);
const found = [];
for (const n of TOKEN_NAMES) { const re = new RegExp('(^|[{;\\s])' + n.replace(/[-]/g, '\\-') + '\\s*:', 'g'); let m; while ((m = re.exec(outside))) found.push(n); }
return found;
}
function stripDefinitions(css) {
// remove "--name:value;" (or the last one before "}") outside the block; keep everything else byte for byte
// the print block and the scene-tokens block keep their text; every other declaration of a token name goes
const keep = [];
const m = css.match(blockRe); if (m) keep.push([css.indexOf(m[0]), css.indexOf(m[0]) + m[0].length]);
let i = -1; while ((i = css.indexOf('@media print{', i + 1)) >= 0) { let depth = 0, j = i + '@media print'.length; for (; j < css.length; j++) { if (css[j] === '{') depth++; else if (css[j] === '}' && --depth === 0) { j++; break; } } keep.push([i, j]); }
keep.sort((a, b) => a[0] - b[0]);
const strip = s => { for (const n of TOKEN_NAMES) s = s.replace(new RegExp('(^|(?<=[{;\\s]))' + n.replace(/[-]/g, '\\-') + '\\s*:[^;}]*;?', 'g'), ''); return s; };
let out = '', at = 0;
for (const [a, b] of keep) { out += strip(css.slice(at, a)) + css.slice(a, b); at = b; }
return out + strip(css.slice(at));
}
export function writeTokens(css, block) {
if (blockRe.test(css)) return css.replace(blockRe, block);
// first adoption: the block goes before the file's first :root rule (after the leading comment), and the names leave the rest
const i = css.search(/^:root\{/m);
if (i < 0) throw new Error('no :root rule to put the scene-tokens block before');
return stripDefinitions(css.slice(0, i) + block + '\n' + css.slice(i));
}
export function scopeFor(repo) {
if (process.env.SCENE_SYNC_SCOPE) return process.env.SCENE_SYNC_SCOPE;
let branch = '';
try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repo, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { }
if (branch === 'HEAD' && process.env.GITHUB_REF_NAME) branch = process.env.GITHUB_REF_NAME;
return /^release-/.test(branch) ? 'app' : 'all';
}
const adopted = (repo, side) => existsSync(join(repo, side === 'site' ? 'site/live-dag.js' : 'app/igneum-app/ui/live-dag.js'));
export function check(repo, scope = scopeFor(repo)) {
const problems = [], seen = [];
const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8'));
const block = tokenBlock(tokens);
for (const t of TARGETS) {
if (scope !== 'all' && t.scope !== scope) continue;
if (!adopted(repo, t.scope)) continue;
const dst = join(repo, t.to);
if (!existsSync(dst)) { problems.push(t.to + ' is missing while ' + t.scope + ' has adopted the scene'); continue; }
seen.push(t.to);
if (t.kind === 'copy') {
const a = readFileSync(join(repo, t.from)), b = readFileSync(dst);
if (!a.equals(b)) {
let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++;
problems.push(t.to + ' differs from ' + t.from + ' at byte ' + i + ' (source ' + a.length + ' bytes, copy ' + b.length + '): the source is right, run tools/scene/sync.mjs');
}
} else {
const css = readFileSync(dst, 'utf8');
const m = css.match(blockRe);
if (!m) { problems.push(t.to + ': no scene-tokens block (run tools/scene/sync.mjs to adopt it)'); continue; }
if (m[0] !== block) {
const theirs = m[0].split('\n'), ours = block.split('\n');
const line = ours.findIndex((l, k) => theirs[k] !== l);
problems.push(t.to + ': scene-tokens block differs from scene/tokens.css at block line ' + (line + 1) + ' (the source is right, run tools/scene/sync.mjs)');
}
const stray = strayDefinitions(css);
if (stray.length) problems.push(t.to + ' defines ' + [...new Set(stray)].join(', ') + ' outside the scene-tokens block: the block is the only definition');
}
}
return { problems, seen, scope };
}
export function sync(repo, scope = 'all', adopt = false) {
const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8'));
const block = tokenBlock(tokens), written = [];
for (const t of TARGETS) {
if (scope !== 'all' && t.scope !== scope) continue;
const dst = join(repo, t.to);
if (!adopt && !adopted(repo, t.scope)) continue;
if (!existsSync(dst) && !(adopt && t.kind === 'copy' && existsSync(dirname(dst)))) continue;
if (t.kind === 'copy') { const src = readFileSync(join(repo, t.from)); if (!existsSync(dst) || !src.equals(readFileSync(dst))) { writeFileSync(dst, src); written.push(t.to); } }
else { const css = readFileSync(dst, 'utf8'), next = writeTokens(css, block); if (next !== css) { writeFileSync(dst, next); written.push(t.to); } }
}
return written;
}
function selfTest(repo) {
const tmp = mkdtempSync(join(tmpdir(), 'scene-sync-'));
const fails = [];
const fixture = () => { rmSync(tmp, { recursive: true, force: true }); for (const d of ['scene', 'site', 'app/igneum-app/ui']) if (existsSync(join(repo, d))) cpSync(join(repo, d), join(tmp, d), { recursive: true, filter: p => !/\/(fonts|img|verify|scenes|api|lib|partials)(\/|$)/.test(p) && !/\.(png|woff2|ico)$/.test(p) }); sync(tmp, 'all'); };
const expectFail = (name, mutate) => { fixture(); mutate(); const r = check(tmp, 'all'); if (!r.problems.length) fails.push('known-failed case passed: ' + name); };
expectFail('a palette token changed on the site side', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--included:#3B7DD8', '--included:#3B7DD9')); });
expectFail('a palette token changed in the source but not the copies', () => { const p = join(tmp, 'scene/tokens.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--ember:#F2541B', '--ember:#F2541C')); });
expectFail('one byte changed in the site copy of live-dag.js', () => { const p = join(tmp, 'site/live-dag.js'); const s = readFileSync(p, 'utf8'); writeFileSync(p, s.replace("version:'2.0", "version:'9.0")); });
if (existsSync(join(tmp, 'app/igneum-app/ui/live-dag.js'))) expectFail('one byte changed in the app copy of live-dag.js', () => { const p = join(tmp, 'app/igneum-app/ui/live-dag.js'); writeFileSync(p, readFileSync(p, 'utf8') + '\n'); });
expectFail('a stray --ember definition outside the block', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n:root{--ember:#000}\n'); });
fixture(); { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n@media print{:root{--ember:#000}.x{color:var(--ember)}}\n'); const r = check(tmp, 'all'); if (r.problems.length) fails.push('a print-block override was flagged as a stray definition: ' + r.problems.join('; ')); }
fixture(); const clean = check(tmp, 'all'); if (clean.problems.length) fails.push('clean fixture failed: ' + clean.problems.join('; '));
rmSync(tmp, { recursive: true, force: true });
return fails;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const repo = process.env.SCENE_SYNC_REPO || repoDefault;
const mode = process.argv[2] || 'write';
if (mode === '--check') {
const r = check(repo);
for (const p of r.problems) console.error('scene-sync: ' + p);
console.log('scene-sync: ' + (r.problems.length ? 'RED, ' + r.problems.length + ' difference(s)' : 'ok') + ' (scope ' + r.scope + ': ' + r.seen.join(', ') + ')');
process.exit(r.problems.length ? 1 : 0);
} else if (mode === '--self-test') {
const fails = selfTest(repo);
for (const f of fails) console.error('scene-sync self-test: ' + f);
console.log('scene-sync self-test: ' + (fails.length ? 'RED' : 'ok, five known-failed cases fail, a print override passes, the clean tree passes'));
process.exit(fails.length ? 1 : 0);
} else if (mode === 'write') {
const w = sync(repo, process.argv.includes('--scope') ? process.argv[process.argv.indexOf('--scope') + 1] : 'all', process.argv.includes('--adopt'));
console.log('scene-sync: wrote ' + (w.length ? w.join(', ') : 'nothing (every copy was current)') + (process.argv.includes('--adopt') ? '' : ' (sides that have not adopted the scene are left alone; --adopt takes one on)'));
} else { console.error('usage: sync.mjs [--check | --self-test]'); process.exit(2); }
}