diff --git a/docs/renderer-parity.md b/docs/renderer-parity.md index cad8797..4534ffc 100644 --- a/docs/renderer-parity.md +++ b/docs/renderer-parity.md @@ -12,7 +12,7 @@ Android/Kotlin. Repixelizer is the first style-parity target. | Renderer | Status | Proof | Gap | | --- | --- | --- | --- | -| Web reference | First generic app target | `web/` renders live VoidBot and fixture surfaces | Needs provider picker, Repixelizer fixture, visual regression fixtures, and command round-trip tests | +| Web reference | First generic app target | `web/` renders live VoidBot and fixture surfaces through the provider picker | Needs visual regression fixtures and command round-trip tests | | iOS / UIKit | Native proof | Renders Odin's fullscreen interface wall from `surface.root`, generic provider trees, and a custom VoidBot cockpit with avatar images | Needs provider picker, Repixelizer style token lowering, and visual parity fixtures | | Android / Kotlin | Device-edge proof | APK builds and shows broker/sensor status | Needs provider picker, full surface tree rendering, style token lowering, asset image lowering, and command controls | | Fensalir Direct2D | Documented landing zone | Existing `AquariumUiDocument` and `DirectWriteOverlay` path | Needs adapter from Eve surface document to `AquariumUiDocument` and DirectWrite/Direct2D token lowering | @@ -20,19 +20,22 @@ Android/Kotlin. Repixelizer is the first style-parity target. ## Spawned Surfaces -The browser reference runner currently exposes hardcoded fixture/live tabs: +The browser reference runner now exposes local advertisements through the +provider picker: - `VoidBot Live`: connects to Mimir's `/eve/deck` broker and opens `voidbot.swarm`. +- `Repixelizer`: loads `web/fixtures/repixelizer.provider-advertisement.json` + and `web/fixtures/repixelizer.eve-surface.json`. - `Fensalir Direct2D`: loads `web/fixtures/fensalir-client-surface.json`, a recorded surface describing the Direct2D client lowering path. - `Sai VN Surface`: loads `web/fixtures/sai-vn-surface.json`. - `Huginn .cc`: compiles `web/fixtures/huginn-cc-surface.eve`. - `Reactive DSL`: compiles `web/fixtures/reactive-composition.eve`. -Those tabs are temporary probes. The runtime app target is a provider picker -fed by `gamecult.eve.provider_advertisement.v1`, with fixtures represented as -local advertisements. +The remaining temporary part is the local provider list itself. The runtime app +target is the same picker fed by live `gamecult.eve.provider_advertisement.v1` +documents through CultMesh/Odin. Start it with: diff --git a/web/fixtures/repixelizer.eve-surface.json b/web/fixtures/repixelizer.eve-surface.json new file mode 100644 index 0000000..ce73297 --- /dev/null +++ b/web/fixtures/repixelizer.eve-surface.json @@ -0,0 +1,558 @@ +{ + "commands": [ + { + "authority": "browser-lowering", + "command": "repixelizer.web.open", + "method": "GET", + "route": "/app/", + "surfaceId": "repixelizer.operator.surface", + "transport": "http" + }, + { + "authority": "repixelizer-route-policy", + "command": "repixelizer.job.submit", + "method": "POST", + "route": "/api/jobs", + "surfaceId": "repixelizer.operator.surface", + "transport": "http" + }, + { + "authority": "repixelizer-job-owner", + "command": "repixelizer.job.cancel_own", + "method": "DELETE", + "route": "/api/jobs/{job_id}", + "surfaceId": "repixelizer.operator.surface", + "transport": "http" + } + ], + "loweringComparison": { + "periwinkleAndroidKotlin": { + "keeps": [ + "surface root traversal", + "pane/card/text hierarchy", + "TextView body content", + "basic ProgressBar metric rendering", + "node-bound command taps" + ], + "losesUntilRendererParity": [ + "Press Start 2P and VT323 font selection", + "Repixelizer shell frame, scanlines, pixel grid, corner accents, and chunky border widths", + "landing/app image assets; current Android lowerer prints asset refs when images are not explicitly implemented", + "upload picker, comparison canvas, pan/zoom inspection, eyedropper, and paint controls", + "provider style tokens; current Kotlin toneColor/textColorFor/textSizeFor are hard-coded" + ], + "minimumParityCuts": [ + "map surface.styles.tokens before toneColor/textColorFor/textSizeFor", + "add packaged pixel fonts or font download cache", + "render image assetUri/assetRef as ImageView with pixelated sampling", + "lower command controls to Android upload/document picker and job route calls" + ], + "source": "E:\\Projects\\Eve\\android\\app\\src\\main\\java\\org\\gamecult\\eve\\MainActivity.kt", + "status": "surface-tree-renderer-present-style-token-gap" + } + }, + "providerId": "repixelizer", + "providerKind": "repixelizer.product", + "runtime": { + "auth": { + "appSlug": "repixelizer", + "enabled": false, + "loginUrl": null, + "logoutUrl": null, + "mode": "off", + "protectQueue": false, + "required": false + }, + "hostedDemo": false, + "limits": { + "defaultSteps": 48, + "heartbeatIntervalSeconds": 10, + "maxInputDimension": 4096, + "maxOutputDimension": 1024, + "maxSteps": 256, + "maxUploadBytes": 16777216, + "queueCapacity": 32, + "staleAfterSeconds": 30 + }, + "queue": { + "activeStatus": null, + "hasActiveJob": false, + "queueCapacity": 32, + "queueDepth": 0, + "waitingCount": 0 + } + }, + "schema": "gamecult.eve.surface.v1", + "surface": { + "id": "repixelizer.operator.surface", + "root": { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.hero.copy", + "kind": "text", + "props": { + "role": "body", + "text": "Force fake pixel art back onto a real grid, then inspect each solver step without sanding off the crunchy old-machine charm." + }, + "role": "body", + "text": "Force fake pixel art back onto a real grid, then inspect each solver step without sanding off the crunchy old-machine charm." + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.hero.open.body", + "kind": "text", + "props": { + "role": "body", + "text": "Browser lowering remains the full interactive app until native Eve gains upload and canvas editing parity." + }, + "role": "body", + "text": "Browser lowering remains the full interactive app until native Eve gains upload and canvas editing parity." + }, + { + "children": [], + "id": "repixelizer.hero.open.route", + "kind": "text", + "props": { + "method": "GET", + "role": "mono", + "route": "/app/", + "text": "GET /app/" + }, + "role": "mono", + "text": "GET /app/" + } + ], + "commandId": "repixelizer.web.open", + "id": "repixelizer.hero.open", + "kind": "card", + "props": { + "commandId": "repixelizer.web.open", + "styleProfile": "repixelizer.retro.pixel", + "title": "Open demo", + "transport": { + "kind": "http", + "method": "GET", + "route": "/app/" + } + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "Open demo" + } + ], + "id": "repixelizer.hero", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Repixelizer hosted demo" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Repixelizer hosted demo" + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.workflow.submit.body", + "kind": "text", + "props": { + "role": "body", + "text": "Submit a PNG/JPEG/WebP/GIF into the GUI job queue; queue ownership stays in repixelizer.gui." + }, + "role": "body", + "text": "Submit a PNG/JPEG/WebP/GIF into the GUI job queue; queue ownership stays in repixelizer.gui." + }, + { + "children": [], + "id": "repixelizer.workflow.submit.route", + "kind": "text", + "props": { + "method": "POST", + "role": "mono", + "route": "/api/jobs", + "text": "POST /api/jobs" + }, + "role": "mono", + "text": "POST /api/jobs" + } + ], + "commandId": "repixelizer.job.submit", + "id": "repixelizer.workflow.submit", + "kind": "card", + "props": { + "commandId": "repixelizer.job.submit", + "styleProfile": "repixelizer.retro.pixel", + "title": "Upload image", + "transport": { + "kind": "http", + "method": "POST", + "route": "/api/jobs" + } + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "Upload image" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.inspect.source", + "kind": "text", + "props": { + "role": "body", + "text": "source, edge map, lattice overlay, phase field, cleaned output" + }, + "role": "body", + "text": "source, edge map, lattice overlay, phase field, cleaned output" + }, + { + "children": [], + "id": "repixelizer.inspect.canvas", + "kind": "text", + "props": { + "role": "body", + "text": "comparison canvas and editor lower to native only after Periwinkle gets image/control parity" + }, + "role": "body", + "text": "comparison canvas and editor lower to native only after Periwinkle gets image/control parity" + } + ], + "id": "repixelizer.workflow.inspect", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Inspect solver" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Inspect solver" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.workflow.cancel.body", + "kind": "text", + "props": { + "role": "body", + "text": "The command intent delegates to the existing owner-bound DELETE route." + }, + "role": "body", + "text": "The command intent delegates to the existing owner-bound DELETE route." + }, + { + "children": [], + "id": "repixelizer.workflow.cancel.route", + "kind": "text", + "props": { + "method": "DELETE", + "role": "mono", + "route": "/api/jobs/{job_id}", + "text": "DELETE /api/jobs/{job_id}" + }, + "role": "mono", + "text": "DELETE /api/jobs/{job_id}" + } + ], + "commandId": "repixelizer.job.cancel_own", + "id": "repixelizer.workflow.cancel", + "kind": "card", + "props": { + "commandId": "repixelizer.job.cancel_own", + "styleProfile": "repixelizer.retro.pixel", + "title": "Cancel own job", + "transport": { + "kind": "http", + "method": "DELETE", + "route": "/api/jobs/{job_id}" + } + }, + "role": "group", + "style": { + "tone": "cool", + "variant": "repixelizer-retro-card" + }, + "text": "Cancel own job" + } + ], + "id": "repixelizer.workflow", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Single page app" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Single page app" + }, + { + "children": [ + { + "children": [ + { + "children": [], + "id": "repixelizer.runtime.hosted", + "kind": "text", + "props": { + "role": "mono", + "text": "hostedDemo: False" + }, + "role": "mono", + "text": "hostedDemo: False" + }, + { + "children": [], + "id": "repixelizer.runtime.steps", + "kind": "text", + "props": { + "role": "mono", + "text": "defaultSteps: 48 maxSteps: 256" + }, + "role": "mono", + "text": "defaultSteps: 48 maxSteps: 256" + }, + { + "children": [], + "id": "repixelizer.runtime.dimensions", + "kind": "text", + "props": { + "role": "mono", + "text": "maxOutputDimension: 1024px" + }, + "role": "mono", + "text": "maxOutputDimension: 1024px" + } + ], + "id": "repixelizer.runtime.config", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Runtime" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Runtime" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.queue.depth", + "kind": "text", + "props": { + "role": "strong", + "text": "0 active or waiting / 32 capacity" + }, + "role": "strong", + "text": "0 active or waiting / 32 capacity" + }, + { + "children": [], + "id": "repixelizer.queue.waiting", + "kind": "text", + "props": { + "role": "mono", + "text": "waitingCount: 0" + }, + "role": "mono", + "text": "waitingCount: 0" + }, + { + "children": [], + "id": "repixelizer.queue.active", + "kind": "text", + "props": { + "role": "mono", + "text": "hasActiveJob: False" + }, + "role": "mono", + "text": "hasActiveJob: False" + } + ], + "id": "repixelizer.runtime.queue", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Queue" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Queue" + }, + { + "children": [ + { + "children": [], + "id": "repixelizer.auth.mode", + "kind": "text", + "props": { + "role": "mono", + "text": "mode: off" + }, + "role": "mono", + "text": "mode: off" + }, + { + "children": [], + "id": "repixelizer.auth.required", + "kind": "text", + "props": { + "role": "mono", + "text": "required: False" + }, + "role": "mono", + "text": "required: False" + }, + { + "children": [], + "id": "repixelizer.auth.providers", + "kind": "text", + "props": { + "role": "body", + "text": "providers: none" + }, + "role": "body", + "text": "providers: none" + } + ], + "id": "repixelizer.runtime.auth", + "kind": "card", + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Access" + }, + "role": "group", + "style": { + "tone": "panel", + "variant": "repixelizer-retro-card" + }, + "text": "Access" + } + ], + "id": "repixelizer.runtime", + "kind": "pane", + "layout": { + "direction": "vertical", + "padding": 10 + }, + "props": { + "styleProfile": "repixelizer.retro.pixel", + "title": "Live projection" + }, + "role": "section", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-pane" + }, + "text": "Live projection" + } + ], + "id": "repixelizer.surface.root", + "kind": "surface", + "layout": { + "direction": "vertical", + "padding": 14 + }, + "props": { + "browserLowering": "https://repixelizer.gamecult.org/app/", + "frontPage": "https://repixelizer.gamecult.org/", + "styleProfile": "repixelizer.retro.pixel", + "subtitle": "Fake pixel art goes in. Real grid-aligned sprite art comes out.", + "title": "Repixelizer" + }, + "role": "root", + "style": { + "tone": "warm", + "variant": "repixelizer-retro-shell" + }, + "text": "Repixelizer" + }, + "schema": "gamecult.eve.surface.v1", + "styles": { + "assets": { + "appUrl": "https://repixelizer.gamecult.org/app/", + "fontCss": "https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap", + "landingInput": "https://repixelizer.gamecult.org/app/landing-assets/character-input.png", + "landingOutput": "https://repixelizer.gamecult.org/app/landing-assets/character-repixelized.png" + }, + "loweringHints": { + "android-native": "Load packaged or downloadable Press Start 2P and VT323 fonts, map style tokens before hard-coded tones, and render asset images instead of asset labels.", + "css": "Use the tokens as CSS custom properties and retain scanline/pixel-grid overlays.", + "tui": "Preserve title/body font distinction as role metadata; canvas editing lowers to artifact refs and command intents only." + }, + "profile": "repixelizer.retro.pixel", + "tokens": { + "borderBottomWidthPx": 5, + "borderWidthPx": 4, + "buttonGlyph": ">", + "colorAccent": "#ffd84a", + "colorAccentStrong": "#ffae1a", + "colorBackgroundBottom": "#01050d", + "colorBackgroundMid": "#020813", + "colorBackgroundTop": "#01040b", + "colorCornerAccent": "#ffb72a", + "colorMuted": "#d3bb7f", + "colorPanel": "#0a2239", + "colorPanelAlt": "#061627", + "colorPanelBorder": "#0a2740", + "colorPanelBorderDeep": "#06192a", + "colorPixelGrid": "rgba(0, 0, 0, 0.3)", + "colorShadow": "rgba(0, 0, 0, 0.55)", + "colorShellSurfaceBottom": "#030b16", + "colorShellSurfaceTop": "#061424", + "colorText": "#efe5c9", + "colorTextBright": "#f7efd8", + "cornerAccentPx": 12, + "dashedInnerFrame": true, + "fontBody": "\"VT323\", \"Courier New\", monospace", + "fontMono": "\"VT323\", \"Courier New\", monospace", + "fontTitle": "\"Press Start 2P\", \"VT323\", monospace", + "imageRendering": "pixelated", + "scanlineOverlay": true + } + }, + "title": "Repixelizer" + }, + "title": "Repixelizer", + "type": "surface-state", + "updatedAt": "2026-06-04T00:00:00Z", + "version": 1 +} diff --git a/web/fixtures/repixelizer.provider-advertisement.json b/web/fixtures/repixelizer.provider-advertisement.json new file mode 100644 index 0000000..74cf930 --- /dev/null +++ b/web/fixtures/repixelizer.provider-advertisement.json @@ -0,0 +1,198 @@ +{ + "commands": [ + { + "authority": "repixelizer-route-policy", + "command": "repixelizer.job.submit", + "method": "POST", + "result": "accepted-denied-or-queued", + "route": "/api/jobs", + "surfaceId": "repixelizer.web.app", + "transport": "http" + }, + { + "authority": "repixelizer-job-owner", + "command": "repixelizer.job.cancel_own", + "method": "DELETE", + "result": "accepted-denied-or-not-found", + "route": "/api/jobs/{job_id}", + "surfaceId": "repixelizer.web.app", + "transport": "http" + } + ], + "contacts": [ + { + "kind": "repo", + "path": "E:\\Projects\\repixelizer" + } + ], + "freshness": { + "lastSeenAt": "2026-06-04T00:00:00Z", + "maxAgeMs": 15000, + "state": "fresh" + }, + "kind": "service.product", + "nestedVerses": [ + { + "authorityBoundary": "repixelizer-local-session-or-heimdall-account", + "carryRules": { + "commands": "repixelizer-route-policy-only", + "identity": "heimdall-claims-projected-when-enabled" + }, + "kind": "session-space", + "parentVerseId": "gamecult.local", + "stateSchemas": [ + "repixelizer.job.v0", + "repixelizer.job_event.v0", + "repixelizer.auth_projection.v0" + ], + "surfaceIds": [ + "repixelizer.web.app" + ], + "verseId": "repixelizer.session.{sessionId}" + }, + { + "authorityBoundary": "read-only-first-cut", + "kind": "hosted-operator-space", + "parentVerseId": "gamecult.local", + "stateSchemas": [ + "repixelizer.job.v0", + "repixelizer.queue_snapshot.v0", + "repixelizer.job_event.v0", + "repixelizer.runtime_config.v0", + "repixelizer.auth_projection.v0" + ], + "surfaceIds": [ + "repixelizer.operator.surface" + ], + "verseId": "repixelizer.operator" + } + ], + "providerId": "repixelizer", + "runtime": { + "auth": { + "enabled": false, + "mode": "off", + "protectQueue": false, + "providers": [], + "required": false + }, + "hostedDemo": false, + "queueCapacity": 32, + "showQueuePanel": false, + "spoolPath": "C:\\Users\\Meta\\AppData\\Local\\Temp\\repixelizer-gui-spool" + }, + "schema": "gamecult.eve.provider_advertisement.v1", + "schemas": [ + { + "authority": "accepted", + "description": "Job id, owner binding, status, timestamps, input/output artifact refs, error summary, and retention state.", + "owner": "repixelizer.gui", + "portable": true, + "schema": "repixelizer.job.v0", + "storage": "memory-dev-only-with-cc-witness-reserved" + }, + { + "authority": "accepted", + "description": "Queue capacity, waiting/running counts, active job ids, oldest waiting age, and hosted mode.", + "owner": "repixelizer.gui", + "portable": true, + "schema": "repixelizer.queue_snapshot.v0", + "storage": "memory-dev-only-with-cc-witness-reserved" + }, + { + "authority": "redacted-projection", + "description": "Redacted progress event, stage, index, timestamp, and job id.", + "owner": "repixelizer.gui", + "portable": true, + "schema": "repixelizer.job_event.v0", + "storage": "memory-dev-only-with-cc-witness-reserved" + }, + { + "authority": "accepted", + "description": "Hosted flags, queue limits, visible UI flags, solver config hash placeholder, deployment id, and spool path.", + "owner": "repixelizer.gui", + "portable": true, + "schema": "repixelizer.runtime_config.v0", + "storage": "memory-dev-only-with-cc-witness-reserved" + }, + { + "authority": "external-authority-projection", + "description": "Auth mode, provider availability, subject capability summary, and Heimdall claim freshness without raw tokens.", + "owner": "repixelizer.access", + "portable": true, + "schema": "repixelizer.auth_projection.v0", + "storage": "heimdall-projection-with-cc-witness-reserved" + } + ], + "serviceId": "repixelizer.service", + "styleCapabilities": [ + { + "lossiness": { + "read-only-first-cut": "operator surface names state and routes but does not own queue truth", + "tui": "no-pixel-canvas-editing; status, queue, artifact refs, and command intents only" + }, + "preferredLowerings": [ + "css", + "android-native", + "native-eve", + "tui" + ], + "styleProfile": "repixelizer.product", + "tokenGroups": [ + "repixelizer.retro.pixel", + "repixelizer.comparisonCanvas", + "repixelizer.cleanupTool", + "repixelizer.queuePanel", + "repixelizer.operatorStatus" + ] + } + ], + "surfaces": [ + { + "audience": "operator", + "commands": [ + "repixelizer.web.open", + "repixelizer.job.submit", + "repixelizer.job.cancel_own" + ], + "key": "cultmesh://repixelizer/surfaces/operator", + "mode": "read-only-first-cut", + "schema": "gamecult.eve.surface.v1", + "status": "available", + "styleProfile": "repixelizer.retro.pixel", + "surfaceId": "repixelizer.operator.surface", + "transport": "http-json", + "url": "https://repixelizer.gamecult.org/eve/surface" + }, + { + "canonical": false, + "canonicalSurfaceId": "repixelizer.operator.surface", + "schema": "gamecult.eve.surface.v1", + "surfaceId": "repixelizer.web.app", + "transport": "browser-lowering", + "url": "https://repixelizer.gamecult.org/app/" + } + ], + "title": "Repixelizer", + "updatedAt": "2026-06-04T00:00:00Z", + "verseId": "gamecult.local", + "witnesses": [ + { + "freshness": { + "state": "planned", + "updatedAt": "2026-06-04T00:00:00Z" + }, + "id": "repixelizer.hosted.witness", + "kind": "cc-export-path-reserved", + "path": "state/repixelizer.witness.cc", + "redaction": "bulk-images-raw-tokens-and-private-claims-removed", + "schemas": [ + "repixelizer.job.v0", + "repixelizer.queue_snapshot.v0", + "repixelizer.job_event.v0", + "repixelizer.runtime_config.v0", + "repixelizer.auth_projection.v0" + ] + } + ] +} diff --git a/web/index.html b/web/index.html index 2a3a48e..ab5af23 100644 --- a/web/index.html +++ b/web/index.html @@ -19,13 +19,11 @@ - +
+ + +
loading providers
+
diff --git a/web/styles.css b/web/styles.css index 37eac32..49d250b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,14 +1,29 @@ :root { color-scheme: dark; --bg: #020909; + --bg-top: var(--bg); + --bg-mid: var(--bg); + --bg-bottom: var(--bg); + --shell-top: #010707; + --shell-bottom: #010707; --panel: #071918; --panel-2: #0a2423; --line: rgba(103, 240, 228, 0.28); + --line-deep: rgba(103, 240, 228, 0.18); --line-hot: rgba(142, 252, 255, 0.72); --text: #e7f1f1; + --text-bright: var(--text); --quiet: #8ba5a3; --accent: #ffb84f; + --accent-strong: var(--accent); + --corner-accent: var(--accent); --cyan: #8efcff; + --shadow: rgba(0, 0, 0, 0.35); + --pixel-grid: rgba(142, 252, 255, 0.04); + --border-width: 1px; + --border-bottom-width: var(--border-width); + --corner-accent-size: 0; + --font-mono: "Cascadia Mono", Consolas, monospace; } * { @@ -18,7 +33,7 @@ body { margin: 0; min-height: 100vh; - background: var(--bg); + background: linear-gradient(180deg, var(--bg-top), var(--bg-mid), var(--bg-bottom)); color: var(--text); font-family: var(--font-body, Inter), "Segoe UI", system-ui, sans-serif; } @@ -30,7 +45,7 @@ body { align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--line); - background: #010707; + background: linear-gradient(180deg, var(--shell-top), var(--shell-bottom)); } h1 { @@ -58,25 +73,37 @@ p { gap: 2px; } -.tabs { +.provider-bar { display: flex; + align-items: center; gap: 8px; - padding: 10px 16px 0; + padding: 10px 16px; + border-bottom: 1px solid var(--line-deep); + background: color-mix(in srgb, var(--panel) 70%, transparent); } -.tabs button { - border: 1px solid var(--line); - border-radius: 6px 6px 0 0; - background: var(--panel); +.provider-bar label { color: var(--quiet); - padding: 8px 12px; - font: 600 13px "Cascadia Mono", Consolas, monospace; + font: 700 11px var(--font-mono); + text-transform: uppercase; } -.tabs button.active { - color: var(--cyan); - border-color: var(--line-hot); - background: var(--panel-2); +.provider-bar select { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--text); + padding: 8px 12px; + font: 700 13px var(--font-mono); + min-width: min(28rem, 52vw); +} + +.provider-meta { + color: var(--quiet); + font: 600 11px var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .surface { @@ -105,6 +132,7 @@ p { border-radius: 6px; background: rgba(7, 25, 24, 0.92); padding: 12px; + box-shadow: 0 12px 26px var(--shadow); } .pane h2 { @@ -120,6 +148,12 @@ p { border-radius: 6px; background: rgba(10, 36, 35, 0.96); padding: 10px; + box-shadow: 0 8px 20px var(--shadow); +} + +.cultui-card[data-command-id] { + cursor: pointer; + border-bottom-width: var(--border-bottom-width); } .ctb .card { @@ -140,17 +174,17 @@ p { .card-title { white-space: pre-line; margin-top: 4px; - font: 700 11px "Cascadia Mono", Consolas, monospace; + font: 700 11px var(--font-mono); } .health { color: var(--accent); - font: 700 10px "Cascadia Mono", Consolas, monospace; + font: 700 10px var(--font-mono); } .detail { white-space: pre-wrap; - font: 600 12px "Cascadia Mono", Consolas, monospace; + font: 600 12px var(--font-mono); line-height: 1.45; } @@ -169,7 +203,7 @@ p { display: flex; justify-content: space-between; color: var(--quiet); - font: 700 10px "Cascadia Mono", Consolas, monospace; + font: 700 10px var(--font-mono); } .bar { @@ -192,8 +226,8 @@ p { 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), + linear-gradient(var(--pixel-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--pixel-grid) 1px, transparent 1px), rgba(3, 15, 17, 0.94); background-size: 44px 44px; } @@ -242,12 +276,49 @@ p { align-content: start; } +.cultui-surface-root { + min-height: calc(100vh - 150px); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 12px; + align-content: start; +} + +.cultui-pane { + border-width: var(--border-width); + border-bottom-width: var(--border-bottom-width); +} + +body[data-provider="repixelizer"] .surface { + position: relative; +} + +body[data-provider="repixelizer"] .surface::after { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 4px); + opacity: 0.28; + mix-blend-mode: screen; +} + +body[data-pixel-art="true"] img, +body[data-pixel-art="true"] .cultui-background { + image-rendering: pixelated; +} + .cultui-title { color: var(--accent); font: 700 16px "Cascadia Mono", Consolas, monospace; line-height: 1.35; } +.mono { + color: var(--accent); + font-family: var(--font-mono); +} + .cultui-reactive-metric { margin-top: 8px; } diff --git a/web/surface.js b/web/surface.js index 8de04c0..c31a7a0 100644 --- a/web/surface.js +++ b/web/surface.js @@ -4,57 +4,147 @@ 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"); -const saiTab = document.querySelector("#sai-tab"); -const huginnTab = document.querySelector("#huginn-tab"); -const dslTab = document.querySelector("#dsl-tab"); +const providerSelect = document.querySelector("#provider-select"); +const providerMeta = document.querySelector("#provider-meta"); let socket; +let providers = []; +let currentProvider; -voidBotTab.addEventListener("click", () => { - setActiveTab(voidBotTab); - openVoidBot(); +const defaultStyleTokens = { + "--bg": "#020909", + "--bg-top": "#020909", + "--bg-mid": "#020909", + "--bg-bottom": "#020909", + "--shell-top": "#010707", + "--shell-bottom": "#010707", + "--panel": "#071918", + "--panel-2": "#0a2423", + "--line": "rgba(103, 240, 228, 0.28)", + "--line-deep": "rgba(103, 240, 228, 0.18)", + "--text": "#e7f1f1", + "--text-bright": "#e7f1f1", + "--quiet": "#8ba5a3", + "--accent": "#ffb84f", + "--accent-strong": "#ffb84f", + "--corner-accent": "#ffb84f", + "--cyan": "#8efcff", + "--shadow": "rgba(0, 0, 0, 0.35)", + "--pixel-grid": "rgba(142, 252, 255, 0.04)", + "--border-width": "1px", + "--border-bottom-width": "1px", + "--corner-accent-size": "0", + "--font-body": "Inter, Segoe UI, system-ui, sans-serif", + "--font-title": "Cascadia Mono, Consolas, monospace", + "--font-mono": "Cascadia Mono, Consolas, monospace", +}; + +const localProviders = [ + { + providerId: "voidbot.swarm", + title: "VoidBot Live", + kind: "service.operator", + freshness: { state: "live" }, + surfaces: [{ transport: "mimir-eve-deck", surfaceId: "voidbot.swarm" }], + }, + { + providerId: "repixelizer", + title: "Repixelizer", + kind: "service.product", + advertisement: "./fixtures/repixelizer.provider-advertisement.json", + surfaces: [{ transport: "local-json", surfaceId: "repixelizer.operator.surface", url: "./fixtures/repixelizer.eve-surface.json" }], + }, + { + providerId: "fensalir.direct2d", + title: "Fensalir Direct2D", + kind: "surface.renderer", + freshness: { state: "fixture" }, + surfaces: [{ transport: "local-json", surfaceId: "fensalir.direct2d.fixture", url: "./fixtures/fensalir-client-surface.json" }], + }, + { + providerId: "sai.visual_novel", + title: "Sai VN Surface", + kind: "content.runtime", + freshness: { state: "fixture" }, + surfaces: [{ transport: "local-json", surfaceId: "sai.visual_novel.surface", url: "./fixtures/sai-vn-surface.json" }], + }, + { + providerId: "cultcache.huginn.inspector", + title: "Huginn .cc", + kind: "inspection.huginn", + freshness: { state: "fixture" }, + surfaces: [{ transport: "local-eve-dsl", surfaceId: "cultcache.huginn.inspector", url: "./fixtures/huginn-cc-surface.eve" }], + }, + { + providerId: "eve.reactive.dsl", + title: "Reactive DSL", + kind: "surface.fixture", + freshness: { state: "fixture" }, + surfaces: [{ transport: "local-eve-dsl", surfaceId: "eve.reactive.dsl", url: "./fixtures/reactive-composition.eve" }], + }, +]; + +providerSelect.addEventListener("change", () => { + const provider = providers.find(candidate => candidate.providerId === providerSelect.value); + if (provider) void openProvider(provider); }); -fensalirTab.addEventListener("click", async () => { - setActiveTab(fensalirTab); - closeSocket(); - const response = await fetch("./fixtures/fensalir-client-surface.json"); - renderSurface(await response.json(), "fixture"); -}); +void bootProviders(); -saiTab.addEventListener("click", async () => { - setActiveTab(saiTab); - closeSocket(); - const response = await fetch("./fixtures/sai-vn-surface.json"); - renderSurface(await response.json(), "fixture"); -}); - -huginnTab.addEventListener("click", async () => { - setActiveTab(huginnTab); - closeSocket(); - const response = await fetch("./fixtures/huginn-cc-surface.eve"); - renderSurface(compileEveDsl(await response.text()), "dsl"); -}); - -dslTab.addEventListener("click", async () => { - setActiveTab(dslTab); - closeSocket(); - const response = await fetch("./fixtures/reactive-composition.eve"); - renderSurface(compileEveDsl(await response.text()), "dsl"); -}); - -openVoidBot(); - -function setActiveTab(tab) { - for (const button of [voidBotTab, fensalirTab, saiTab, huginnTab, dslTab]) { - button.classList.toggle("active", button === tab); - } +async function bootProviders() { + providers = await Promise.all(localProviders.map(loadProviderAdvertisement)); + providerSelect.replaceChildren(...providers.map(provider => { + const option = document.createElement("option"); + option.value = provider.providerId; + option.textContent = provider.title || provider.providerId; + return option; + })); + const firstProduct = providers.find(provider => provider.providerId === "repixelizer") || providers[0]; + providerSelect.value = firstProduct.providerId; + await openProvider(firstProduct); } -function openVoidBot() { +async function loadProviderAdvertisement(provider) { + if (!provider.advertisement) return provider; + const response = await fetch(provider.advertisement); + const advertisement = await response.json(); + return { + ...advertisement, + ...provider, + surfaces: provider.surfaces || advertisement.surfaces || [], + localAdvertisement: advertisement, + }; +} + +async function openProvider(provider) { closeSocket(); + currentProvider = provider; + providerMeta.textContent = `${provider.kind || "provider"} | ${provider.providerId} | ${provider.freshness?.state || "unknown"}`; + surfaceId.textContent = provider.providerId; + surfaceVersion.textContent = "opening"; + const surface = provider.surfaces?.[0]; + if (!surface) { + statusEl.textContent = `${provider.title || provider.providerId} has no advertised surface`; + app.replaceChildren(emptyState("No advertised surface")); + return; + } + + if (surface.transport === "mimir-eve-deck") { + openVoidBot(provider); + return; + } + + if (surface.transport === "local-eve-dsl") { + const response = await fetch(surface.url); + renderSurface(compileEveDsl(await response.text()), "local dsl"); + return; + } + + const response = await fetch(surface.url); + renderSurface(await response.json(), "local fixture"); +} + +function openVoidBot(provider) { const scheme = location.protocol === "https:" ? "wss:" : "ws:"; const host = location.hostname || "127.0.0.1"; const url = `${scheme}//${host}:8795/eve/deck`; @@ -64,7 +154,7 @@ function openVoidBot() { socket.addEventListener("open", () => { if (socket !== activeSocket) return; statusEl.textContent = "connected to Mimir Eve broker"; - activeSocket.send(JSON.stringify({ type: "open-provider", providerId: "voidbot.swarm" })); + activeSocket.send(JSON.stringify({ type: "open-provider", providerId: provider.providerId })); }); socket.addEventListener("message", event => { if (socket !== activeSocket) return; @@ -96,6 +186,7 @@ function renderSurface(state, source) { surfaceVersion.textContent = `v${state.version ?? "?"}`; statusEl.textContent = `${state.title || "surface"} (${source})`; applySurfaceStyles(state.surface?.styles); + document.body.dataset.provider = state.providerId || currentProvider?.providerId || ""; if (state.surface?.root) { app.replaceChildren(renderCultComponent(state.surface.root)); } else if (state.providerId === "voidbot.swarm") { @@ -108,20 +199,41 @@ function renderSurface(state, source) { function applySurfaceStyles(styles) { const tokens = styles?.tokens || {}; const root = document.documentElement.style; + for (const [variable, value] of Object.entries(defaultStyleTokens)) { + root.setProperty(variable, value); + } const map = { colorBackground: "--bg", + colorBackgroundTop: "--bg-top", + colorBackgroundMid: "--bg-mid", + colorBackgroundBottom: "--bg-bottom", + colorShellSurfaceTop: "--shell-top", + colorShellSurfaceBottom: "--shell-bottom", colorPanel: "--panel", colorPanelAlt: "--panel-2", + colorPanelBorder: "--line", + colorPanelBorderDeep: "--line-deep", colorText: "--text", + colorTextBright: "--text-bright", colorMuted: "--quiet", colorAccent: "--accent", + colorAccentStrong: "--accent-strong", + colorCornerAccent: "--corner-accent", colorLink: "--cyan", + colorShadow: "--shadow", + colorPixelGrid: "--pixel-grid", }; for (const [token, variable] of Object.entries(map)) { if (tokens[token]) root.setProperty(variable, tokens[token]); } if (tokens.fontBody) root.setProperty("--font-body", tokens.fontBody); if (tokens.fontTitle) root.setProperty("--font-title", tokens.fontTitle); + if (tokens.fontMono) root.setProperty("--font-mono", tokens.fontMono); + if (tokens.borderWidthPx) root.setProperty("--border-width", `${tokens.borderWidthPx}px`); + if (tokens.borderBottomWidthPx) root.setProperty("--border-bottom-width", `${tokens.borderBottomWidthPx}px`); + if (tokens.cornerAccentPx) root.setProperty("--corner-accent-size", `${tokens.cornerAccentPx}px`); + document.body.dataset.pixelArt = tokens.pixelArt || tokens.imageRendering === "pixelated" ? "true" : "false"; + document.body.dataset.scanlines = tokens.scanlineOverlay ? "true" : "false"; } function renderCultComponent(node) { @@ -187,10 +299,34 @@ function renderCultComponent(node) { return card; } + if (kind === "surface") { + const surface = el("section", "cultui-surface-root"); + for (const child of children) surface.append(renderCultComponent(child)); + return surface; + } + + if (kind === "pane" || kind === "panel") { + const pane = el("section", "pane cultui-pane"); + if (props.title || node.text) pane.append(el("h2", "", props.title || node.text)); + for (const child of children) pane.append(renderCultComponent(child)); + return pane; + } + if (kind === "card") { const card = el("article", "card cultui-card"); - if (props.title) card.append(el("div", "card-title", props.title)); + if (props.title || node.text) card.append(el("div", "card-title", props.title || node.text)); for (const child of children) card.append(renderCultComponent(child)); + if (props.commandId || node.commandId) { + card.dataset.commandId = props.commandId || node.commandId; + card.tabIndex = 0; + card.addEventListener("click", () => publishCommandIntent(card.dataset.commandId, props)); + card.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + publishCommandIntent(card.dataset.commandId, props); + } + }); + } return card; } @@ -212,7 +348,7 @@ function renderCultComponent(node) { } if (kind === "text.dialogue" || kind === "text" || kind === "text.title") { - const text = el("div", kind === "text.title" ? "cultui-title" : "detail", props.text || ""); + const text = el("div", textClassName(kind, props), props.text || node.text || ""); bindText(text, props); return text; } @@ -327,6 +463,36 @@ function currentMesh() { return window.__eveCurrentMesh; } +function textClassName(kind, props) { + if (kind === "text.title" || props.role === "title") return "cultui-title"; + if (props.role === "mono") return "detail mono"; + return "detail"; +} + +function emptyState(message) { + const pane = el("section", "pane"); + pane.append(el("h2", "", "No Surface")); + pane.append(el("div", "detail", message)); + return pane; +} + +function publishCommandIntent(commandId, props = {}) { + const command = { + type: "surface-command", + schema: "gamecult.eve.command.v1", + providerId: currentProvider?.providerId || surfaceId.textContent, + surfaceId: currentProvider?.surfaces?.[0]?.surfaceId || surfaceId.textContent, + command: commandId || "invoke", + payload: { + transport: props.transport || null, + }, + issuedAt: new Date().toISOString(), + clientId: "browser.reference", + }; + statusEl.textContent = `command ${command.command}`; + console.info("Eve command intent", command); +} + function renderCultGraph(props) { const graph = el("section", "cultui-graph"); graph.dataset.engine = props.engine?.id || "graph";