diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..623cfbb
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,38 @@
+name: Deploy LeanViz to GitHub Pages
+
+on:
+ push:
+ branches:
+ - devnet-1
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: web
+
+ - name: Deploy
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index a148f6f..e1f5915 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,25 @@
-# leanviz
\ No newline at end of file
+# leanviz
+
+LeanViz is a standalone, client-agnostic dashboard for Lean consensus networks.
+
+## Run
+Serve the static files:
+`python3 -m http.server 7070 --directory web`
+
+Open:
+`http://localhost:7070/index.html`
+
+API check page:
+`http://localhost:7070/health.html`
+
+## Configure
+Edit `web/config/config.json` or use query params:
+- `?beacon=http://localhost:5052`
+- `?metrics=http://localhost:9090`
+- `?mode=live`
+- `?ns=eth` (use `/eth/v1/...` instead of `/lean/v0/...`)
+
+## Docs
+- `docs/api-contract.md`
+- `docs/runbook.md`
+- `docs/changes.md`
diff --git a/docs/api-contract.md b/docs/api-contract.md
new file mode 100644
index 0000000..ae83b6b
--- /dev/null
+++ b/docs/api-contract.md
@@ -0,0 +1,148 @@
+# LeanViz Monitoring API Contract (v0)
+
+This document defines the minimum API surface required by the LeanViz dashboard.
+It is intentionally small and focused on monitoring data.
+
+## Status
+- UI defaults to Lean-style paths (`/lean/v0/...`).
+- Clients may also serve `/eth/v1/...` as a compatibility alias during migration.
+
+## Base URL
+All endpoints are relative to a base URL (configured in `web/config/config.json`).
+
+## Endpoints (Required)
+1. `GET /lean/v0/beacon/genesis`
+2. `GET /lean/v0/beacon/headers/head`
+3. `GET /lean/v0/beacon/states/head/finality_checkpoints`
+4. `GET /lean/v0/beacon/states/head/validators`
+5. `GET /lean/v0/node/peers`
+6. `GET /lean/v0/node/syncing`
+7. `GET /lean/v0/events` (SSE)
+
+Compatibility alias (optional):
+- `/eth/v1/...` with the same semantics
+
+## Response Shape (Minimum Fields)
+The UI only depends on the following fields. Extra fields are ignored.
+
+1. `/beacon/headers/head`
+- `data.root` or `root`
+- `data.header.message.slot` or `slot`
+- `data.header.message.proposer_index` or `proposer_index`
+
+2. `/beacon/states/head/finality_checkpoints`
+- `data.current_justified.slot` or `current_justified_slot`
+- `data.current_justified.epoch` or `current_justified.epoch`
+- `data.current_justified.root` or `current_justified.root`
+- `data.finalized.slot` or `finalized_slot`
+- `data.finalized.epoch` or `finalized.epoch`
+- `data.finalized.root` or `finalized.root`
+
+3. `/beacon/states/head/validators`
+- `data[]` array length determines validator count
+- `data[i].index` or `data[i].validator.index`
+- `data[i].status` or `data[i].validator.status`
+
+4. `/node/peers`
+- `data[]` array length determines peer count
+
+5. `/node/syncing`
+- `data.head_slot` (optional; used only for display)
+
+## Example Responses
+1. `/beacon/headers/head`
+```json
+{
+ "data": {
+ "root": "0xabc123...",
+ "header": {
+ "message": {
+ "slot": "107",
+ "proposer_index": "22"
+ }
+ }
+ }
+}
+```
+
+2. `/beacon/states/head/finality_checkpoints`
+```json
+{
+ "data": {
+ "current_justified": {
+ "epoch": "3",
+ "slot": "96",
+ "root": "0xdef456..."
+ },
+ "finalized": {
+ "epoch": "3",
+ "slot": "98",
+ "root": "0x789abc..."
+ }
+ }
+}
+```
+
+3. `/beacon/states/head/validators`
+```json
+{
+ "data": [
+ { "index": "0", "status": "active_ongoing" },
+ { "index": "1", "status": "active_ongoing" }
+ ]
+}
+```
+
+4. `/node/peers`
+```json
+{
+ "data": [
+ { "peer_id": "16Uiu2H..." }
+ ]
+}
+```
+
+5. `/node/syncing`
+```json
+{
+ "data": {
+ "head_slot": "107"
+ }
+}
+```
+
+## SSE Events
+Endpoint: `/events?topics=head,block,finalized_checkpoint,chain_reorg,attester_slashing,proposer_slashing`
+
+The UI uses only:
+- `head` or `block` events
+ - `slot`
+ - `proposer_index` (optional)
+ - `block` or `root`
+- `finalized_checkpoint` events
+ - `slot` or `finalized_slot`
+ - `epoch` (optional)
+ - `block` or `root`
+- `chain_reorg` events
+ - payload is not parsed
+- `attester_slashing` or `proposer_slashing` events
+ - `validator_index` or `proposer_index` or `index`
+
+## Example SSE Events
+```
+event: head
+data: {"slot":"107","proposer_index":"22","block":"0xabc123..."}
+
+event: finalized_checkpoint
+data: {"slot":"98","epoch":"3","block":"0x789abc..."}
+```
+
+## CORS
+The API must allow the dashboard origin.
+Recommended:
+- `Access-Control-Allow-Origin: *` (dev)
+- Restrict origin in production.
+
+## Notes
+- The UI will run in "demo" mode if API calls fail.
+- Missing fields will degrade the display but should not crash the UI.
diff --git a/docs/changes.md b/docs/changes.md
new file mode 100644
index 0000000..73934cb
--- /dev/null
+++ b/docs/changes.md
@@ -0,0 +1,12 @@
+# Change Notes
+
+This file tracks significant refactors and structural changes in the LeanViz codebase.
+
+## 2026-03-11
+- Extracted dashboard into a standalone repo layout under `web/`.
+- Split monolithic HTML into `index.html`, `styles/main.css`, and JS modules.
+- Added runtime config support via `web/config/config.json` and query params.
+- Split JS into modules: `api/`, `render/`, `sim/`, `state`, `dom`, and `utils`.
+- Preserved visual design and animation behavior.
+- Default API namespace switched to `/lean/v0` with optional `/eth/v1` override.
+- Added `web/health.html` API check page.
diff --git a/docs/runbook.md b/docs/runbook.md
new file mode 100644
index 0000000..74f9c49
--- /dev/null
+++ b/docs/runbook.md
@@ -0,0 +1,32 @@
+# LeanViz Runbook
+
+## Local Dev
+Serve the static files:
+`python3 -m http.server 7070 --directory web`
+
+Open:
+`http://localhost:7070/index.html`
+
+API check page:
+`http://localhost:7070/health.html`
+
+## Configure API URLs
+Edit `web/config/config.json`:
+```
+{
+ "beaconUrl": "http://localhost:5052",
+ "metricsUrl": "http://localhost:9090",
+ "mode": "demo",
+ "apiNamespace": "lean"
+}
+```
+
+Or use query params:
+- `?beacon=http://localhost:5052`
+- `?metrics=http://localhost:9090`
+- `?mode=live`
+- `?ns=eth` (use `/eth/v1/...` instead of `/lean/v0/...`)
+
+## Troubleshooting
+- If UI shows no movement, check console errors.
+- If API is unreachable, UI falls back to demo mode.
diff --git a/web/config/config.json b/web/config/config.json
new file mode 100644
index 0000000..40db964
--- /dev/null
+++ b/web/config/config.json
@@ -0,0 +1,6 @@
+{
+ "beaconUrl": "http://localhost:5052",
+ "metricsUrl": "http://localhost:9090",
+ "mode": "demo",
+ "apiNamespace": "lean"
+}
diff --git a/web/health.html b/web/health.html
new file mode 100644
index 0000000..85d96f7
--- /dev/null
+++ b/web/health.html
@@ -0,0 +1,266 @@
+
+
+
+
+
+ LeanViz API Check
+
+
+
+ LeanViz API Check
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..5188e2e
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ LeanViz - 3 Slot Finality Dashboard
+
+
+
+
+
+
+
+
+
+
⚠ Cannot reach Lean node - running in demo mode
+
+ Modedemo
+ Transportsimulation
+ Last REST OKnever
+ Last SSE Eventnever
+ Last Errornone
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Debug Metrics
+
+
+
+
+
+
+
+
+
+
Chain River
+ newest right / oldest left
+
+
+
+
+
+
+
+
+
Validator Network
+ 24 validators / vote particles
+
+
+
+
+
+
Active24
+
Voted0
+
Total24
+
Rate0%
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/api/client.js b/web/src/api/client.js
new file mode 100644
index 0000000..bbf23e1
--- /dev/null
+++ b/web/src/api/client.js
@@ -0,0 +1,71 @@
+import { asNumber, pickFirstNumber, pickFirstString } from "../utils.js";
+
+export async function fetchJSON(url, { timeout = 5000 } = {}) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeout);
+ try {
+ const res = await fetch(url, { signal: controller.signal });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.json();
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export async function fetchText(url, timeoutMs = 2500) {
+ const controller = new AbortController();
+ const tid = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return await res.text();
+ } finally {
+ clearTimeout(tid);
+ }
+}
+
+export function parseHeadResponse(headResp) {
+ const slot = pickFirstNumber(headResp, ["slot"]);
+ const proposer = pickFirstNumber(headResp, ["proposer_index", "proposerIndex"]);
+ const root = pickFirstString(headResp, ["root", "block"]);
+ return { slot, proposer, root };
+}
+
+export function parseFinalityResponse(finalityData) {
+ const jSlot = asNumber(finalityData?.current_justified?.slot) ??
+ asNumber(finalityData?.current_justified_slot) ??
+ asNumber(finalityData?.justified_slot);
+ const jEpoch = asNumber(finalityData?.current_justified?.epoch) ??
+ pickFirstNumber(finalityData?.current_justified, ["epoch"]);
+ const jRoot = finalityData?.current_justified?.root || pickFirstString(finalityData?.current_justified, ["root"]);
+
+ const fSlot = asNumber(finalityData?.finalized?.slot) ??
+ asNumber(finalityData?.finalized_slot);
+ const fEpoch = asNumber(finalityData?.finalized?.epoch) ??
+ pickFirstNumber(finalityData?.finalized, ["epoch"]);
+ const fRoot = finalityData?.finalized?.root || pickFirstString(finalityData?.finalized, ["root"]);
+
+ return {
+ justified: { slot: jSlot, epoch: jEpoch, root: jRoot },
+ finalized: { slot: fSlot, epoch: fEpoch, root: fRoot }
+ };
+}
+
+export function parseHeadEvent(payload) {
+ const slot = pickFirstNumber(payload, ["slot"]);
+ const proposer = pickFirstNumber(payload, ["proposer_index", "proposerIndex"]);
+ const root = pickFirstString(payload, ["block", "block_root", "root"]);
+ return { slot, proposer, root };
+}
+
+export function parseFinalizedEvent(payload) {
+ const slot = pickFirstNumber(payload, ["slot", "finalized_slot"]);
+ const epoch = pickFirstNumber(payload, ["epoch"]);
+ const root = pickFirstString(payload, ["block", "root"]);
+ return { slot, epoch, root };
+}
+
+export function parseReorgEvent(payload) {
+ const idx = pickFirstNumber(payload, ["validator_index", "proposer_index", "index"]);
+ return { index: idx };
+}
diff --git a/web/src/app.js b/web/src/app.js
new file mode 100644
index 0000000..bbb02cb
--- /dev/null
+++ b/web/src/app.js
@@ -0,0 +1,514 @@
+import { fetchJSON, fetchText, parseFinalityResponse, parseFinalizedEvent, parseHeadEvent, parseHeadResponse, parseReorgEvent } from "./api/client.js";
+import { loadConfig } from "./config.js";
+import { COLORS, PHASES, peers, state } from "./state.js";
+import { chainCanvas, chainCtx, nodes, validatorCanvas, validatorCtx } from "./dom.js";
+import { formatSince, randomInt } from "./utils.js";
+import { drawChainCanvas } from "./render/chain.js";
+import { drawValidatorCanvas } from "./render/validators.js";
+import {
+ addBlock,
+ applyFinalityCheckpoint,
+ applyHead,
+ applyLiveValidatorStatuses,
+ chooseProposer,
+ createSlotPlan,
+ currentSlotMs,
+ ensureValidators,
+ findLatestByStatus,
+ getPhase,
+ getValidatorCount,
+ onNewSlot,
+ updateVotes
+} from "./sim/logic.js";
+
+"use strict";
+
+ function setupPeerPills() {
+ nodes.peerPills.innerHTML = "";
+ for (const p of peers) {
+ const el = document.createElement("div");
+ el.className = `peer-pill ${p.self ? "self" : ""}`;
+ el.innerHTML = `${p.name} · ${p.lang}`;
+ nodes.peerPills.appendChild(el);
+ }
+ }
+
+ function setWarning(msg) {
+ if (!msg) {
+ nodes.warningBanner.classList.remove("show");
+ nodes.warningBanner.textContent = "";
+ return;
+ }
+ nodes.warningBanner.textContent = `⚠ ${msg}`;
+ nodes.warningBanner.classList.add("show");
+ }
+
+ function toUserError(err, phase) {
+ const raw = err && err.message ? String(err.message) : String(err || "unknown error");
+ const lower = raw.toLowerCase();
+ if (lower.includes("failed to fetch") || lower.includes("networkerror") || lower.includes("load failed")) {
+ return `${phase}: network/CORS blocked (${state.live.beaconUrl})`;
+ }
+ if (lower.includes("abort")) {
+ return `${phase}: request timeout`;
+ }
+ return `${phase}: ${raw}`;
+ }
+
+ function setLiveError(errText) {
+ state.live.lastError = errText || "";
+ updateConnectionStrip();
+ }
+
+ function updateConnectionStrip() {
+ nodes.connMode.textContent = state.mode;
+ if (state.mode === "demo") {
+ nodes.connTransport.textContent = "simulation";
+ } else {
+ nodes.connTransport.textContent = state.live.connected ? "sse + rest" : "connecting";
+ }
+ nodes.connLastRest.textContent = formatSince(state.live.lastRestSuccessAt);
+ const evt = state.live.lastEventType ? `${state.live.lastEventType} · ${formatSince(state.live.lastEventAt)}` : "never";
+ nodes.connLastEvent.textContent = evt;
+ nodes.connError.textContent = state.live.lastError || "none";
+ }
+
+ function setModeBadge() {
+ if (state.mode === "live") {
+ nodes.liveBadge.textContent = state.live.connected ? "● LIVE API" : "● LIVE (CONNECTING)";
+ nodes.liveBadge.classList.toggle("warn", !state.live.connected);
+ } else {
+ nodes.liveBadge.textContent = "● LIVE SIM";
+ nodes.liveBadge.classList.remove("warn");
+ }
+ updateConnectionStrip();
+ }
+
+ function setMode(mode, reason = "") {
+ if (mode !== "live") mode = "demo";
+ state.mode = mode;
+ localStorage.setItem("leanviz_mode", mode);
+ nodes.modeSelect.value = mode;
+ setModeBadge();
+ if (mode === "demo") {
+ disconnectLive();
+ if (reason) setWarning(reason);
+ } else {
+ setWarning("");
+ setLiveError("");
+ connectLive();
+ }
+ updateSlotCycleLabel();
+ }
+
+
+ function updateSlotCycleLabel() {
+ const sec = (currentSlotMs() / 1000).toFixed(1);
+ nodes.slotCycleLabel.textContent = `3 slot finality / ${sec}s cycle`;
+ }
+
+ function setupPhases() {
+ nodes.phaseList.innerHTML = "";
+ for (const phase of PHASES) {
+ const item = document.createElement("div");
+ item.className = "phase-item pending";
+ item.dataset.phase = phase.id;
+ item.innerHTML = `
+
+ ${phase.icon}
+ ${phase.label}
+ ···
+
+ ${phase.desc}
+
+ `;
+ nodes.phaseList.appendChild(item);
+ }
+ }
+
+ function resizeCanvas(canvas, ctx) {
+ const ratio = Math.min(window.devicePixelRatio || 1, 2);
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ canvas.width = Math.floor(w * ratio);
+ canvas.height = Math.floor(h * ratio);
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ async function pollLiveREST() {
+ const base = state.live.beaconUrl.replace(/\/+$/, "");
+ const ns = state.live.apiNamespace === "eth" ? "eth/v1" : "lean/v0";
+ try {
+ const [headResp, finalityResp, peersResp, validatorsResp] = await Promise.all([
+ fetchJSON(`${base}/${ns}/beacon/headers/head`),
+ fetchJSON(`${base}/${ns}/beacon/states/head/finality_checkpoints`),
+ fetchJSON(`${base}/${ns}/node/peers`),
+ fetchJSON(`${base}/${ns}/beacon/states/head/validators`)
+ ]);
+
+ const headData = headResp?.data || headResp || {};
+ const headParsed = parseHeadResponse(headData);
+ applyHead(headParsed.slot, headParsed.proposer, headParsed.root);
+
+ const finalityData = finalityResp?.data || finalityResp || {};
+ const finalityParsed = parseFinalityResponse(finalityData);
+ if (finalityParsed.justified.slot != null) {
+ applyFinalityCheckpoint("justified", finalityParsed.justified.root, finalityParsed.justified.slot);
+ } else if (finalityParsed.justified.epoch != null) {
+ applyFinalityCheckpoint("justified", finalityParsed.justified.root, finalityParsed.justified.epoch * 32);
+ }
+ if (finalityParsed.finalized.slot != null) {
+ applyFinalityCheckpoint("finalized", finalityParsed.finalized.root, finalityParsed.finalized.slot);
+ } else if (finalityParsed.finalized.epoch != null) {
+ applyFinalityCheckpoint("finalized", finalityParsed.finalized.root, finalityParsed.finalized.epoch * 32);
+ }
+
+ const peerCount = Array.isArray(peersResp?.data) ? peersResp.data.length : peers.filter((p) => p.status !== "offline").length;
+ state.live.peerCount = peerCount;
+ nodes.mPeers.textContent = String(peerCount);
+
+ const validatorRows = Array.isArray(validatorsResp?.data) ? validatorsResp.data : null;
+ const validatorCount = validatorRows ? validatorRows.length : getValidatorCount();
+ ensureValidators(validatorCount);
+ if (validatorRows) applyLiveValidatorStatuses(validatorRows);
+ if (state.proposer >= getValidatorCount()) state.proposer = getValidatorCount() - 1;
+ state.live.lastRestSuccessAt = Date.now();
+ setLiveError("");
+ } catch (err) {
+ const userErr = toUserError(err, "REST");
+ setLiveError(userErr);
+ setMode("demo", `${userErr} - running in demo mode`);
+ }
+ }
+
+ async function pollMetrics() {
+ if (!state.live.metricsUrl) return;
+ try {
+ const raw = await fetchText(`${state.live.metricsUrl.replace(/\/+$/, "")}/metrics`, 2000);
+ const gor = raw.match(/^go_goroutines\\s+([0-9.]+)/m);
+ const mem = raw.match(/^process_resident_memory_bytes\\s+([0-9.]+)/m);
+ nodes.dbgGoroutines.textContent = gor ? Math.round(Number(gor[1])).toString() : "-";
+ nodes.dbgMemory.textContent = mem ? (Number(mem[1]) / (1024 * 1024)).toFixed(1) : "-";
+ state.live.lastMetricsSuccessAt = Date.now();
+ } catch (_) {
+ nodes.dbgGoroutines.textContent = "-";
+ nodes.dbgMemory.textContent = "-";
+ }
+ }
+
+ function handleSSE(type, payload) {
+ state.live.lastEventAt = Date.now();
+ state.live.lastEventType = type;
+ state.live.eventsSeen += 1;
+ nodes.dbgEvents.textContent = String(state.live.eventsSeen);
+ nodes.dbgLastEvent.textContent = type;
+ if (state.mode === "live") setLiveError("");
+
+ if (type === "head" || type === "block") {
+ const parsed = parseHeadEvent(payload);
+ applyHead(parsed.slot, parsed.proposer, parsed.root);
+ return;
+ }
+
+ if (type === "finalized_checkpoint") {
+ const parsed = parseFinalizedEvent(payload);
+ if (parsed.slot != null) applyFinalityCheckpoint("finalized", parsed.root, parsed.slot);
+ else if (parsed.epoch != null) applyFinalityCheckpoint("finalized", parsed.root, parsed.epoch * 32);
+ return;
+ }
+
+ if (type === "chain_reorg") {
+ setWarning("Chain reorg event observed from Lean node");
+ return;
+ }
+
+ if (type === "attester_slashing" || type === "proposer_slashing") {
+ const parsed = parseReorgEvent(payload);
+ const idx = parsed.index;
+ if (idx != null && idx < state.validators.length) {
+ state.validators[idx].status = "slashed";
+ setTimeout(() => {
+ if (state.validators[idx].status === "slashed") state.validators[idx].status = "idle";
+ }, 2500);
+ }
+ }
+ }
+
+ function connectLive() {
+ disconnectLive();
+ state.live.connected = false;
+ setModeBadge();
+ const base = state.live.beaconUrl.replace(/\/+$/, "");
+ const ns = state.live.apiNamespace === "eth" ? "eth/v1" : "lean/v0";
+ const sseUrl = `${base}/${ns}/events?topics=head,block,finalized_checkpoint,chain_reorg,attester_slashing,proposer_slashing`;
+
+ try {
+ const es = new EventSource(sseUrl);
+ state.live.eventSource = es;
+
+ es.onopen = () => {
+ state.live.connected = true;
+ state.live.lastEventAt = Date.now();
+ setWarning("");
+ setLiveError("");
+ setModeBadge();
+ };
+
+ es.onerror = () => {
+ const errText = `SSE: network/CORS blocked (${state.live.beaconUrl})`;
+ setLiveError(errText);
+ setMode("demo", `${errText} - running in demo mode`);
+ };
+
+ const bindEvent = (name) => {
+ es.addEventListener(name, (ev) => {
+ let payload = {};
+ try {
+ payload = ev.data ? JSON.parse(ev.data) : {};
+ } catch (_) {}
+ handleSSE(name, payload);
+ });
+ };
+ ["head", "block", "finalized_checkpoint", "chain_reorg", "attester_slashing", "proposer_slashing"].forEach(bindEvent);
+ } catch (err) {
+ const userErr = toUserError(err, "SSE");
+ setLiveError(userErr);
+ setMode("demo", `${userErr} - running in demo mode`);
+ return;
+ }
+
+ state.live.restTimer = setInterval(() => {
+ pollLiveREST();
+ pollMetrics();
+ }, currentSlotMs());
+ pollLiveREST();
+ pollMetrics();
+
+ state.live.watchdogTimer = setInterval(() => {
+ if (state.mode !== "live") return;
+ const idleFor = Date.now() - state.live.lastEventAt;
+ if (idleFor > 20000) {
+ setLiveError("SSE: timeout waiting for events");
+ setMode("demo", "Live SSE timeout - running in demo mode");
+ }
+ }, 5000);
+ }
+
+ function disconnectLive() {
+ if (state.live.eventSource) {
+ state.live.eventSource.close();
+ state.live.eventSource = null;
+ }
+ if (state.live.restTimer) {
+ clearInterval(state.live.restTimer);
+ state.live.restTimer = null;
+ }
+ if (state.live.watchdogTimer) {
+ clearInterval(state.live.watchdogTimer);
+ state.live.watchdogTimer = null;
+ }
+ state.live.connected = false;
+ setModeBadge();
+ }
+
+ function bindControls() {
+ nodes.beaconInput.value = state.live.beaconUrl;
+ nodes.metricsInput.value = state.live.metricsUrl;
+ nodes.nsSelect.value = state.live.apiNamespace === "eth" ? "eth" : "lean";
+ nodes.modeSelect.value = state.mode;
+
+ nodes.settingsBtn.addEventListener("click", () => {
+ nodes.settingsPanel.classList.toggle("open");
+ });
+
+ nodes.applySettings.addEventListener("click", () => {
+ state.live.beaconUrl = nodes.beaconInput.value.trim() || "http://localhost:5052";
+ state.live.metricsUrl = nodes.metricsInput.value.trim() || "http://localhost:9090";
+ state.live.apiNamespace = nodes.nsSelect.value === "eth" ? "eth" : "lean";
+ localStorage.setItem("leanviz_beacon_url", state.live.beaconUrl);
+ localStorage.setItem("leanviz_metrics_url", state.live.metricsUrl);
+ localStorage.setItem("leanviz_api_ns", state.live.apiNamespace);
+
+ const mode = nodes.modeSelect.value === "live" ? "live" : "demo";
+ setMode(mode);
+ });
+ }
+
+
+
+ function updateUI(now) {
+ updateConnectionStrip();
+ const phase = getPhase(now);
+ state.currentPhase = phase.id;
+ state.phaseProgress = phase.progress;
+
+ const untilNext = Math.max(0, currentSlotMs() - phase.elapsed) / 1000;
+ nodes.countdown.textContent = `NEXT SLOT IN ${untilNext.toFixed(1)}s`;
+ nodes.leftSlot.textContent = `SLOT ${state.currentSlot}`;
+ nodes.proposerText.textContent = `Proposer: Validator #${state.proposer}`;
+ nodes.finalizeHint.textContent = `Slot ${Math.max(0, state.currentSlot - 3)} finalizes this round`;
+
+ const phaseItems = nodes.phaseList.querySelectorAll(".phase-item");
+ PHASES.forEach((p, idx) => {
+ const item = phaseItems[idx];
+ const bar = item.querySelector(".phase-progress > span");
+ const phaseState = item.querySelector(".phase-state");
+ const currentIdx = PHASES.findIndex((x) => x.id === phase.id);
+
+ item.classList.remove("active", "done", "pending");
+ if (idx < currentIdx) {
+ item.classList.add("done");
+ phaseState.textContent = "✓";
+ bar.style.width = "100%";
+ } else if (idx === currentIdx) {
+ item.classList.add("active");
+ phaseState.textContent = "●";
+ bar.style.width = `${Math.max(3, phase.progress * 100)}%`;
+ } else {
+ item.classList.add("pending");
+ phaseState.textContent = "···";
+ bar.style.width = "0%";
+ }
+ });
+
+ const justified = findLatestByStatus("JUSTIFIED");
+ const finalized = findLatestByStatus("FINALIZED");
+
+ const finalizedSlot = state.finalizedCheckpoint?.slot ?? (finalized ? finalized.slot : Math.max(0, state.currentSlot - 2));
+ const justifiedSlot = state.justifiedCheckpoint?.slot ?? (justified ? justified.slot : Math.max(0, state.currentSlot - 1));
+ const gap = Math.max(0, state.currentSlot - finalizedSlot);
+
+ nodes.topSlot.textContent = String(state.currentSlot);
+ nodes.topEpoch.textContent = String(state.epoch);
+ nodes.topFinalized.textContent = String(finalizedSlot);
+ nodes.topPart.textContent = `${state.participationRate}%`;
+
+ nodes.justSlot.textContent = String(justifiedSlot);
+ nodes.justHash.textContent = state.justifiedCheckpoint?.root ?? (justified ? justified.hash : "0x-");
+ nodes.finSlot.textContent = String(finalizedSlot);
+ nodes.finHash.textContent = state.finalizedCheckpoint?.root ?? (finalized ? finalized.hash : "0x-");
+ nodes.gapTitle.textContent = `HEAD → FINALIZED GAP: ${gap} SLOTS`;
+
+ const maxGap = 14;
+ nodes.gapFill.style.width = `${Math.min(100, (gap / maxGap) * 100)}%`;
+ if (gap <= 5) {
+ nodes.gapFill.style.backgroundColor = COLORS.green;
+ } else if (gap <= 10) {
+ nodes.gapFill.style.backgroundColor = COLORS.amber;
+ } else {
+ nodes.gapFill.style.backgroundColor = COLORS.red;
+ }
+
+ const chips = state.finalizedHistory.slice(0, 10);
+ nodes.chipWrap.innerHTML = "";
+ for (const slot of chips) {
+ const chip = document.createElement("span");
+ chip.className = "chip";
+ chip.textContent = slot;
+ nodes.chipWrap.appendChild(chip);
+ }
+
+ nodes.mSlot.textContent = String(state.currentSlot);
+ nodes.mPart.textContent = `${state.participationRate}%`;
+ nodes.mMissed.textContent = String(state.missedSlots);
+ nodes.mPeers.textContent = state.mode === "live"
+ ? String(state.live.peerCount || 0)
+ : String(peers.filter((p) => p.status !== "offline").length);
+
+ const validatorCount = getValidatorCount();
+ const activeValidatorCount = state.validators.slice(0, validatorCount).filter((v) => v.active !== false).length;
+ const votedActiveCount = Array.from(state.currentVoters.values())
+ .filter((id) => state.validators[id] && state.validators[id].active !== false).length;
+ const rateBase = Math.max(1, activeValidatorCount);
+ nodes.statTotal.textContent = String(validatorCount);
+ nodes.statActive.textContent = String(activeValidatorCount);
+ nodes.validatorSubtitle.textContent = `${activeValidatorCount} active / ${validatorCount} total validators`;
+ nodes.statVoted.textContent = String(votedActiveCount);
+ nodes.statRate.textContent = `${Math.round((votedActiveCount / rateBase) * 100)}%`;
+ }
+
+
+
+
+
+ function bootstrap() {
+ setupPeerPills();
+ setupPhases();
+ bindControls();
+ updateSlotCycleLabel();
+ resizeCanvas(chainCanvas, chainCtx);
+ resizeCanvas(validatorCanvas, validatorCtx);
+ requestAnimationFrame(() => {
+ resizeCanvas(chainCanvas, chainCtx);
+ resizeCanvas(validatorCanvas, validatorCtx);
+ });
+
+ createSlotPlan();
+ for (let s = state.currentSlot - 14; s <= state.currentSlot; s++) {
+ state.proposer = chooseProposer();
+ state.participationRate = randomInt(82, 99);
+ addBlock(s, Math.random() < 0.06);
+ }
+ state.finalizedHistory = state.blocks
+ .filter((b) => !b.missed && b.slot <= state.currentSlot - 2)
+ .slice(-12)
+ .map((b) => b.slot)
+ .reverse();
+ const j = state.blocks.find((b) => b.slot === state.currentSlot - 1 && !b.missed);
+ const f = state.blocks.find((b) => b.slot === state.currentSlot - 2 && !b.missed);
+ if (j) state.justifiedCheckpoint = { slot: j.slot, root: j.hash };
+ if (f) state.finalizedCheckpoint = { slot: f.slot, root: f.hash };
+ setModeBadge();
+ if (state.mode === "live") connectLive();
+
+ state.lastFrame = performance.now();
+ requestAnimationFrame(tick);
+ }
+
+ function tick(now) {
+ try {
+ while (state.mode === "demo" && now - state.slotStartedAt >= currentSlotMs()) {
+ onNewSlot();
+ }
+
+ updateUI(now);
+ updateVotes(now);
+ drawChainCanvas(now);
+ drawValidatorCanvas(now);
+
+ state.lastFrame = now;
+ requestAnimationFrame(tick);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(err);
+ }
+ }
+
+ window.addEventListener("resize", () => {
+ resizeCanvas(chainCanvas, chainCtx);
+ resizeCanvas(validatorCanvas, validatorCtx);
+ });
+ window.addEventListener("load", () => {
+ resizeCanvas(chainCanvas, chainCtx);
+ resizeCanvas(validatorCanvas, validatorCtx);
+ });
+
+ export async function init() {
+ await loadConfig(state);
+ bootstrap();
+ }
diff --git a/web/src/config.js b/web/src/config.js
new file mode 100644
index 0000000..8527000
--- /dev/null
+++ b/web/src/config.js
@@ -0,0 +1,34 @@
+export async function loadConfig(state) {
+ const params = new URLSearchParams(window.location.search);
+ let cfg = {};
+ try {
+ const res = await fetch("config/config.json", { cache: "no-store" });
+ if (res.ok) {
+ cfg = await res.json();
+ }
+ } catch (err) {
+ // Optional config file; ignore missing/blocked fetch.
+ }
+
+ const beacon = params.get("beacon") || cfg.beaconUrl;
+ const metrics = params.get("metrics") || cfg.metricsUrl;
+ const mode = params.get("mode") || cfg.mode;
+ const ns = params.get("ns") || cfg.apiNamespace;
+
+ if (beacon) {
+ state.live.beaconUrl = beacon;
+ localStorage.setItem("leanviz_beacon_url", beacon);
+ }
+ if (metrics) {
+ state.live.metricsUrl = metrics;
+ localStorage.setItem("leanviz_metrics_url", metrics);
+ }
+ if (mode) {
+ state.mode = mode;
+ localStorage.setItem("leanviz_mode", mode);
+ }
+ if (ns) {
+ state.live.apiNamespace = ns;
+ localStorage.setItem("leanviz_api_ns", ns);
+ }
+}
diff --git a/web/src/dom.js b/web/src/dom.js
new file mode 100644
index 0000000..5319ffd
--- /dev/null
+++ b/web/src/dom.js
@@ -0,0 +1,52 @@
+export const nodes = {
+ peerPills: document.getElementById("peerPills"),
+ liveBadge: document.getElementById("liveBadge"),
+ warningBanner: document.getElementById("warningBanner"),
+ connMode: document.getElementById("connMode"),
+ connTransport: document.getElementById("connTransport"),
+ connLastRest: document.getElementById("connLastRest"),
+ connLastEvent: document.getElementById("connLastEvent"),
+ connError: document.getElementById("connError"),
+ settingsBtn: document.getElementById("settingsBtn"),
+ settingsPanel: document.getElementById("settingsPanel"),
+ beaconInput: document.getElementById("beaconInput"),
+ metricsInput: document.getElementById("metricsInput"),
+ nsSelect: document.getElementById("nsSelect"),
+ modeSelect: document.getElementById("modeSelect"),
+ applySettings: document.getElementById("applySettings"),
+ topSlot: document.getElementById("topSlot"),
+ topEpoch: document.getElementById("topEpoch"),
+ topFinalized: document.getElementById("topFinalized"),
+ topPart: document.getElementById("topPart"),
+ validatorSubtitle: document.getElementById("validatorSubtitle"),
+ leftSlot: document.getElementById("leftSlot"),
+ proposerText: document.getElementById("proposerText"),
+ phaseList: document.getElementById("phaseList"),
+ slotCycleLabel: document.getElementById("slotCycleLabel"),
+ countdown: document.getElementById("countdown"),
+ finalizeHint: document.getElementById("finalizeHint"),
+ statActive: document.getElementById("statActive"),
+ statVoted: document.getElementById("statVoted"),
+ statTotal: document.getElementById("statTotal"),
+ statRate: document.getElementById("statRate"),
+ justSlot: document.getElementById("justSlot"),
+ justHash: document.getElementById("justHash"),
+ finSlot: document.getElementById("finSlot"),
+ finHash: document.getElementById("finHash"),
+ gapTitle: document.getElementById("gapTitle"),
+ gapFill: document.getElementById("gapFill"),
+ chipWrap: document.getElementById("chipWrap"),
+ mSlot: document.getElementById("mSlot"),
+ mPart: document.getElementById("mPart"),
+ mMissed: document.getElementById("mMissed"),
+ mPeers: document.getElementById("mPeers"),
+ dbgGoroutines: document.getElementById("dbgGoroutines"),
+ dbgMemory: document.getElementById("dbgMemory"),
+ dbgEvents: document.getElementById("dbgEvents"),
+ dbgLastEvent: document.getElementById("dbgLastEvent")
+};
+
+export const chainCanvas = document.getElementById("chainCanvas");
+export const chainCtx = chainCanvas.getContext("2d");
+export const validatorCanvas = document.getElementById("validatorCanvas");
+export const validatorCtx = validatorCanvas.getContext("2d");
diff --git a/web/src/main.js b/web/src/main.js
new file mode 100644
index 0000000..b629053
--- /dev/null
+++ b/web/src/main.js
@@ -0,0 +1,3 @@
+import { init } from "./app.js";
+
+init();
diff --git a/web/src/render/chain.js b/web/src/render/chain.js
new file mode 100644
index 0000000..1f60ef0
--- /dev/null
+++ b/web/src/render/chain.js
@@ -0,0 +1,141 @@
+import { COLORS, state } from "../state.js";
+import { chainCanvas, chainCtx } from "../dom.js";
+import { currentSlotMs, getBlockStatus } from "../sim/logic.js";
+import { roundRect } from "./primitives.js";
+
+export function drawChainCanvas(now) {
+ const ctx = chainCtx;
+ const w = chainCanvas.clientWidth;
+ const h = chainCanvas.clientHeight;
+ ctx.clearRect(0, 0, w, h);
+
+ for (let x = 0; x < w; x += 40) {
+ ctx.strokeStyle = "rgba(255,255,255,0.04)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(x + 0.5, 0);
+ ctx.lineTo(x + 0.5, h);
+ ctx.stroke();
+ }
+ for (let y = 0; y < h; y += 40) {
+ ctx.strokeStyle = "rgba(255,255,255,0.03)";
+ ctx.beginPath();
+ ctx.moveTo(0, y + 0.5);
+ ctx.lineTo(w, y + 0.5);
+ ctx.stroke();
+ }
+
+ const blockW = 128;
+ const blockH = 116;
+ const spacing = 30;
+ const slotMs = currentSlotMs();
+ const speed = (blockW + spacing) / slotMs;
+ const baseY = h * 0.48;
+ const visible = [];
+
+ const phaseElapsed = (now - state.slotStartedAt + slotMs) % slotMs;
+
+ for (const block of state.blocks) {
+ const ageMs = (state.currentSlot - block.slot) * slotMs + phaseElapsed;
+ const x = w - 130 - ageMs * speed;
+ if (x < -blockW - 80 || x > w + 120) continue;
+ visible.push({ block, x, y: baseY });
+ }
+
+ visible.sort((a, b) => a.block.slot - b.block.slot);
+
+ for (let i = 1; i < visible.length; i++) {
+ const prev = visible[i - 1];
+ const cur = visible[i];
+ const gap = cur.block.slot - prev.block.slot;
+ ctx.beginPath();
+ ctx.moveTo(prev.x + blockW, prev.y + blockH / 2);
+ ctx.lineTo(cur.x, cur.y + blockH / 2);
+ ctx.strokeStyle = gap > 1 ? "rgba(100,116,139,0.7)" : "rgba(59,130,246,0.45)";
+ ctx.setLineDash(gap > 1 ? [6, 4] : []);
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }
+ ctx.setLineDash([]);
+
+ for (const item of visible) {
+ const { block, x, y } = item;
+ const status = getBlockStatus(block);
+ const fade = Math.max(0.1, Math.min(1, (x + 80) / (w * 0.52)));
+
+ let color = COLORS.blue;
+ if (status === "JUSTIFIED") color = COLORS.amber;
+ if (status === "FINALIZED") color = COLORS.green;
+ if (status === "MISSED") color = COLORS.grey;
+
+ ctx.globalAlpha = fade;
+ roundRect(ctx, x, y, blockW, blockH, 12);
+ ctx.fillStyle = "rgba(13,13,20,0.95)";
+ ctx.fill();
+ ctx.lineWidth = 1.2;
+ ctx.strokeStyle = status === "MISSED" ? "rgba(42,42,58,1)" : color;
+ if (status === "MISSED") ctx.setLineDash([5, 3]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ ctx.shadowBlur = status === "MISSED" ? 0 : 18;
+ ctx.shadowColor = status === "FINALIZED" ? "rgba(34,197,94,0.65)" : `${color}99`;
+ if (block.flashUntil > now) {
+ ctx.shadowBlur = 30;
+ ctx.shadowColor = "rgba(34,197,94,0.9)";
+ }
+ roundRect(ctx, x, y, blockW, blockH, 12);
+ ctx.strokeStyle = status === "MISSED" ? "rgba(42,42,58,0.9)" : `${color}cc`;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+
+ ctx.font = "700 13px 'Space Mono'";
+ ctx.fillStyle = status === "MISSED" ? "#9aa8bb" : COLORS.cyan;
+ ctx.fillText(`SLOT ${block.slot}`, x + 8, y + 16);
+
+ ctx.font = "10px 'Space Mono'";
+ ctx.fillStyle = COLORS.muted;
+ ctx.fillText(block.hash, x + 8, y + 30);
+
+ ctx.fillStyle = "#a6b5cb";
+ ctx.fillText(`Proposer: #${block.proposer}`, x + 8, y + 43);
+
+ const sourceSlot = block.checkpoint?.sourceSlot ?? Math.max(0, block.slot - 2);
+ const targetSlot = block.checkpoint?.targetSlot ?? Math.max(0, block.slot - 1);
+ const headSlot = block.checkpoint?.headSlot ?? block.slot;
+ ctx.fillStyle = "#8197b8";
+ ctx.fillText(`CP S:${sourceSlot} T:${targetSlot} H:${headSlot}`, x + 8, y + 56);
+
+ const barY = y + 70;
+ roundRect(ctx, x + 8, barY, blockW - 16, 7, 4);
+ ctx.fillStyle = "rgba(255,255,255,0.07)";
+ ctx.fill();
+
+ const fill = status === "MISSED" ? 0 : (Math.max(0, Math.min(100, block.participation)) / 100) * (blockW - 16);
+ roundRect(ctx, x + 8, barY, fill, 7, 4);
+ ctx.fillStyle = status === "MISSED" ? "#434a5d" : COLORS.purple;
+ ctx.fill();
+ ctx.font = "700 8px 'Space Mono'";
+ ctx.fillStyle = "#9bb3d7";
+ ctx.fillText(`ATT ${Math.round(block.participation)}%`, x + blockW - 58, barY - 2);
+
+ const badgeY = y + 92;
+ roundRect(ctx, x + 8, badgeY, blockW - 16, 16, 6);
+ ctx.fillStyle = status === "MISSED" ? "rgba(42,42,58,0.9)" : `${color}26`;
+ ctx.fill();
+ ctx.strokeStyle = status === "MISSED" ? "#3c4354" : `${color}cc`;
+ ctx.stroke();
+
+ ctx.font = "700 9px 'Space Mono'";
+ ctx.fillStyle = status === "MISSED" ? "#9aa8bb" : color;
+ const label = status === "FINALIZED" ? "FINALIZED 🔒" : status;
+ ctx.fillText(label, x + 12, y + 103);
+
+ if (status === "MISSED") {
+ ctx.font = "700 24px 'Space Mono'";
+ ctx.fillStyle = "rgba(154,168,187,0.65)";
+ ctx.fillText("×", x + blockW - 28, y + 24);
+ }
+ }
+ ctx.globalAlpha = 1;
+}
diff --git a/web/src/render/primitives.js b/web/src/render/primitives.js
new file mode 100644
index 0000000..a1c6ea8
--- /dev/null
+++ b/web/src/render/primitives.js
@@ -0,0 +1,52 @@
+export function roundRect(ctx, x, y, w, h, r) {
+ const rr = Math.min(r, w / 2, h / 2);
+ ctx.beginPath();
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ ctx.closePath();
+}
+
+export function drawValidatorAvatar(ctx, x, y, size, color, label, emphasis, labelColor = "#9fb5d7") {
+ roundRect(ctx, x - size / 2, y - size / 2, size, size, 6);
+ ctx.fillStyle = "rgba(12,15,24,0.95)";
+ ctx.fill();
+ ctx.lineWidth = 1.2;
+ ctx.strokeStyle = color;
+ ctx.stroke();
+
+ // Head
+ ctx.beginPath();
+ ctx.arc(x, y - 3, 2.2, 0, Math.PI * 2);
+ ctx.fillStyle = color;
+ ctx.fill();
+ // Body
+ ctx.beginPath();
+ ctx.moveTo(x, y - 0.5);
+ ctx.lineTo(x, y + 4.5);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.3;
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(x - 2.8, y + 2);
+ ctx.lineTo(x + 2.8, y + 2);
+ ctx.stroke();
+
+ if (emphasis) {
+ ctx.shadowBlur = 12;
+ ctx.shadowColor = `${color}aa`;
+ roundRect(ctx, x - size / 2, y - size / 2, size, size, 6);
+ ctx.strokeStyle = color;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+
+ if (label) {
+ ctx.font = "700 9px 'Space Mono'";
+ ctx.fillStyle = labelColor;
+ ctx.textAlign = "center";
+ ctx.fillText(label, x, y - size / 2 - 4);
+ }
+}
diff --git a/web/src/render/validators.js b/web/src/render/validators.js
new file mode 100644
index 0000000..b087bec
--- /dev/null
+++ b/web/src/render/validators.js
@@ -0,0 +1,128 @@
+import { COLORS, state } from "../state.js";
+import { validatorCanvas, validatorCtx } from "../dom.js";
+import { getValidatorCount } from "../sim/logic.js";
+import { drawValidatorAvatar } from "./primitives.js";
+
+export function drawValidatorCanvas(now) {
+ const ctx = validatorCtx;
+ const w = validatorCanvas.clientWidth;
+ const h = validatorCanvas.clientHeight;
+ ctx.clearRect(0, 0, w, h);
+
+ const cx = w / 2;
+ const cy = h / 2 - 4;
+ const ringR = Math.min(w, h) * 0.34;
+
+ const centerPulse = ((now % 1300) / 1300);
+ ctx.beginPath();
+ ctx.arc(cx, cy, 18, 0, Math.PI * 2);
+ ctx.fillStyle = "rgba(0,255,204,0.2)";
+ ctx.fill();
+ ctx.strokeStyle = COLORS.cyan;
+ ctx.lineWidth = 1.4;
+ ctx.shadowBlur = 18;
+ ctx.shadowColor = "rgba(0,255,204,0.7)";
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, 18 + centerPulse * 36, 0, Math.PI * 2);
+ ctx.strokeStyle = `rgba(0,255,204,${1 - centerPulse})`;
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+
+ ctx.font = "700 11px 'Space Mono'";
+ ctx.fillStyle = "#c9fff4";
+ ctx.textAlign = "center";
+ ctx.fillText("BLOCK", cx, cy - 2);
+ ctx.font = "700 13px 'Space Mono'";
+ ctx.fillText(String(state.currentSlot), cx, cy + 12);
+
+ const count = getValidatorCount();
+ const nodePos = [];
+ for (let i = 0; i < count; i++) {
+ const a = (-Math.PI / 2) + (i / count) * Math.PI * 2;
+ nodePos.push({ id: i, x: cx + Math.cos(a) * ringR, y: cy + Math.sin(a) * ringR });
+ }
+
+ ctx.strokeStyle = "rgba(100,116,139,0.2)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(cx, cy, ringR, 0, Math.PI * 2);
+ ctx.stroke();
+
+ state.voteRings = state.voteRings.filter((r) => now - r.startAt < r.duration);
+ for (const ring of state.voteRings) {
+ const p = nodePos[ring.validatorId];
+ if (!p) continue;
+ const t = (now - ring.startAt) / ring.duration;
+ const rr = 6 + t * 18;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, rr, 0, Math.PI * 2);
+ ctx.strokeStyle = `rgba(123,97,255,${1 - t})`;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+
+ state.particles = state.particles.filter((pt) => now - pt.startAt < pt.duration);
+ for (const pt of state.particles) {
+ const p = nodePos[pt.validatorId];
+ if (!p) continue;
+ const t = Math.min(1, (now - pt.startAt) / pt.duration);
+ const x = p.x + (cx - p.x) * (1 - (1 - t) * (1 - t));
+ const y = p.y + (cy - p.y) * (1 - (1 - t) * (1 - t));
+
+ ctx.beginPath();
+ ctx.moveTo(p.x, p.y);
+ ctx.lineTo(x, y);
+ ctx.strokeStyle = `rgba(123,97,255,${0.5 - t * 0.4})`;
+ ctx.lineWidth = 1.6;
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(x, y, 3.2, 0, Math.PI * 2);
+ ctx.fillStyle = COLORS.purple;
+ ctx.shadowBlur = 10;
+ ctx.shadowColor = "rgba(123,97,255,0.9)";
+ ctx.fill();
+ ctx.shadowBlur = 0;
+
+ if (pt.kind === "attestation" && t < 0.9) {
+ ctx.font = "700 8px 'Space Mono'";
+ ctx.fillStyle = `rgba(174,196,255,${1 - t})`;
+ ctx.textAlign = "left";
+ ctx.fillText("attestation", x + 5, y - 4);
+ }
+ }
+
+ for (const v of state.validators.slice(0, count)) {
+ const p = nodePos[v.id];
+ let color = "#54607a";
+ let size = 10;
+ let label = `#${v.id}`;
+ let labelColor = "#7f8eaa";
+ let emphasis = false;
+ if (v.active === false) {
+ color = "#2f3748";
+ size = 10;
+ label = `#${v.id} off`;
+ labelColor = "#5f6d86";
+ } else if (v.status === "proposer") {
+ color = COLORS.cyan;
+ size = 14;
+ labelColor = "#b8fff2";
+ emphasis = true;
+ } else if (v.status === "voted") {
+ color = COLORS.green;
+ size = 12;
+ labelColor = "#9cf1b8";
+ } else if (v.status === "slashed") {
+ color = COLORS.red;
+ size = 12;
+ labelColor = "#ffb1b1";
+ emphasis = true;
+ }
+ drawValidatorAvatar(ctx, p.x, p.y, size, color, label, emphasis, labelColor);
+ }
+ ctx.textAlign = "left";
+}
diff --git a/web/src/sim/logic.js b/web/src/sim/logic.js
new file mode 100644
index 0000000..9904361
--- /dev/null
+++ b/web/src/sim/logic.js
@@ -0,0 +1,261 @@
+import { DEFAULT_VALIDATOR_COUNT, DEMO_SLOT_MS, LIVE_SLOT_MS, PHASES, state } from "../state.js";
+import { asNumber, randomHash, randomInt, truncateRoot } from "../utils.js";
+
+export function currentSlotMs() {
+ return state.mode === "live" ? LIVE_SLOT_MS : DEMO_SLOT_MS;
+}
+
+export function getValidatorCount() {
+ return Math.max(1, state.validatorCount || state.validators.length || DEFAULT_VALIDATOR_COUNT);
+}
+
+export function ensureValidators(count) {
+ const c = Math.max(1, Number(count) || DEFAULT_VALIDATOR_COUNT);
+ state.validatorCount = c;
+ if (state.validators.length === c) return;
+ state.validators = Array.from({ length: c }, (_, i) => {
+ const prev = state.validators[i];
+ return {
+ id: i,
+ status: prev?.status || "idle",
+ active: prev?.active !== false
+ };
+ });
+ state.currentVoters = new Set();
+ state.targetVoters = [];
+ state.voteCursor = 0;
+ if (state.proposer >= c) state.proposer = c - 1;
+}
+
+export function isValidatorActiveStatus(status) {
+ const s = String(status || "").toLowerCase();
+ if (!s) return true;
+ if (s.includes("pending") || s.includes("exited") || s.includes("withdraw")) return false;
+ return s.includes("active");
+}
+
+export function applyLiveValidatorStatuses(rows) {
+ if (!Array.isArray(rows)) return;
+ ensureValidators(rows.length || getValidatorCount());
+
+ const activeByIndex = new Map();
+ for (const row of rows) {
+ const idx = asNumber(row?.index ?? row?.validator?.index);
+ const status = row?.status ?? row?.validator?.status;
+ if (idx == null || idx < 0 || idx >= state.validators.length) continue;
+ activeByIndex.set(idx, isValidatorActiveStatus(status));
+ }
+
+ for (const v of state.validators) {
+ if (activeByIndex.has(v.id)) v.active = activeByIndex.get(v.id);
+ }
+}
+
+export function makeDeterministicVoters(slot, proposer, voteCount, count, seed = "") {
+ const pool = Array.from({ length: count }, (_, i) => i).filter((v) => v !== proposer);
+ const baseSeed = String(seed || "") + ":" + slot.toString() + ":" + proposer.toString();
+ let h = 2166136261;
+ for (let i = 0; i < baseSeed.length; i++) {
+ h ^= baseSeed.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ for (let i = pool.length - 1; i > 0; i--) {
+ h ^= i + 0x9e3779b9;
+ h = Math.imul(h, 16777619);
+ const j = Math.abs(h) % (i + 1);
+ [pool[i], pool[j]] = [pool[j], pool[i]];
+ }
+ return pool.slice(0, Math.min(voteCount, pool.length));
+}
+
+export function chooseProposer() {
+ const count = getValidatorCount();
+ let proposer = randomInt(0, count - 1);
+ while (count > 1 && proposer === state.previousProposer) proposer = randomInt(0, count - 1);
+ state.previousProposer = proposer;
+ return proposer;
+}
+
+export function createSlotPlan(opts = {}) {
+ const count = getValidatorCount();
+ const proposer = opts.proposer != null ? opts.proposer : chooseProposer();
+ const participationRate = opts.participationRate != null ? opts.participationRate : randomInt(82, 99);
+ const voteCount = Math.max(0, Math.round((participationRate / 100) * (count - 1)));
+
+ state.targetVoters = makeDeterministicVoters(state.currentSlot, proposer, voteCount, count, opts.seed);
+ state.currentVoters = new Set();
+ state.voteCursor = 0;
+ state.nextVoteAt = performance.now() + randomInt(400, 800);
+
+ for (const v of state.validators) {
+ v.status = v.id === proposer ? "proposer" : "idle";
+ }
+
+ if (!state.blocks.some((b) => b.slot === state.currentSlot)) {
+ addBlock(state.currentSlot, false, {
+ proposer,
+ participationRate,
+ root: opts.root || "",
+ checkpoint: {
+ sourceSlot: state.justifiedCheckpoint?.slot ?? Math.max(0, state.currentSlot - 2),
+ targetSlot: Math.max(0, state.currentSlot - 1),
+ headSlot: state.currentSlot
+ }
+ });
+ }
+}
+
+export function addBlock(slot, missed = false, opts = {}) {
+ const hash = opts.root ? truncateRoot(opts.root) : randomHash(slot);
+ const proposer = opts.proposer != null ? opts.proposer : chooseProposer();
+ const participationRate = opts.participationRate != null ? opts.participationRate : randomInt(82, 99);
+
+ const checkpoint = opts.checkpoint || {
+ sourceSlot: Math.max(0, slot - 2),
+ targetSlot: Math.max(0, slot - 1),
+ headSlot: slot
+ };
+
+ const block = {
+ slot,
+ hash,
+ proposer,
+ participation: participationRate,
+ missed,
+ checkpoint,
+ createdAt: performance.now(),
+ synthetic: !!opts.synthetic,
+ countMissed: opts.countMissed !== false,
+ flashUntil: 0
+ };
+
+ state.blocks = state.blocks.filter((b) => b.slot !== slot).concat(block);
+ state.blocks.sort((a, b) => a.slot - b.slot);
+ if (missed && block.countMissed) state.missedSlots += 1;
+}
+
+export function getBlockStatus(block) {
+ if (block.missed) return "MISSED";
+ if (state.finalizedCheckpoint && block.slot <= state.finalizedCheckpoint.slot) return "FINALIZED";
+ if (state.justifiedCheckpoint && block.slot <= state.justifiedCheckpoint.slot) return "JUSTIFIED";
+ return "HEAD";
+}
+
+export function findLatestByStatus(status) {
+ for (let i = state.blocks.length - 1; i >= 0; i--) {
+ const b = state.blocks[i];
+ if (getBlockStatus(b) === status) return b;
+ }
+ return null;
+}
+
+export function onNewSlot() {
+ state.currentSlot += 1;
+ state.epoch = Math.floor(state.currentSlot / 32);
+ state.slotStartedAt = performance.now();
+ state.proposer = chooseProposer();
+ state.participationRate = randomInt(82, 99);
+ createSlotPlan({ proposer: state.proposer, participationRate: state.participationRate, seed: `${state.currentSlot}` });
+ addBlock(state.currentSlot, Math.random() < 0.06);
+ hydrateHistory();
+}
+
+export function hydrateHistory() {
+ state.finalizedHistory = state.blocks
+ .filter((b) => !b.missed && b.slot <= state.currentSlot - 2)
+ .slice(-12)
+ .map((b) => b.slot)
+ .reverse();
+}
+
+export function markFinalized(slot) {
+ const block = state.blocks.find((b) => b.slot === slot && !b.missed);
+ if (block) {
+ block.flashUntil = performance.now() + 600;
+ }
+ if (!state.finalizedHistory.includes(slot)) {
+ state.finalizedHistory.unshift(slot);
+ state.finalizedHistory = state.finalizedHistory.slice(0, 12);
+ }
+}
+
+export function applyHead(slot, proposer, root) {
+ if (slot == null) return;
+
+ if (slot < state.currentSlot) {
+ state.blocks = state.blocks.filter((b) => b.slot <= slot);
+ state.finalizedHistory = state.finalizedHistory.filter((s) => s <= slot);
+ state.missedSlots = state.blocks.filter((b) => b.missed && !b.synthetic).length;
+ state.currentSlot = slot - 1;
+ }
+
+ if (slot === state.currentSlot) {
+ const existing = state.blocks.find((b) => b.slot === slot);
+ if (existing) {
+ if (proposer != null) existing.proposer = proposer;
+ if (root) existing.hash = truncateRoot(root);
+ existing.checkpoint = {
+ sourceSlot: state.justifiedCheckpoint?.slot ?? Math.max(0, slot - 2),
+ targetSlot: Math.max(0, slot - 1),
+ headSlot: slot
+ };
+ existing.createdAt = performance.now();
+ }
+ return;
+ }
+
+ for (let s = state.currentSlot + 1; s < slot; s++) {
+ state.proposer = chooseProposer();
+ state.participationRate = randomInt(82, 99);
+ addBlock(s, true, { countMissed: false, synthetic: true });
+ }
+
+ state.currentSlot = slot;
+ state.epoch = Math.floor(slot / 32);
+ state.slotStartedAt = performance.now();
+ state.proposer = proposer != null ? proposer : chooseProposer();
+ state.previousProposer = state.proposer;
+ state.participationRate = randomInt(82, 99);
+ createSlotPlan({ proposer: state.proposer, participationRate: state.participationRate, seed: `${slot}:${root || ""}`, root: root || "" });
+}
+
+export function applyFinalityCheckpoint(kind, root, slot) {
+ if (slot == null) return;
+ const cp = { root: truncateRoot(root), slot };
+ if (kind === "justified") state.justifiedCheckpoint = cp;
+ if (kind === "finalized") {
+ const prev = state.finalizedCheckpoint ? state.finalizedCheckpoint.slot : null;
+ state.finalizedCheckpoint = cp;
+ if (prev == null || slot > prev) markFinalized(slot);
+ }
+}
+
+export function getPhase(now) {
+ const slotMs = currentSlotMs();
+ const elapsed = (now - state.slotStartedAt + slotMs) % slotMs;
+ let cursor = 0;
+ for (const phase of PHASES) {
+ const duration = phase.ratio * slotMs;
+ if (elapsed >= cursor && elapsed < cursor + duration) {
+ return { id: phase.id, progress: (elapsed - cursor) / duration, elapsed };
+ }
+ cursor += duration;
+ }
+ return { id: "merge", progress: 1, elapsed };
+}
+
+export function updateVotes(now) {
+ const phase = state.currentPhase;
+ if (phase !== "vote") return;
+ while (state.voteCursor < state.targetVoters.length && now >= state.nextVoteAt) {
+ const id = state.targetVoters[state.voteCursor];
+ state.voteCursor += 1;
+ state.currentVoters.add(id);
+ if (state.validators[id].status !== "proposer") state.validators[id].status = "voted";
+
+ state.particles.push({ validatorId: id, startAt: now, duration: 1450, kind: "attestation" });
+ state.voteRings.push({ validatorId: id, startAt: now, duration: 1300 });
+
+ state.nextVoteAt = now + randomInt(900, 1300);
+ }
+}
diff --git a/web/src/state.js b/web/src/state.js
new file mode 100644
index 0000000..80afa65
--- /dev/null
+++ b/web/src/state.js
@@ -0,0 +1,74 @@
+export const DEMO_SLOT_MS = 8000;
+export const LIVE_SLOT_MS = 4000;
+export const DEFAULT_VALIDATOR_COUNT = 24;
+
+export const PHASES = [
+ { id: "propose", label: "PROPOSE", icon: "📡", ratio: 0.20, desc: "Proposer broadcasts block" },
+ { id: "vote", label: "VOTE", icon: "🗳️", ratio: 0.45, desc: "Validators cast Head + FFG votes" },
+ { id: "confirm", label: "CONFIRM", icon: "⚡", ratio: 0.20, desc: "Quorum reaches two thirds" },
+ { id: "merge", label: "MERGE", icon: "🔗", ratio: 0.15, desc: "View merge enters canonical chain" }
+];
+
+export const COLORS = {
+ cyan: "#00ffcc",
+ purple: "#7b61ff",
+ blue: "#3b82f6",
+ amber: "#f59e0b",
+ green: "#22c55e",
+ red: "#ef4444",
+ grey: "#2a2a3a",
+ text: "#e2e8f0",
+ muted: "#64748b",
+ border: "#1a1a2e",
+ surface: "#0d0d14"
+};
+
+export const peers = [
+ { name: "LEAN", lang: "Go", status: "online", self: true },
+ { name: "ZEAM", lang: "Zig", status: "online", self: false },
+ { name: "REAM", lang: "Rust", status: "syncing", self: false },
+ { name: "LANTERN", lang: "C", status: "online", self: false },
+ { name: "QLEAN", lang: "C++", status: "offline", self: false }
+];
+
+export const state = {
+ currentSlot: 100,
+ epoch: 3,
+ slotStartedAt: performance.now(),
+ lastFrame: performance.now(),
+ mode: localStorage.getItem("leanviz_mode") || "demo",
+ currentPhase: "propose",
+ phaseProgress: 0,
+ participationRate: 0,
+ missedSlots: 0,
+ validatorCount: DEFAULT_VALIDATOR_COUNT,
+ blocks: [],
+ finalizedHistory: [],
+ proposer: 0,
+ previousProposer: null,
+ justifiedCheckpoint: null,
+ finalizedCheckpoint: null,
+ currentVoters: new Set(),
+ targetVoters: [],
+ voteCursor: 0,
+ nextVoteAt: 0,
+ validators: Array.from({ length: DEFAULT_VALIDATOR_COUNT }, (_, i) => ({ id: i, status: "idle", active: true })),
+ particles: [],
+ voteRings: [],
+ live: {
+ beaconUrl: localStorage.getItem("leanviz_beacon_url") || "http://localhost:5052",
+ metricsUrl: localStorage.getItem("leanviz_metrics_url") || "http://localhost:9090",
+ apiNamespace: localStorage.getItem("leanviz_api_ns") || "lean",
+ eventSource: null,
+ restTimer: null,
+ watchdogTimer: null,
+ lastEventAt: 0,
+ lastEventType: "",
+ lastRestSuccessAt: 0,
+ lastMetricsSuccessAt: 0,
+ lastError: "",
+ peerCount: 0,
+ eventsSeen: 0,
+ connected: false
+ }
+};
diff --git a/web/src/utils.js b/web/src/utils.js
new file mode 100644
index 0000000..04f0e4b
--- /dev/null
+++ b/web/src/utils.js
@@ -0,0 +1,66 @@
+export function formatSince(ts) {
+ if (!ts) return "never";
+ const deltaMs = Math.max(0, Date.now() - ts);
+ const s = Math.floor(deltaMs / 1000);
+ if (s < 60) return `${s}s ago`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return `${m}m ${rs}s ago`;
+ const h = Math.floor(m / 60);
+ return `${h}h ${(m % 60)}m ago`;
+}
+
+export function randomInt(min, max) {
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function randomHash(slot) {
+ const hex = (Math.imul(slot, 2654435761) >>> 0).toString(16).padStart(8, "0");
+ const tail = (Math.random() * 0xffffffff >>> 0).toString(16).padStart(8, "0");
+ return `0x${hex}${tail}...`;
+}
+
+export function truncateRoot(root) {
+ if (!root || typeof root !== "string") return "0x-";
+ return root.length > 13 ? `${root.slice(0, 12)}...` : root;
+}
+
+export function asNumber(value) {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ if (typeof value === "string") {
+ const n = Number(value);
+ if (Number.isFinite(n)) return n;
+ }
+ return null;
+}
+
+export function pickFirstNumber(obj, keys, seen = new Set()) {
+ if (!obj || typeof obj !== "object" || seen.has(obj)) return null;
+ seen.add(obj);
+ for (const key of keys) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ const n = asNumber(obj[key]);
+ if (n != null) return n;
+ }
+ }
+ const values = Array.isArray(obj) ? obj : Object.values(obj);
+ for (const v of values) {
+ const n = pickFirstNumber(v, keys, seen);
+ if (n != null) return n;
+ }
+ return null;
+}
+
+export function pickFirstString(obj, keys, seen = new Set()) {
+ if (!obj || typeof obj !== "object" || seen.has(obj)) return null;
+ seen.add(obj);
+ for (const key of keys) {
+ if (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === "string") return obj[key];
+ }
+ const values = Array.isArray(obj) ? obj : Object.values(obj);
+ for (const v of values) {
+ const s = pickFirstString(v, keys, seen);
+ if (typeof s === "string") return s;
+ }
+ return null;
+}
diff --git a/web/styles/main.css b/web/styles/main.css
new file mode 100644
index 0000000..ab32f94
--- /dev/null
+++ b/web/styles/main.css
@@ -0,0 +1,757 @@
+ :root {
+ --bg: #050508;
+ --surface: #0d0d14;
+ --border: #1a1a2e;
+ --cyan: #00ffcc;
+ --purple: #7b61ff;
+ --blue: #3b82f6;
+ --amber: #f59e0b;
+ --green: #22c55e;
+ --red: #ef4444;
+ --grey: #2a2a3a;
+ --text: #e2e8f0;
+ --muted: #64748b;
+ }
+
+ * { box-sizing: border-box; }
+
+ body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(1200px 700px at 80% -10%, rgba(59, 130, 246, 0.09), transparent 55%),
+ radial-gradient(900px 500px at 2% 30%, rgba(123, 97, 255, 0.08), transparent 55%),
+ radial-gradient(800px 450px at 85% 85%, rgba(0, 255, 204, 0.07), transparent 55%),
+ var(--bg);
+ font-family: "Syne", sans-serif;
+ overflow-x: hidden;
+ }
+
+ body::after {
+ content: "";
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ opacity: 0.07;
+ background: repeating-linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.65) 0,
+ rgba(255, 255, 255, 0.65) 1px,
+ transparent 1px,
+ transparent 4px
+ );
+ }
+
+ .app {
+ width: min(1680px, calc(100vw - 24px));
+ margin: 12px auto;
+ border: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.005));
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(4px);
+ }
+
+ .topbar {
+ min-height: 52px;
+ padding: 10px 12px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ .peer-group, .counter-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .peer-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ background: rgba(255,255,255,0.03);
+ font-family: "Space Mono", monospace;
+ font-size: 10px;
+ color: var(--muted);
+ padding: 5px 10px;
+ letter-spacing: 0.3px;
+ transition: border-color 0.35s ease, box-shadow 0.35s ease;
+ }
+
+ .peer-pill.self {
+ border-color: rgba(0,255,204,0.75);
+ color: #cffef4;
+ box-shadow: 0 0 14px rgba(0,255,204,0.18);
+ }
+
+ .dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ display: inline-block;
+ background: var(--grey);
+ }
+
+ .dot.online, .dot.syncing {
+ animation: dotPulse 2s ease-in-out infinite;
+ }
+
+ .dot.online { background: var(--green); }
+ .dot.syncing { background: var(--amber); }
+
+ .badge {
+ border: 1px solid transparent;
+ border-radius: 12px;
+ padding: 3px 9px;
+ font-family: "Space Mono", monospace;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.35px;
+ white-space: nowrap;
+ }
+
+ .badge.pq {
+ border-color: rgba(123,97,255,0.52);
+ color: #baaaff;
+ background: rgba(123,97,255,0.12);
+ }
+
+ .badge.live {
+ border-color: rgba(0,255,204,0.55);
+ color: #aefcea;
+ background: rgba(0,255,204,0.12);
+ animation: breathe 2s ease-in-out infinite;
+ }
+
+ .badge.live.warn {
+ border-color: rgba(239,68,68,0.5);
+ color: #ffc0c0;
+ background: rgba(239,68,68,0.14);
+ animation: none;
+ }
+
+ .icon-btn {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: rgba(255,255,255,0.03);
+ color: var(--text);
+ width: 30px;
+ height: 30px;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ transition: all 0.25s ease;
+ }
+
+ .icon-btn:hover {
+ border-color: rgba(0,255,204,0.5);
+ box-shadow: 0 0 12px rgba(0,255,204,0.18);
+ }
+
+ .warn-banner {
+ display: none;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-bottom: 1px solid rgba(239,68,68,0.45);
+ color: #ffc9c9;
+ background: rgba(239,68,68,0.12);
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ letter-spacing: 0.25px;
+ }
+
+ .warn-banner.show {
+ display: flex;
+ }
+
+ .conn-strip {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(9, 11, 18, 0.95);
+ }
+
+ .conn-item {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 6px 8px;
+ background: rgba(255,255,255,0.02);
+ min-height: 42px;
+ }
+
+ .conn-item .k {
+ display: block;
+ font-family: "Space Mono", monospace;
+ font-size: 8px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ }
+
+ .conn-item .v {
+ display: block;
+ margin-top: 2px;
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ color: #d6e8ff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .settings-panel {
+ display: none;
+ border-bottom: 1px solid var(--border);
+ background: rgba(13,13,20,0.97);
+ padding: 10px 12px;
+ }
+
+ .settings-panel.open {
+ display: block;
+ }
+
+ .settings-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr auto auto;
+ gap: 8px;
+ align-items: end;
+ }
+
+ .field label {
+ display: block;
+ margin-bottom: 4px;
+ font-family: "Space Mono", monospace;
+ font-size: 9px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ }
+
+ .field input, .field select {
+ width: 100%;
+ height: 34px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: rgba(255,255,255,0.03);
+ color: var(--text);
+ padding: 0 10px;
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ }
+
+ .btn {
+ height: 34px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: rgba(255,255,255,0.05);
+ color: var(--text);
+ padding: 0 11px;
+ cursor: pointer;
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ }
+
+ .btn.primary {
+ border-color: rgba(0,255,204,0.5);
+ color: #b9fff2;
+ background: rgba(0,255,204,0.12);
+ }
+
+ .debug {
+ margin-top: 8px;
+ border-top: 1px solid var(--border);
+ padding-top: 7px;
+ }
+
+ .debug summary {
+ cursor: pointer;
+ font-family: "Space Mono", monospace;
+ font-size: 10px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ }
+
+ .debug-grid {
+ margin-top: 8px;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ }
+
+ .debug-card {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 6px;
+ background: rgba(255,255,255,0.02);
+ }
+
+ .debug-card .k {
+ font-family: "Space Mono", monospace;
+ font-size: 8px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ }
+
+ .debug-card .v {
+ margin-top: 2px;
+ font-family: "Space Mono", monospace;
+ font-size: 12px;
+ color: #cde2ff;
+ }
+
+ .counter {
+ min-width: 74px;
+ padding: 4px 6px;
+ border-left: 1px solid var(--border);
+ }
+
+ .counter-label {
+ display: block;
+ font-family: "Space Mono", monospace;
+ font-size: 8px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.9px;
+ }
+
+ .counter-value {
+ display: block;
+ font-family: "Space Mono", monospace;
+ font-size: 13px;
+ font-weight: 700;
+ margin-top: 2px;
+ color: #b7c6db;
+ }
+
+ .counter-value.slot { color: var(--cyan); }
+
+ .main {
+ display: grid;
+ grid-template-columns: 290px 1fr 350px;
+ gap: 0;
+ min-height: 640px;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .panel {
+ background: rgba(13,13,20,0.78);
+ border-right: 1px solid var(--border);
+ padding: 16px 14px;
+ position: relative;
+ }
+
+ .panel:last-child { border-right: 0; }
+
+ .panel h2 {
+ margin: 0 0 10px;
+ font-size: 14px;
+ letter-spacing: 0.7px;
+ font-weight: 700;
+ text-transform: uppercase;
+ }
+
+ .panel-sub {
+ margin: 0;
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.5px;
+ text-transform: uppercase;
+ font-family: "Space Mono", monospace;
+ }
+
+ .slot-head {
+ margin: 14px 0 10px;
+ font-family: "Space Mono", monospace;
+ font-size: 33px;
+ color: var(--cyan);
+ letter-spacing: 0.8px;
+ text-shadow: 0 0 12px rgba(0,255,204,0.3);
+ }
+
+ .phase-list {
+ margin-top: 14px;
+ display: grid;
+ gap: 8px;
+ position: relative;
+ }
+
+ .phase-item {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 8px 10px;
+ background: rgba(255,255,255,0.014);
+ transition: all 0.35s ease;
+ }
+
+ .phase-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.35px;
+ }
+
+ .phase-name {
+ font-family: "Space Mono", monospace;
+ color: #95a9c7;
+ font-size: 12px;
+ }
+
+ .phase-desc {
+ margin-top: 4px;
+ font-size: 10px;
+ color: var(--muted);
+ line-height: 1.3;
+ }
+
+ .phase-progress {
+ margin-top: 6px;
+ height: 5px;
+ border-radius: 999px;
+ background: rgba(255,255,255,0.06);
+ overflow: hidden;
+ }
+
+ .phase-progress > span {
+ display: block;
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, rgba(0,255,204,0.2), var(--cyan));
+ transition: width 0.1s linear;
+ }
+
+ .phase-item.active {
+ border-color: rgba(0,255,204,0.45);
+ box-shadow: 0 0 16px rgba(0,255,204,0.14);
+ background: rgba(0,255,204,0.05);
+ }
+
+ .phase-item.active .phase-name,
+ .phase-item.active .phase-icon {
+ color: var(--cyan);
+ text-shadow: 0 0 10px rgba(0,255,204,0.28);
+ }
+
+ .phase-item.done {
+ border-color: rgba(34,197,94,0.3);
+ background: rgba(34,197,94,0.07);
+ opacity: 0.86;
+ }
+
+ .phase-item.done .phase-name,
+ .phase-item.done .phase-icon {
+ color: var(--green);
+ }
+
+ .phase-item.pending {
+ opacity: 0.58;
+ filter: saturate(0.65);
+ }
+
+ .slot-foot {
+ margin-top: 14px;
+ border-top: 1px solid var(--border);
+ padding-top: 10px;
+ font-family: "Space Mono", monospace;
+ font-size: 12px;
+ color: #a5b6cf;
+ line-height: 1.45;
+ }
+
+ .slot-foot .countdown {
+ font-size: 18px;
+ color: #d0fff5;
+ text-shadow: 0 0 8px rgba(0,255,204,0.25);
+ display: block;
+ margin-bottom: 3px;
+ }
+
+ .canvas-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px;
+ background: rgba(13,13,20,0.64);
+ border-right: 1px solid var(--border);
+ }
+
+ .canvas-title {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 6px;
+ }
+
+ .canvas-title h2 {
+ margin: 0;
+ font-size: 14px;
+ text-transform: uppercase;
+ letter-spacing: 0.7px;
+ }
+
+ .canvas-title span {
+ font-family: "Space Mono", monospace;
+ color: var(--muted);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ }
+
+ .canvas-wrap {
+ position: relative;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ overflow: hidden;
+ background: linear-gradient(180deg, rgba(0,0,0,0.18), rgba(255,255,255,0.01));
+ }
+
+ canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ #chainCanvas { height: 520px; }
+ #validatorCanvas { height: 420px; }
+
+ .net-stats {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ margin-top: 8px;
+ }
+
+ .net-stat {
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ padding: 8px 7px;
+ background: rgba(255,255,255,0.02);
+ }
+
+ .net-stat span {
+ display: block;
+ font-family: "Space Mono", monospace;
+ }
+
+ .net-stat .k {
+ font-size: 9px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.7px;
+ }
+
+ .net-stat .v {
+ font-size: 14px;
+ color: #cee8ff;
+ margin-top: 3px;
+ }
+
+ .bottom {
+ background: rgba(5,5,8,0.9);
+ border-top: 1px solid var(--border);
+ padding: 12px;
+ display: grid;
+ grid-template-columns: 220px 220px 1fr 300px;
+ gap: 10px;
+ align-items: stretch;
+ }
+
+ .cp-box {
+ border-radius: 10px;
+ padding: 10px;
+ background: rgba(255,255,255,0.02);
+ min-height: 112px;
+ }
+
+ .cp-box .label {
+ font-family: "Space Mono", monospace;
+ font-size: 9px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ }
+
+ .cp-box .slot {
+ font-family: "Space Mono", monospace;
+ font-size: 28px;
+ margin-top: 3px;
+ font-weight: 700;
+ }
+
+ .cp-box .hash {
+ font-family: "Space Mono", monospace;
+ font-size: 10px;
+ color: #8a9cb6;
+ margin-top: 5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .cp-box.justified {
+ border: 1px solid rgba(245,158,11,0.4);
+ background: rgba(245,158,11,0.05);
+ }
+
+ .cp-box.justified .label,
+ .cp-box.justified .slot { color: var(--amber); }
+
+ .cp-box.finalized {
+ border: 1px solid rgba(34,197,94,0.44);
+ background: rgba(34,197,94,0.05);
+ }
+
+ .cp-box.finalized .label,
+ .cp-box.finalized .slot { color: var(--green); }
+
+ .gap {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px;
+ background: rgba(255,255,255,0.02);
+ }
+
+ .gap .title {
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ letter-spacing: 0.5px;
+ color: #9bb0d0;
+ }
+
+ .gap-bar {
+ margin-top: 8px;
+ height: 6px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255,255,255,0.08);
+ }
+
+ .gap-fill {
+ height: 100%;
+ width: 0%;
+ transition: width 0.8s ease, background-color 0.4s ease;
+ background: var(--green);
+ box-shadow: 0 0 12px rgba(34,197,94,0.5);
+ }
+
+ .gap .helper {
+ margin-top: 8px;
+ color: var(--muted);
+ font-size: 11px;
+ letter-spacing: 0.2px;
+ }
+
+ .chip-wrap {
+ margin-top: 10px;
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ }
+
+ .chip {
+ min-width: 30px;
+ height: 30px;
+ border-radius: 9px;
+ border: 1px solid rgba(34,197,94,0.35);
+ background: rgba(34,197,94,0.08);
+ color: #6ef3a0;
+ font-family: "Space Mono", monospace;
+ font-size: 11px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .bottom-metrics {
+ border-left: 1px solid var(--border);
+ padding-left: 10px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ align-content: start;
+ }
+
+ .bottom-metric {
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ padding: 8px;
+ background: rgba(255,255,255,0.02);
+ }
+
+ .bottom-metric .v {
+ font-family: "Space Mono", monospace;
+ font-size: 16px;
+ color: #d3e7ff;
+ font-weight: 700;
+ }
+
+ .bottom-metric .k {
+ margin-top: 2px;
+ font-family: "Space Mono", monospace;
+ font-size: 8px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--muted);
+ }
+
+ @keyframes dotPulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.58; transform: scale(1.1); }
+ }
+
+ @keyframes breathe {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.62; }
+ }
+
+ @media (max-width: 1300px) {
+ .settings-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .conn-strip {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .debug-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .main {
+ grid-template-columns: 1fr;
+ }
+
+ .panel,
+ .canvas-panel {
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ #chainCanvas { height: 420px; }
+ #validatorCanvas { height: 360px; }
+
+ .bottom {
+ grid-template-columns: 1fr;
+ }
+
+ .bottom-metrics { border-left: 0; padding-left: 0; }
+ }
+