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 + + + +
+
+

Eve Surface Reference

+

booting browser reference renderer

+
+
+ EVE + surface none + v0 +
+
+ + + +
+ + + + 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; +}