Add web layout probe evidence

This commit is contained in:
Meta Vens 2026-07-09 12:24:52 +01:00
commit c0c17beb45
17 changed files with 605 additions and 7 deletions

View file

@ -286,6 +286,14 @@ Exit criteria:
Recently cut:
- The web reference now has a schema-backed layout probe for the
`embedded-surface` fixture. `scripts/run-web-reference-layout-probe-smoke.ps1`
builds the browser lowerer, runs Chrome headless through
`tools/web-reference/run-layout-probe.mjs`, writes
`gamecult.eve.web_layout_probe.v1` evidence under
`artifacts/web-reference-layout-probe/latest/embedded-surface.json`, and
parity consumes that artifact so `web:embedded-surface:bounding-boxes` is
measured runtime evidence instead of `pending-runtime-probe`.
- The conformance export now carries `screenshotComparisonMetrics[]` records.
Runtime owners get structure, color-token, bounding-box, and text-presence
metrics per runtime/fixture pair without relying on byte-identical screenshots

View file

@ -434,6 +434,14 @@ box records name whether authored layout boxes exist or whether a runtime still
owes a capture/layout probe. A missing Unity, Electron, TUI, or Direct2D capture
body therefore appears as `pending-capture`, not as fake visual parity.
The web reference has the first measured bounding-box probe. The harness runs
`scripts/run-web-reference-layout-probe-smoke.ps1`, which builds the browser
lowerer and uses Chrome headless to write
`artifacts/web-reference-layout-probe/latest/embedded-surface.json` as
`gamecult.eve.web_layout_probe.v1`. Parity consumes that artifact and marks
`web:embedded-surface:bounding-boxes` as `pass` with
`evidenceLayer: runtime-layout-probe`.
The fixture matrix also includes `embedded-surface`, which proves that nested
CultMesh document slots survive the shared surface contract. The web reference,
Flutter parity body, Unity UI Toolkit lowerer, iOS UIKit renderer, and native
@ -501,9 +509,8 @@ Pending runtimes are allowed. Silent fake parity is not.
## Next Cuts
1. Attach runtime-owned image/layout probes to the screenshot comparison metric
lane so bounding-box records can move from `pending-runtime-probe` or
`pending-capture` to measured runtime evidence.
1. Extend runtime-owned image/layout probes beyond web `embedded-surface`,
starting with Sai VN in web and the Unity/Electron/TUI capture blockers.
2. Normalize text scale and font loading across web, Flutter Android, Flutter
desktop, and iOS.
3. Give iOS a real `vn.stage` scene compositor instead of compact stacked

View file

@ -2109,6 +2109,10 @@ function withJapaneseGuiFallback(stack) {
function assignId(element, node) {
if (node.id)
element.id = node.id;
if (node.id)
element.dataset.eveNodeId = node.id;
if (node.kind)
element.dataset.eveKind = node.kind;
}
function objectProps(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};

View file

@ -2454,6 +2454,8 @@ function withJapaneseGuiFallback(stack: string): string {
function assignId(element: HTMLElement, node: EveSurfaceComponent): void {
if (node.id) element.id = node.id;
if (node.id) element.dataset.eveNodeId = node.id;
if (node.kind) element.dataset.eveKind = node.kind;
}
function objectProps(value: unknown): Record<string, unknown> {

View file

@ -0,0 +1,108 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.web_layout_probe.v1",
"title": "Eve Web Layout Probe",
"type": "object",
"required": [
"schema",
"runtimeId",
"providerId",
"surfaceId",
"fixtureId",
"generatedAt",
"viewport",
"summary",
"nodes"
],
"properties": {
"schema": {
"const": "gamecult.eve.web_layout_probe.v1"
},
"runtimeId": {
"const": "web"
},
"providerId": {
"type": "string",
"minLength": 1
},
"surfaceId": {
"type": "string",
"minLength": 1
},
"fixtureId": {
"type": "string",
"minLength": 1
},
"generatedAt": {
"type": "string",
"minLength": 1
},
"viewport": {
"type": "object",
"required": ["width", "height"],
"properties": {
"width": {
"type": "number"
},
"height": {
"type": "number"
}
},
"additionalProperties": true
},
"summary": {
"type": "object",
"required": ["nodeCount", "measuredNodeCount", "zeroAreaCount"],
"properties": {
"nodeCount": {
"type": "number"
},
"measuredNodeCount": {
"type": "number"
},
"zeroAreaCount": {
"type": "number"
}
},
"additionalProperties": true
},
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["nodeId", "kind", "rect"],
"properties": {
"nodeId": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"minLength": 1
},
"rect": {
"type": "object",
"required": ["x", "y", "width", "height"],
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"width": {
"type": "number"
},
"height": {
"type": "number"
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}
}
},
"additionalProperties": true
}

View file

@ -47,6 +47,7 @@ try {
--expect-schema gamecult.eve.provider_handoff.v1 `
--expect-schema gamecult.eve.electron_shell_projection.v1 `
--expect-schema gamecult.eve.tui_grid.v1 `
--expect-schema gamecult.eve.web_layout_probe.v1 `
--expect-capability-gap runtime:Fensalir:direct2d:capture:missing `
--expect-world-lowering-coverage aetheria:aetheria.daemon.game:web-reference:claimed:Eve:web `
--expect-world-lowering-coverage aetheria:aetheria.daemon.game:unity-uitoolkit:claimed:EveUnity:unity-uitoolkit `
@ -65,7 +66,7 @@ try {
--expect-screenshot-metric web:embedded-surface:structure:pass `
--expect-screenshot-metric web:embedded-surface:color-tokens:pass `
--expect-screenshot-metric web:embedded-surface:text-presence:pass `
--expect-screenshot-metric web:embedded-surface:bounding-boxes:pending-runtime-probe `
--expect-screenshot-metric web:embedded-surface:bounding-boxes:pass `
--expect-screenshot-metric electron-shell:aetheria-world:bounding-boxes:pending-capture `
--expect-capability-gap "split-target:EveUnity:EveUnity:proof:Tagged UPM release" `
--expect-conformance-handoff

View file

@ -92,6 +92,7 @@ foreach ($contract in @(
"gamecult.eve.provider_handoff.v1",
"gamecult.eve.electron_shell_projection.v1",
"gamecult.eve.tui_grid.v1",
"gamecult.eve.web_layout_probe.v1",
"gamecult.eve.runtime_capability.v1",
"gamecult.eve.runtime_lifecycle.v1",
"gamecult.eve.runtime_split_handoff.v1"
@ -128,6 +129,7 @@ if (Test-Path -LiteralPath (Join-Path $absoluteExportDirectory "index.json")) {
"gamecult.eve.provider_handoff.v1",
"gamecult.eve.electron_shell_projection.v1",
"gamecult.eve.tui_grid.v1",
"gamecult.eve.web_layout_probe.v1",
"gamecult.eve.runtime_capability.v1",
"gamecult.eve.runtime_lifecycle.v1",
"gamecult.eve.runtime_split_handoff.v1",

View file

@ -13,6 +13,7 @@ $env:EVE_PARITY_OUTPUT = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
Push-Location $projectRoot
try {
& (Join-Path $PSScriptRoot "run-web-reference-layout-probe-smoke.ps1")
node .\tools\parity\run-parity.mjs
& (Join-Path $PSScriptRoot "run-eveconformance-handoff-smoke.ps1")
& (Join-Path $PSScriptRoot "run-plugin-handoff-smoke.ps1")

View file

@ -47,6 +47,7 @@ node $consumerScript $consumerExport `
--expect-schema gamecult.eve.runtime_split_handoff.v1 `
--expect-schema gamecult.eve.electron_shell_projection.v1 `
--expect-schema gamecult.eve.tui_grid.v1 `
--expect-schema gamecult.eve.web_layout_probe.v1 `
--expect-runtime-status web:active `
--expect-runtime-status windows-flutter:active `
--expect-runtime-status linux-flutter:active `
@ -126,7 +127,7 @@ node $consumerScript $consumerExport `
--expect-screenshot-metric web:embedded-surface:structure:pass `
--expect-screenshot-metric web:embedded-surface:color-tokens:pass `
--expect-screenshot-metric web:embedded-surface:text-presence:pass `
--expect-screenshot-metric web:embedded-surface:bounding-boxes:pending-runtime-probe `
--expect-screenshot-metric web:embedded-surface:bounding-boxes:pass `
--expect-screenshot-metric unity-scene:aetheria-world:bounding-boxes:pending-capture `
--expect-screenshot-metric electron-shell:aetheria-world:bounding-boxes:pending-capture `
--expect-screenshot-metric tui:aetheria-world:bounding-boxes:pending-capture `

View file

@ -0,0 +1,59 @@
param(
[string] $ProviderId = "gamecult.eve.embedded-demo",
[string] $FixtureId = "embedded-surface",
[string] $OutputPath = "artifacts\web-reference-layout-probe\latest\embedded-surface.json",
[int] $Port = 8892,
[int] $Width = 1280,
[int] $Height = 720
)
$ErrorActionPreference = "Stop"
$projectRoot = Split-Path -Parent $PSScriptRoot
$absoluteOutputPath = if ([System.IO.Path]::IsPathRooted($OutputPath)) {
$OutputPath
} else {
Join-Path $projectRoot $OutputPath
}
Push-Location $projectRoot
try {
npm --prefix packages/eve-browser-lowering run build | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Eve browser lowering build failed with exit code $LASTEXITCODE"
}
node .\tools\web-reference\run-layout-probe.mjs `
--provider $ProviderId `
--fixture $FixtureId `
--output $OutputPath `
--port $Port `
--width $Width `
--height $Height | Out-Host
if ($LASTEXITCODE -ne 0) {
throw "Web reference layout probe failed with exit code $LASTEXITCODE"
}
if (-not (Test-Path $absoluteOutputPath)) {
throw "Web reference layout probe did not write output: $absoluteOutputPath"
}
$probe = Get-Content $absoluteOutputPath -Raw | ConvertFrom-Json
if ($probe.schema -ne "gamecult.eve.web_layout_probe.v1") {
throw "Unexpected web layout probe schema: $($probe.schema)"
}
if ($probe.runtimeId -ne "web") {
throw "Unexpected web layout probe runtime: $($probe.runtimeId)"
}
if ($probe.providerId -ne $ProviderId) {
throw "Unexpected web layout probe provider: $($probe.providerId)"
}
if ($probe.fixtureId -ne $FixtureId) {
throw "Unexpected web layout probe fixture: $($probe.fixtureId)"
}
if ($probe.summary.measuredNodeCount -le 0) {
throw "Web layout probe measured no node boxes."
}
Write-Host "Web reference layout probe smoke passed: $absoluteOutputPath"
} finally {
Pop-Location
}

View file

@ -28,6 +28,7 @@
"schemas/gamecult.eve.provider_handoff.v1.schema.json",
"schemas/gamecult.eve.electron_shell_projection.v1.schema.json",
"schemas/gamecult.eve.tui_grid.v1.schema.json",
"schemas/gamecult.eve.web_layout_probe.v1.schema.json",
"schemas/gamecult.eve.runtime_capability.v1.schema.json",
"schemas/gamecult.eve.runtime_lifecycle.v1.schema.json",
"schemas/gamecult.eve.runtime_split_handoff.v1.schema.json",
@ -92,6 +93,7 @@
"gamecult.eve.provider_handoff.v1",
"gamecult.eve.electron_shell_projection.v1",
"gamecult.eve.tui_grid.v1",
"gamecult.eve.web_layout_probe.v1",
"gamecult.eve.runtime_capability.v1",
"gamecult.eve.runtime_lifecycle.v1",
"gamecult.eve.runtime_split_handoff.v1"

View file

@ -24,6 +24,7 @@
"gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json",
"gamecult.eve.runtime_release_request.v1": "schemas/gamecult.eve.runtime_release_request.v1.schema.json",
"gamecult.eve.runtime_capture_request.v1": "schemas/gamecult.eve.runtime_capture_request.v1.schema.json",
"gamecult.eve.web_layout_probe.v1": "schemas/gamecult.eve.web_layout_probe.v1.schema.json",
"gamecult.eve.provider_scenario.v1": "schemas/gamecult.eve.provider_scenario.v1.schema.json",
"gamecult.eve.provider_advertisement.v1": "schemas/gamecult.eve.provider_advertisement.v1.schema.json",
"gamecult.eve.provider_handoff.v1": "schemas/gamecult.eve.provider_handoff.v1.schema.json",
@ -1112,7 +1113,17 @@
},
"capture": {
"status": "chrome-headless",
"note": "Chrome headless captures phone, tablet, and desktop PNGs through the provider query parameter."
"note": "Chrome headless captures phone, tablet, and desktop PNGs through the provider query parameter.",
"layoutProbes": [
{
"fixtureId": "embedded-surface",
"providerId": "gamecult.eve.embedded-demo",
"surfaceId": "gamecult.eve.embedded-demo",
"schema": "gamecult.eve.web_layout_probe.v1",
"path": "artifacts/web-reference-layout-probe/latest/embedded-surface.json",
"runner": "scripts/run-web-reference-layout-probe-smoke.ps1"
}
]
}
},
{

View file

@ -2503,6 +2503,47 @@ function buildColorTokenMetric(runtime, fixture) {
}
function buildBoundingBoxMetric(runtime, fixture) {
const layoutProbe = readRuntimeLayoutProbe(runtime, fixture.id);
if (layoutProbe.config) {
if (layoutProbe.document && !layoutProbe.errors.length) {
const measured = Number(layoutProbe.document.summary?.measuredNodeCount || 0);
const total = Number(layoutProbe.document.summary?.nodeCount || 0);
return buildScreenshotMetric(runtime, fixture, {
metricKind: "bounding-boxes",
status: measured > 0 ? "pass" : "fail",
score: ratio(measured, total),
expected: {
schema: layoutProbe.config.schema || "",
path: layoutProbe.config.path || "",
providerId: layoutProbe.config.providerId || "",
surfaceId: layoutProbe.config.surfaceId || "",
},
actual: {
nodeCount: total,
measuredNodeCount: measured,
zeroAreaCount: Number(layoutProbe.document.summary?.zeroAreaCount || 0),
path: layoutProbe.config.path || "",
},
evidenceLayer: "runtime-layout-probe",
detail: "Compares runtime-owned DOM bounding boxes from the web reference layout probe.",
});
}
return buildScreenshotMetric(runtime, fixture, {
metricKind: "bounding-boxes",
status: "fail",
score: 0,
expected: {
schema: layoutProbe.config.schema || "",
path: layoutProbe.config.path || "",
},
actual: {
errors: layoutProbe.errors,
},
evidenceLayer: "runtime-layout-probe",
detail: "Declared runtime layout probe exists, but the artifact is missing or invalid.",
});
}
const captureStatus = runtime.capture?.status || "";
const runtimeHasCaptureBody = ["chrome-headless", "ssh-png", "adb-png", "golden", "ssh-golden"].includes(captureStatus);
let status = "pass";
@ -2564,7 +2605,7 @@ function buildScreenshotMetric(runtime, fixture, metric) {
score: Number(metric.score.toFixed(3)),
expected: metric.expected,
actual: metric.actual,
evidenceLayer: "surface-structure",
evidenceLayer: metric.evidenceLayer || "surface-structure",
detail: metric.detail,
};
}
@ -2573,6 +2614,37 @@ function ratio(numerator, denominator) {
return denominator ? Math.max(0, Math.min(1, numerator / denominator)) : 1;
}
function readRuntimeLayoutProbe(runtime, fixtureId) {
const config = (runtime.capture?.layoutProbes || []).find(candidate => candidate.fixtureId === fixtureId);
if (!config) return { config: null, document: null, errors: [] };
const errors = [];
const probePath = config.path || "";
const absoluteProbePath = path.join(repoRoot, probePath);
if (!probePath || !existsSync(absoluteProbePath)) {
return { config, document: null, errors: [`${probePath || "layoutProbe.path"}:missing`] };
}
let document;
try {
document = JSON.parse(readFileSync(absoluteProbePath, "utf8"));
} catch (error) {
return { config, document: null, errors: [`${probePath}:invalid-json:${error instanceof Error ? error.message : String(error)}`] };
}
if (config.schema && document.schema !== config.schema) errors.push(`${probePath}:schema:expected ${config.schema} got ${document.schema || ""}`);
if (config.providerId && document.providerId !== config.providerId) errors.push(`${probePath}:providerId:expected ${config.providerId} got ${document.providerId || ""}`);
if (config.surfaceId && document.surfaceId !== config.surfaceId) errors.push(`${probePath}:surfaceId:expected ${config.surfaceId} got ${document.surfaceId || ""}`);
if (document.fixtureId !== fixtureId) errors.push(`${probePath}:fixtureId:expected ${fixtureId} got ${document.fixtureId || ""}`);
const schemaPath = config.schema ? manifest.schemas?.[config.schema] : "";
if (schemaPath) {
try {
const schema = JSON.parse(readFileSync(path.join(repoRoot, schemaPath), "utf8"));
errors.push(...validateSchemaSubset(schema, document, `layoutProbe:${fixtureId}`));
} catch (error) {
errors.push(`${schemaPath}:unreadable:${error instanceof Error ? error.message : String(error)}`);
}
}
return { config, document, errors };
}
function collectSplitTargetBlockers(report) {
return (report.splitTargets || []).flatMap(target => target.blockerRecords || []);
}

View file

@ -0,0 +1,191 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { existsSync, readFile as readFileCallback } from "node:fs";
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const args = parseArgs(process.argv.slice(2));
const port = Number(args.port || 8892);
const debugPort = Number(args.debugPort || 18892);
const providerId = args.provider || "gamecult.eve.embedded-demo";
const fixtureId = args.fixture || "embedded-surface";
const width = Number(args.width || 1280);
const height = Number(args.height || 720);
const outputPath = path.resolve(repoRoot, args.output || `artifacts/web-reference-layout-probe/latest/${fixtureId}.json`);
const server = createStaticServer(repoRoot);
await new Promise(resolve => server.listen(port, "127.0.0.1", resolve));
const profile = await mkdtemp(path.join(os.tmpdir(), "eve-web-layout-probe-"));
try {
const url = `http://127.0.0.1:${port}/web/layout-probe.html?provider=${encodeURIComponent(providerId)}&fixture=${encodeURIComponent(fixtureId)}`;
const probe = await runChromeProbe(url, profile);
if (probe.error) throw new Error(probe.error);
if (probe.schema !== "gamecult.eve.web_layout_probe.v1") throw new Error(`Unexpected probe schema: ${probe.schema || ""}`);
if (probe.runtimeId !== "web") throw new Error(`Unexpected probe runtime: ${probe.runtimeId || ""}`);
if (probe.providerId !== providerId) throw new Error(`Unexpected probe provider: ${probe.providerId || ""}`);
if (probe.fixtureId !== fixtureId) throw new Error(`Unexpected probe fixture: ${probe.fixtureId || ""}`);
if (!probe.summary?.measuredNodeCount) throw new Error("Web layout probe measured no node boxes.");
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(probe, null, 2)}\n`);
console.log(outputPath);
} finally {
server.close();
await rm(profile, { recursive: true, force: true });
}
function createStaticServer(root) {
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",
".eve": "text/plain; charset=utf-8",
};
return createServer((request, response) => {
const urlPath = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
const clean = urlPath === "/" ? "/web/index.html" : urlPath;
const file = path.normalize(path.join(root, clean));
if (!file.startsWith(root)) {
response.writeHead(403);
response.end("forbidden");
return;
}
readFileCallback(file, (error, data) => {
if (error) {
response.writeHead(404);
response.end("not found");
return;
}
response.writeHead(200, { "content-type": types[path.extname(file)] || "application/octet-stream" });
response.end(data);
});
});
}
async function runChromeProbe(url, profile) {
const chrome = findChrome();
const child = spawn(chrome, [
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profile}`,
`--window-size=${width},${height}`,
url,
], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
let closed = false;
child.stderr.on("data", chunk => { stderr += chunk; });
child.once("close", () => { closed = true; });
try {
const target = await waitForPageTarget(url);
const client = await connectCdp(target.webSocketDebuggerUrl);
try {
await client.send("Runtime.enable");
for (let attempt = 0; attempt < 100; attempt += 1) {
const ready = await client.send("Runtime.evaluate", {
expression: "document.body?.dataset.probeReady === 'true'",
returnByValue: true,
});
if (ready.result?.value === true) break;
await delay(100);
}
const result = await client.send("Runtime.evaluate", {
expression: "document.querySelector('#eve-layout-probe-output')?.textContent || ''",
returnByValue: true,
});
const text = result.result?.value || "";
if (!text.trim()) throw new Error("Chrome layout probe produced an empty output document.");
return JSON.parse(text);
} finally {
client.close();
}
} finally {
if (!closed) {
child.kill();
await new Promise(resolve => child.once("close", resolve));
}
if (stderr && process.env.EVE_DEBUG_WEB_LAYOUT_PROBE) process.stderr.write(stderr);
}
}
async function waitForPageTarget(url) {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
const response = await fetch(`http://127.0.0.1:${debugPort}/json`);
const targets = await response.json();
const target = targets.find(candidate => candidate.type === "page" && candidate.url === url)
|| targets.find(candidate => candidate.type === "page" && candidate.url?.includes("/web/layout-probe.html"));
if (target?.webSocketDebuggerUrl) return target;
} catch {
// Chrome is still booting.
}
await delay(100);
}
throw new Error(`Timed out waiting for Chrome DevTools target on port ${debugPort}.`);
}
function connectCdp(webSocketUrl) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(webSocketUrl);
let nextId = 1;
const pending = new Map();
socket.addEventListener("open", () => {
resolve({
send(method, params = {}) {
const id = nextId++;
socket.send(JSON.stringify({ id, method, params }));
return new Promise((messageResolve, messageReject) => {
pending.set(id, { resolve: messageResolve, reject: messageReject });
});
},
close() {
socket.close();
},
});
});
socket.addEventListener("message", event => {
const message = JSON.parse(event.data);
if (!message.id || !pending.has(message.id)) return;
const callbacks = pending.get(message.id);
pending.delete(message.id);
if (message.error) callbacks.reject(new Error(message.error.message || JSON.stringify(message.error)));
else callbacks.resolve(message.result || {});
});
socket.addEventListener("error", reject);
});
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function findChrome() {
const candidates = [
process.env.CHROME_PATH,
path.join(process.env.ProgramFiles || "", "Google/Chrome/Application/chrome.exe"),
path.join(process.env["ProgramFiles(x86)"] || "", "Google/Chrome/Application/chrome.exe"),
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
].filter(Boolean);
const chrome = candidates.find(candidate => existsSync(candidate));
if (!chrome) throw new Error("Chrome is required for the web layout probe and was not found.");
return chrome;
}
function parseArgs(values) {
const parsed = {};
for (let index = 0; index < values.length; index += 1) {
const key = values[index];
if (!key.startsWith("--")) continue;
parsed[key.slice(2)] = values[index + 1];
index += 1;
}
return parsed;
}

View file

@ -92,6 +92,10 @@
"status": "chrome-headless",
"evidencePaths": [
"scripts/capture-web-reference.ps1",
"scripts/run-web-reference-layout-probe-smoke.ps1",
"tools/web-reference/run-layout-probe.mjs",
"web/layout-probe.html",
"web/layout-probe.js",
"scripts/run-parity-smoke.ps1",
"tools/parity/render-contact-sheet.py"
],

15
web/layout-probe.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Eve Web Layout Probe</title>
<link rel="stylesheet" href="../packages/eve-browser-lowering/styles.css" />
<link rel="stylesheet" href="./cultui-reference-overrides.css" />
</head>
<body>
<main id="app" class="surface"></main>
<pre id="eve-layout-probe-output"></pre>
<script type="module" src="./layout-probe.js"></script>
</body>
</html>

110
web/layout-probe.js Normal file
View file

@ -0,0 +1,110 @@
import { renderEveSurface } from "../packages/eve-browser-lowering/dist/index.js";
import { compileEveDsl } from "./eve-dsl.js";
const app = document.querySelector("#app");
const output = document.querySelector("#eve-layout-probe-output");
const params = new URLSearchParams(location.search);
const providerId = params.get("provider") || "gamecult.eve.embedded-demo";
const fixtureId = params.get("fixture") || providerId;
try {
const provider = await loadProvider(providerId);
const surfaceEntry = provider.surfaces?.[0];
if (!surfaceEntry) throw new Error(`${providerId} has no advertised surface`);
const state = await loadSurface(surfaceEntry);
renderEveSurface(state, app, {
activeSurfaceId: state.surface?.id,
body: document.body,
clientId: "browser.reference.layout-probe",
provider,
source: "web layout probe",
});
await animationFrame();
await animationFrame();
publishProbe(measureLayout(state, provider, fixtureId));
} catch (error) {
publishProbe({
schema: "gamecult.eve.web_layout_probe.v1",
runtimeId: "web",
providerId,
surfaceId: "",
fixtureId,
generatedAt: new Date().toISOString(),
viewport: { width: window.innerWidth, height: window.innerHeight },
summary: { nodeCount: 0, measuredNodeCount: 0, zeroAreaCount: 0 },
nodes: [],
error: error instanceof Error ? error.message : String(error),
});
}
async function loadProvider(requestedProviderId) {
const response = await fetch("./local-provider-catalog.json", { cache: "no-store" });
const catalog = await response.json();
const provider = (catalog.providers || []).find(candidate =>
candidate.providerId === requestedProviderId || candidate.aliases?.includes(requestedProviderId));
if (!provider) throw new Error(`Unknown local provider: ${requestedProviderId}`);
if (!provider.advertisement) return provider;
const advertisement = await (await fetch(provider.advertisement, { cache: "no-store" })).json();
return {
...advertisement,
...provider,
surfaces: provider.surfaces || advertisement.surfaces || [],
localAdvertisement: advertisement,
};
}
async function loadSurface(surface) {
const response = await fetch(surface.url, { cache: "no-store" });
const source = await response.text();
if (surface.transport === "local-eve-dsl") return compileEveDsl(source);
if (surface.transport === "local-json") return JSON.parse(source);
throw new Error(`Unsupported probe surface transport: ${surface.transport}`);
}
function measureLayout(state, provider, fixture) {
const nodes = [...document.querySelectorAll("[data-eve-node-id]")].map(element => {
const rect = element.getBoundingClientRect();
return {
nodeId: element.dataset.eveNodeId || "",
kind: element.dataset.eveKind || "",
rect: {
x: round(rect.x),
y: round(rect.y),
width: round(rect.width),
height: round(rect.height),
},
};
}).filter(node => node.nodeId && node.kind);
const measuredNodeCount = nodes.filter(node => node.rect.width > 0 && node.rect.height > 0).length;
return {
schema: "gamecult.eve.web_layout_probe.v1",
runtimeId: "web",
providerId: state.providerId || provider.providerId || providerId,
surfaceId: state.surface?.id || "",
fixtureId: fixture,
generatedAt: new Date().toISOString(),
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
summary: {
nodeCount: nodes.length,
measuredNodeCount,
zeroAreaCount: nodes.length - measuredNodeCount,
},
nodes,
};
}
function publishProbe(probeDocument) {
output.textContent = JSON.stringify(probeDocument, null, 2);
document.body?.setAttribute?.("data-probe-ready", "true");
}
function animationFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
function round(value) {
return Math.round(value * 100) / 100;
}