diff --git a/README.md b/README.md
index 8109694..3f2eebb 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,22 @@ Mimir dashboard broker, and surfaces timestamped touch/motion sensor data.
accepted state, commands, and side effects.
See `docs/cultmesh-streaming-ui-framework.md` for the target architecture.
+See `docs/renderer-parity.md` for current browser/iOS/Android/Fensalir/Flutter
+renderer parity.
+
+## Browser Reference
+
+Start the browser reference surface runner:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1
+```
+
+Open `http://127.0.0.1:8891/`. The page can spawn:
+
+- `VoidBot Live`, connected to Mimir's `/eve/deck` broker.
+- `Fensalir Direct2D`, a fixture surface for the planned
+ CultMesh-to-`AquariumUiDocument` lowering.
## VoidBot CEF Stream
diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md
new file mode 100644
index 0000000..ad0c659
--- /dev/null
+++ b/docs/renderer-parity.md
@@ -0,0 +1,36 @@
+# Eve Renderer Parity
+
+Parity is currently uneven by design. The shared surface contract is forming,
+but the browser reference is the first renderer that can be used as a practical
+comparison oracle.
+
+## Current State
+
+| Renderer | Status | Proof | Gap |
+| --- | --- | --- | --- |
+| Browser | First reference runner | `web/` renders live VoidBot and Fensalir fixture surfaces | Needs visual regression fixtures and command round-trip tests |
+| iOS / EveCanvas | Native proof | Renders Mimir provider trees and a custom VoidBot cockpit with avatar images | Uses provider-specific UIKit rendering for VoidBot |
+| Android / Periwinkle | Device-edge proof | APK builds and shows broker/sensor status | Install blocked until device allows ADB sideload; dashboard rendering is not implemented |
+| Fensalir Direct2D | Documented landing zone | Existing `AquariumUiDocument` and `DirectWriteOverlay` path | Needs adapter from Eve surface document to `AquariumUiDocument` |
+| Flutter | Candidate shared native path | Not installed locally | Needs toolchain and client scaffold |
+
+## Spawned Surfaces
+
+The browser reference runner spawns two surfaces:
+
+- `VoidBot Live`: connects to Mimir's `/eve/deck` broker and opens
+ `voidbot.swarm`.
+- `Fensalir Direct2D`: loads `web/fixtures/fensalir-client-surface.json`, a
+ recorded surface describing the Direct2D client lowering path.
+
+Start it with:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1
+```
+
+Then open:
+
+```text
+http://127.0.0.1:8891/
+```
diff --git a/scripts/start-browser-reference.ps1 b/scripts/start-browser-reference.ps1
new file mode 100644
index 0000000..e2d941e
--- /dev/null
+++ b/scripts/start-browser-reference.ps1
@@ -0,0 +1,56 @@
+param(
+ [int] $Port = 8891
+)
+
+$ErrorActionPreference = "Stop"
+$projectRoot = Split-Path -Parent $PSScriptRoot
+$webRoot = Join-Path $projectRoot "web"
+$logRoot = Join-Path $projectRoot "artifacts\web"
+New-Item -ItemType Directory -Force $logRoot | Out-Null
+
+$pidPath = Join-Path $logRoot "eve-browser-reference.pid"
+$outLog = Join-Path $logRoot "eve-browser-reference.out.log"
+$errLog = Join-Path $logRoot "eve-browser-reference.err.log"
+
+if (Test-Path $pidPath) {
+ $oldPid = Get-Content $pidPath -ErrorAction SilentlyContinue
+ if ($oldPid) {
+ Stop-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue
+ }
+}
+
+$nodeScript = @'
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const root = process.argv[2];
+const port = Number(process.argv[3]);
+const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8'};
+http.createServer((req, res) => {
+ const urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
+ const clean = urlPath === '/' ? '/index.html' : urlPath;
+ const file = path.normalize(path.join(root, clean));
+ if (!file.startsWith(root)) {
+ res.writeHead(403);
+ res.end('forbidden');
+ return;
+ }
+ fs.readFile(file, (err, data) => {
+ if (err) {
+ res.writeHead(404);
+ res.end('not found');
+ return;
+ }
+ res.writeHead(200, {'content-type': types[path.extname(file)] || 'application/octet-stream'});
+ res.end(data);
+ });
+}).listen(port, '127.0.0.1', () => console.log(`Eve browser reference listening on http://127.0.0.1:${port}`));
+'@
+
+$scriptPath = Join-Path $logRoot "serve-eve-reference.cjs"
+Set-Content -LiteralPath $scriptPath -Value $nodeScript -Encoding UTF8
+$proc = Start-Process -FilePath "node" -ArgumentList @($scriptPath, $webRoot, $Port) -WorkingDirectory $projectRoot -RedirectStandardOutput $outLog -RedirectStandardError $errLog -PassThru -WindowStyle Hidden
+$proc.Id | Set-Content -LiteralPath $pidPath
+Start-Sleep -Milliseconds 500
+Get-Content $outLog -Tail 5
+Write-Host "PID $($proc.Id)"
diff --git a/web/fixtures/fensalir-client-surface.json b/web/fixtures/fensalir-client-surface.json
new file mode 100644
index 0000000..3ef7b85
--- /dev/null
+++ b/web/fixtures/fensalir-client-surface.json
@@ -0,0 +1,88 @@
+{
+ "type": "dashboard-state",
+ "schema": "mimir.eve_dashboard_state.v1",
+ "providerId": "fensalir.direct2d",
+ "title": "Fensalir Direct2D Client Surface",
+ "version": 1,
+ "updatedAt": "2026-05-31T03:34:00.000Z",
+ "selectedNodeId": "surface-contract",
+ "nodes": [
+ {
+ "id": "surface-contract",
+ "label": "Eve Surface Contract\nCultMesh -> AquariumUiDocument",
+ "kind": "state-detail",
+ "x": -0.62,
+ "y": -0.18,
+ "width": 0.34,
+ "height": 0.34,
+ "health": "owner",
+ "detail": "Browser Eve is the reference renderer. Fensalir consumes the same provider-owned surface document and lowers it into AquariumUiDocument. Provider repos own state and command semantics; Fensalir owns DirectWrite/Direct2D rendering, focus, keyboard, pointer dispatch, and overlay timing."
+ },
+ {
+ "id": "direct2d-overlay",
+ "label": "DirectWriteOverlay\nD3D11-on-12 Direct2D",
+ "kind": "renderer",
+ "x": -0.12,
+ "y": -0.54,
+ "width": 0.34,
+ "height": 0.22,
+ "health": "ready",
+ "detail": "Draws Eve panels after the D3D12 scene. Existing Fensalir mechanism: DirectWriteOverlay.Render(frame, debugUi, clientUiPanels, performanceText)."
+ },
+ {
+ "id": "aquarium-ui",
+ "label": "AquariumUiDocument\npanels and controls",
+ "kind": "surface",
+ "x": 0.36,
+ "y": -0.48,
+ "width": 0.34,
+ "height": 0.22,
+ "health": "adapter target",
+ "detail": "First lowering target for shared Eve documents: readouts, buttons, toggles, sliders, options, text boxes, tree items, and detail panes."
+ },
+ {
+ "id": "field-reservoir",
+ "label": "Field Reservoir\nengine timing witness",
+ "kind": "telemetry",
+ "x": -0.12,
+ "y": 0.02,
+ "width": 0.34,
+ "height": 0.22,
+ "health": "live",
+ "detail": "Fensalir remains a field/evidence engine. Eve surface rendering must stay overlay/client UI; it must not become another field-truth path."
+ },
+ {
+ "id": "program-output",
+ "label": "Program Output\nshared D3D12 texture",
+ "kind": "media",
+ "x": 0.36,
+ "y": 0.06,
+ "width": 0.34,
+ "height": 0.22,
+ "health": "external surface",
+ "detail": "The program-output texture is external publication. Eve UI controls it; the resolved HDR scene and shared texture publication stay renderer-owned."
+ },
+ {
+ "id": "command-sink",
+ "label": "Command Sink\nEve/CultNet outbound",
+ "kind": "command",
+ "x": -0.38,
+ "y": 0.48,
+ "width": 0.30,
+ "height": 0.18,
+ "health": "pending",
+ "detail": "The first implementation cut needs a provider command sink so Direct2D control activation sends the same command messages as browser, iOS, and Android."
+ },
+ {
+ "id": "parity-probe",
+ "label": "Parity Probe\nbrowser reference fixture",
+ "kind": "test",
+ "x": 0.28,
+ "y": 0.50,
+ "width": 0.34,
+ "height": 0.18,
+ "health": "required",
+ "detail": "Recorded surface fixtures should render in browser and Fensalir with comparable hierarchy, selection, controls, and detail text before provider-specific polish."
+ }
+ ]
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..303d1cb
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Eve Surface Reference
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/styles.css b/web/styles.css
new file mode 100644
index 0000000..1c53119
--- /dev/null
+++ b/web/styles.css
@@ -0,0 +1,226 @@
+:root {
+ color-scheme: dark;
+ --bg: #020909;
+ --panel: #071918;
+ --panel-2: #0a2423;
+ --line: rgba(103, 240, 228, 0.28);
+ --line-hot: rgba(142, 252, 255, 0.72);
+ --text: #e7f1f1;
+ --quiet: #8ba5a3;
+ --accent: #ffb84f;
+ --cyan: #8efcff;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, "Segoe UI", system-ui, sans-serif;
+}
+
+.topbar {
+ height: 84px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--line);
+ background: #010707;
+}
+
+h1 {
+ margin: 0;
+ font-size: 20px;
+ letter-spacing: 0;
+}
+
+p {
+ margin: 4px 0 0;
+ color: var(--quiet);
+ font-size: 13px;
+}
+
+.hud {
+ min-width: 168px;
+ padding: 8px;
+ border: 1px solid var(--line-hot);
+ border-radius: 6px;
+ background: rgba(7, 25, 24, 0.88);
+ color: var(--accent);
+ font-family: "Cascadia Mono", Consolas, monospace;
+ font-size: 11px;
+ display: grid;
+ gap: 2px;
+}
+
+.tabs {
+ display: flex;
+ gap: 8px;
+ padding: 10px 16px 0;
+}
+
+.tabs button {
+ border: 1px solid var(--line);
+ border-radius: 6px 6px 0 0;
+ background: var(--panel);
+ color: var(--quiet);
+ padding: 8px 12px;
+ font: 600 13px "Cascadia Mono", Consolas, monospace;
+}
+
+.tabs button.active {
+ color: var(--cyan);
+ border-color: var(--line-hot);
+ background: var(--panel-2);
+}
+
+.surface {
+ padding: 10px 16px 16px;
+}
+
+.cockpit {
+ display: grid;
+ grid-template-columns: 0.78fr 1.08fr 1.34fr;
+ gap: 10px;
+ min-height: calc(100vh - 138px);
+}
+
+.ctb {
+ grid-column: 1 / -1;
+ min-height: 88px;
+ overflow-x: auto;
+ display: flex;
+ gap: 8px;
+ padding-bottom: 8px;
+}
+
+.pane {
+ min-height: 0;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: rgba(7, 25, 24, 0.92);
+ padding: 12px;
+}
+
+.pane h2 {
+ margin: 0 0 10px;
+ color: var(--quiet);
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.card {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: rgba(10, 36, 35, 0.96);
+ padding: 10px;
+}
+
+.ctb .card {
+ flex: 0 0 96px;
+ min-height: 76px;
+ padding: 6px;
+}
+
+.avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ border: 1px solid var(--line-hot);
+ object-fit: cover;
+ background: #123;
+}
+
+.card-title {
+ white-space: pre-line;
+ margin-top: 4px;
+ font: 700 11px "Cascadia Mono", Consolas, monospace;
+}
+
+.health {
+ color: var(--accent);
+ font: 700 10px "Cascadia Mono", Consolas, monospace;
+}
+
+.detail {
+ white-space: pre-wrap;
+ font: 600 12px "Cascadia Mono", Consolas, monospace;
+ line-height: 1.45;
+}
+
+.list {
+ display: grid;
+ gap: 8px;
+}
+
+.metrics {
+ display: grid;
+ gap: 9px;
+ margin: 12px 0;
+}
+
+.metric label {
+ display: flex;
+ justify-content: space-between;
+ color: var(--quiet);
+ font: 700 10px "Cascadia Mono", Consolas, monospace;
+}
+
+.bar {
+ height: 7px;
+ border-radius: 7px;
+ background: #010707;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ overflow: hidden;
+}
+
+.bar span {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, #bca8ff, #8efcff);
+}
+
+.graph {
+ position: relative;
+ min-height: calc(100vh - 166px);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background:
+ linear-gradient(rgba(142, 252, 255, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(142, 252, 255, 0.04) 1px, transparent 1px),
+ rgba(3, 15, 17, 0.94);
+ background-size: 44px 44px;
+}
+
+.node {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: rgba(10, 36, 35, 0.95);
+ padding: 10px;
+ overflow: hidden;
+}
+
+.node.selected {
+ border-color: var(--line-hot);
+ box-shadow: 0 0 0 1px rgba(142, 252, 255, 0.38);
+}
+
+.node .detail {
+ margin-top: 6px;
+ color: var(--quiet);
+ font-size: 10px;
+}
+
+@media (max-width: 900px) {
+ .cockpit {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/web/surface.js b/web/surface.js
new file mode 100644
index 0000000..4e11989
--- /dev/null
+++ b/web/surface.js
@@ -0,0 +1,182 @@
+const statusEl = document.querySelector("#status");
+const app = document.querySelector("#app");
+const surfaceId = document.querySelector("#surface-id");
+const surfaceVersion = document.querySelector("#surface-version");
+const voidBotTab = document.querySelector("#voidbot-tab");
+const fensalirTab = document.querySelector("#fensalir-tab");
+
+let socket;
+
+voidBotTab.addEventListener("click", () => {
+ setActiveTab(voidBotTab);
+ openVoidBot();
+});
+
+fensalirTab.addEventListener("click", async () => {
+ setActiveTab(fensalirTab);
+ closeSocket();
+ const response = await fetch("./fixtures/fensalir-client-surface.json");
+ renderSurface(await response.json(), "fixture");
+});
+
+openVoidBot();
+
+function setActiveTab(tab) {
+ for (const button of [voidBotTab, fensalirTab]) {
+ button.classList.toggle("active", button === tab);
+ }
+}
+
+function openVoidBot() {
+ closeSocket();
+ const url = "ws://127.0.0.1:8795/eve/deck";
+ statusEl.textContent = `connecting ${url}`;
+ socket = new WebSocket(url);
+ socket.addEventListener("open", () => {
+ statusEl.textContent = "connected to Mimir Eve broker";
+ socket.send(JSON.stringify({ type: "open-provider", providerId: "voidbot.swarm" }));
+ });
+ socket.addEventListener("message", event => {
+ const state = JSON.parse(event.data);
+ if (state.providerId === "voidbot.swarm") {
+ renderSurface(state, "live");
+ }
+ });
+ socket.addEventListener("close", () => {
+ statusEl.textContent = "Mimir broker disconnected";
+ });
+ socket.addEventListener("error", () => {
+ statusEl.textContent = "Mimir broker error; is port 8795 running?";
+ });
+}
+
+function closeSocket() {
+ if (socket) {
+ socket.close();
+ socket = undefined;
+ }
+}
+
+function renderSurface(state, source) {
+ surfaceId.textContent = state.providerId || "surface unknown";
+ surfaceVersion.textContent = `v${state.version ?? "?"}`;
+ statusEl.textContent = `${state.title || "surface"} (${source})`;
+ if (state.providerId === "voidbot.swarm") {
+ renderVoidBot(state);
+ } else {
+ renderGraphSurface(state);
+ }
+}
+
+function renderVoidBot(state) {
+ const nodes = state.nodes || [];
+ const ctb = nodes.filter(n => n.kind === "ctb-turn");
+ const leaves = nodes.filter(n => n.kind === "state-leaf");
+ const summary = nodeById(nodes, "voidbot-summary");
+ const agent = nodeById(nodes, "agent-detail");
+ const detail = nodeById(nodes, state.selectedNodeId) || nodeById(nodes, "state-detail") || agent || summary;
+
+ app.innerHTML = "";
+ const root = el("section", "cockpit");
+ const rail = el("div", "ctb");
+ for (const turn of ctb) {
+ const card = el("button", "card");
+ card.type = "button";
+ if (turn.avatarUrl) {
+ const img = el("img", "avatar");
+ img.src = turn.avatarUrl;
+ img.alt = "";
+ card.append(img);
+ }
+ card.append(el("div", "card-title", turn.label || turn.id));
+ card.append(el("div", "health", turn.health || ""));
+ rail.append(card);
+ }
+
+ const left = el("section", "pane");
+ left.append(el("h2", "", "Controls / Selected Face"));
+ left.append(el("div", "detail", `${summary?.label || "VoidBot Swarm"}\n${summary?.detail || ""}`));
+ left.append(metricStack());
+ left.append(el("div", "detail", agent?.detail || "No selected Face detail."));
+
+ const middle = el("section", "pane");
+ middle.append(el("h2", "", "State Graph"));
+ const list = el("div", "list");
+ for (const leaf of leaves) {
+ const card = el("div", `card ${leaf.id === state.selectedNodeId ? "selected" : ""}`);
+ card.append(el("div", "card-title", leaf.label || leaf.id));
+ card.append(el("div", "health", leaf.health || leaf.statePath || ""));
+ list.append(card);
+ }
+ middle.append(list);
+
+ const right = el("section", "pane");
+ right.append(el("h2", "", "State Detail"));
+ right.append(el("div", "detail", `${detail?.label || "detail"}\n\n${detail?.detail || ""}`));
+
+ root.append(rail, left, middle, right);
+ app.append(root);
+}
+
+function renderGraphSurface(state) {
+ app.innerHTML = "";
+ const graph = el("section", "graph");
+ for (const node of state.nodes || []) {
+ const view = el("article", `node ${node.id === state.selectedNodeId ? "selected" : ""}`);
+ const width = Math.max(160, graphWidth(node.width));
+ const height = Math.max(92, graphHeight(node.height));
+ view.style.width = `${width}px`;
+ view.style.minHeight = `${height}px`;
+ view.style.left = `${((node.x ?? 0) + 1) * 50}%`;
+ view.style.top = `${((node.y ?? 0) + 1) * 50}%`;
+ view.append(el("div", "card-title", node.label || node.id));
+ view.append(el("div", "health", `${node.kind || "node"} ${node.health || ""}`));
+ if (node.detail) {
+ view.append(el("div", "detail", node.detail));
+ }
+ graph.append(view);
+ }
+ app.append(graph);
+}
+
+function graphWidth(value) {
+ return Math.round((value || 0.25) * Math.max(900, window.innerWidth - 32));
+}
+
+function graphHeight(value) {
+ return Math.round((value || 0.16) * Math.max(520, window.innerHeight - 150));
+}
+
+function metricStack() {
+ const wrapper = el("div", "metrics");
+ const metrics = [
+ ["TURN", 0.96],
+ ["MEMORY", 0.70],
+ ["PRESSURE", 0.42],
+ ["HEAT", 0.34],
+ ["LOAD", 0.99],
+ ["SPEED", 0.88],
+ ];
+ for (const [label, value] of metrics) {
+ const metric = el("div", "metric");
+ metric.append(el("label", "", `${label} ${Math.round(value * 100)}%`));
+ const bar = el("div", "bar");
+ const fill = el("span");
+ fill.style.width = `${Math.round(value * 100)}%`;
+ bar.append(fill);
+ metric.append(bar);
+ wrapper.append(metric);
+ }
+ return wrapper;
+}
+
+function nodeById(nodes, id) {
+ return nodes.find(node => node.id === id);
+}
+
+function el(tag, className = "", text = "") {
+ const element = document.createElement(tag);
+ if (className) element.className = className;
+ if (text) element.textContent = text;
+ return element;
+}