> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ton.org/llms.txt
> Use this file to discover all available pages before exploring further.

<AgentInstructions>

## Submitting Feedback

If you encounter incorrect, outdated, or confusing documentation on this page, submit feedback:

POST https://docs.ton.org/feedback

```json
{
  "path": "/foundations/consensus/catchain-visualizer",
  "feedback": "Description of the issue"
}
```

Only submit feedback when you have something specific and actionable to report.

</AgentInstructions>

# Catchain & BCP visualizer

> Interactive visual simulation of Catchain + BCP rounds

export const CatchainVisualizer = () => {
  const MESSAGE_COLORS = {
    Submit: "#6366f1",
    Approve: "#22c55e",
    Vote: "#0ea5e9",
    VoteFor: "#06b6d4",
    Precommit: "#f59e0b",
    Commit: "#3b82f6",
    DepRequest: "#475569"
  };
  const MESSAGE_LABELS = {
    Submit: "Submit",
    Approve: "Approve",
    Vote: "Vote",
    VoteFor: "VoteFor",
    Precommit: "PreCommit",
    Commit: "Commit",
    DepRequest: "Dep req"
  };
  const MESSAGE_DESCRIPTIONS = {
    Submit: "Proposer shares its round candidate with peers.",
    Approve: "Validator approves a seen proposal so others can vote.",
    Vote: "Validator votes for a proposal once approvals reach quorum.",
    VoteFor: "Coordinator guidance for slow attempts; points voting to a candidate.",
    Precommit: "Validator precommits after quorum votes to lock on a candidate.",
    Commit: "Validator finalizes a candidate after quorum precommits.",
    DepRequest: "Catchain-level dependency request for missing messages; peers will resend the requested blocks."
  };
  const LAYOUT = {
    centerX: 230,
    centerY: 200,
    nodeRing: 150,
    backdropRadius: 170,
    svgWidth: 520,
    svgHeight: 380,
    nodeRadius: 30,
    ringRadius: 34,
    proposerTimerRadius: 40
  };
  const LOG_LIMIT = 14;
  const PRIORITY_MOD = 1000;
  const PRIORITY_LAG_FACTOR = 18;
  const APPROVAL_JITTER_MIN = 25;
  const APPROVAL_JITTER_MAX = 120;
  const NULL_PRIORITY = 9999;
  const VOTEFOR_RETRY_MS = 400;
  const PROPOSER_SELF_APPROVE_EXTRA_MS = 120;
  const CANVAS_ARROW_MARKER = {
    width: 6,
    height: 6,
    refX: 5,
    refY: 3
  };
  const LOGO_TEXT_OFFSET = 24;
  const LAGGING_DROP_PROBABILITY = 0.5;
  const VOTEFOR_INITIAL_DELAY_MS = 500;
  const DEP_REQUEST_RETRY_MS = 300;
  const DEFAULT_MAX_DEPS = 4;
  const SCROLLBAR_CSS = `
    .catchain-scroll {
      scrollbar-width: thin;
      scrollbar-color: #94a3b8 #e2e8f0;
    }
    .catchain-scroll::-webkit-scrollbar {
      width: 10px;
    }
    .catchain-scroll::-webkit-scrollbar-track {
      background: #e2e8f0;
      border-radius: 9999px;
    }
    .catchain-scroll::-webkit-scrollbar-thumb {
      background: #94a3b8;
      border-radius: 9999px;
      border: 2px solid #e2e8f0;
    }
    .catchain-scroll::-webkit-scrollbar-thumb:hover {
      background: #64748b;
    }
    .committed-scroll {
      scrollbar-width: thin;
      scrollbar-color: #94a3b8 #e2e8f0;
    }
    .committed-scroll::-webkit-scrollbar {
      height: 10px;
    }
    .committed-scroll::-webkit-scrollbar-track {
      background: #e2e8f0;
      border-radius: 9999px;
    }
    .committed-scroll::-webkit-scrollbar-thumb {
      background: #94a3b8;
      border-radius: 9999px;
      border: 2px solid #e2e8f0;
    }
    .committed-scroll::-webkit-scrollbar-thumb:hover {
      background: #64748b;
    }
  `;
  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }
  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }
  function createPositions(count) {
    const cx = LAYOUT.centerX;
    const cy = LAYOUT.centerY;
    const r = LAYOUT.nodeRing;
    const result = [];
    for (let i = 0; i < count; i += 1) {
      const angle = Math.PI * 2 * i / count - Math.PI / 2;
      result.push({
        x: cx + r * Math.cos(angle),
        y: cy + r * Math.sin(angle)
      });
    }
    return result;
  }
  function createNode(index, pos) {
    return {
      id: `V${index + 1}`,
      label: `S${index + 1}`,
      pos,
      approved: new Set(),
      voted: new Set(),
      precommitted: new Set(),
      receivedEvents: {},
      committedTo: null,
      voteTarget: null,
      crashed: false,
      pendingActions: [],
      flushScheduled: false,
      votedThisAttempt: false,
      precommittedThisAttempt: false,
      lastVotedFor: null,
      lastPrecommitFor: null,
      lockedCandidate: null,
      lockedAtAttempt: 0,
      status: "good",
      catchainStore: new Map(),
      pendingCatchain: new Map(),
      missingRequests: new Set(),
      lastCatchainHeight: 0
    };
  }
  function makeCandidate(round, attempt, proposerIndex, proposerId) {
    const short = `${round}.${attempt}`;
    return {
      id: `R${round}-P${proposerIndex + 1}`,
      short,
      round,
      attempt,
      proposerIndex,
      proposerId,
      approvals: new Set(),
      votes: new Set(),
      precommits: new Set(),
      commits: new Set(),
      createdAt: null,
      priority: (proposerIndex + round - 1) % PRIORITY_MOD,
      submitted: false,
      submitAt: null,
      submitDelay: 0
    };
  }
  function logEvent(model, text) {
    model.log.unshift({
      t: model.time,
      text
    });
    if (model.log.length > LOG_LIMIT) {
      model.log = model.log.slice(0, LOG_LIMIT);
    }
  }
  function scheduleTask(model, delayMs, fn, label = "") {
    model.tasks.push({
      runAt: model.time + delayMs,
      fn,
      label
    });
  }
  function getNode(model, nodeId) {
    return model.nodes.find(n => n.id === nodeId);
  }
  function createCatchainEnvelope(model, from, actions, deps = []) {
    const nextHeight = (model.catchainHeights[from] || 0) + 1;
    model.catchainHeights[from] = nextHeight;
    const senderNode = getNode(model, from);
    const prevId = senderNode?.lastCatchainId || null;
    const idSet = new Set();
    const senderSet = new Set();
    let depList = [];
    const baseDeps = Array.from(new Set(deps || []));
    const senderFromId = depId => {
      if (!depId) return null;
      const m = (/^([^-/]+)-h/).exec(depId);
      return m ? m[1] : depId;
    };
    const pushDep = (depId, depSender) => {
      if (!depId) return;
      const senderKey = depSender || senderFromId(depId) || depId;
      if (senderKey === from || idSet.has(depId) || senderSet.has(senderKey)) return;
      idSet.add(depId);
      senderSet.add(senderKey);
      depList.push(depId);
    };
    if (baseDeps.length > 0 && senderNode) {
      baseDeps.forEach(depId => {
        const env = senderNode.catchainStore.get(depId);
        pushDep(depId, env?.sender);
      });
    }
    if (depList.length === 0 && senderNode) {
      const candidates = Array.from(senderNode.frontier.values()).filter(entry => entry && entry.sender !== from);
      candidates.sort((a, b) => (b.height || 0) - (a.height || 0));
      for (const c of candidates) {
        if (depList.length >= model.config.maxDeps) break;
        pushDep(c.id, c.sender);
      }
    }
    depList = Array.from(new Set(depList)).slice(0, model.config.maxDeps);
    return {
      id: `${from}-h${nextHeight}`,
      sender: from,
      height: nextHeight,
      prev: prevId,
      deps: depList,
      actions
    };
  }
  function sendCatchainEnvelope(model, envelope, options = {}) {
    const {from, to, delay = 0, includeSelf = false} = options;
    const sender = getNode(model, from);
    model.nodes.forEach(node => {
      if (to && node.id !== to) return;
      if (!includeSelf && node.id === from) return;
      if (sender && sender.status === "lagging" && Math.random() < LAGGING_DROP_PROBABILITY) {
        return;
      }
      const latency = randomBetween(model.config.latency[0], model.config.latency[1]);
      const sendAt = model.time + delay;
      const primary = envelope.actions?.[0]?.type || "Catchain";
      model.messages.push({
        id: `${envelope.id}-${from}-${node.id}-${Math.random().toString(16).slice(2, 6)}`,
        transport: "Catchain",
        envelope,
        actions: envelope.actions || [],
        primary,
        from,
        to: node.id,
        sendTime: sendAt,
        recvTime: sendAt + latency
      });
    });
  }
  function sendDepRequest(model, from, to, missingIds) {
    if (!missingIds || missingIds.length === 0) return;
    const sender = getNode(model, from);
    if (sender && sender.status === "lagging" && Math.random() < LAGGING_DROP_PROBABILITY) {
      return;
    }
    const latency = randomBetween(model.config.latency[0], model.config.latency[1]);
    const sendAt = model.time;
    model.messages.push({
      id: `REQ-${from}-${to}-${Math.random().toString(16).slice(2, 6)}`,
      transport: "DepRequest",
      missingIds,
      primary: "DepRequest",
      from,
      to,
      sendTime: sendAt,
      recvTime: sendAt + latency,
      actions: []
    });
  }
  function requestMissingDeps(model, node, missingIds, preferredPeer, force = false) {
    const uniqueIds = Array.from(new Set(missingIds || []));
    const outstanding = uniqueIds.filter(id => force || !node.missingRequests.has(id));
    if (outstanding.length === 0) return;
    outstanding.forEach(id => node.missingRequests.add(id));
    const preferred = preferredPeer && preferredPeer !== node.id ? getNode(model, preferredPeer) : null;
    const target = preferred && preferred.status !== "crashed" ? preferred : model.nodes.find(n => n.id !== node.id && n.status !== "crashed");
    if (!target) return;
    logEvent(model, `${node.label} requested ${outstanding.length} dep(s) from ${target.label}`);
    sendDepRequest(model, node.id, target.id, outstanding);
  }
  function tryDeliverPendingCatchain(model, node) {
    let progressed = true;
    while (progressed) {
      progressed = false;
      node.pendingCatchain.forEach((entry, mid) => {
        const remaining = [...entry.missing].filter(dep => !node.catchainStore.has(dep));
        if (remaining.length === 0) {
          node.pendingCatchain.delete(mid);
          deliverCatchainEnvelope(model, node, entry.envelope, entry.from);
          progressed = true;
        } else {
          entry.missing = new Set(remaining);
        }
      });
    }
  }
  function deliverCatchainEnvelope(model, node, envelope, originalFrom) {
    if (!node || node.status === "crashed") return;
    if (node.catchainStore.has(envelope.id)) return;
    const depsAndPrev = Array.from(new Set([...envelope.prev ? [envelope.prev] : [], ...envelope.deps || []]));
    const missing = depsAndPrev.filter(dep => !node.catchainStore.has(dep));
    if (missing.length > 0) {
      logEvent(model, `${node.label} missing ${missing.length} dep(s) for ${envelope.id}: ${missing.join(", ")}`);
      node.pendingCatchain.set(envelope.id, {
        envelope,
        missing: new Set(missing),
        from: originalFrom
      });
      requestMissingDeps(model, node, missing, originalFrom);
      scheduleTask(model, DEP_REQUEST_RETRY_MS, () => {
        const pending = node.pendingCatchain.get(envelope.id);
        if (!pending) return;
        requestMissingDeps(model, node, [...pending.missing], originalFrom, true);
      }, "dep-retry");
      return;
    }
    const depSenders = new Set();
    for (const depId of envelope.deps || []) {
      const depEnv = node.catchainStore.get(depId);
      if (depEnv) {
        if (depSenders.has(depEnv.sender)) {
          logEvent(model, `${node.label} rejected ${envelope.id} (duplicate deps from ${depEnv.sender})`);
          return;
        }
        depSenders.add(depEnv.sender);
      }
    }
    node.catchainStore.set(envelope.id, envelope);
    node.missingRequests.delete(envelope.id);
    node.lastCatchainHeight = Math.max(node.lastCatchainHeight || 0, envelope.height || 0);
    node.lastCatchainId = envelope.id;
    node.frontier.set(envelope.sender, {
      id: envelope.id,
      sender: envelope.sender,
      height: envelope.height || 0
    });
    (envelope.actions || []).forEach(action => handleAction(model, node, action, envelope.sender));
    tryDeliverPendingCatchain(model, node);
  }
  function handleDepRequest(model, node, message) {
    if (!message.missingIds || message.missingIds.length === 0) return;
    console.log(message.missingIds);
    message.missingIds.forEach(id => {
      const stored = node.catchainStore.get(id);
      if (stored) {
        sendCatchainEnvelope(model, stored, {
          from: node.id,
          to: message.from,
          includeSelf: false,
          delay: DEP_REQUEST_RETRY_MS / 10
        });
      }
    });
  }
  function chooseVoteTarget(model, node) {
    const eligible = Object.values(model.candidates).filter(c => {
      const state = c.approvals.size >= model.config.quorum;
      const hasCurrentSeen = !node.receivedEvents[c.id] ? false : node.receivedEvents[c.id].approved >= model.config.quorum;
      return state && hasCurrentSeen;
    });
    if (eligible.length === 0) return null;
    if (model.isSlow) {
      if (!node.voteTarget) return null;
      const target = model.candidates[node.voteTarget];
      return target && target.approvals.size >= model.config.quorum ? target : null;
    }
    if (node.lockedCandidate) {
      const locked = model.candidates[node.lockedCandidate];
      if (locked && locked.approvals.size >= model.config.quorum) return locked;
    }
    if (node.lastVotedFor) {
      const prev = model.candidates[node.lastVotedFor];
      if (prev && prev.approvals.size >= model.config.quorum) return prev;
    }
    return eligible.reduce((best, cand) => {
      if (!best) return cand;
      return cand.priority < best.priority ? cand : best;
    }, null);
  }
  function broadcastBlock(model, options) {
    const {from, actions, delay = 0, includeSelf = false} = options;
    if (!actions || actions.length === 0) return;
    const sender = getNode(model, from);
    if (!sender || sender.status === "crashed") return;
    const envelope = createCatchainEnvelope(model, from, actions);
    deliverCatchainEnvelope(model, sender, envelope, from);
    sendCatchainEnvelope(model, envelope, {
      from,
      delay,
      includeSelf
    });
  }
  function addEvent(node, candidateId, eventType) {
    if (!node.receivedEvents[candidateId]) {
      node.receivedEvents[candidateId] = {
        approved: 0,
        voted: 0,
        precommitted: 0,
        commited: 0
      };
    }
    switch (eventType) {
      case "approve":
        {
          node.receivedEvents[candidateId].approved += 1;
          break;
        }
      case "vote":
        {
          node.receivedEvents[candidateId].voted += 1;
          break;
        }
      case "precommit":
        {
          node.receivedEvents[candidateId].precommitted += 1;
          break;
        }
      case "commit":
        {
          node.receivedEvents[candidateId].commited += 1;
          break;
        }
    }
  }
  function enqueueAction(model, node, action, delay = 0, includeSelf = false) {
    scheduleTask(model, delay, () => {
      if (node.status === "crashed") return;
      if (action.type === "Submit") {
        const cand = model.candidates[action.candidateId];
        if (cand && !cand.createdAt) {
          cand.createdAt = model.time;
        }
        if (cand) {
          cand.submitted = true;
        }
      }
      broadcastBlock(model, {
        from: node.id,
        actions: [action],
        includeSelf
      });
    }, "flush-block");
  }
  function issueApproval(model, node, candidateId, opts = {}) {
    const candidate = model.candidates[candidateId];
    if (!candidate || node.status === "crashed" || node.approved.has(candidateId)) return;
    node.approved.add(candidateId);
    addEvent(node, candidateId, "approve");
    candidate.approvals.add(node.id);
    if (!candidate.createdAt && candidate.approvals.size === 1) {
      candidate.createdAt = model.time;
    }
    logEvent(model, `${node.label} approved ${candidate.short} (approvals ${candidate.approvals.size}/${model.config.quorum})`);
    enqueueAction(model, node, {
      type: "Approve",
      candidateId
    }, opts.delay || 0);
    tryVote(model, candidateId);
  }
  function issueVote(model, node, candidateId) {
    const candidate = model.candidates[candidateId];
    if (!candidate || node.status === "crashed" || node.votedThisAttempt) return;
    if (candidate.approvals.size < model.config.quorum) return;
    node.votedThisAttempt = true;
    node.lastVotedFor = candidateId;
    node.voted.add(candidateId);
    addEvent(node, candidateId, "vote");
    candidate.votes.add(node.id);
    logEvent(model, `${node.label} voted ${candidate.short} (votes ${candidate.votes.size}/${model.config.quorum})`);
    enqueueAction(model, node, {
      type: "Vote",
      candidateId
    });
    tryPrecommit(model, node, candidateId);
  }
  function issuePrecommit(model, node, candidateId) {
    const candidate = model.candidates[candidateId];
    if (!candidate || node.status === "crashed" || node.precommittedThisAttempt) return;
    if (candidate.votes.size < model.config.quorum) return;
    if (node.lastVotedFor !== candidateId) return;
    node.precommittedThisAttempt = true;
    node.lastPrecommitFor = candidateId;
    node.lockedCandidate = candidateId;
    node.lockedAtAttempt = model.attempt;
    node.precommitted.add(candidateId);
    addEvent(node, candidateId, "precommit");
    candidate.precommits.add(node.id);
    logEvent(model, `${node.label} precommitted ${candidate.short} (precommits ${candidate.precommits.size}/${model.config.quorum})`);
    enqueueAction(model, node, {
      type: "Precommit",
      candidateId
    });
    tryCommit(model, node, candidateId);
  }
  function issueCommit(model, node, candidateId) {
    const candidate = model.candidates[candidateId];
    if (!candidate || node.status === "crashed" || node.committedTo === candidateId) return;
    if (!node.precommittedThisAttempt || node.lastPrecommitFor !== candidateId) return;
    node.committedTo = candidateId;
    candidate.commits.add(node.id);
    addEvent(node, candidateId, "commit");
    logEvent(model, `${node.label} committed ${candidate.short} (commits ${candidate.commits.size}/${model.config.quorum})`);
    enqueueAction(model, node, {
      type: "Commit",
      candidateId
    });
    if (!model.committedCandidate && candidate.commits.size >= model.config.quorum) {
      model.committedCandidate = candidateId;
      model.committedHistory = [...model.committedHistory || [], {
        id: candidateId,
        short: candidate.short,
        round: model.round,
        attempt: model.attempt,
        proposerId: candidate.proposerId,
        committedAt: model.time
      }];
      model.nextRoundAt = model.time + model.config.roundGap;
      logEvent(model, `✔️ Round ${model.round} locked on ${candidate.short}, starting next round soon`);
    }
  }
  function tryVote(model) {
    model.nodes.forEach(node => {
      if (node.votedThisAttempt) return;
      const target = chooseVoteTarget(model, node);
      if (!target) return;
      scheduleTask(model, model.config.simDelay, () => issueVote(model, node, target.id), "vote");
    });
  }
  function tryPrecommit(model, node, candidateId) {
    const candidate = model.candidates[candidateId];
    if (!candidate || candidate.votes.size < model.config.quorum) return;
    if (!node.receivedEvents[candidateId] || node.receivedEvents[candidateId].voted < model.config.quorum) {
      return;
    }
    scheduleTask(model, model.config.simDelay, () => issuePrecommit(model, node, candidateId), "precommit");
  }
  function tryCommit(model, node, candidateId) {
    const candidate = model.candidates[candidateId];
    if (!candidate || candidate.precommits.size < model.config.quorum) return;
    if (!node.receivedEvents[candidateId] || node.receivedEvents[candidateId].precommitted < model.config.quorum) {
      return;
    }
    scheduleTask(model, model.config.simDelay, () => issueCommit(model, node, candidateId), "commit");
  }
  function calcApprovalDelay(model, node, candidate, isSlow) {
    const base = isSlow ? model.config.DeltaInfinity : model.config.Delta;
    const priorityLag = (candidate.proposerIndex + node.label.length) * PRIORITY_LAG_FACTOR;
    const jitter = randomBetween(APPROVAL_JITTER_MIN, APPROVAL_JITTER_MAX);
    return base + priorityLag + jitter;
  }
  function getSimDelay() {
    return randomBetween(APPROVAL_JITTER_MIN, APPROVAL_JITTER_MAX);
  }
  function pickCoordinator(model, attempt) {
    const idx = attempt % model.nodes.length;
    return model.nodes[idx];
  }
  function getNodePriority(round, idx, total, C) {
    const start = (round - 1 + total) % total;
    let adj = idx;
    if (adj < start) adj += total;
    const prio = adj - start;
    return prio < C ? prio : -1;
  }
  function ensureNullCandidate(model) {
    if (model.nullCandidateId) return;
    const id = `R${model.round}-NULL`;
    const candidate = {
      id,
      short: `${model.round}.⊥`,
      round: model.round,
      attempt: model.attempt,
      proposerIndex: -1,
      proposerId: "NULL",
      approvals: new Set(),
      votes: new Set(),
      precommits: new Set(),
      commits: new Set(),
      createdAt: model.time,
      priority: NULL_PRIORITY,
      submitted: false,
      submitAt: null,
      submitDelay: 0
    };
    model.candidates[id] = candidate;
    model.nullCandidateId = id;
    model.nodes.forEach(node => {
      scheduleTask(model, model.config.DeltaInfinity, () => issueApproval(model, node, id), "null-approve");
    });
  }
  function sendVoteFor(model) {
    if (!model.isSlow) return;
    const coord = pickCoordinator(model, model.attempt);
    const candidates = Object.values(model.candidates).filter(c => !!c.createdAt);
    if (candidates.length === 0) {
      scheduleTask(model, VOTEFOR_RETRY_MS, () => sendVoteFor(model), "voteFor-retry");
      return;
    }
    const eligible = candidates.filter(c => c.approvals.size >= model.config.quorum);
    if (eligible.length === 0) return;
    const choice = eligible[Math.floor(Math.random() * eligible.length)];
    model.voteForTarget = choice.id;
    logEvent(model, `${coord.label} suggests ${choice.short} for slow attempt via VoteFor`);
    enqueueAction(model, coord, {
      type: "VoteFor",
      candidateId: choice.id
    });
  }
  function handleAction(model, node, action, fromId) {
    let candidate = model.candidates[action.candidateId];
    switch (action.type) {
      case "Submit":
        {
          if (!candidate) {
            const existing = Object.values(model.candidates).find(c => c.proposerId === (action.proposerId || fromId) && c.round === (action.round || model.round));
            if (existing) {
              candidate = existing;
            } else {
              candidate = makeCandidate(action.round || model.round, action.attempt || model.attempt, action.proposerIndex ?? 0, action.proposerId || fromId);
              model.candidates[action.candidateId] = candidate;
            }
          }
          if (!candidate.createdAt) candidate.createdAt = model.time;
          if (node.id === candidate.proposerId) {
            scheduleTask(model, 500, () => issueApproval(model, node, candidate.id), "proposer-self-approve");
          } else if (!model.isSlow) {
            scheduleTask(model, getSimDelay(), () => issueApproval(model, node, candidate.id), "auto-approve");
          }
          break;
        }
      case "VoteFor":
        {
          node.voteTarget = action.candidateId;
          if (candidate && !node.approved.has(candidate.id)) {
            const delay = calcApprovalDelay(model, node, candidate, true);
            scheduleTask(model, delay, () => issueApproval(model, node, candidate.id), "voteFor-approve");
          }
          tryVote(model);
          break;
        }
      case "Approve":
        {
          if (candidate && !candidate.approvals.has(fromId)) {
            candidate.approvals.add(fromId);
          }
          addEvent(node, candidate.id, "approve");
          tryVote(model);
          break;
        }
      case "Vote":
        {
          if (candidate && !candidate.votes.has(fromId)) {
            candidate.votes.add(fromId);
            if (candidate.votes.size >= model.config.quorum) {
              model.nodes.forEach(n => {
                if (n.lockedCandidate && n.lockedCandidate !== candidate.id && model.attempt > n.lockedAtAttempt) {
                  n.lockedCandidate = null;
                  n.lockedAtAttempt = 0;
                }
              });
            }
          }
          addEvent(node, candidate.id, "vote");
          tryPrecommit(model, node, candidate.id);
          break;
        }
      case "Precommit":
        {
          if (candidate && !candidate.precommits.has(fromId)) {
            candidate.precommits.add(fromId);
          }
          addEvent(node, candidate.id, "precommit");
          tryCommit(model, node, candidate.id);
          break;
        }
      case "Commit":
        {
          addEvent(node, candidate.id, "commit");
          if (candidate && node.committedTo !== candidate.id) {
            node.committedTo = candidate.id;
            candidate.commits.add(node.id);
            if (!model.committedCandidate && candidate.commits.size >= model.config.quorum) {
              if (!node.receivedEvents[candidate.id] || node.receivedEvents[candidate.id] < model.config.quorum) {
                break;
              }
              model.committedCandidate = candidate.id;
              model.nextRoundAt = model.time + model.config.roundGap;
              logEvent(model, `✔️ Round ${model.round} locked on ${candidate.short}, starting next round soon`);
            }
          }
          break;
        }
      default:
        break;
    }
  }
  function handleMessage(model, message) {
    const node = getNode(model, message.to);
    if (!node || node.status === "crashed") return;
    if (node.status === "lagging" && Math.random() < LAGGING_DROP_PROBABILITY) return;
    if (message.transport === "Catchain") {
      deliverCatchainEnvelope(model, node, message.envelope, message.from);
    } else if (message.transport === "DepRequest") {
      handleDepRequest(model, node, message);
    }
  }
  function deliverMessages(model) {
    const ready = [];
    const pending = [];
    model.messages.forEach(msg => {
      if (msg.recvTime <= model.time) {
        ready.push(msg);
      } else {
        pending.push(msg);
      }
    });
    model.messages = pending;
    ready.forEach(msg => handleMessage(model, msg));
  }
  function runTasks(model) {
    const ready = [];
    const future = [];
    model.tasks.forEach(task => {
      if (task.runAt <= model.time) {
        ready.push(task);
      } else {
        future.push(task);
      }
    });
    model.tasks = future;
    ready.forEach(task => {
      try {
        task.fn();
      } catch (err) {
        logEvent(model, `Task error: ${err?.message || err}`);
      }
    });
  }
  function startAttempt(model, options = {}) {
    const forced = options.forceSlow === true;
    model.attempt = options.attempt || model.attempt + 1;
    model.isSlow = forced || model.attempt > model.config.Y;
    model.attemptStartedAt = model.time;
    model.messages = [];
    model.tasks = [];
    model.voteForTarget = null;
    model.currentProposers = [];
    model.nodes.forEach(node => {
      node.voted = new Set();
      node.precommitted = new Set();
      node.votedThisAttempt = false;
      node.precommittedThisAttempt = false;
      node.lastVotedFor = null;
      node.lastPrecommitFor = null;
      node.voteTarget = null;
    });
    Object.values(model.candidates).forEach(cand => {
      cand.votes = new Set();
      cand.precommits = new Set();
    });
    const proposerSet = [];
    for (let i = 0; i < model.nodes.length; i += 1) {
      const prio = getNodePriority(model.round, i, model.nodes.length, model.config.C);
      if (prio >= 0) {
        proposerSet.push({
          node: model.nodes[i],
          priority: prio,
          proposerIndex: i
        });
      }
    }
    proposerSet.sort((a, b) => a.priority - b.priority);
    proposerSet.forEach(({node: proposer, priority, proposerIndex}) => {
      let cand = Object.values(model.candidates).find(c => c.proposerId === proposer.id && c.round === model.round);
      if (!cand) {
        cand = makeCandidate(model.round, model.attempt, proposerIndex, proposer.id);
        cand.priority = priority;
        model.candidates[cand.id] = cand;
      } else {
        cand.priority = priority;
      }
      const submitDelay = Math.max(0, priority * model.config.Delta);
      const submitAt = model.time + submitDelay;
      model.currentProposers.push({
        nodeId: proposer.id,
        candidateId: cand.id,
        submitAt,
        submitDelay
      });
      if (cand.submitted) {
        cand.submitAt = null;
        cand.submitDelay = 0;
        return;
      }
      cand.submitAt = submitAt;
      cand.submitDelay = submitDelay;
      enqueueAction(model, proposer, {
        type: "Submit",
        candidateId: cand.id,
        round: model.round,
        attempt: model.attempt,
        proposerId: proposer.id,
        proposerIndex,
        priority
      }, submitDelay);
      scheduleTask(model, submitDelay + PROPOSER_SELF_APPROVE_EXTRA_MS, () => issueApproval(model, proposer, cand.id), "proposer-instant-approve");
    });
    const best = proposerSet.find(() => true);
    model.activeCandidateId = best ? Object.values(model.candidates).find(c => c.proposerId === best.node.id && c.round === model.round)?.id || "" : "";
    logEvent(model, `▶️ Round ${model.round}, attempt ${model.attempt} (${model.isSlow ? "slow" : "fast"}), proposer window size ${model.config.C}`);
    if (model.isSlow) {
      scheduleTask(model, VOTEFOR_INITIAL_DELAY_MS, () => sendVoteFor(model), "voteFor");
    }
    ensureNullCandidate(model);
    scheduleTask(model, model.config.K, () => {
      if (!model.committedCandidate) {
        logEvent(model, `⏱️ Attempt ${model.attempt} timed out, moving on`);
        startAttempt(model, {
          attempt: model.attempt + 1
        });
      }
    });
    tryVote(model);
  }
  function startRound(model, resetRoundNumber = false) {
    if (!resetRoundNumber) {
      model.round += 1;
    }
    model.attempt = 0;
    model.candidates = {};
    model.messages = [];
    model.tasks = [];
    model.committedCandidate = null;
    model.nextRoundAt = null;
    model.nullCandidateId = null;
    model.currentProposers = [];
    model.catchainHeights = {};
    model.nodes.forEach(node => {
      model.catchainHeights[node.id] = 0;
    });
    model.nodes.forEach(node => {
      node.approved = new Set();
      node.voted = new Set();
      node.precommitted = new Set();
      node.committedTo = null;
      node.voteTarget = null;
      node.pendingActions = [];
      node.flushScheduled = false;
      node.votedThisAttempt = false;
      node.precommittedThisAttempt = false;
      node.lastVotedFor = null;
      node.lastPrecommitFor = null;
      node.lockedCandidate = null;
      node.catchainStore = new Map();
      node.pendingCatchain = new Map();
      node.missingRequests = new Set();
      node.lastCatchainHeight = 0;
      node.lastCatchainId = null;
      node.frontier = new Map();
    });
    startAttempt(model, {
      attempt: 1
    });
  }
  function createModel(config) {
    const positions = createPositions(config.numNodes);
    const nodes = positions.map((pos, idx) => createNode(idx, pos));
    const heights = {};
    nodes.forEach(n => {
      heights[n.id] = 0;
    });
    const model = {
      config,
      time: 0,
      nodes,
      messages: [],
      tasks: [],
      candidates: {},
      activeCandidateId: "",
      attempt: 0,
      round: 1,
      attemptStartedAt: 0,
      isSlow: false,
      committedCandidate: null,
      nextRoundAt: null,
      log: [],
      committedHistory: [],
      voteForTarget: null,
      nullCandidateId: null,
      currentProposers: [],
      catchainHeights: heights
    };
    startRound(model, true);
    return model;
  }
  function stepModel(model, dt) {
    model.time += dt;
    runTasks(model);
    deliverMessages(model);
    if (model.nextRoundAt && model.time >= model.nextRoundAt) {
      startRound(model);
    }
    return model;
  }
  const {useEffect, useRef, useState} = React;
  const DEFAULT_CONFIG = {
    numNodes: 5,
    latency: [80, 150],
    K: 8000,
    roundGap: 200,
    Delta: 2000,
    DeltaInfinity: 4000,
    Y: 3,
    C: 2,
    simDelay: 70,
    frameMs: 90,
    quorum: 4,
    maxDeps: DEFAULT_MAX_DEPS
  };
  const CONFIG_FIELDS = [{
    key: "K",
    label: "K (ms)",
    description: "Attempt duration; 8000ms means 8 seconds per attempt."
  }, {
    key: "Delta",
    label: "Delta (ms)",
    description: "Base Δ_i delay; 2000ms equals 2s for first step."
  }, {
    key: "DeltaInfinity",
    label: "DeltaInfinity (ms)",
    description: "Upper delay bound for slow attempts; 2*C seconds."
  }, {
    key: "Y",
    label: "Y",
    description: "Fast attempts before switching to slow attempts."
  }, {
    key: "C",
    label: "C",
    description: "Number of round candidates in rotation."
  }, {
    key: "maxDeps",
    label: "maxDeps",
    description: "Catchain: max dependency links per block (one per sender)."
  }];
  const [config, setConfig] = useState(() => ({
    ...DEFAULT_CONFIG
  }));
  const [configDraft, setConfigDraft] = useState(() => ({
    K: `${DEFAULT_CONFIG.K}`,
    Delta: `${DEFAULT_CONFIG.Delta}`,
    DeltaInfinity: `${DEFAULT_CONFIG.DeltaInfinity}`,
    Y: `${DEFAULT_CONFIG.Y}`,
    C: `${DEFAULT_CONFIG.C}`,
    maxDeps: `${DEFAULT_CONFIG.maxDeps}`
  }));
  const [configModalOpen, setConfigModalOpen] = useState(false);
  const [eventLogOpen, setEventLogOpen] = useState(false);
  const modelRef = useRef(null);
  const [tick, setTick] = useState(0);
  const [running, setRunning] = useState(true);
  const [speed, setSpeed] = useState(0.05);
  const [selectedNodeId, setSelectedNodeId] = useState(null);
  const [selectedMessage, setSelectedMessage] = useState(null);
  const [selectedCandidateId, setSelectedCandidateId] = useState(null);
  const [hoveredEventType, setHoveredEventType] = useState(null);
  const [eventTooltipPos, setEventTooltipPos] = useState({
    x: 0,
    y: 0,
    placement: "bottom"
  });
  if (!modelRef.current) {
    modelRef.current = createModel(config);
  }
  useEffect(() => {
    const id = setInterval(() => {
      if (!running) return;
      stepModel(modelRef.current, config.frameMs * speed);
      setTick(t => t + 1);
    }, config.frameMs);
    return () => clearInterval(id);
  }, [config.frameMs, running, speed]);
  useEffect(() => {
    if (typeof window === "undefined") return undefined;
    const handleKeyDown = event => {
      const tagName = (event.target?.tagName || "").toUpperCase();
      const isTyping = tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "BUTTON" || event.target?.isContentEditable;
      if ((event.key === " " || event.key === "Spacebar") && !isTyping) {
        event.preventDefault();
        setRunning(prev => !prev);
        return;
      }
      if (event.key === "Escape") {
        if (selectedMessage) {
          setSelectedMessage(null);
        } else if (selectedCandidateId) {
          setSelectedCandidateId(null);
        } else if (selectedNodeId) {
          setSelectedNodeId(null);
        } else if (configModalOpen) {
          setConfigModalOpen(false);
        } else if (eventLogOpen) {
          setEventLogOpen(false);
        }
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [configModalOpen, eventLogOpen, selectedCandidateId, selectedMessage, selectedNodeId]);
  const model = modelRef.current;
  const activeCandidate = model.activeCandidateId ? model.candidates[model.activeCandidateId] : null;
  const candidates = Object.values(model.candidates).filter(c => c.proposerId === "NULL" ? c.approvals.size > 0 : !!c.createdAt).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
  const elapsedAttempt = Math.max(0, model.time - (model.attemptStartedAt || 0));
  const attemptProgress = clamp(elapsedAttempt / (model.config.K || 1), 0, 1);
  const attemptRemaining = Math.max(0, (model.config.K || 0) - elapsedAttempt);
  const proposerTimerRadius = LAYOUT.proposerTimerRadius || LAYOUT.ringRadius + 6;
  const proposerTimerCircumference = 2 * Math.PI * proposerTimerRadius;
  const proposerTimersByNode = {};
  (model.currentProposers || []).forEach(entry => {
    proposerTimersByNode[entry.nodeId] = entry;
  });
  const rebuildModel = nextConfig => {
    modelRef.current = createModel(nextConfig);
    setTick(t => t + 1);
    setSelectedNodeId(null);
    setSelectedMessage(null);
    setSelectedCandidateId(null);
    setEventLogOpen(false);
  };
  const reset = () => {
    rebuildModel(config);
  };
  const openConfigModal = () => {
    setConfigDraft({
      K: `${config.K}`,
      Delta: `${config.Delta}`,
      DeltaInfinity: `${config.DeltaInfinity}`,
      Y: `${config.Y}`,
      C: `${config.C}`,
      maxDeps: `${config.maxDeps}`
    });
    setSelectedNodeId(null);
    setSelectedMessage(null);
    setSelectedCandidateId(null);
    setConfigModalOpen(true);
  };
  const submitConfig = e => {
    e.preventDefault();
    const toNumber = (val, fallback) => {
      if (val === "") return fallback;
      const parsed = Number(val);
      return Number.isFinite(parsed) ? parsed : fallback;
    };
    const updatedConfig = {
      ...config,
      K: toNumber(configDraft.K, config.K),
      Delta: toNumber(configDraft.Delta, config.Delta),
      DeltaInfinity: toNumber(configDraft.DeltaInfinity, config.DeltaInfinity),
      Y: toNumber(configDraft.Y, config.Y),
      C: toNumber(configDraft.C, config.C),
      maxDeps: toNumber(configDraft.maxDeps, config.maxDeps)
    };
    setConfig(updatedConfig);
    rebuildModel(updatedConfig);
    setConfigModalOpen(false);
  };
  const showEventTooltip = (evt, key) => {
    if (!MESSAGE_DESCRIPTIONS[key]) {
      setHoveredEventType(null);
      return;
    }
    const rect = evt.currentTarget.getBoundingClientRect();
    const viewportWidth = typeof window !== "undefined" ? window.innerWidth : LAYOUT.svgWidth;
    const viewportHeight = typeof window !== "undefined" ? window.innerHeight : LAYOUT.svgHeight;
    const tooltipWidth = 240;
    const tooltipHeight = 90;
    const gap = 12;
    const preferAbove = rect.bottom + tooltipHeight > viewportHeight - gap;
    const left = clamp(rect.left + rect.width / 2, tooltipWidth / 2 + gap, viewportWidth - tooltipWidth / 2 - gap);
    const rawTop = preferAbove ? rect.top - tooltipHeight - gap : rect.bottom + gap;
    const top = Math.max(gap, rawTop);
    setEventTooltipPos({
      x: left,
      y: top,
      placement: preferAbove ? "top" : "bottom"
    });
    setHoveredEventType(key);
  };
  const hideEventTooltip = () => {
    setHoveredEventType(null);
  };
  return <div className="rounded-2xl border border-slate-200 bg-white shadow-sm p-4 md:p-6">
      <style>{SCROLLBAR_CSS}</style>
      <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-2">
        <div className="flex items-center gap-2">
          <button className="inline-flex items-center gap-2 rounded-lg bg-sky-600 text-white px-3 py-2 text-sm font-medium shadow-sm hover:bg-sky-700" onClick={openConfigModal}>
            Adjust simulation config
          </button>
          <button className="inline-flex items-center gap-2 rounded-lg bg-sky-600 text-white px-3 py-2 text-sm font-medium shadow-sm hover:bg-sky-700" onClick={() => setEventLogOpen(true)}>
            <span role="img" aria-label="log">
              📖
            </span>
            Event log
          </button>
        </div>
        <div className="flex items-center gap-3">
          <button className="inline-flex items-center gap-2 rounded-lg bg-sky-600 text-white px-3 py-2 text-sm font-medium shadow-sm hover:bg-sky-700" onClick={() => setRunning(v => !v)}>
            <span>{running ? "Pause (Space)" : "Resume (Space)"}</span>
          </button>
          <button className="inline-flex items-center gap-2 rounded-lg bg-sky-600 text-white px-3 py-2 text-sm font-medium shadow-sm hover:bg-sky-700" onClick={reset}>
            Restart round
          </button>
        </div>
      </div>
      <p className="text-xs text-slate-600 mb-4">
        Shortcuts: Space to pause/resume the simulation, Esc to close any open
        pop-up.
      </p>

      <div className="grid gap-4 lg:grid-cols-3">
        <div className="lg:col-span-2 rounded-xl border border-slate-100 bg-slate-50 px-2 py-3">
          <svg viewBox={`0 0 ${LAYOUT.svgWidth} ${LAYOUT.svgHeight}`} className="w-full h-[360px]">
            <defs>
              <marker id="arrow-head" markerWidth={CANVAS_ARROW_MARKER.width} markerHeight={CANVAS_ARROW_MARKER.height} refX={CANVAS_ARROW_MARKER.refX} refY={CANVAS_ARROW_MARKER.refY} orient="auto" markerUnits="strokeWidth">
                <path d="M0,0 L0,6 L6,3 z" fill="#1e293b" />
              </marker>
            </defs>
            <circle cx={LAYOUT.centerX} cy={LAYOUT.centerY} r={LAYOUT.backdropRadius} fill="#f8fafc" stroke="#e2e8f0" strokeWidth="2" />
            {model.nodes.map(node => {
    const committed = node.committedTo === model.committedCandidate && model.committedCandidate;
    const precommitted = !committed && activeCandidate && node.precommitted.has(activeCandidate.id) ? true : false;
    const approved = !committed && activeCandidate && node.approved.has(activeCandidate.id);
    const proposerTimer = proposerTimersByNode[node.id];
    let proposerProgress = 0;
    if (proposerTimer) {
      const remaining = Math.max(0, (proposerTimer.submitAt || 0) - model.time);
      const total = proposerTimer.submitDelay || 1;
      proposerProgress = clamp(1 - remaining / Math.max(total, 1), 0, 1);
      if (model.candidates[proposerTimer.candidateId]?.submitted) {
        proposerProgress = 1;
      }
    }
    const ring = committed ? "#3b82f6" : precommitted ? "#f59e0b" : approved ? "#22c55e" : node.status === "lagging" ? "#eab308" : node.status === "crashed" ? "#ef4444" : "#94a3b8";
    return <g key={node.id} transform={`translate(${node.pos.x}, ${node.pos.y})`} onClick={() => setSelectedNodeId(node.id)} className="cursor-pointer">
                  <circle r={LAYOUT.nodeRadius} fill={node.status === "crashed" ? "#fee2e2" : node.status === "lagging" ? "#fef3c7" : "#e5e7eb"} stroke="#94a3b8" strokeWidth="3" />
                  {proposerTimer && <circle r={proposerTimerRadius} fill="none" stroke="#4a5358ff" strokeWidth="7" strokeLinecap="round" strokeDasharray={`${proposerTimerCircumference} ${proposerTimerCircumference}`} strokeDashoffset={proposerTimerCircumference * (1 - proposerProgress)} transform="rotate(-90)" />}
                  <circle r={LAYOUT.ringRadius} fill="none" stroke={ring} strokeWidth="4" />
                  <text textAnchor="middle" dominantBaseline="middle" className="font-medium" fill="#374151">
                    {node.label}
                  </text>
                  {node.committedTo && <text y={LOGO_TEXT_OFFSET} textAnchor="middle" className="text-[9px] font-medium" fill="#374151">
                      {node.committedTo}
                    </text>}
                </g>;
  })}

            {model.messages.map(msg => {
    const fromNode = getNode(model, msg.from);
    const toNode = getNode(model, msg.to);
    if (!fromNode || !toNode) return null;
    const duration = msg.recvTime - msg.sendTime || 1;
    const progress = clamp((model.time - msg.sendTime) / duration, 0, 1);
    const isRequest = msg.transport === "DepRequest";
    const x = fromNode.pos.x + (toNode.pos.x - fromNode.pos.x) * progress;
    const y = fromNode.pos.y + (toNode.pos.y - fromNode.pos.y) * progress;
    const primary = msg.primary || msg.type;
    const color = isRequest ? MESSAGE_COLORS.DepRequest || "#475569" : MESSAGE_COLORS[primary] || "#0ea5e9";
    const label = isRequest ? "Req" : msg.actions && msg.actions.length > 1 ? `${MESSAGE_LABELS[primary] || primary}+${msg.actions.length - 1}` : MESSAGE_LABELS[primary] || primary;
    return <g key={msg.id} className="cursor-pointer" onClick={() => {
      setSelectedMessage(msg);
      setSelectedNodeId(null);
    }}>
                  <line x1={fromNode.pos.x} y1={fromNode.pos.y} x2={toNode.pos.x} y2={toNode.pos.y} stroke="#cbd5e1" strokeDasharray={isRequest ? "2 4" : "4 6"} />
                  <circle cx={x} cy={y} r={isRequest ? "7" : "6"} fill={color} stroke="#6b7280" strokeWidth="1.5" />
                  <text x={x} y={y - 10} textAnchor="middle" className="text-[9px] font-medium" fill="#4b5563">
                    {label}
                  </text>
                </g>;
  })}
          </svg>
        </div>

        <div className="rounded-xl border border-slate-100 bg-white p-3 shadow-inner">
          <div className="flex flex-col gap-2 text-sm">
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">Round</span>
              <span className="text-slate-700">#{model.round}</span>
            </div>
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">Attempt</span>
              <span className="text-slate-700">
                {model.attempt} ({model.isSlow ? "slow" : "fast"})
              </span>
            </div>
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">Proposer</span>
              <span className="text-slate-700">
                {activeCandidate ? `S${activeCandidate.proposerIndex + 1}` : "—"}
              </span>
            </div>
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">Coordinator</span>
              <span className="text-slate-700">
                {model.isSlow ? pickCoordinator(model, model.attempt).label : "N/A (fast)"}
              </span>
            </div>
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">
                VoteFor target
              </span>
              <span className="text-slate-700">
                {model.voteForTarget || "—"}
              </span>
            </div>
            <div className="flex items-center justify-between">
              <span className="font-semibold text-slate-800">Committed</span>
              <span className="text-slate-700">
                {model.committedCandidate || "—"}
              </span>
            </div>
          </div>

          <div className="mt-3 rounded-lg border border-slate-100 bg-slate-50 p-2 flex flex-col overflow-hidden h-40">
            <p className="text-xs font-semibold text-slate-700 mb-1">
              Candidates
            </p>
            <div className="flex-1 min-h-0 overflow-y-scroll pr-1 catchain-scroll" style={{
    scrollbarGutter: "stable"
  }}>
              <div className="flex flex-col gap-2">
                {candidates.slice(0, 4).map(cand => <div key={cand.id} className="rounded-md bg-white border border-slate-200 p-2 cursor-pointer hover:border-slate-300" onClick={() => {
    setSelectedCandidateId(cand.id);
    setSelectedMessage(null);
    setSelectedNodeId(null);
  }}>
                    <div className="flex items-center justify-between text-sm font-semibold text-slate-800">
                      <span>{cand.id}</span>
                      <span className="text-xs text-slate-600">
                        #{cand.short}
                      </span>
                    </div>
                    <div className="mt-1 grid grid-cols-4 gap-2 text-[11px] text-slate-700">
                      <div>
                        <span className="font-semibold text-green-600">
                          {cand.approvals.size}
                        </span>{" "}
                        Approve
                      </div>
                      <div>
                        <span className="font-semibold text-cyan-600">
                          {cand.votes.size}
                        </span>{" "}
                        Vote
                      </div>
                      <div>
                        <span className="font-semibold text-amber-600">
                          {cand.precommits.size}
                        </span>{" "}
                        PreCommit
                      </div>
                      <div>
                        <span className="font-semibold text-blue-600">
                          {cand.commits.size}
                        </span>{" "}
                        Commit
                      </div>
                    </div>
                  </div>)}
              </div>
            </div>
          </div>

          <div className="mt-3">
            <p className="text-xs font-semibold text-slate-700 mb-1">
              Committed chain
            </p>
            <div className="overflow-x-auto committed-scroll border border-slate-200 rounded-xl bg-white shadow-inner" style={{
    scrollbarGutter: "stable both-edges"
  }}>
              <div className="flex items-center gap-3 py-3 px-3 min-h-[120px]">
                {model.committedHistory.length === 0 ? <p className="text-[11px] text-slate-500 px-2 py-1">
                    No committed candidates yet.
                  </p> : model.committedHistory.map((entry, idx) => {
    const proposer = entry.proposerId ? model.nodes.find(n => n.id === entry.proposerId) : null;
    const isLast = idx === (model.committedHistory || []).length - 1;
    return <div key={`${entry.id}-${idx}`} className="flex items-center gap-3">
                        <div className="min-w-[150px] rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 shadow-sm shadow-slate-200">
                          <div className="text-xs font-semibold text-slate-700">
                            #{idx + 1} · {entry.short}
                          </div>
                          <div className="text-[11px] text-slate-600">
                            Round {entry.round}, attempt {entry.attempt}
                          </div>
                          <div className="text-[11px] text-slate-600">
                            Proposer{" "}
                            {proposer ? proposer.label : entry.proposerId}
                          </div>
                          <div className="text-[11px] text-slate-500">
                            t+{Math.round(entry.committedAt)}ms
                          </div>
                        </div>
                        {!isLast && <span className="text-slate-400 text-lg select-none">
                            →
                          </span>}
                      </div>;
  })}
              </div>
            </div>
          </div>
        </div>
      </div>

      <div className="mt-4 flex flex-col gap-3">
        <div>
          <div className="flex items-center justify-between text-sm font-semibold text-slate-800">
            <span>Attempt timer</span>
            <span className="text-slate-700">
              {attemptRemaining > 0 ? `${(attemptRemaining / 1000).toFixed(1)}s left` : "next attempt soon"}
            </span>
          </div>
          <div className="mt-2 h-2 rounded-full bg-slate-200 overflow-hidden">
            <div className="h-full bg-sky-500 transition-[width]" style={{
    width: `${Math.min(100, attemptProgress * 100)}%`
  }} />
          </div>
        </div>

        <div className="flex items-center gap-3 relative z-50" style={{
    pointerEvents: "auto"
  }}>
          <span className="text-sm font-semibold text-slate-800">Speed</span>
          <input type="range" min="0.00005" max="0.25" step="0.0005" value={speed} onChange={e => setSpeed(parseFloat(e.target.value))} className="flex-1" style={{
    position: "relative",
    zIndex: 60,
    pointerEvents: "auto"
  }} />
          <span className="text-sm text-slate-700">
            {(speed * 4).toFixed(2)}x
          </span>
        </div>
      </div>
      <div className="mt-4 rounded-xl border border-slate-100 bg-slate-50 p-3">
        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:flex-wrap">
          <div className="flex items-center gap-2 text-sm font-semibold text-slate-800">
            <span>Event types</span>
          </div>
          <div className="flex flex-wrap gap-2 text-[12px] text-slate-700">
            {Object.entries(MESSAGE_COLORS).map(([key, color]) => <button key={key} type="button" className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-sm hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-sky-200" onMouseEnter={e => showEventTooltip(e, key)} onMouseLeave={hideEventTooltip} onFocus={e => showEventTooltip(e, key)} onBlur={hideEventTooltip}>
                <span className="inline-flex items-center justify-center h-5 w-5 rounded-full text-[11px] font-semibold text-white" style={{
    background: color
  }}>
                  i
                </span>
                {MESSAGE_LABELS[key] || key}
              </button>)}
          </div>
        </div>
      </div>
      {hoveredEventType && <div className="pointer-events-none fixed z-40" style={{
    left: eventTooltipPos.x,
    top: eventTooltipPos.y,
    transform: "translateX(-50%)"
  }}>
          <div className="min-w-[190px] max-w-[260px] rounded-md bg-slate-900 px-3 py-2 text-white text-[12px] shadow-lg ring-1 ring-slate-800/70">
            <div className="flex items-center gap-2 mb-1">
              <span className="inline-flex h-3.5 w-3.5 rounded-full" style={{
    background: MESSAGE_COLORS[hoveredEventType]
  }} />
              <span className="font-semibold">
                {MESSAGE_LABELS[hoveredEventType] || hoveredEventType}
              </span>
            </div>
            <div className="whitespace-pre-line leading-snug text-slate-100">
              {MESSAGE_DESCRIPTIONS[hoveredEventType]}
            </div>
          </div>
        </div>}
      {configModalOpen && <div className="fixed inset-0 bg-slate-900/40 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl shadow-xl border border-slate-200 w-[440px] max-w-full p-5 space-y-4">
            <div className="flex items-start justify-between">
              <div className="space-y-1">
                <p className="text-base font-semibold text-slate-800 leading-snug">
                  Simulation config
                </p>
                <br />
                <p className="text-sm text-slate-600 leading-snug">
                  Update timing parameters; applying will restart the emulation.
                </p>
              </div>
              <button className="text-slate-500 hover:text-slate-800" onClick={() => setConfigModalOpen(false)} type="button">
                ✕
              </button>
            </div>
            <form className="space-y-3" onSubmit={submitConfig}>
              {CONFIG_FIELDS.map(field => <label key={field.key} className="block text-sm text-slate-700">
                  <div className="flex items-center justify-between">
                    <span className="font-semibold text-slate-800">
                      {field.label}
                    </span>
                    <input type="number" name={field.key} value={configDraft[field.key]} onChange={e => setConfigDraft(prev => ({
    ...prev,
    [field.key]: e.target.value
  }))} className="ml-3 w-36 rounded-md border border-slate-200 bg-white text-slate-900 px-3 py-2 text-sm focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200" />
                  </div>
                  <p className="mt-1 text-xs text-slate-600">
                    {field.description}
                  </p>
                </label>)}
              <div className="flex items-center justify-end gap-2 pt-2">
                <button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium shadow-sm hover:bg-slate-50" onClick={() => setConfigModalOpen(false)}>
                  Cancel
                </button>
                <button type="submit" className="inline-flex items-center gap-2 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-medium text-sky-800 shadow-sm hover:bg-sky-100">
                  Apply config
                </button>
              </div>
            </form>
          </div>
        </div>}
      {eventLogOpen && <div className="fixed inset-0 bg-slate-900/40 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl shadow-xl border border-slate-200 w-[460px] max-w-full p-5 space-y-4">
            <div className="flex items-start justify-between">
              <div className="space-y-1">
                <p className="text-base font-semibold text-slate-800 leading-snug">
                  Event log
                </p>
                <br />
                <p className="text-sm text-slate-600 leading-snug">
                  Latest simulation events (newest first).
                </p>
              </div>
              <button className="text-slate-500 hover:text-slate-800" onClick={() => setEventLogOpen(false)} type="button">
                ✕
              </button>
            </div>
            <div className="h-64 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3 text-[12px] text-slate-800">
              {model.log.length === 0 ? <p className="text-slate-500">Simulation warming up…</p> : <div className="flex flex-col gap-1">
                  {model.log.map((item, idx) => <div key={`${item.t}-${idx}`} className="leading-tight py-[1px] border-b border-slate-200 last:border-b-0">
                      <span className="text-slate-500 mr-1">
                        t+{Math.round(item.t)}ms
                      </span>
                      {item.text}
                    </div>)}
                </div>}
            </div>
            <div className="flex justify-end">
              <button className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium shadow-sm hover:bg-slate-50" onClick={() => setEventLogOpen(false)}>
                Close
              </button>
            </div>
          </div>
        </div>}
      {selectedNodeId && <div className="fixed inset-0 bg-slate-900/40 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl shadow-xl border border-slate-200 w-[380px] max-w-full p-5 space-y-4">
            {(() => {
    const node = model.nodes.find(n => n.id === selectedNodeId);
    if (!node) return null;
    const setStatus = status => {
      node.status = status;
      if (status === "crashed") {
        node.pendingActions = [];
        node.flushScheduled = false;
      }
      setTick(t => t + 1);
    };
    return <>
                  <div className="flex items-start justify-between mb-4">
                    <div>
                      <p className="text-base font-semibold text-slate-800">
                        {node.label}
                        <span className="ml-2 text-sm font-normal">
                          Status:{" "}
                          <span className={node.status === "crashed" ? "text-red-600" : node.status === "lagging" ? "text-amber-600" : "text-emerald-600"}>
                            {node.status}
                          </span>
                        </span>
                      </p>
                    </div>
                    <button className="text-slate-500 hover:text-slate-800" onClick={() => setSelectedNodeId(null)}>
                      ✕
                    </button>
                  </div>
                  <dl className="text-sm text-slate-700 grid grid-cols-2 gap-x-6 gap-y-2 mb-4">
                    <dt className="font-semibold text-slate-800">Committed</dt>
                    <dd>{node.committedTo || "—"}</dd>
                    <dt className="font-semibold text-slate-800">Locked</dt>
                    <dd>{node.lockedCandidate || "—"}</dd>
                    <dt className="font-semibold text-slate-800">
                      Vote target
                    </dt>
                    <dd>{node.voteTarget || "—"}</dd>
                    <dt className="font-semibold text-slate-800">Approvals</dt>
                    <dd>{node.approved.size}</dd>
                    <dt className="font-semibold text-slate-800">Votes</dt>
                    <dd>{node.voted.size}</dd>
                    <dt className="font-semibold text-slate-800">Precommits</dt>
                    <dd>{node.precommitted.size}</dd>
                    <dt className="font-semibold text-slate-800">
                      Pending deps
                    </dt>
                    <dd>{node.pendingCatchain.size}</dd>
                  </dl>
                  <div className="flex flex-col gap-2">
                    <button className="rounded-lg border px-3 py-2 text-sm font-medium shadow-sm bg-emerald-50 border-emerald-200 text-emerald-800 hover:bg-emerald-100" onClick={() => setStatus("good")}>
                      Make good
                    </button>
                    <button className="rounded-lg border px-3 py-2 text-sm font-medium shadow-sm bg-red-50 border-red-200 text-red-800 hover:bg-red-100" onClick={() => setStatus("crashed")}>
                      Crash
                    </button>
                    <button className="rounded-lg border px-3 py-2 text-sm font-medium shadow-sm bg-amber-50 border-amber-200 text-amber-800 hover:bg-amber-100" onClick={() => setStatus("lagging")}>
                      Lagging (50% drop)
                    </button>
                  </div>
                </>;
  })()}
          </div>
        </div>}
      {selectedMessage && <div className="fixed inset-0 bg-slate-900/40 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl shadow-xl border border-slate-200 w-[380px] max-w-full p-5 space-y-4">
            {(() => {
    const fromNode = getNode(model, selectedMessage.from);
    const toNode = getNode(model, selectedMessage.to);
    const envelope = selectedMessage.envelope;
    const actions = selectedMessage.actions || [];
    return <>
                  <div className="flex items-start justify-between mb-3">
                    <div>
                      <p className="text-base font-semibold text-slate-800">
                        Message
                        <span className="ml-2 text-sm font-normal text-slate-600 align-middle">
                          {selectedMessage.id}
                        </span>
                      </p>
                      <br />
                      <p className="text-sm text-slate-700 font-semibold">
                        Type:{" "}
                        <span className="font-normal">
                          {selectedMessage.primary || selectedMessage.transport}
                        </span>
                      </p>
                    </div>
                    <button className="text-slate-500 hover:text-slate-800" onClick={() => setSelectedMessage(null)}>
                      ✕
                    </button>
                  </div>
                  <dl className="text-sm text-slate-700">
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800">From</dt>
                      <dd className="text-right">
                        {fromNode ? fromNode.label : selectedMessage.from}
                      </dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800">To</dt>
                      <dd className="text-right">
                        {toNode ? toNode.label : selectedMessage.to}
                      </dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800">
                        Send → Receive
                      </dt>
                      <dd className="text-right">
                        {Math.round(selectedMessage.sendTime)} →{" "}
                        {Math.round(selectedMessage.recvTime)} ms
                      </dd>
                    </div>
                  </dl>
                  {selectedMessage.transport === "Catchain" && envelope && <div className="text-sm text-slate-800">
                      <p className="font-semibold mb-1">Catchain info</p>
                      <div className="text-slate-700">
                        <span className="font-semibold">Message</span>:{" "}
                        {envelope.id} (h{envelope.height || 0})
                      </div>
                      <div className="text-slate-700">
                        <span className="font-semibold">Sender</span>:{" "}
                        {envelope.sender}
                      </div>
                      <div className="text-slate-700">
                        <span className="font-semibold">Prev</span>:{" "}
                        {envelope.prev || "None"}
                      </div>
                      <div className="text-slate-700">
                        <span className="font-semibold">Deps</span>:{" "}
                        {(envelope.deps || []).length === 0 ? "None" : (envelope.deps || []).join(", ")}
                      </div>
                    </div>}
                  {selectedMessage.transport === "DepRequest" && <div className="text-sm text-slate-800">
                      <p className="font-semibold mb-1">Requested deps</p>
                      <div className="text-slate-700">
                        {(selectedMessage.missingIds || []).length === 0 ? "None listed" : (selectedMessage.missingIds || []).join(", ")}
                      </div>
                    </div>}
                  <div className="text-sm text-slate-800">
                    <p className="font-semibold mb-1">Actions</p>
                    {actions.length === 0 ? <p className="text-slate-600">—</p> : <ul className="list-disc pl-4 space-y-1">
                        {actions.map((act, idx) => <li key={`${act.type}-${idx}`} className="text-slate-700">
                            {act.type}{" "}
                            {act.candidateId ? `→ ${act.candidateId}` : ""}
                          </li>)}
                      </ul>}
                  </div>
                  <div className="flex items-center justify-between pt-2">
                    <button className="rounded-lg border px-3 py-2 text-sm font-medium shadow-sm bg-red-50 border-red-200 text-red-800 hover:bg-red-100" onClick={() => {
      model.messages = model.messages.filter(m => m !== selectedMessage);
      setSelectedMessage(null);
      setTick(t => t + 1);
    }}>
                      Drop message
                    </button>
                    <button className="text-slate-500 hover:text-slate-800 text-sm" onClick={() => setSelectedMessage(null)}>
                      Close
                    </button>
                  </div>
                </>;
  })()}
          </div>
        </div>}
      {selectedCandidateId && <div className="fixed inset-0 bg-slate-900/40 flex items-center justify-center z-50">
          <div className="bg-white rounded-xl shadow-xl border border-slate-200 w-[340px] max-w-full p-4 space-y-3 max-h-[90vh] overflow-auto">
            {(() => {
    const cand = model.candidates[selectedCandidateId];
    if (!cand) return null;
    const proposer = cand.proposerId ? model.nodes.find(n => n.id === cand.proposerId) : null;
    return <>
                  <div className="flex items-start justify-between mb-2">
                    <div>
                      <p className="text-base font-semibold text-slate-800">
                        Candidate{" "}
                        <span className="font-normal text-slate-600">
                          {cand.id}
                        </span>
                      </p>
                      <p className="text-sm text-slate-700">
                        Round {cand.round}, attempt {cand.attempt}
                      </p>
                    </div>
                    <button className="text-slate-500 hover:text-slate-800" onClick={() => setSelectedCandidateId(null)}>
                      ✕
                    </button>
                  </div>
                  <dl className="text-sm text-slate-700">
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Proposer
                      </dt>
                      <dd className="text-right">
                        {proposer ? proposer.label : cand.proposerId}
                      </dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Priority
                      </dt>
                      <dd className="text-right">{cand.priority}</dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Approvals
                      </dt>
                      <dd className="text-right">{cand.approvals.size}</dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Votes
                      </dt>
                      <dd className="text-right">{cand.votes.size}</dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Precommits
                      </dt>
                      <dd className="text-right">{cand.precommits.size}</dd>
                    </div>
                    <div className="flex items-center justify-between py-1">
                      <dt className="font-semibold text-slate-800 pr-4">
                        Commits
                      </dt>
                      <dd className="text-right">{cand.commits.size}</dd>
                    </div>
                  </dl>
                  <div className="text-sm text-slate-800">
                    <p className="font-semibold mb-1">
                      Per-node approvals seen
                    </p>
                    <div className="grid grid-cols-2 gap-2 max-h-32 overflow-auto border border-slate-100 rounded p-2 bg-slate-50">
                      {model.nodes.map(n => <div key={n.id} className="flex items-center justify-between text-xs">
                          <span className="text-slate-700">{n.label}</span>
                          <span className="text-slate-900">
                            {n.receivedEvents[cand.id]?.approved || 0}
                          </span>
                        </div>)}
                    </div>
                  </div>
                  <div className="text-sm text-slate-800">
                    <p className="font-semibold mb-1">Per-node votes seen</p>
                    <div className="grid grid-cols-2 gap-2 max-h-32 overflow-auto border border-slate-100 rounded p-2 bg-slate-50">
                      {model.nodes.map(n => <div key={n.id} className="flex items-center justify-between text-xs">
                          <span className="text-slate-700">{n.label}</span>
                          <span className="text-slate-900">
                            {n.receivedEvents[cand.id]?.voted || 0}
                          </span>
                        </div>)}
                    </div>
                  </div>
                  <div className="text-sm text-slate-800">
                    <p className="font-semibold mb-1">
                      Per-node precommits seen
                    </p>
                    <div className="grid grid-cols-2 gap-2 max-h-32 overflow-auto border border-slate-100 rounded p-2 bg-slate-50">
                      {model.nodes.map(n => <div key={n.id} className="flex items-center justify-between text-xs">
                          <span className="text-slate-700">{n.label}</span>
                          <span className="text-slate-900">
                            {n.receivedEvents[cand.id]?.precommitted || 0}
                          </span>
                        </div>)}
                    </div>
                  </div>
                  <div className="text-sm text-slate-800">
                    <p className="font-semibold mb-1">Per-node commits seen</p>
                    <div className="grid grid-cols-2 gap-2 max-h-32 overflow-auto border border-slate-100 rounded p-2 bg-slate-50">
                      {model.nodes.map(n => <div key={n.id} className="flex items-center justify-between text-xs">
                          <span className="text-slate-700">{n.label}</span>
                          <span className="text-slate-900">
                            {n.receivedEvents[cand.id]?.commited || 0}
                          </span>
                        </div>)}
                    </div>
                  </div>
                </>;
  })()}
          </div>
        </div>}
    </div>;
};

export const Aside = ({type = "note", title = "", icon = "", iconType = "regular", children}) => {
  const asideVariants = ["note", "tip", "caution", "danger"];
  const asideComponents = {
    note: {
      outerStyle: "border-sky-500/20 bg-sky-50/50 dark:border-sky-500/30 dark:bg-sky-500/10",
      innerStyle: "text-sky-900 dark:text-sky-200",
      calloutType: "note",
      icon: <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-sky-500" aria-label="Note">
          <path fill-rule="evenodd" clip-rule="evenodd" d="M7 1.3C10.14 1.3 12.7 3.86 12.7 7C12.7 10.14 10.14 12.7 7 12.7C5.48908 12.6974 4.0408 12.096 2.97241 11.0276C1.90403 9.9592 1.30264 8.51092 1.3 7C1.3 3.86 3.86 1.3 7 1.3ZM7 0C3.14 0 0 3.14 0 7C0 10.86 3.14 14 7 14C10.86 14 14 10.86 14 7C14 3.14 10.86 0 7 0ZM8 3H6V8H8V3ZM8 9H6V11H8V9Z"></path>
        </svg>
    },
    tip: {
      outerStyle: "border-emerald-500/20 bg-emerald-50/50 dark:border-emerald-500/30 dark:bg-emerald-500/10",
      innerStyle: "text-emerald-900 dark:text-emerald-200",
      calloutType: "tip",
      icon: <svg width="11" height="14" viewBox="0 0 11 14" fill="currentColor" xmlns="http://www.w3.org/2000/svg" className="text-emerald-600 dark:text-emerald-400/80 w-3.5 h-auto" aria-label="Tip">
          <path d="M3.12794 12.4232C3.12794 12.5954 3.1776 12.7634 3.27244 12.907L3.74114 13.6095C3.88471 13.8248 4.21067 14 4.46964 14H6.15606C6.41415 14 6.74017 13.825 6.88373 13.6095L7.3508 12.9073C7.43114 12.7859 7.49705 12.569 7.49705 12.4232L7.50055 11.3513H3.12521L3.12794 12.4232ZM5.31288 0C2.52414 0.00875889 0.5 2.26889 0.5 4.78826C0.5 6.00188 0.949566 7.10829 1.69119 7.95492C2.14321 8.47011 2.84901 9.54727 3.11919 10.4557C3.12005 10.4625 3.12175 10.4698 3.12261 10.4771H7.50342C7.50427 10.4698 7.50598 10.463 7.50684 10.4557C7.77688 9.54727 8.48281 8.47011 8.93484 7.95492C9.67728 7.13181 10.1258 6.02703 10.1258 4.78826C10.1258 2.15486 7.9709 0.000106649 5.31288 0ZM7.94902 7.11267C7.52078 7.60079 6.99082 8.37878 6.6077 9.18794H4.02051C3.63739 8.37878 3.10743 7.60079 2.67947 7.11294C2.11997 6.47551 1.8126 5.63599 1.8126 4.78826C1.8126 3.09829 3.12794 1.31944 5.28827 1.3126C7.2435 1.3126 8.81315 2.88226 8.81315 4.78826C8.81315 5.63599 8.50688 6.47551 7.94902 7.11267ZM4.87534 2.18767C3.66939 2.18767 2.68767 3.16939 2.68767 4.37534C2.68767 4.61719 2.88336 4.81288 3.12521 4.81288C3.36705 4.81288 3.56274 4.61599 3.56274 4.37534C3.56274 3.6515 4.1515 3.06274 4.87534 3.06274C5.11719 3.06274 5.31288 2.86727 5.31288 2.62548C5.31288 2.38369 5.11599 2.18767 4.87534 2.18767Z"></path>
        </svg>
    },
    caution: {
      outerStyle: "border-amber-500/20 bg-amber-50/50 dark:border-amber-500/30 dark:bg-amber-500/10",
      innerStyle: "text-amber-900 dark:text-amber-200",
      calloutType: "warning",
      icon: <svg className="flex-none w-5 h-5 text-amber-400 dark:text-amber-300/80" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Warning">
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
        </svg>
    },
    danger: {
      outerStyle: "border-red-500/20 bg-red-50/50 dark:border-red-500/30 dark:bg-red-500/10",
      innerStyle: "text-red-900 dark:text-red-200",
      calloutType: "danger",
      icon: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor" className="text-red-600 dark:text-red-400/80 w-4 h-4" aria-label="Danger">
          <path d="M17.1 292c-12.9-22.3-12.9-49.7 0-72L105.4 67.1c12.9-22.3 36.6-36 62.4-36l176.6 0c25.7 0 49.5 13.7 62.4 36L494.9 220c12.9 22.3 12.9 49.7 0 72L406.6 444.9c-12.9 22.3-36.6 36-62.4 36l-176.6 0c-25.7 0-49.5-13.7-62.4-36L17.1 292zm41.6-48c-4.3 7.4-4.3 16.6 0 24l88.3 152.9c4.3 7.4 12.2 12 20.8 12l176.6 0c8.6 0 16.5-4.6 20.8-12L453.4 268c4.3-7.4 4.3-16.6 0-24L365.1 91.1c-4.3-7.4-12.2-12-20.8-12l-176.6 0c-8.6 0-16.5 4.6-20.8 12L58.6 244zM256 128c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"></path>
        </svg>
    }
  };
  let variant = type;
  let gotInvalidVariant = false;
  if (!asideVariants.includes(type)) {
    gotInvalidVariant = true;
    variant = "danger";
  }
  const iconVariants = ["regular", "solid", "light", "thin", "sharp-solid", "duotone", "brands"];
  if (!iconVariants.includes(iconType)) {
    iconType = "regular";
  }
  return <>
      <div className={`callout my-4 px-5 py-4 overflow-hidden rounded-2xl flex gap-3 border ${asideComponents[variant].outerStyle}`} data-callout-type={asideComponents[variant].calloutType}>
        <div className="mt-0.5 w-4" data-component-part="callout-icon">
          {}
          {icon === "" ? asideComponents[variant].icon : <Icon icon={icon} iconType={iconType} size={14} />}
        </div>
        <div className={`text-sm prose min-w-0 w-full ${asideComponents[variant].innerStyle}`} data-component-part="callout-content">
          {gotInvalidVariant ? <p>
              <span className="font-bold">
                Invalid <code>type</code> passed!
              </span>
              <br />
              <span className="font-bold">Received: </span>
              {type}
              <br />
              <span className="font-bold">Expected one of: </span>
              {asideVariants.join(", ")}
            </p> : <>
              {title && <p className="font-bold">{title}</p>}
              {children}
            </>}
        </div>
      </div>
    </>;
};

To understand this simulation, read [Catchain whitepaper](/foundations/whitepapers/catchain) first.

<CatchainVisualizer />

## Interact with the simulation

* Click nodes to view status (locks/commits) and toggle behavior (good, crash, lagging).
* Click **Adjust simulation config** at the top to change consensus simulation parameters.
* Hover over the **Event types** at the bottom to read short description.
* Click moving messages to inspect sender/receiver, timings, and embedded actions; drop a message from the overlay with **Drop message** button.
* Click **Candidates** in the sidebar to see proposer, priority, and per-node approvals/votes/pre-commits/commits observed.
* Use the speed slider to slow down/accelerate the sim; use **Restart round** to begin a new round.

## Simulation notes

<Aside>
  This is a teaching simulation, not the exact C++ `validator-session` implementation of the whitepaper. It contains certain simplifications.
</Aside>

* Timing is approximate; actions are driven by the sim loop and simplified delays, not the precise `check_all/process_blocks` cadence.
* Catchain dependency handling is simplified: if a node sees a message but lacks one of its parents, it asks peers for that missing block (no vector-time sync).
* Quorums use equal weights (counts), not stake weights.
* Submits are auto-scheduled per priority window; retries and network sync differ from the real catchain/validator logic.
* Null candidate is assumed after its delay; VoteFor/slow-mode handling is simplified.
