diff --git a/docs/voidbot-swarm-web-interface.md b/docs/voidbot-swarm-web-interface.md
index 9cefa6a..8e09611 100644
--- a/docs/voidbot-swarm-web-interface.md
+++ b/docs/voidbot-swarm-web-interface.md
@@ -37,7 +37,7 @@ Open the LAN URL printed by the command on the iPad. The server refreshes the sn
The CTB strip is intentionally compact: avatar icons show upcoming Face turns in scheduler order. It occupies the long side of the viewport: top edge in landscape, left edge in portrait. A direct mention should appear as a shuffle in the mirrored snapshot once the heartbeat state records the pending obligation or queued turn.
-The graph surface is the interface, not decoration. Face nodes represent scheduler participants. UI facts and controls are graph nodes too: the cadence node contains the slider, the manual-turn node contains the selector/button, status/count nodes show their live values, and the selected-Face node carries the currently focused participant facts. The SPA starts from a deterministic force-style layout, then runs a DOM-aware settle pass so spring-attached UI nodes stay near the viewport center while avoiding rendered graph content.
+The main viewport is a direct cockpit: summary, controls, participant list, selected-Face detail, and recent mesh events. The screen should be readable first and clever never.
For a one-shot render without serving:
diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs
index 56d63c5..b732050 100644
--- a/scripts/render-voidbot-swarm-dashboard.mjs
+++ b/scripts/render-voidbot-swarm-dashboard.mjs
@@ -426,11 +426,10 @@ function renderHtml(snapshot) {
:root {
color-scheme: dark;
--bg: #03070d;
- --field: #07110f;
- --panel: rgba(5, 13, 17, 0.78);
- --panel-strong: rgba(8, 19, 24, 0.92);
+ --panel: rgba(6, 15, 19, 0.9);
+ --panel-soft: rgba(142, 223, 176, 0.07);
--line: rgba(142, 223, 176, 0.18);
- --line-strong: rgba(142, 223, 176, 0.38);
+ --line-strong: rgba(142, 223, 176, 0.4);
--text: #effcf8;
--muted: rgba(239, 252, 248, 0.66);
--green: #8edfb0;
@@ -447,17 +446,18 @@ function renderHtml(snapshot) {
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
background:
- radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.16), transparent 24%),
- radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.16), transparent 28%),
+ radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.14), transparent 24%),
+ radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.14), transparent 28%),
linear-gradient(180deg, #03070d, #07110f 52%, #091712);
color: var(--text);
font: 15px/1.45 var(--font);
letter-spacing: 0;
}
+ h1, h2, h3, p { margin: 0; }
button, select, input { font: inherit; }
button, select {
- min-height: 38px;
+ min-height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(142, 223, 176, 0.09);
@@ -468,7 +468,6 @@ function renderHtml(snapshot) {
button:hover, select:hover { border-color: var(--amber); }
select { min-width: 0; padding: 0 10px; }
input[type="range"] { width: 100%; accent-color: var(--cyan); }
- h1, h2, h3, p { margin: 0; }
.shell {
position: fixed;
@@ -484,9 +483,8 @@ function renderHtml(snapshot) {
gap: 8px;
padding: 10px;
border-color: var(--line);
- background: rgba(3, 7, 13, 0.84);
+ background: rgba(3, 7, 13, 0.86);
backdrop-filter: blur(18px);
- z-index: 4;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: rgba(142, 223, 176, 0.58) rgba(3, 7, 13, 0.32);
@@ -503,12 +501,11 @@ function renderHtml(snapshot) {
padding: 7px 6px;
border: 1px solid var(--line);
border-radius: 8px;
- background: rgba(8, 19, 24, 0.88);
+ background: rgba(8, 19, 24, 0.9);
color: var(--text);
text-align: center;
- box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
}
- .turn-card.active-turn { border-color: var(--cyan); box-shadow: 0 0 24px rgba(105, 226, 239, 0.24); }
+ .turn-card.active-turn { border-color: var(--cyan); box-shadow: 0 0 24px rgba(105, 226, 239, 0.22); }
.turn-card.mention-turn { border-color: var(--amber); }
.turn-card strong, .turn-card span {
width: 100%;
@@ -535,95 +532,84 @@ function renderHtml(snapshot) {
}
.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
- .graph-stage {
- position: relative;
+ .workspace {
min-width: 0;
min-height: 0;
- overflow: hidden;
- }
-
- .graph-svg {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- }
- .edge { stroke: rgba(142, 223, 176, 0.28); stroke-width: 1.5; }
- .edge.hot { stroke: rgba(255, 174, 88, 0.52); stroke-width: 2.4; }
- .hub-core { fill: rgba(142, 223, 176, 0.16); stroke: rgba(142, 223, 176, 0.56); stroke-width: 2; }
-
- .graph-node-layer {
- position: absolute;
- inset: 0;
- z-index: 2;
- }
-
- .graph-node {
- position: absolute;
- display: grid;
display: grid;
gap: 12px;
- min-width: 0;
- width: var(--node-width, 190px);
- min-height: var(--node-height, 96px);
padding: 12px;
+ overflow: hidden;
+ grid-template-columns: minmax(280px, 0.88fr) minmax(360px, 1.4fr) minmax(280px, 0.92fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-areas:
+ "header header controls"
+ "queue detail events";
+ }
+
+ .pane {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ gap: 12px;
+ padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
- box-shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
- backdrop-filter: blur(18px);
- opacity: 0.9;
- transform: translate(-50%, -50%);
- transition: opacity 0.16s ease, transform 0.16s ease, border-color 0.16s ease;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
+ overflow: hidden;
}
- .graph-node.spring-node { z-index: 3; }
- .graph-node.graph-content-node { z-index: 2; }
- .graph-node:hover, .graph-node:focus-within, .graph-node.selected {
- opacity: 1;
- transform: translate(-50%, calc(-50% - 2px));
- border-color: var(--line-strong);
- }
- .graph-node.hot { border-color: rgba(255, 174, 88, 0.58); }
- .graph-node.face-node { --node-width: 110px; --node-height: 100px; justify-items: center; text-align: center; }
- .graph-node.active-focus { box-shadow: 0 0 38px rgba(105, 226, 239, 0.28), 0 24px 80px rgba(0, 0, 0, 0.34); }
- .graph-node.system-node { --node-width: 210px; --node-height: 112px; border-color: rgba(142, 223, 176, 0.46); background: rgba(5, 13, 17, 0.86); }
- .graph-node.control-node { --node-width: 220px; }
- .graph-node.detail-node { --node-width: 238px; }
- .graph-node.event-node { --node-width: 238px; }
- .graph-node.metric-node { --node-width: 130px; --node-height: 78px; align-content: space-between; }
- .graph-node.metric-node strong { font-size: 1.25rem; line-height: 1; }
- .node-title { display: grid; min-width: 0; gap: 2px; }
- .node-title h2, .node-title h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .face-node .avatar { width: 44px; height: 44px; }
- .face-node strong, .face-node span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .face-node strong { font-size: 0.92rem; line-height: 1.05; }
- .face-node span { color: var(--muted); font: 0.72rem/1.1 var(--mono); }
+ .pane-scroll { overflow: auto; }
+ .header-pane { grid-area: header; align-content: center; }
+ .controls-pane { grid-area: controls; align-content: center; }
+ .queue-pane { grid-area: queue; }
+ .detail-pane { grid-area: detail; }
+ .events-pane { grid-area: events; }
- .system-node h1 { font-size: clamp(1.3rem, 3vw, 2rem); line-height: 0.95; }
- .system-node p.muted { max-height: 2.8em; overflow: hidden; }
.kicker { color: var(--green); font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
+ h1 { font-size: clamp(1.8rem, 5vw, 4rem); line-height: 0.95; }
+ h2 { font-size: 1rem; text-transform: uppercase; }
.muted { color: var(--muted); }
.mono { font-family: var(--mono); }
+ .metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
+ .metric, .fact {
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid rgba(142, 223, 176, 0.12);
+ border-radius: 8px;
+ background: var(--panel-soft);
+ }
+ .metric span, .fact span { display: block; color: var(--muted); font-size: 0.68rem; font-weight: 800; text-transform: uppercase; }
+ .metric strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 1.12rem; }
+ .fact strong { display: block; overflow-wrap: anywhere; font: 0.9rem/1.15 var(--mono); }
+
.control-grid { display: grid; gap: 10px; }
.control-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.force-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
- .details-head { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 10px; align-items: center; }
- .details-head h2 { overflow-wrap: anywhere; font-size: 1.22rem; }
- .facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
- .fact {
- min-width: 0;
- padding: 6px;
- border: 1px solid rgba(142, 223, 176, 0.12);
+ .queue-list, .event-list { display: grid; gap: 8px; overflow: auto; }
+ .agent-row {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) auto;
+ gap: 9px;
+ align-items: center;
+ width: 100%;
+ min-height: 58px;
+ padding: 8px;
+ border: 1px solid var(--line);
border-radius: 8px;
background: rgba(142, 223, 176, 0.06);
+ color: var(--text);
+ text-align: left;
}
- .fact span { display: block; color: var(--muted); font-size: 0.68rem; font-weight: 800; text-transform: uppercase; }
- .fact strong { display: block; overflow-wrap: anywhere; font: 0.82rem/1.1 var(--mono); }
- .event-list { display: grid; gap: 6px; }
- .event-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; color: var(--muted); font-size: 0.82rem; }
+ .agent-row.selected { border-color: var(--amber); }
+ .agent-row strong, .agent-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .agent-row span { color: var(--muted); font: 0.72rem/1.1 var(--mono); }
+
+ .details-head { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 12px; align-items: center; }
+ .details-head .avatar { width: 58px; height: 58px; }
+ .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+ .event-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 8px; border: 1px solid rgba(142, 223, 176, 0.1); border-radius: 8px; background: rgba(142, 223, 176, 0.05); color: var(--muted); font-size: 0.82rem; }
.badge {
display: inline-flex;
@@ -652,7 +638,7 @@ function renderHtml(snapshot) {
align-items: center;
border-bottom: 1px solid var(--line);
}
- .graph-stage { grid-row: 2; grid-column: 1; }
+ .workspace { grid-row: 2; grid-column: 1; }
.turn-card { width: 76px; height: 96px; }
}
@@ -666,17 +652,28 @@ function renderHtml(snapshot) {
align-items: center;
border-right: 1px solid var(--line);
}
- .graph-stage { grid-row: 1; grid-column: 2; }
+ .workspace {
+ grid-row: 1;
+ grid-column: 2;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 0.88fr);
+ grid-template-areas:
+ "header"
+ "controls"
+ "detail"
+ "queue";
+ }
+ .events-pane { display: none; }
+ .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.turn-card { width: 72px; height: 96px; }
- .graph-node { --node-width: 168px; }
- .graph-node.face-node { --node-width: 112px; --node-height: 108px; padding: 9px; }
- .graph-node.system-node { --node-width: 230px; }
- .graph-node.control-node, .graph-node.detail-node, .graph-node.event-node { --node-width: 220px; }
}
@media (max-width: 760px) {
- .system-node p { display: none; }
- .facts { grid-template-columns: 1fr 1fr; }
+ .workspace { padding: 10px; gap: 10px; }
+ .pane { padding: 12px; }
+ .header-pane .muted { display: none; }
+ .controls-pane { align-content: start; }
}
@@ -717,14 +714,17 @@ function renderHtml(snapshot) {
app.innerHTML = [
"
",
renderCtb(snapshot.upcomingTurns || []),
- "",
- renderGraph(snapshot, participants, selected),
+ "",
+ renderHeader(snapshot),
+ renderControls(snapshot, participants),
+ renderQueue(participants),
+ renderDetails(selected),
+ renderEvents(snapshot),
" ",
"
",
].join("");
attachControls();
- attachGraphSelection();
- window.requestAnimationFrame(settleGraphNodes);
+ attachSelection();
}
function sortedParticipants(snapshot) {
@@ -744,306 +744,56 @@ function renderHtml(snapshot) {
}).join("") + "";
}
- function renderGraph(snapshot, participants, selectedAgent) {
- const nodes = graphNodes(snapshot, participants, selectedAgent);
- const hub = nodes.find((node) => node.id === "system") || { x: 50, y: 50 };
- const edges = nodes.map((node) => {
- if (node.kind === "system") return "";
- return " ";
- }).join("");
- return "" +
- " " +
- edges +
- " " + nodes.map(renderGraphNode).join("") + "
";
- }
-
- function graphNodes(snapshot, participants, selectedAgent) {
+ function renderHeader(snapshot) {
const summary = snapshot.summary || {};
+ return "VoidBot Swarm Control
CTB Cockpit CultMesh mirror and scheduler command surface. State comes from the heartbeat snapshot.
" +
+ metric("State", badge(summary.state)) +
+ metric("Participants", number(summary.participantCount)) +
+ metric("Ready", number(summary.readyNowCount)) +
+ metric("Mentions", number(summary.pendingMentionCount)) +
+ metric("Shuffle", summary.lastShuffle ? summary.lastShuffle.kind.replace(/_/g, " ") : "none") +
+ "
";
+ }
+
+ function renderControls(snapshot, participants) {
const controls = snapshot.controls || {};
- const latestRequest = (controls.manualTurnRequests || [])[0];
- const activeAgent = participants.find((agent) => agent.activeJobId) || selectedAgent || participants[0] || null;
- const focus = focusPointForAgent(participants, activeAgent);
- const nodes = [
- {
- id: "system",
- kind: "system",
- spring: true,
- anchorX: 50,
- anchorY: 50,
- selected: false,
- hot: summary.state === "running" || summary.state === "paused",
- html: "VoidBot Swarm Control
CTB Mesh CultMesh mirror. The graph is made of interface facts; each node carries its own affordance.
" + badge(summary.state) + " " + esc(relative(snapshot.generatedAt)) + "
",
- },
- metricNode("participants", 34, 25, "Participants", number(summary.participantCount), "metric-node"),
- metricNode("pending", 66, 25, "Pending Mentions", number(summary.pendingMentionCount), summary.pendingMentionCount > 0 ? "metric-node hot" : "metric-node"),
- metricNode("ready", 34, 75, "Ready Now", number(summary.readyNowCount), summary.readyNowCount > 0 ? "metric-node hot" : "metric-node"),
- metricNode("shuffle", 66, 75, "Last Shuffle", summary.lastShuffle ? summary.lastShuffle.kind.replace(/_/g, " ") : "none", summary.lastShuffle ? "metric-node hot" : "metric-node"),
- {
- id: "cadence-control",
- kind: "control",
- spring: true,
- anchorX: 70,
- anchorY: 45,
- selected: false,
- hot: controls.cadenceMultiplier !== 1,
- html: "Cadence
x" + esc(number(controls.cadenceMultiplier || 1)) + "
Apply ",
- },
- {
- id: "force-control",
- kind: "control",
- spring: true,
- anchorX: 70,
- anchorY: 55,
- selected: false,
- hot: Boolean(latestRequest && latestRequest.status !== "consumed"),
- html: "Manual Next Turn
" + participants.map((agent) => "" + esc(agent.displayName) + " / " + esc(agent.repoName) + " ").join("") + " Pull
" + esc(latestRequest?.identityId || "none") + " " + esc(latestRequest?.status || "") + "
",
- },
- detailNode(selectedAgent),
- eventNode(snapshot),
- ];
- const count = Math.max(participants.length, 1);
- for (const [index, agent] of participants.entries()) {
- const angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
- const isFocus = agent.identityId === focus.identityId;
- const heat = Math.max(0, Math.min(1, (agent.heat || 0) / 4));
- const radiusX = isFocus ? 6 : 20 + heat * 3;
- const radiusY = isFocus ? 5 : 20 + heat * 2;
- nodes.push({
- id: "face-" + agent.identityId,
- kind: "face",
- graphContent: true,
- agent,
- focus: isFocus,
- selected: agent.identityId === selectedIdentity,
- hot: Boolean(agent.activeJobId || agent.pendingMentionCount > 0),
- x: Math.round((focus.x + Math.cos(angle) * radiusX) * 10) / 10,
- y: Math.round((focus.y + Math.sin(angle) * radiusY) * 10) / 10,
- width: 9,
- height: 17,
- html: faceNodeHtml(agent),
- });
- }
- return forceLayout(nodes, focus);
+ const latest = (controls.manualTurnRequests || [])[0];
+ return "Controls
" +
+ "
" +
+ "
Manual next turn
" + participants.map((agent) => "" + esc(agent.displayName) + " / " + esc(agent.repoName) + " ").join("") + " Pull
" +
+ "
Latest request: " + esc(latest?.identityId || "none") + " " + esc(latest?.status || "") + "
" +
+ "
";
}
- function metricNode(id, x, y, label, value, klass) {
- return {
- id,
- kind: klass || "metric-node",
- spring: true,
- anchorX: x,
- anchorY: y,
- selected: false,
- hot: String(klass || "").includes("hot"),
- html: "" + esc(label) + "
" + esc(value) + " ",
- };
+ function renderQueue(participants) {
+ return "Participants
" + participants.map((agent) => {
+ const klass = "agent-row" + (agent.identityId === selectedIdentity ? " selected" : "");
+ return "" + avatarHtml(agent) + "" + esc(agent.displayName) + " " + esc(agent.identityId) + " / " + esc(agent.repoName) + " " + badge(agent.activeJobId ? "running" : agent.status) + " ";
+ }).join("") + "
";
}
- function focusPointForAgent(participants, activeAgent) {
- const index = Math.max(0, participants.findIndex((agent) => agent.identityId === activeAgent?.identityId));
- const count = Math.max(participants.length, 1);
- const angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
- return {
- identityId: activeAgent?.identityId || null,
- x: Math.round((50 + Math.cos(angle) * 24) * 10) / 10,
- y: Math.round((50 + Math.sin(angle) * 20) * 10) / 10,
- };
+ function renderDetails(agent) {
+ if (!agent) return "Selected Face
No participant state.
";
+ return "" + avatarHtml(agent) + "
Selected Face
" + esc(agent.displayName) + " " + esc(agent.identityId) + " / " + esc(agent.repoName) + "
" + badge(agent.activeJobId ? "running" : agent.status) + " " + (agent.pendingMentionCount > 0 ? badge("warning", "mention") : "") + "
" +
+ fact("Next", minutes(agent.nextTurnInMinutes)) +
+ fact("Load", number(agent.currentLoad)) +
+ fact("Speed", number(agent.effectiveSpeed)) +
+ fact("Heat", number(agent.heat)) +
+ fact("Queued", number(agent.queuedCount)) +
+ fact("Mentions", number(agent.pendingMentionCount)) +
+ fact("Channels", number(agent.channelCount)) +
+ fact("Last queued", relative(agent.lastQueuedAt)) +
+ fact("Job", agent.activeJobId || "none") +
+ "
";
}
- function forceLayout(nodes, focus) {
- const laidOut = nodes.map((node) => ({
- ...node,
- x: node.x ?? node.anchorX ?? 50,
- y: node.y ?? node.anchorY ?? 50,
- vx: 0,
- vy: 0,
- width: node.width ?? widthForKind(node.kind),
- height: node.height ?? heightForKind(node.kind),
- }));
- for (let tick = 0; tick < 160; tick += 1) {
- for (const node of laidOut) {
- if (node.spring) {
- node.vx += ((node.anchorX ?? 50) - node.x) * 0.01;
- node.vy += ((node.anchorY ?? 50) - node.y) * 0.01;
- } else if (node.graphContent && node.focus) {
- node.vx += (focus.x - node.x) * 0.035;
- node.vy += (focus.y - node.y) * 0.035;
- } else if (node.graphContent) {
- node.vx += (focus.x - node.x) * 0.004;
- node.vy += (focus.y - node.y) * 0.004;
- }
- }
- for (let i = 0; i < laidOut.length; i += 1) {
- for (let j = i + 1; j < laidOut.length; j += 1) {
- repelIfOverlapping(laidOut[i], laidOut[j]);
- }
- }
- for (const node of laidOut) {
- node.vx *= 0.72;
- node.vy *= 0.72;
- node.x = clampNumber(node.x + node.vx, node.width / 2 + 1, 99 - node.width / 2);
- node.y = clampNumber(node.y + node.vy, node.height / 2 + 1, 99 - node.height / 2);
- }
- }
- return laidOut.map((node) => ({
- ...node,
- x: Math.round(node.x * 10) / 10,
- y: Math.round(node.y * 10) / 10,
- }));
+ function renderEvents(snapshot) {
+ const events = (snapshot.recentEvents || []).slice(0, 18);
+ return "Mesh Events
" + (events.length ? events.map((event) => "
" + esc(event.type) + " " + esc(event.identityId || "") + " " + esc(relative(event.observedAt)) + "
").join("") : "
No recent events.
") + "
";
}
- function repelIfOverlapping(left, right) {
- const dx = right.x - left.x || 0.1;
- const dy = right.y - left.y || 0.1;
- const minX = (left.width + right.width) / 2 + 2;
- const minY = (left.height + right.height) / 2 + 2;
- const overlapX = minX - Math.abs(dx);
- const overlapY = minY - Math.abs(dy);
- if (overlapX <= 0 || overlapY <= 0) return;
- const force = Math.min(8, Math.min(overlapX, overlapY) * 0.28);
- const length = Math.max(1, Math.hypot(dx, dy));
- const nx = dx / length;
- const ny = dy / length;
- const leftWeight = left.spring && right.graphContent ? 3.8 : 1.1;
- const rightWeight = right.spring && left.graphContent ? 3.8 : 1.1;
- left.vx -= nx * force * leftWeight;
- left.vy -= ny * force * leftWeight;
- right.vx += nx * force * rightWeight;
- right.vy += ny * force * rightWeight;
- }
-
- function widthForKind(kind) {
- if (kind === "system") return 20;
- if (kind === "control") return 18;
- if (kind === "detail" || kind === "event") return 20;
- if (String(kind).includes("metric")) return 11;
- return 9;
- }
-
- function heightForKind(kind) {
- if (kind === "system") return 24;
- if (kind === "control") return 22;
- if (kind === "detail") return 32;
- if (kind === "event") return 26;
- if (String(kind).includes("metric")) return 15;
- return 17;
- }
-
- function clampNumber(value, min, max) {
- return Math.max(min, Math.min(max, value));
- }
-
- function detailNode(agent) {
- if (!agent) {
- return { id: "details", kind: "detail", spring: true, anchorX: 28, anchorY: 60, selected: false, hot: false, html: "Selected Face
No participant state.
" };
- }
- return {
- id: "details",
- kind: "detail",
- spring: true,
- anchorX: 28,
- anchorY: 60,
- selected: true,
- hot: Boolean(agent.activeJobId || agent.pendingMentionCount > 0),
- html: "" + avatarHtml(agent) + "
Selected Face
" + esc(agent.displayName) + " " + esc(agent.identityId) + "
" + fact("Next", minutes(agent.nextTurnInMinutes)) + fact("Heat", number(agent.heat)) + fact("Speed", number(agent.effectiveSpeed)) + fact("Mentions", number(agent.pendingMentionCount)) + "
",
- };
- }
-
- function eventNode(snapshot) {
- const events = (snapshot.recentEvents || []).slice(0, 4);
- return {
- id: "events",
- kind: "event",
- spring: true,
- anchorX: 28,
- anchorY: 40,
- selected: false,
- hot: Boolean(snapshot.summary?.lastShuffle),
- html: "Mesh Events
" + (events.length ? events.map((event) => "
" + esc(event.type) + " " + esc(event.identityId || "") + " " + esc(relative(event.observedAt)) + "
").join("") : "
No recent events.
") + "
",
- };
- }
-
- function faceNodeHtml(agent) {
- return avatarHtml(agent) + "" + esc(agent.displayName) + " " + esc(minutes(agent.nextTurnInMinutes)) + " " + (agent.pendingMentionCount > 0 ? "mention " : agent.activeJobId ? "active " : "");
- }
-
- function renderGraphNode(node) {
- const kindClass = node.kind.includes("metric-node") ? "metric-node" : node.kind + "-node";
- const className = [
- "graph-node",
- kindClass,
- node.spring ? "spring-node" : "graph-content-node",
- node.focus ? "active-focus" : "",
- node.hot ? "hot" : "",
- node.selected ? "selected" : "",
- ].filter(Boolean).join(" ");
- const selectable = node.agent ? " data-select=\\"" + esc(node.agent.identityId) + "\\" role=\\"button\\" tabindex=\\"0\\"" : "";
- return "" + node.html + " ";
- }
-
- function settleGraphNodes() {
- const stage = document.querySelector(".graph-stage");
- if (!stage) return;
- const stageRect = stage.getBoundingClientRect();
- const nodes = [...document.querySelectorAll(".graph-node")].map((element) => {
- const rect = element.getBoundingClientRect();
- return {
- element,
- spring: element.dataset.spring === "true",
- content: element.dataset.content === "true",
- x: ((rect.left + rect.width / 2 - stageRect.left) / stageRect.width) * 100,
- y: ((rect.top + rect.height / 2 - stageRect.top) / stageRect.height) * 100,
- anchorX: Number(element.dataset.anchorX || "50"),
- anchorY: Number(element.dataset.anchorY || "50"),
- vx: 0,
- vy: 0,
- w: (rect.width / stageRect.width) * 100,
- h: (rect.height / stageRect.height) * 100,
- };
- });
- for (let tick = 0; tick < 140; tick += 1) {
- for (const node of nodes) {
- if (node.spring) {
- node.vx += (node.anchorX - node.x) * 0.008;
- node.vy += (node.anchorY - node.y) * 0.008;
- }
- }
- for (let i = 0; i < nodes.length; i += 1) {
- for (let j = i + 1; j < nodes.length; j += 1) {
- repelDomNodes(nodes[i], nodes[j]);
- }
- }
- for (const node of nodes) {
- node.vx *= 0.7;
- node.vy *= 0.7;
- node.x = clampNumber(node.x + node.vx, node.w / 2 + 0.5, 99.5 - node.w / 2);
- node.y = clampNumber(node.y + node.vy, node.h / 2 + 0.5, 99.5 - node.h / 2);
- }
- }
- for (const node of nodes) {
- node.element.style.left = node.x.toFixed(2) + "%";
- node.element.style.top = node.y.toFixed(2) + "%";
- }
- }
-
- function repelDomNodes(left, right) {
- const dx = right.x - left.x || 0.1;
- const dy = right.y - left.y || 0.1;
- const minX = (left.w + right.w) / 2 + 1.2;
- const minY = (left.h + right.h) / 2 + 1.2;
- const overlapX = minX - Math.abs(dx);
- const overlapY = minY - Math.abs(dy);
- if (overlapX <= 0 || overlapY <= 0) return;
- const force = Math.min(6, Math.min(overlapX, overlapY) * 0.22);
- const length = Math.max(1, Math.hypot(dx, dy));
- const nx = dx / length;
- const ny = dy / length;
- const springContent = (left.spring && right.content) || (right.spring && left.content);
- const weight = springContent ? 3.5 : 1.2;
- left.vx -= nx * force * weight;
- left.vy -= ny * force * weight;
- right.vx += nx * force * weight;
- right.vy += ny * force * weight;
+ function metric(label, value) {
+ return "" + esc(label) + " " + value + "
";
}
function fact(label, value) {
@@ -1056,19 +806,12 @@ function renderHtml(snapshot) {
: "" + esc(initials(agent.displayName)) + " ";
}
- function attachGraphSelection() {
+ function attachSelection() {
document.querySelectorAll("[data-select]").forEach((element) => {
element.addEventListener("click", () => {
selectedIdentity = element.getAttribute("data-select");
refresh();
});
- element.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- selectedIdentity = element.getAttribute("data-select");
- refresh();
- }
- });
});
}
@@ -1131,587 +874,6 @@ function renderHtml(snapshot) {
`;
}
-function renderLegacyHtml(snapshot) {
- return `
-
-
-
-
- VoidBot Swarm State
-
-
-
-
-
-
-
-
-`;
-}
-
function redactSnapshot(snapshot) {
const redacted = JSON.parse(JSON.stringify(snapshot));
redacted.visibility = "public_redacted";
diff --git a/state/evidence.jsonl b/state/evidence.jsonl
index 9544029..1f6d690 100644
--- a/state/evidence.jsonl
+++ b/state/evidence.jsonl
@@ -326,3 +326,4 @@
{"ts":"2026-05-24T21:27:15Z","type":"implementation","status":"ok","note":"Added read-only VoidBot swarm web exporter: renders .voidbot/status/swarm-dashboard.html and swarm-state.json from heartbeat/orchestrator status, writes a CultCache snapshot for CultMesh at .voidbot/status/cultmesh/voidbot-swarm-state.cc, and supports --public redaction for future voidbot.gamecult.org hosting."}
{"ts":"2026-05-24T21:42:11Z","type":"implementation","status":"ok","note":"Swarm dashboard now acts as local CTB control surface: heartbeat state carries scheduler-owned controls for cadenceMultiplier and manualTurnRequests, the heartbeat runner consumes those controls on pulse, and the CultMesh snapshot mirrors compact avatar-based upcoming turn order plus direct-mention shuffle evidence."}
{"ts":"2026-05-24T22:07:44Z","type":"implementation","status":"ok","note":"Swarm dashboard UI changed from stacked report to fullscreen single-viewport graph SPA: CTB rail stays on the long viewport side, Face state and UI facts/controls are graph nodes, center-spring affordance nodes use a DOM-aware settle pass to avoid rendered Face graph content, and active/selected agent state pulls the graph focus."}
+{"ts":"2026-05-24T22:14:01Z","type":"implementation","status":"ok","note":"Swarm dashboard graph view was cut after proving awkward in use. The served SPA is now a direct cockpit: long-edge CTB rail plus summary, controls, participant list, selected-Face details, and recent mesh events, with scheduler/CultMesh ownership unchanged."}