Miner app: the chain scene is the site's scene (scene/ 2.0.3), one feed contract, the viewpoint as an overlay (scene-parity for 0.3.21, 7 October 2026, 15:5x UK)
The drift the app carried against the site's home fold and /live, and what moved:
- renderer: both were 2.0.2 byte for byte; the app now takes the shared scene/live-dag.js 2.0.3 (paint on push whatever the
document's visibility says: the blank /live; the phone rule on the viewport width) and proof-core.js through tools/scene/sync.mjs,
and the gate refuses a drifted copy.
- palette: the dark tokens were equal; the light theme's --ember was #E04A14 and --ember-hi #F2541B against the brand package's
#D0420D / #E04A14. The fourteen scene tokens now sit in the scene-tokens block app.css takes from scene/tokens.css (dark,
[data-theme="light"], prefers-color-scheme light) and are defined nowhere else in the file.
- phone rule: the app passed narrow: window.innerWidth < 720 at mount time and never again; the site keyed it on the canvas
width (a 640 px hero on a laptop rendered as a phone). Both now leave it to the renderer: the viewport, live on resize.
- feed window: the app asked the engine for 120 s, the site 300 s; both 300 now, so the viewer can pan the same range.
- Inspect view: 360 px tall against /live's 420 (five lanes against seven); 420 now.
- feed shape: the engine rewrote this machine's blocks to miner: "you" (a word the observer never emits) and its node-only
fallback carried now as a float of seconds, rows with timestamp_ms / is_chain_block / vote_key_hash / timestamp_source and no
number or rx, miners as {id, vote_key_hash, blocks_10m}, no proving, a finality with checkpoints alone. live.rs now passes the
observer's rows through untouched (state.you_blocks counts them; the UI's mine function marks the lane from the card ids, which
is the overlay: own blocks glow, the lane reads YOUR KEY) and node_only_reply builds the fallback in the contract's shape
(every key of scene/feed-contract.json, null where the node cannot know, partial: true, state.source "node", ISO now). The
test the_node_only_reply_has_the_contract_shape reads the contract file itself (include_str!), so the Rust side and
tools/scene/feed-contract.mjs cannot drift.
App gate on build-2 (test --release, --priority gate): 228 + 32 + 8 passed. Parity on build-2: app Inspect = /live = home fold
at T+0, T+2, T+4 s, the overlay identical when both surfaces know the key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ea669b9c69
commit
429fcdf000
4 changed files with 166 additions and 57 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue