diff --git a/app/igneum-app/src/live.rs b/app/igneum-app/src/live.rs index b6615cef..3e391233 100644 --- a/app/igneum-app/src/live.rs +++ b/app/igneum-app/src/live.rs @@ -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 = 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) -> 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::>(), + "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::>(), }) } +/// 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) -> 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) -> Vec { @@ -236,17 +280,8 @@ pub fn fetch(shared: &Arc, 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 = ["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 = ["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 { + let mut k: Vec = v.as_object().expect("an object").keys().cloned().collect(); + k.sort(); + k + } + fn listed(c: &Value, name: &str) -> Vec { + let mut k: Vec = 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 = ["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(); diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 62b7c4fa..3599e294 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -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} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 4407ff8a..73dc0f86 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -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; diff --git a/app/igneum-app/ui/live-dag.js b/app/igneum-app/ui/live-dag.js index b4e1705a..0d28305d 100644 --- a/app/igneum-app/ui/live-dag.js +++ b/app/igneum-app/ui/live-dag.js @@ -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&&W0&&viewportW()=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);