Add web game (plan2.md) on the independent runtime, merge-blocking gates
Builds the browser game around the existing Rust runtime: a window into the universe, not a second simulation. Pure std, no external crates. New crates: - protocol: versioned, hashable client/server messages + hand-rolled JSON value and total parser (malformed packet -> Err, never panic). - game_runtime: authoritative match state. Resolves turns through the INDEPENDENT interpreter (runtime_under_test::native_resolve), not the reference engine; filters visibility/knowledge; records and regenerates replays. A match is a pure function of (seed, roster, ordered inputs). - web_assets/web_client: embedded browser client (arena, rune editor, knowledge panels, replay viewer) + static HTTP delivery. - server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing), turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock. - web_tests: dependency-free WebSocket test client + Phase H gates. Trust hardening per review: - game_runtime no longer delegates to reference_runtime::execute; it runs the independent interpreter that the runtime-equivalence gate proves correct. - Protocol/socket/replay/visibility/resilience gates are merge-blocking (added to the merge_group-required job in merge-gates.yml): 1k matches/0 drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks. - Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only (continue-on-error, artifacts) until CI infrastructure with a browser exists; it is treated as unsatisfied, not green. The headless 100-match gate is labeled protocol-level coverage, not rendered-browser coverage. - README documents the hand-rolled crypto/parser audit risk explicitly. Fixes an integer-overflow panic in observed-volatility inference (i64 sum / abs near i64::MIN) that could poison the server mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"use strict";
|
||||
// Browser client for Magicka VM. The browser only ever sends INTENT; the server
|
||||
// is the sole authority. This file mirrors the `protocol` crate's wire format
|
||||
// (version 1, envelope {v,type,body}). It never simulates the world — it renders
|
||||
// exactly what the server says is observable.
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
const OPS = ["mix","channel","branch","schedule","resonate","observe",
|
||||
"collapse","invert","diffuse","anchor","echoback","imprint"];
|
||||
|
||||
const state = {
|
||||
ws: null,
|
||||
playerId: null,
|
||||
matchId: null,
|
||||
turn: 0,
|
||||
deadline: 0,
|
||||
locked: false,
|
||||
snapshot: null,
|
||||
selectedTarget: null,
|
||||
program: [],
|
||||
slots: [[], [], []],
|
||||
activeSlot: 0,
|
||||
liveHashes: {}, // turn -> runtime_hash seen live
|
||||
replay: null, // { turns: [...], final_hash, cursor }
|
||||
};
|
||||
|
||||
// ---- wire helpers -------------------------------------------------------
|
||||
function send(type, body) {
|
||||
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
||||
state.ws.send(JSON.stringify({ v: PROTOCOL_VERSION, type, body }));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
state.ws = ws;
|
||||
ws.onopen = () => {
|
||||
setConn(true);
|
||||
send("JoinMatch", { name: "dev-" + Math.floor(Math.random() * 1000), match_id: null });
|
||||
};
|
||||
ws.onclose = () => { setConn(false); setTimeout(connect, 1000); };
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (!msg || msg.v !== PROTOCOL_VERSION) return;
|
||||
handle(msg.type, msg.body || {});
|
||||
};
|
||||
}
|
||||
|
||||
function setConn(on) {
|
||||
const el = document.getElementById("conn");
|
||||
el.textContent = on ? "connected" : "disconnected";
|
||||
el.className = "badge " + (on ? "on" : "off");
|
||||
}
|
||||
|
||||
// ---- server message handling -------------------------------------------
|
||||
function handle(type, body) {
|
||||
switch (type) {
|
||||
case "MatchState":
|
||||
state.playerId = body.player_id;
|
||||
state.matchId = body.match_id;
|
||||
state.turn = body.turn;
|
||||
state.snapshot = body.snapshot;
|
||||
render();
|
||||
break;
|
||||
case "TurnStarted":
|
||||
state.turn = body.turn;
|
||||
state.deadline = Date.now() + (body.deadline_ms || 0);
|
||||
state.locked = false;
|
||||
renderTimer();
|
||||
break;
|
||||
case "TurnResolved":
|
||||
state.turn = body.turn;
|
||||
state.snapshot = body.snapshot;
|
||||
state.liveHashes[body.turn] = body.runtime_hash;
|
||||
(body.events || []).forEach((e) => log(`t${body.turn}: ${e}`));
|
||||
showHashes();
|
||||
render();
|
||||
break;
|
||||
case "ObservationResult":
|
||||
renderDiagnostics(body.diagnostics, `target ${body.target}`);
|
||||
break;
|
||||
case "ValidationReport":
|
||||
log((body.accepted ? "✓ " : "✗ ") + body.detail);
|
||||
if (body.diagnostics) renderDiagnostics(body.diagnostics, "program");
|
||||
break;
|
||||
case "ReplayChunk":
|
||||
loadReplay(body);
|
||||
break;
|
||||
case "ErrorEvent":
|
||||
log(`! ${body.code}: ${body.detail}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rendering ----------------------------------------------------------
|
||||
function render() {
|
||||
if (!state.snapshot) return;
|
||||
renderArena();
|
||||
renderDomains();
|
||||
renderLogHistory();
|
||||
renderTimer();
|
||||
}
|
||||
|
||||
function renderArena() {
|
||||
const s = state.snapshot;
|
||||
const arena = document.getElementById("arena");
|
||||
arena.style.gridTemplateColumns = `repeat(${s.arena_w}, 34px)`;
|
||||
arena.innerHTML = "";
|
||||
const at = {};
|
||||
(s.observed_entities || []).forEach((e) => { at[`${e.x},${e.y}`] = e; });
|
||||
for (let y = 0; y < s.arena_h; y++) {
|
||||
for (let x = 0; x < s.arena_w; x++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "cell";
|
||||
const e = at[`${x},${y}`];
|
||||
if (e) {
|
||||
cell.textContent = e.is_self ? "@" : (e.is_dummy ? "▣" : "&");
|
||||
if (e.is_self) cell.classList.add("self");
|
||||
if (state.selectedTarget === e.id) cell.classList.add("target");
|
||||
if (!e.alive) cell.classList.add("dead");
|
||||
const hp = document.createElement("span");
|
||||
hp.className = "hp"; hp.textContent = e.hp;
|
||||
cell.appendChild(hp);
|
||||
cell.title = `${e.name} (#${e.id}) hp ${e.hp}`;
|
||||
cell.onclick = () => { state.selectedTarget = e.id; renderArena(); };
|
||||
}
|
||||
arena.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDomains() {
|
||||
const s = state.snapshot;
|
||||
const wrap = document.getElementById("domains");
|
||||
wrap.innerHTML = "";
|
||||
(s.observed_domains || []).forEach((d) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "domain";
|
||||
const name = document.createElement("div");
|
||||
name.className = "name"; name.textContent = `${d.index}: ${d.name}`;
|
||||
el.appendChild(name);
|
||||
d.observed.forEach((v, i) => {
|
||||
const lane = document.createElement("span");
|
||||
const k = (d.knowledge && d.knowledge[i]) || "unknown";
|
||||
lane.className = "lane " + k;
|
||||
lane.textContent = v === null ? "▒" : v;
|
||||
lane.title = k;
|
||||
el.appendChild(lane);
|
||||
});
|
||||
wrap.appendChild(el);
|
||||
});
|
||||
document.getElementById("redactions").textContent =
|
||||
`${s.hidden_state_redactions} hidden state values withheld (hidden lanes + masked observations)`;
|
||||
const inf = document.getElementById("inferred");
|
||||
inf.innerHTML = "";
|
||||
(s.inferred_markers || []).forEach((m) => {
|
||||
const li = document.createElement("li"); li.textContent = m; inf.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogHistory() {
|
||||
// History from the snapshot is authoritative; live events are appended too.
|
||||
const known = state.snapshot.known_history || [];
|
||||
const log = document.getElementById("log");
|
||||
if (log.dataset.lastTurn !== String(state.turn)) {
|
||||
log.dataset.lastTurn = String(state.turn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimer() {
|
||||
const t = document.getElementById("timer");
|
||||
const remain = Math.max(0, Math.ceil((state.deadline - Date.now()) / 1000));
|
||||
t.textContent = `turn ${state.turn} — ${remain}s ${state.locked ? "(locked)" : ""}`;
|
||||
}
|
||||
setInterval(() => {
|
||||
if (state.deadline) {
|
||||
if (Date.now() > state.deadline) state.locked = true;
|
||||
renderTimer();
|
||||
}
|
||||
}, 250);
|
||||
|
||||
function renderDiagnostics(d, label) {
|
||||
const body = document.getElementById("diag-body");
|
||||
body.innerHTML = "";
|
||||
const row = (k, v) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "diag-row";
|
||||
div.innerHTML = `<span class="diag-key">${k}:</span> ${v}`;
|
||||
body.appendChild(div);
|
||||
};
|
||||
row("for", label);
|
||||
row("known reads", (d.known_reads || []).join(", ") || "—");
|
||||
row("known writes", (d.known_writes || []).join(", ") || "—");
|
||||
row("observed risks", (d.observed_risks || []).join(", ") || "none observed");
|
||||
row("unknown listeners", `<span class="warn">${d.unknown_listeners || 0}</span> (writes you cannot observe)`);
|
||||
row("previous outcomes", (d.previous_outcomes || []).join(" | ") || "—");
|
||||
}
|
||||
|
||||
// ---- rune editor --------------------------------------------------------
|
||||
function renderTokens() {
|
||||
const wrap = document.getElementById("tokens");
|
||||
wrap.innerHTML = "";
|
||||
state.program.forEach((t, i) => {
|
||||
const el = document.createElement("span");
|
||||
el.className = "token";
|
||||
el.textContent = `${OPS[t.op % OPS.length]} ${t.a},${t.b},${t.c}#${t.imm}`;
|
||||
el.title = "click to remove";
|
||||
el.onclick = () => { state.program.splice(i, 1); renderTokens(); };
|
||||
wrap.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibrary() {
|
||||
const wrap = document.getElementById("library");
|
||||
wrap.innerHTML = "";
|
||||
state.slots.forEach((slot, i) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "slot" + (i === state.activeSlot ? " active" : "");
|
||||
el.textContent = `slot ${i + 1} (${slot.length})`;
|
||||
el.onclick = () => {
|
||||
state.slots[state.activeSlot] = state.program.slice();
|
||||
state.activeSlot = i;
|
||||
state.program = state.slots[i].slice();
|
||||
renderTokens(); renderLibrary();
|
||||
};
|
||||
wrap.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
const sel = document.getElementById("op-select");
|
||||
OPS.forEach((op, i) => {
|
||||
const o = document.createElement("option");
|
||||
o.value = i; o.textContent = op; sel.appendChild(o);
|
||||
});
|
||||
document.getElementById("btn-add").onclick = () => {
|
||||
state.program.push({
|
||||
op: parseInt(sel.value, 10),
|
||||
a: clampByte("tok-a"), b: clampByte("tok-b"), c: clampByte("tok-c"),
|
||||
imm: parseInt(document.getElementById("tok-imm").value, 10) || 0,
|
||||
});
|
||||
renderTokens();
|
||||
};
|
||||
document.getElementById("btn-clear").onclick = () => { state.program = []; renderTokens(); };
|
||||
document.getElementById("btn-save").onclick = () => {
|
||||
send("EditRuneProgram", { tokens: state.program });
|
||||
log("saved program (" + state.program.length + " runes)");
|
||||
};
|
||||
renderTokens(); renderLibrary();
|
||||
}
|
||||
|
||||
function clampByte(id) {
|
||||
let v = parseInt(document.getElementById(id).value, 10) || 0;
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
function submit(action) {
|
||||
if (state.locked) { log("turn locked — submission rejected client-side"); return; }
|
||||
send("SubmitTurn", { turn: state.turn, action });
|
||||
}
|
||||
|
||||
function initActions() {
|
||||
document.querySelectorAll("[data-move]").forEach((b) => {
|
||||
b.onclick = () => {
|
||||
const [dx, dy] = b.dataset.move.split(",").map((n) => parseInt(n, 10));
|
||||
submit({ kind: "move", dx, dy });
|
||||
};
|
||||
});
|
||||
document.getElementById("btn-cast").onclick = () => submit({ kind: "cast" });
|
||||
document.getElementById("btn-wait").onclick = () => submit({ kind: "wait" });
|
||||
document.getElementById("btn-attack").onclick = () => {
|
||||
if (state.selectedTarget === null) { log("select a target first"); return; }
|
||||
submit({ kind: "attack", target: state.selectedTarget });
|
||||
};
|
||||
document.getElementById("btn-inspect").onclick = () => {
|
||||
if (state.selectedTarget === null) { log("select a target first"); return; }
|
||||
send("InspectTarget", { target: state.selectedTarget });
|
||||
};
|
||||
}
|
||||
|
||||
// ---- replay -------------------------------------------------------------
|
||||
function initReplay() {
|
||||
document.getElementById("btn-replay").onclick = () => {
|
||||
if (state.matchId === null) return;
|
||||
state.replay = null;
|
||||
send("RequestReplay", { match_id: state.matchId });
|
||||
};
|
||||
document.getElementById("btn-replay-step").onclick = stepReplay;
|
||||
}
|
||||
|
||||
function loadReplay(chunk) {
|
||||
if (!state.replay) state.replay = { turns: [], final_hash: chunk.final_hash, cursor: 0, seed: chunk.seed };
|
||||
state.replay.turns = state.replay.turns.concat(chunk.turns || []);
|
||||
state.replay.final_hash = chunk.final_hash;
|
||||
document.getElementById("replay-status").textContent =
|
||||
`replay loaded: ${state.replay.turns.length} turns (seed ${chunk.seed})`;
|
||||
}
|
||||
|
||||
function stepReplay() {
|
||||
if (!state.replay || state.replay.cursor >= state.replay.turns.length) {
|
||||
document.getElementById("replay-status").textContent = "replay complete";
|
||||
return;
|
||||
}
|
||||
const rt = state.replay.turns[state.replay.cursor++];
|
||||
// Verify browser replay event order/hash matches what we saw live (Phase G).
|
||||
const live = state.liveHashes[rt.turn];
|
||||
const ok = live === undefined || live === rt.runtime_hash;
|
||||
log(`replay t${rt.turn}: hash ${rt.runtime_hash} ${ok ? "✓ matches live" : "✗ MISMATCH"}`);
|
||||
const hd = document.getElementById("hashes");
|
||||
hd.innerHTML += `<div class="${ok ? "hash-ok" : "hash-bad"}">t${rt.turn} ${rt.runtime_hash}</div>`;
|
||||
document.getElementById("replay-status").textContent =
|
||||
`replay turn ${rt.turn} / ${state.replay.turns.length}`;
|
||||
}
|
||||
|
||||
function showHashes() {
|
||||
const hd = document.getElementById("hashes");
|
||||
hd.innerHTML = `<div>live final-turn hash: ${state.liveHashes[state.turn] || "—"}</div>`;
|
||||
}
|
||||
|
||||
// ---- misc ---------------------------------------------------------------
|
||||
function log(msg) {
|
||||
const el = document.getElementById("log");
|
||||
const li = document.createElement("li");
|
||||
li.textContent = msg;
|
||||
el.appendChild(li);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initEditor();
|
||||
initActions();
|
||||
initReplay();
|
||||
connect();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Magicka VM — playable window</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Magicka VM</h1>
|
||||
<div id="conn" class="badge off">disconnected</div>
|
||||
<div id="timer" class="timer">turn —</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="arena-panel" class="panel">
|
||||
<h2>Arena</h2>
|
||||
<div id="arena" class="arena" aria-label="arena grid"></div>
|
||||
<div class="actions">
|
||||
<div class="dpad">
|
||||
<button data-move="0,-1">↑</button>
|
||||
<div class="dpad-row">
|
||||
<button data-move="-1,0">←</button>
|
||||
<button data-move="0,1">↓</button>
|
||||
<button data-move="1,0">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button id="btn-cast">Cast</button>
|
||||
<button id="btn-attack">Attack</button>
|
||||
<button id="btn-inspect">Inspect</button>
|
||||
<button id="btn-wait">Wait</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Select a target entity (click it), then Attack/Inspect. Cast uses your current program.</p>
|
||||
</section>
|
||||
|
||||
<section id="editor-panel" class="panel">
|
||||
<h2>Rune editor</h2>
|
||||
<div id="library" class="library"></div>
|
||||
<div id="tokens" class="tokens"></div>
|
||||
<div class="editor-controls">
|
||||
<select id="op-select"></select>
|
||||
<label>a<input id="tok-a" type="number" value="0" min="0" max="255" /></label>
|
||||
<label>b<input id="tok-b" type="number" value="0" min="0" max="255" /></label>
|
||||
<label>c<input id="tok-c" type="number" value="0" min="0" max="255" /></label>
|
||||
<label>imm<input id="tok-imm" type="number" value="0" /></label>
|
||||
<button id="btn-add">Add rune</button>
|
||||
<button id="btn-clear">Clear</button>
|
||||
<button id="btn-save">Save program</button>
|
||||
</div>
|
||||
<div id="diagnostics" class="diagnostics">
|
||||
<h3>Observed diagnostics</h3>
|
||||
<div id="diag-body">cast or save a program to preview observed diagnostics</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="domains-panel" class="panel">
|
||||
<h2>Domains (observed)</h2>
|
||||
<div id="domains" class="domains"></div>
|
||||
<p id="redactions" class="redactions"></p>
|
||||
<h3>Inferred</h3>
|
||||
<ul id="inferred"></ul>
|
||||
</section>
|
||||
|
||||
<section id="log-panel" class="panel">
|
||||
<h2>Turn log</h2>
|
||||
<ul id="log" class="log"></ul>
|
||||
<h3>Replay</h3>
|
||||
<div class="replay-controls">
|
||||
<button id="btn-replay">Request replay</button>
|
||||
<button id="btn-replay-step">Step ▶</button>
|
||||
<span id="replay-status">no replay loaded</span>
|
||||
</div>
|
||||
<div id="hashes" class="hashes"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
:root {
|
||||
--bg: #0e1014;
|
||||
--panel: #171a21;
|
||||
--ink: #d7dce5;
|
||||
--dim: #828b9c;
|
||||
--accent: #6ad0ff;
|
||||
--warn: #ffb454;
|
||||
--bad: #ff6a6a;
|
||||
--good: #7be08a;
|
||||
--grid: #2a2f3a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: #11131a;
|
||||
border-bottom: 1px solid var(--grid);
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0; color: var(--accent); }
|
||||
h2 { font-size: 14px; margin: 0 0 8px; color: var(--accent); }
|
||||
h3 { font-size: 12px; margin: 12px 0 6px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.badge.off { background: #3a1f23; color: var(--bad); }
|
||||
.badge.on { background: #1f3a26; color: var(--good); }
|
||||
.timer { margin-left: auto; color: var(--warn); }
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.2fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.panel { background: var(--panel); border: 1px solid var(--grid); border-radius: 8px; padding: 12px; }
|
||||
#arena-panel { grid-row: span 2; }
|
||||
#log-panel { grid-row: span 2; }
|
||||
|
||||
.arena {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
background: var(--grid);
|
||||
border: 1px solid var(--grid);
|
||||
width: max-content;
|
||||
}
|
||||
.cell {
|
||||
width: 34px; height: 34px;
|
||||
background: #10131a;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; cursor: pointer; position: relative;
|
||||
}
|
||||
.cell.self { outline: 2px solid var(--accent); }
|
||||
.cell.target { outline: 2px solid var(--warn); }
|
||||
.cell.dead { opacity: 0.35; }
|
||||
.cell .hp { position: absolute; bottom: 0; right: 2px; font-size: 9px; color: var(--dim); }
|
||||
|
||||
.actions { display: flex; gap: 24px; margin-top: 12px; align-items: center; }
|
||||
.dpad { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.dpad-row { display: flex; gap: 2px; }
|
||||
button {
|
||||
background: #222733; color: var(--ink); border: 1px solid var(--grid);
|
||||
border-radius: 5px; padding: 6px 10px; cursor: pointer; font: inherit;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.action-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.hint { color: var(--dim); font-size: 12px; }
|
||||
|
||||
.library { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.slot { border: 1px dashed var(--grid); border-radius: 5px; padding: 4px 8px; cursor: pointer; color: var(--dim); }
|
||||
.slot.active { border-color: var(--accent); color: var(--accent); }
|
||||
.tokens { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; padding: 6px; background: #10131a; border-radius: 5px; }
|
||||
.token { background: #232a36; border: 1px solid var(--grid); border-radius: 4px; padding: 2px 6px; font-size: 12px; cursor: pointer; }
|
||||
.token:hover { border-color: var(--bad); }
|
||||
.editor-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
|
||||
.editor-controls label { display: flex; flex-direction: column; font-size: 10px; color: var(--dim); }
|
||||
.editor-controls input { width: 64px; background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 3px; }
|
||||
.editor-controls select { background: #10131a; border: 1px solid var(--grid); color: var(--ink); border-radius: 4px; padding: 4px; }
|
||||
|
||||
.diagnostics { margin-top: 12px; background: #10131a; border-radius: 5px; padding: 8px; }
|
||||
.diag-row { margin: 2px 0; }
|
||||
.diag-key { color: var(--dim); }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.domains { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.domain { background: #10131a; border: 1px solid var(--grid); border-radius: 5px; padding: 6px; }
|
||||
.domain .name { color: var(--accent); font-size: 12px; }
|
||||
.lane { display: inline-block; min-width: 40px; text-align: right; padding: 1px 4px; margin: 1px; border-radius: 3px; font-size: 11px; }
|
||||
.lane.known { background: #15251a; color: var(--good); }
|
||||
.lane.newly_observed { background: #2a2410; color: var(--warn); }
|
||||
.lane.unknown { background: #25151a; color: var(--dim); }
|
||||
.lane.suspected { background: #1a1a2a; color: #9aa0ff; }
|
||||
.lane.contradicted { background: #2a1525; color: #ff9ae0; }
|
||||
.redactions { color: var(--bad); font-size: 12px; }
|
||||
|
||||
.log { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
|
||||
.log li { padding: 2px 0; border-bottom: 1px solid #1c2029; font-size: 12px; }
|
||||
.replay-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.hashes { margin-top: 8px; font-size: 11px; color: var(--dim); word-break: break-all; }
|
||||
.hash-ok { color: var(--good); }
|
||||
.hash-bad { color: var(--bad); }
|
||||
ul#inferred { margin: 0; padding-left: 16px; color: var(--dim); font-size: 12px; }
|
||||
Reference in New Issue
Block a user