diff --git a/docs/eve-surface-lowering.md b/docs/eve-surface-lowering.md new file mode 100644 index 0000000..9259693 --- /dev/null +++ b/docs/eve-surface-lowering.md @@ -0,0 +1,83 @@ +# Repixelizer Eve Surface Lowering + +Repixelizer now emits a read-only `gamecult.eve.surface.v1` projection for its +front page, single-page app workflow, runtime config, auth projection, queue +summary, and command intents. + +## Authority Map + +- Owner: `repixelizer.gui` still owns hosted config, route policy, queue state, + job ownership, uploads, solver execution, and browser assets. +- Inputs: `HostedDemoConfig`, `AccessController.public_payload()`, and + `GuiJobManager.get_queue_summary()`. +- Output: `repixelizer.eve_surface.build_repixelizer_eve_surface()` emits the + retained Eve surface tree, style tokens, command descriptors, and renderer + comparison notes. +- Derived state: `/eve/surface`, `repixelize eve-surface`, and + `docs/fixtures/repixelizer.eve-surface.json` are projections. They do not + decide queue truth. +- Forbidden writers: Eve renderers, Odin, Periwinkle, and fixtures must route + commands back through `/api/jobs`, `/api/jobs/{job_id}`, or `/app/`; they do + not mutate Repixelizer jobs directly. +- Shared paths: browser GUI, CLI fixture export, hosted `/eve/surface`, and + provider advertisement all name the same surface and route authority. +- Cut line: no parallel status-card service, no renderer-owned queue summary, + and no fake native canvas authority until Eve can lower upload and image-edit + controls coherently. + +## Published Surface + +Generate the deterministic fixture: + +```powershell +repixelize eve-surface --out docs/fixtures/repixelizer.eve-surface.json --updated-at 2026-06-04T00:00:00Z --public-base-url https://repixelizer.gamecult.org +``` + +The hosted GUI serves the live projection at: + +```text +/eve/surface +``` + +The provider advertisement now marks `repixelizer.operator.surface` as +`available` with an HTTP JSON lowering at `/eve/surface`. + +## Retro Style Contract + +The Eve style profile is `repixelizer.retro.pixel`. The tokens intentionally +mirror the browser CSS instead of flattening the app into generic dashboard +chrome: + +- title font: `Press Start 2P` +- body/mono font: `VT323` +- dark shell gradient and blue panel surfaces +- warm yellow/orange accent tokens +- chunky pixel borders, lower border weight, corner accents, scanline overlay, + dashed inner frame, and pixelated image sampling + +The browser lowering remains the visual oracle for this pass because it already +implements the full upload, solver inspection, comparison canvas, editor, and +retro CSS. + +## Periwinkle Comparison + +Periwinkle's Android Kotlin lowerer currently has useful structural support: +it connects to the Eve dashboard broker, traverses `surface.root`, renders +pane/card/text hierarchies, supports basic metric progress bars, and attaches +some command handlers. + +The current gap is style and control parity. `MainActivity.kt` still hard-codes +`toneColor`, `textColorFor`, and `textSizeFor`; it does not consume +`surface.styles.tokens`, does not load Repixelizer's pixel fonts, does not +render image assets from the surface tree, and does not lower the upload picker, +comparison canvas, pan/zoom inspection, eyedropper, or paint controls. + +Minimum Periwinkle parity work: + +- map provider style tokens before applying fallback tone/text/font rules +- package or cache `Press Start 2P` and `VT323` +- render `assetUri` / `assetRef` as pixelated `ImageView` content +- lower Repixelizer command controls to Android document picker and job route + calls +- add native canvas controls only after ownership remains routed through the + existing Repixelizer job and editor command paths diff --git a/docs/fixtures/repixelizer.eve-surface.json b/docs/fixtures/repixelizer.eve-surface.json new file mode 100644 index 0000000..ce73297 --- /dev/null +++ b/docs/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/docs/fixtures/repixelizer.provider-advertisement.json b/docs/fixtures/repixelizer.provider-advertisement.json index de72044..74cf930 100644 --- a/docs/fixtures/repixelizer.provider-advertisement.json +++ b/docs/fixtures/repixelizer.provider-advertisement.json @@ -26,7 +26,7 @@ } ], "freshness": { - "lastSeenAt": "2026-06-03T00:00:00Z", + "lastSeenAt": "2026-06-04T00:00:00Z", "maxAgeMs": 15000, "state": "fresh" }, @@ -139,6 +139,7 @@ ], "styleProfile": "repixelizer.product", "tokenGroups": [ + "repixelizer.retro.pixel", "repixelizer.comparisonCanvas", "repixelizer.cleanupTool", "repixelizer.queuePanel", @@ -149,13 +150,19 @@ "surfaces": [ { "audience": "operator", - "commands": [], + "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": "planned", + "status": "available", + "styleProfile": "repixelizer.retro.pixel", "surfaceId": "repixelizer.operator.surface", - "transport": "cultmesh-document" + "transport": "http-json", + "url": "https://repixelizer.gamecult.org/eve/surface" }, { "canonical": false, @@ -167,13 +174,13 @@ } ], "title": "Repixelizer", - "updatedAt": "2026-06-03T00:00:00Z", + "updatedAt": "2026-06-04T00:00:00Z", "verseId": "gamecult.local", "witnesses": [ { "freshness": { "state": "planned", - "updatedAt": "2026-06-03T00:00:00Z" + "updatedAt": "2026-06-04T00:00:00Z" }, "id": "repixelizer.hosted.witness", "kind": "cc-export-path-reserved", diff --git a/docs/repixelizer-witness-contract.md b/docs/repixelizer-witness-contract.md index 0ff90fe..fd6bbcf 100644 --- a/docs/repixelizer-witness-contract.md +++ b/docs/repixelizer-witness-contract.md @@ -67,14 +67,16 @@ The reserved witness path is `state/repixelizer.witness.cc`. - schema catalog entries for the witness shapes above - a reserved `.cc` witness entry with bulk image bytes, raw tokens, and private claims marked as redacted -- a planned canonical Eve operator surface key: +- an available canonical Eve operator surface projection at `/eve/surface`, + retaining the canonical CultMesh surface key: `cultmesh://repixelizer/surfaces/operator` - the current browser lowering at `/app/` -- route-owned command boundaries for job submit and own-job cancellation +- route-owned command boundaries for web open, job submit, and own-job cancellation - nested Verse declarations for session and hosted-operator spaces - runtime projection from `HostedDemoConfig.from_env()` and `AccessController.from_env()` This is deliberately read-only. The current queue is still in-process memory. -The advertisement only tells discovery organs where truth lives and what shape -future durable witnesses must take. +The advertisement only tells discovery organs where truth lives, where the +read-only Eve projection is served, and what shape future durable witnesses must +take. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 661db31..53e643e 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -87,7 +87,7 @@ tool palettes, and component variants. A Kotlin Android Eve runtime should be able to run the cleanup flow with native controls while preserving the same command boundary and product identity. -## Read-Only Witness Export +## Read-Only Witness And Eve Surface Export The first read-only provider advertisement is generated by: @@ -96,9 +96,20 @@ repixelize witness-advertisement --out docs/fixtures/repixelizer.provider-advert ``` It names the witness schemas above, reserves `state/repixelizer.witness.cc` for -the future CultCache export, and publishes the planned Eve operator surface key -without changing queue ownership. The detailed authority map lives in -`docs/repixelizer-witness-contract.md`. +the future CultCache export, and publishes the available Eve operator surface +HTTP projection without changing queue ownership. The detailed authority maps +live in `docs/repixelizer-witness-contract.md` and +`docs/eve-surface-lowering.md`. + +The read-only Eve surface fixture is generated by: + +```powershell +repixelize eve-surface --out docs/fixtures/repixelizer.eve-surface.json +``` + +The hosted GUI serves the live projection at `/eve/surface`. It reads +`HostedDemoConfig`, `AccessController.public_payload()`, and +`GuiJobManager.get_queue_summary()`; it does not own queue truth. ## Migration Order @@ -110,6 +121,7 @@ without changing queue ownership. The detailed authority map lives in `.cc` witness. 4. Publish the witness through CultMesh. 5. Add an Eve DSL provider over the witness and existing health/config routes. + First read-only HTTP projection is live at `/eve/surface`. 6. Translate the existing browser GUI style and tool behavior into Eve style tokens, canvas primitives, and command descriptors. 7. Lower the current website from the Eve surface instead of treating it as the diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index 69c7cd7..f57d7bf 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -6,6 +6,7 @@ import sys from .benchmark import run_roundtrip_benchmark from .compare import run_compare from .corpus import prepare_corpus +from .eve_surface import main as eve_surface_main from .gui import main as gui_main from .pipeline import run_pipeline from .spritesheet import run_spritesheet @@ -142,6 +143,13 @@ def build_parser() -> argparse.ArgumentParser: witness_parser.add_argument("--public-base-url", default=None, help="Hosted browser lowering base URL.") witness_parser.add_argument("--cc-witness-path", default=None, help="Reserved CultCache .cc witness path.") witness_parser.add_argument("--eve-surface-key", default=None, help="Planned CultMesh Eve surface key.") + eve_surface_parser = subparsers.add_parser( + "eve-surface", + help="Emit the read-only Repixelizer Eve surface projection.", + ) + eve_surface_parser.add_argument("--out", default=None, help="Write JSON to this path instead of stdout.") + eve_surface_parser.add_argument("--updated-at", default=None, help="Override the surface timestamp.") + eve_surface_parser.add_argument("--public-base-url", default=None, help="Hosted browser lowering base URL.") gui_parser = subparsers.add_parser("gui", help="Launch the web GUI.") gui_parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind") gui_parser.add_argument("--port", type=int, default=8000, help="Port to bind") @@ -159,6 +167,7 @@ def main(argv: list[str] | None = None) -> int: "tune", "prepare-corpus", "witness-advertisement", + "eve-surface", "gui", "-h", "--help", @@ -254,6 +263,16 @@ def main(argv: list[str] | None = None) -> int: if value is not None: witness_args.extend([option, str(value)]) return witness_advertisement_main(witness_args) + if command == "eve-surface": + eve_surface_args: list[str] = [] + for option, value in ( + ("--out", args.out), + ("--updated-at", args.updated_at), + ("--public-base-url", args.public_base_url), + ): + if value is not None: + eve_surface_args.extend([option, str(value)]) + return eve_surface_main(eve_surface_args) if command == "gui": return gui_main(host=args.host, port=args.port, reload=args.reload) diff --git a/src/repixelizer/eve_surface.py b/src/repixelizer/eve_surface.py new file mode 100644 index 0000000..79c0974 --- /dev/null +++ b/src/repixelizer/eve_surface.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import argparse +import json +import sys +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, TextIO + +from .access import AccessController +from .gui import HostedDemoConfig + + +EVE_SURFACE_SCHEMA = "gamecult.eve.surface.v1" +PROVIDER_ID = "repixelizer" +PROVIDER_KIND = "repixelizer.product" +DEFAULT_PUBLIC_BASE_URL = "https://repixelizer.gamecult.org" + + +def utc_now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _text(element_id: str, text: str, *, role: str = "body", **props: Any) -> dict[str, Any]: + return { + "id": element_id, + "kind": "text", + "role": role, + "text": text, + "props": {"text": text, "role": role, **props}, + "children": [], + } + + +def _card(element_id: str, title: str, children: list[dict[str, Any]], *, tone: str = "panel") -> dict[str, Any]: + return { + "id": element_id, + "kind": "card", + "text": title, + "role": "group", + "style": {"tone": tone, "variant": "repixelizer-retro-card"}, + "props": {"title": title, "styleProfile": "repixelizer.retro.pixel"}, + "children": children, + } + + +def _pane(element_id: str, title: str, children: list[dict[str, Any]], *, direction: str = "vertical") -> dict[str, Any]: + return { + "id": element_id, + "kind": "pane", + "text": title, + "role": "section", + "layout": {"direction": direction, "padding": 10}, + "style": {"tone": "warm", "variant": "repixelizer-retro-pane"}, + "props": {"title": title, "styleProfile": "repixelizer.retro.pixel"}, + "children": children, + } + + +def _command_card( + element_id: str, + title: str, + body: str, + *, + command_id: str, + route: str, + method: str, +) -> dict[str, Any]: + element = _card( + element_id, + title, + [ + _text(f"{element_id}.body", body), + _text(f"{element_id}.route", f"{method} {route}", role="mono", route=route, method=method), + ], + tone="cool", + ) + element["commandId"] = command_id + element["props"]["commandId"] = command_id + element["props"]["transport"] = {"kind": "http", "route": route, "method": method} + return element + + +def _styles(public_base_url: str) -> dict[str, Any]: + base = public_base_url.rstrip("/") + return { + "profile": "repixelizer.retro.pixel", + "tokens": { + "fontTitle": "\"Press Start 2P\", \"VT323\", monospace", + "fontBody": "\"VT323\", \"Courier New\", monospace", + "fontMono": "\"VT323\", \"Courier New\", monospace", + "colorBackgroundTop": "#01040b", + "colorBackgroundMid": "#020813", + "colorBackgroundBottom": "#01050d", + "colorShellSurfaceTop": "#061424", + "colorShellSurfaceBottom": "#030b16", + "colorPanel": "#0a2239", + "colorPanelAlt": "#061627", + "colorTextBright": "#f7efd8", + "colorText": "#efe5c9", + "colorMuted": "#d3bb7f", + "colorAccent": "#ffd84a", + "colorAccentStrong": "#ffae1a", + "colorPanelBorder": "#0a2740", + "colorPanelBorderDeep": "#06192a", + "colorCornerAccent": "#ffb72a", + "colorShadow": "rgba(0, 0, 0, 0.55)", + "colorPixelGrid": "rgba(0, 0, 0, 0.3)", + "imageRendering": "pixelated", + "borderWidthPx": 4, + "borderBottomWidthPx": 5, + "cornerAccentPx": 12, + "scanlineOverlay": True, + "dashedInnerFrame": True, + "buttonGlyph": ">", + }, + "assets": { + "fontCss": "https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap", + "landingInput": f"{base}/app/landing-assets/character-input.png", + "landingOutput": f"{base}/app/landing-assets/character-repixelized.png", + "appUrl": f"{base}/app/", + }, + "loweringHints": { + "css": "Use the tokens as CSS custom properties and retain scanline/pixel-grid overlays.", + "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.", + "tui": "Preserve title/body font distinction as role metadata; canvas editing lowers to artifact refs and command intents only.", + }, + } + + +def _runtime_cards( + *, + config: HostedDemoConfig, + auth_payload: dict[str, Any], + queue_summary: dict[str, Any], +) -> list[dict[str, Any]]: + provider_labels = [] + for provider in auth_payload.get("providers", []): + if isinstance(provider, dict): + label = provider.get("label") or provider.get("slug") + if isinstance(label, str) and label: + provider_labels.append(label) + elif isinstance(provider, str): + provider_labels.append(provider) + queue_copy = ( + f"{queue_summary.get('queueDepth', 0)} active or waiting / " + f"{queue_summary.get('queueCapacity', config.queue_capacity)} capacity" + ) + return [ + _card( + "repixelizer.runtime.config", + "Runtime", + [ + _text("repixelizer.runtime.hosted", f"hostedDemo: {config.hosted_demo}", role="mono"), + _text("repixelizer.runtime.steps", f"defaultSteps: {config.default_steps} maxSteps: {config.max_steps}", role="mono"), + _text("repixelizer.runtime.dimensions", f"maxOutputDimension: {config.max_output_dimension}px", role="mono"), + ], + ), + _card( + "repixelizer.runtime.queue", + "Queue", + [ + _text("repixelizer.queue.depth", queue_copy, role="strong"), + _text("repixelizer.queue.waiting", f"waitingCount: {queue_summary.get('waitingCount', 0)}", role="mono"), + _text("repixelizer.queue.active", f"hasActiveJob: {queue_summary.get('hasActiveJob', False)}", role="mono"), + ], + ), + _card( + "repixelizer.runtime.auth", + "Access", + [ + _text("repixelizer.auth.mode", f"mode: {auth_payload.get('mode', 'off')}", role="mono"), + _text("repixelizer.auth.required", f"required: {bool(auth_payload.get('required'))}", role="mono"), + _text("repixelizer.auth.providers", f"providers: {', '.join(provider_labels) or 'none'}"), + ], + ), + ] + + +def _lowering_comparison() -> dict[str, Any]: + return { + "periwinkleAndroidKotlin": { + "source": "E:\\Projects\\Eve\\android\\app\\src\\main\\java\\org\\gamecult\\eve\\MainActivity.kt", + "status": "surface-tree-renderer-present-style-token-gap", + "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", + ], + } + } + + +def build_repixelizer_eve_surface( + *, + updated_at: str | None = None, + public_base_url: str = DEFAULT_PUBLIC_BASE_URL, + config: HostedDemoConfig | None = None, + access_controller: AccessController | None = None, + queue_summary: dict[str, Any] | None = None, +) -> dict[str, Any]: + config = config or HostedDemoConfig.from_env() + access_controller = access_controller or AccessController.from_env(hosted_demo=config.hosted_demo) + auth_payload = access_controller.public_payload() + queue_summary = queue_summary or { + "queueDepth": 0, + "waitingCount": 0, + "queueCapacity": config.queue_capacity, + "hasActiveJob": False, + "activeStatus": None, + } + updated_at = updated_at or utc_now_iso() + base = public_base_url.rstrip("/") + root = { + "id": "repixelizer.surface.root", + "kind": "surface", + "text": "Repixelizer", + "role": "root", + "layout": {"direction": "vertical", "padding": 14}, + "style": {"tone": "warm", "variant": "repixelizer-retro-shell"}, + "props": { + "title": "Repixelizer", + "subtitle": "Fake pixel art goes in. Real grid-aligned sprite art comes out.", + "styleProfile": "repixelizer.retro.pixel", + "browserLowering": f"{base}/app/", + "frontPage": f"{base}/", + }, + "children": [ + _pane( + "repixelizer.hero", + "Repixelizer hosted demo", + [ + _text( + "repixelizer.hero.copy", + "Force fake pixel art back onto a real grid, then inspect each solver step without sanding off the crunchy old-machine charm.", + ), + _command_card( + "repixelizer.hero.open", + "Open demo", + "Browser lowering remains the full interactive app until native Eve gains upload and canvas editing parity.", + command_id="repixelizer.web.open", + route="/app/", + method="GET", + ), + ], + ), + _pane( + "repixelizer.workflow", + "Single page app", + [ + _command_card( + "repixelizer.workflow.submit", + "Upload image", + "Submit a PNG/JPEG/WebP/GIF into the GUI job queue; queue ownership stays in repixelizer.gui.", + command_id="repixelizer.job.submit", + route="/api/jobs", + method="POST", + ), + _card( + "repixelizer.workflow.inspect", + "Inspect solver", + [ + _text("repixelizer.inspect.source", "source, edge map, lattice overlay, phase field, cleaned output"), + _text("repixelizer.inspect.canvas", "comparison canvas and editor lower to native only after Periwinkle gets image/control parity"), + ], + ), + _command_card( + "repixelizer.workflow.cancel", + "Cancel own job", + "The command intent delegates to the existing owner-bound DELETE route.", + command_id="repixelizer.job.cancel_own", + route="/api/jobs/{job_id}", + method="DELETE", + ), + ], + ), + _pane("repixelizer.runtime", "Live projection", _runtime_cards(config=config, auth_payload=auth_payload, queue_summary=queue_summary)), + ], + } + return { + "type": "surface-state", + "schema": EVE_SURFACE_SCHEMA, + "providerId": PROVIDER_ID, + "providerKind": PROVIDER_KIND, + "title": "Repixelizer", + "version": 1, + "updatedAt": updated_at, + "surface": { + "id": "repixelizer.operator.surface", + "schema": EVE_SURFACE_SCHEMA, + "title": "Repixelizer", + "root": root, + "styles": _styles(public_base_url), + }, + "commands": [ + { + "command": "repixelizer.web.open", + "surfaceId": "repixelizer.operator.surface", + "transport": "http", + "route": "/app/", + "method": "GET", + "authority": "browser-lowering", + }, + { + "command": "repixelizer.job.submit", + "surfaceId": "repixelizer.operator.surface", + "transport": "http", + "route": "/api/jobs", + "method": "POST", + "authority": "repixelizer-route-policy", + }, + { + "command": "repixelizer.job.cancel_own", + "surfaceId": "repixelizer.operator.surface", + "transport": "http", + "route": "/api/jobs/{job_id}", + "method": "DELETE", + "authority": "repixelizer-job-owner", + }, + ], + "runtime": { + "hostedDemo": config.hosted_demo, + "queue": queue_summary, + "auth": auth_payload, + "limits": config.public_payload()["limits"], + }, + "loweringComparison": _lowering_comparison(), + } + + +def write_repixelizer_eve_surface( + output: Path | None, + *, + updated_at: str | None = None, + public_base_url: str = DEFAULT_PUBLIC_BASE_URL, + stream: TextIO = sys.stdout, +) -> None: + payload = build_repixelizer_eve_surface(updated_at=updated_at, public_base_url=public_base_url) + encoded = json.dumps(payload, indent=2, sort_keys=True) + if output is None: + stream.write(f"{encoded}\n") + return + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(f"{encoded}\n", encoding="utf-8") + + +def build_eve_surface_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="repixelize eve-surface", + description="Emit Repixelizer's read-only Eve surface projection.", + ) + parser.add_argument("--out", type=Path, default=None, help="Write JSON to this path instead of stdout.") + parser.add_argument("--updated-at", default=None, help="Override the surface timestamp.") + parser.add_argument("--public-base-url", default=DEFAULT_PUBLIC_BASE_URL, help="Hosted browser lowering base URL.") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_eve_surface_parser() + args = parser.parse_args(argv) + write_repixelizer_eve_surface(args.out, updated_at=args.updated_at, public_base_url=args.public_base_url) + return 0 diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index 0d3c5b5..6c7e302 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -1191,6 +1191,20 @@ def create_app(): def get_queue(): return JSONResponse(manager.get_queue_summary()) + @app.get("/eve/surface") + def get_eve_surface(request: Request): + from .eve_surface import build_repixelizer_eve_surface + + base_url = str(request.base_url).rstrip("/") + return JSONResponse( + build_repixelizer_eve_surface( + public_base_url=base_url, + config=config, + access_controller=access_controller, + queue_summary=manager.get_queue_summary(), + ) + ) + @app.get("/api/jobs/{job_id}") def get_job(job_id: str): job = manager.get_job(job_id) diff --git a/src/repixelizer/witness.py b/src/repixelizer/witness.py index 334b469..4de73eb 100644 --- a/src/repixelizer/witness.py +++ b/src/repixelizer/witness.py @@ -113,12 +113,14 @@ def build_provider_advertisement( { "surfaceId": "repixelizer.operator.surface", "schema": "gamecult.eve.surface.v1", - "transport": "cultmesh-document", + "transport": "http-json", "key": eve_surface_key, + "url": f"{public_base_url.rstrip('/')}/eve/surface", "audience": "operator", "mode": "read-only-first-cut", - "commands": [], - "status": "planned", + "commands": ["repixelizer.web.open", "repixelizer.job.submit", "repixelizer.job.cancel_own"], + "status": "available", + "styleProfile": "repixelizer.retro.pixel", }, { "surfaceId": "repixelizer.web.app", @@ -179,6 +181,7 @@ def build_provider_advertisement( { "styleProfile": "repixelizer.product", "tokenGroups": [ + "repixelizer.retro.pixel", "repixelizer.comparisonCanvas", "repixelizer.cleanupTool", "repixelizer.queuePanel", diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4aafc38..3116d69 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -35,3 +35,4 @@ {"ts": "2026-05-30T23:39:55+00:00", "type": "implementation", "status": "ok", "note": "Spritesheet automatic sizing now uses a sheet-level median texel density before reconstruction; on the 12 generated spritesheets in 019e7aa3-4bbb-7302-bafd-273f2aa31e53, worst per-sheet density drift improved from 42.21% to 1.786% across 153 sprites."} {"ts": "2026-05-31T01:13:17+00:00", "type": "implementation", "status": "ok", "note": "Spritesheet mode now supports explicit grid ownership via sheet_columns/sheet_rows plus individual sprite export. The 12 generated character sheets were rerun as 3x2 grids, producing 72 exports with no component chopping and worst within-sheet density deviation 0.124%."} {"ts": "2026-06-03T02:21:02+00:00", "type": "implementation", "status": "ok", "note": "Added read-only Repixelizer provider advertisement fixture/export naming job/queue/event/runtime/auth witness schemas and reserved state/repixelizer.witness.cc path without moving gui.py queue ownership."} +{"ts": "2026-06-04T01:55:00+00:00", "type": "implementation", "status": "ok", "note": "Added read-only Repixelizer Eve surface projection: CLI fixture/export, hosted /eve/surface route, retro pixel style tokens, route-owned command descriptors, and Android/Periwinkle lowering comparison without moving gui.py queue ownership."} diff --git a/state/map.yaml b/state/map.yaml index f241e4a..071dafc 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -54,6 +54,7 @@ accepted_design: - `src/repixelizer/access.py` owns the hosted access seam: trusted-header ingress still works, `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally, and auth attempts are adopted into local httpOnly cookie sessions. - In Heimdall mode, Repixelizer keeps a separate httpOnly refresh cookie and asks Heimdall to reissue a short-lived access claim before forcing the user back through provider OAuth. - `src/repixelizer/witness.py` owns the read-only first-cut Odin provider advertisement export. It names Repixelizer job, queue, event, runtime, and auth witness schemas plus the reserved `state/repixelizer.witness.cc` path, but it does not own queue truth or write CultCache. + - `src/repixelizer/eve_surface.py` owns the read-only Eve surface projection for Repixelizer's front page, single-page workflow, runtime/auth/queue status, retro style tokens, command descriptors, and Periwinkle lowering comparison; `/eve/surface` serves the live projection from `gui.py` without owning queue truth. - The solver starts from fixed lattice centers, adds tiny seeded noise, follows a direct hierarchical flatness signal, enforces local lattice coherence through spring relaxation, then rounds to real source pixels for final output. - Cleanup is local and secondary; it does not define the core reconstruction model. - `source_fidelity` and `source_structure` separate lattice agreement from visible structure. @@ -70,6 +71,7 @@ landed_baseline: - `source_structure` reporting alongside `source_fidelity`. - Hosted landing page and Heimdall auth seam in the web layer. - Read-only `gamecult.eve.provider_advertisement.v1` fixture/export for Odin discovery and future CultCache/Eve witness work. + - Read-only `gamecult.eve.surface.v1` fixture/export plus hosted `/eve/surface` projection, preserving Repixelizer's retro CSS identity as Eve style tokens and documenting Android/Periwinkle style-control parity gaps. rejected_paths: - name: tray_optimizer_sprawl @@ -101,7 +103,15 @@ current_status: `gamecult.eve.provider_advertisement.v1` export and checked-in fixture that names the Repixelizer job/queue/event/runtime/auth witness schemas and reserved `state/repixelizer.witness.cc` path for Odin/Huginn discovery, - without moving queue ownership out of `gui.py`. Self-host remains + and `src/repixelizer/eve_surface.py` emits a read-only + `gamecult.eve.surface.v1` projection served at `/eve/surface` and checked in + as `docs/fixtures/repixelizer.eve-surface.json`. The Eve surface keeps the + browser app as the lossless retro lowering, publishes Press Start 2P/VT323 + and pixel-shell style tokens, routes commands back to existing GUI routes, + and records that Periwinkle's Kotlin renderer can traverse the surface tree + but does not yet honor provider style tokens, pixel fonts, image assets, or + native upload/canvas controls. These exports do not move queue ownership out + of `gui.py`. Self-host remains permissive by default unless `GC_ACCESS_*` turns the seam on. The solver is shipping with a usable but not final tuned config: local search radius `0.215`, blend `0.24`, move weight `0.09`, grid alignment `4.75`, signal diff --git a/tests/test_eve_surface.py b/tests/test_eve_surface.py new file mode 100644 index 0000000..1954741 --- /dev/null +++ b/tests/test_eve_surface.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from repixelizer.cli import main as cli_main +from repixelizer.eve_surface import build_repixelizer_eve_surface + + +def test_repixelizer_eve_surface_preserves_retro_style_tokens(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_QUEUE_CAPACITY", "7") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "off") + + payload = build_repixelizer_eve_surface( + updated_at="2026-06-04T00:00:00Z", + public_base_url="https://repixelizer.example", + ) + + assert payload["schema"] == "gamecult.eve.surface.v1" + assert payload["providerId"] == "repixelizer" + assert payload["surface"]["root"]["props"]["styleProfile"] == "repixelizer.retro.pixel" + assert payload["runtime"]["hostedDemo"] is True + assert payload["runtime"]["queue"]["queueCapacity"] == 7 + + tokens = payload["surface"]["styles"]["tokens"] + assert "Press Start 2P" in tokens["fontTitle"] + assert "VT323" in tokens["fontBody"] + assert tokens["colorBackgroundTop"] == "#01040b" + assert tokens["colorAccent"] == "#ffd84a" + assert tokens["imageRendering"] == "pixelated" + assert tokens["scanlineOverlay"] is True + + commands = {entry["command"]: entry for entry in payload["commands"]} + assert commands["repixelizer.job.submit"]["route"] == "/api/jobs" + assert commands["repixelizer.job.cancel_own"]["method"] == "DELETE" + + android = payload["loweringComparison"]["periwinkleAndroidKotlin"] + assert android["status"] == "surface-tree-renderer-present-style-token-gap" + assert any("Press Start 2P" in gap for gap in android["losesUntilRendererParity"]) + + +def test_eve_surface_cli_writes_json(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "off") + out = tmp_path / "repixelizer.eve-surface.json" + + exit_code = cli_main( + [ + "eve-surface", + "--out", + str(out), + "--updated-at", + "2026-06-04T00:00:00Z", + "--public-base-url", + "https://repixelizer.example", + ] + ) + + assert exit_code == 0 + payload = json.loads(out.read_text(encoding="utf-8")) + assert payload["updatedAt"] == "2026-06-04T00:00:00Z" + assert payload["surface"]["styles"]["assets"]["appUrl"] == "https://repixelizer.example/app/" diff --git a/tests/test_gui.py b/tests/test_gui.py index b8883a0..1923c50 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1195,6 +1195,26 @@ def test_gui_queue_panel_defaults_off_for_local_runs_and_can_be_forced(monkeypat assert payload["ui"]["showQueuePanel"] is True +def test_gui_serves_eve_surface_from_live_runtime_projection(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_QUEUE_CAPACITY", "5") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "off") + + app = create_app() + status, _headers, body = asyncio.run(_get_response(app, "/eve/surface")) + payload = _response_json(type("Response", (), {"body": body})()) + + assert status == 200 + assert payload["schema"] == "gamecult.eve.surface.v1" + assert payload["surface"]["styles"]["tokens"]["colorAccent"] == "#ffd84a" + assert payload["runtime"]["queue"]["queueCapacity"] == 5 + assert payload["runtime"]["auth"]["mode"] == "off" + assert payload["surface"]["styles"]["assets"]["appUrl"] == "http://testserver/app/" + + def test_gui_queue_rejects_eleventh_waiting_job(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app diff --git a/tests/test_witness.py b/tests/test_witness.py index 0eb6c14..396afe6 100644 --- a/tests/test_witness.py +++ b/tests/test_witness.py @@ -33,6 +33,11 @@ def test_provider_advertisement_names_witness_schemas(monkeypatch, tmp_path: Pat assert witness["kind"] == "cc-export-path-reserved" assert witness["path"] == "state/repixelizer.witness.cc" assert witness["freshness"]["state"] == "planned" + surface = payload["surfaces"][0] + assert surface["status"] == "available" + assert surface["transport"] == "http-json" + assert surface["url"] == "https://repixelizer.gamecult.org/eve/surface" + assert surface["styleProfile"] == "repixelizer.retro.pixel" def test_witness_advertisement_cli_writes_json(monkeypatch, tmp_path: Path) -> None: