Restore GUI operator HUD overlay

This commit is contained in:
Meta Vens 2026-05-05 03:49:37 +01:00
commit df735b92e3
4 changed files with 118 additions and 102 deletions

View file

@ -6,6 +6,7 @@ import { createServer } from "vite";
const root = resolve(import.meta.dirname, "..", "..", "..");
const artifactDir = resolve(root, ".epiphany-gui");
const desktopScreenshotPath = resolve(artifactDir, "operator-console-smoke-desktop.png");
const wideScreenshotPath = resolve(artifactDir, "operator-console-smoke-wide.png");
const mobileScreenshotPath = resolve(artifactDir, "operator-console-smoke-mobile.png");
const url = "http://127.0.0.1:1420";
const fluidStorageKey = "epiphany:aquarium-fluid-params:v3";
@ -25,10 +26,8 @@ const server = await createServer({
try {
await server.listen();
await waitForServer(url);
const browser = await chromium.launch({
channel: "msedge",
headless: true,
});
const browser = await launchSmokeBrowser();
const wide = await smokeViewport(browser, { width: 2048, height: 1024 }, wideScreenshotPath, false);
const desktop = await smokeViewport(browser, { width: 1366, height: 900 }, desktopScreenshotPath, true);
const mobile = await smokeViewport(browser, { width: 390, height: 844 }, mobileScreenshotPath, false);
await browser.close();
@ -36,8 +35,8 @@ try {
JSON.stringify(
{
ok: true,
screenshots: [desktopScreenshotPath, mobileScreenshotPath],
probes: { desktop, mobile },
screenshots: [wideScreenshotPath, desktopScreenshotPath, mobileScreenshotPath],
probes: { wide, desktop, mobile },
},
null,
2,
@ -47,6 +46,20 @@ try {
await server.close();
}
async function launchSmokeBrowser() {
const requestedChannel = process.env.EPIPHANY_SMOKE_BROWSER;
const channels = requestedChannel ? [requestedChannel] : ["chrome", "msedge"];
let lastError = null;
for (const channel of channels) {
try {
return await chromium.launch({ channel, headless: true });
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPanel) {
const page = await browser.newPage({ viewport });
await page.goto(url, { waitUntil: "networkidle" });
@ -61,20 +74,26 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
if (!smokeProbe.nonBlank) {
throw new Error(`agent smoke canvas did not render: ${smokeProbe.reason}`);
}
if (!crispProbe.nonBlank) {
throw new Error(`agent crisp canvas did not render: ${crispProbe.reason}`);
}
const hiddenSurface = await page.evaluate(() => {
return [".immersiveTopbar", ".deckRail", ".diegeticPanel", ".hudToastStack"].every((selector) => {
const operatorSurface = await page.evaluate(() => {
return [".immersiveTopbar", ".deckRail", ".diegeticPanel"].every((selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const style = window.getComputedStyle(element);
return style.opacity === "0" && style.pointerEvents === "none";
const rect = element.getBoundingClientRect();
return (
style.opacity !== "0" &&
style.visibility !== "hidden" &&
rect.width > 20 &&
rect.height > 20
);
});
});
if (!hiddenSurface) {
throw new Error("static operator overlays are still visible or interactive");
if (!operatorSurface) {
throw new Error("static operator overlays are not visible");
}
if (!crispProbe.nonBlank && !operatorSurface) {
throw new Error(`no crisp operator surface rendered: ${crispProbe.reason}`);
}
let persistedParams = null;
@ -83,7 +102,8 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
await page.reload({ waitUntil: "networkidle" });
await page.locator(".agentSmokeCanvas").waitFor();
await page.waitForTimeout(700);
await page.mouse.click(viewport.width - 48, viewport.height - 48);
const inspectorGuard = viewport.width >= 720 ? Math.min(230, viewport.height * 0.25) : 0;
await page.mouse.click(viewport.width - 48, viewport.height - 48 - inspectorGuard);
await page.waitForTimeout(120);
const railY = Math.round(viewport.height * 0.47);
await page.mouse.move(viewport.width - 226, railY);
@ -125,14 +145,18 @@ async function probeCanvas(page, selector) {
}
const context = canvas.getContext("2d");
if (!context) return { nonBlank: false, reason: "no readable canvas context" };
const width = Math.min(64, canvas.width);
const height = Math.min(64, canvas.height);
const centerX = Math.max(0, Math.floor(canvas.width / 2 - width / 2));
const centerY = Math.max(0, Math.floor(canvas.height / 2 - height / 2));
const pixels = context.getImageData(centerX, centerY, width, height).data;
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const stride = Math.max(4, Math.floor(pixels.length / 4096 / 4) * 4);
let nonBlank = false;
for (let index = 0; index < pixels.length - 3; index += stride) {
if (pixels[index] !== 0 || pixels[index + 1] !== 0 || pixels[index + 2] !== 0 || pixels[index + 3] !== 0) {
nonBlank = true;
break;
}
}
return {
nonBlank: Array.from(pixels).some((value) => value !== 0),
reason: "2d sample",
nonBlank,
reason: "2d whole-canvas sample",
};
});
}

View file

@ -1517,60 +1517,65 @@ function AgentConstellation({
aria-hidden="true"
/>
<div className="agentStageVignette" aria-hidden="true" />
{variant === "band" ? (
agents.map((agent) => (
<button
className={`agentCharacter ${agent.shape} ${agent.tone} ${selectedAgentId === agent.id ? "selected" : ""}`}
key={agent.id}
type="button"
data-agent-node={agent.id}
onClick={() => setSelectedAgentId(agent.id)}
title={`${agent.name}: ${agent.thought}`}
style={
{
"--agent-x": `${agent.baseX}%`,
"--agent-y": `${agent.baseY}%`,
"--agent-color": agent.color,
"--agent-glow": agent.glow,
"--agent-activity": agent.activity,
"--agent-bubble-opacity": 0.38 + agent.activity * 0.28,
} as React.CSSProperties
}
>
<span className="agentGlyph" aria-hidden="true">{agent.glyph}</span>
<span className="agentCaption">
<strong>{agent.name}</strong>
<span>{agent.status}</span>
</span>
</button>
))
) : (
<div className="simulationOnlyControls">
{agents.map((agent) => (
<button
type="button"
key={agent.id}
onClick={() => setSelectedAgentId(agent.id)}
aria-label={`${agent.name} ${agent.status}`}
/>
))}
{agents.map((agent) => (
<button
className={`agentCharacter ${agent.shape} ${agent.tone} ${selectedAgentId === agent.id ? "selected" : ""}`}
key={agent.id}
type="button"
data-agent-node={agent.id}
onClick={() => setSelectedAgentId(agent.id)}
title={`${agent.name}: ${agent.thought}`}
style={
{
"--agent-x": `${agent.baseX}%`,
"--agent-y": `${agent.baseY}%`,
"--agent-color": agent.color,
"--agent-glow": agent.glow,
"--agent-activity": agent.activity,
"--agent-bubble-opacity": 0.38 + agent.activity * 0.28,
} as React.CSSProperties
}
>
<span className="agentGlyph" aria-hidden="true">{agent.glyph}</span>
<span className="agentCaption">
<strong>{agent.name}</strong>
<span>{agent.status}</span>
</span>
</button>
))}
{agents.map((agent) => (
<div
className={`thoughtBubble ${agent.tone} ${selectedAgentId === agent.id ? "selected" : ""}`}
key={`${agent.id}-thought`}
data-agent-thought={agent.id}
style={
{
"--agent-x": `${agent.baseX}%`,
"--agent-y": `${agent.baseY}%`,
"--agent-color": agent.color,
"--agent-glow": agent.glow,
"--agent-activity": agent.activity,
"--agent-bubble-opacity": 0.38 + agent.activity * 0.28,
} as React.CSSProperties
}
>
<strong>{agent.name}</strong>
<span>{agent.thought}</span>
</div>
)}
{variant === "band" && (
<div className="constellationInspector">
<div>
<span>{selectedAgent.title}</span>
<strong>{selectedAgent.name}</strong>
<p>{selectedAgent.thought}</p>
</div>
<dl className="facts compact">
<div><dt>Status</dt><dd><Pill tone={selectedAgent.tone}>{selectedAgent.status}</Pill></dd></div>
<div><dt>Detail</dt><dd>{selectedAgent.detail}</dd></div>
<div><dt>Jobs</dt><dd>{selectedAgent.jobs}</dd></div>
<div><dt>Review</dt><dd>{selectedAgent.review}</dd></div>
</dl>
))}
<div className="constellationInspector">
<div>
<span>{selectedAgent.title}</span>
<strong>{selectedAgent.name}</strong>
<p>{selectedAgent.thought}</p>
</div>
)}
<dl className="facts compact">
<div><dt>Status</dt><dd><Pill tone={selectedAgent.tone}>{selectedAgent.status}</Pill></dd></div>
<div><dt>Detail</dt><dd>{selectedAgent.detail}</dd></div>
<div><dt>Jobs</dt><dd>{selectedAgent.jobs}</dd></div>
<div><dt>Review</dt><dd>{selectedAgent.review}</dd></div>
</dl>
</div>
</div>
</section>
);

View file

@ -888,8 +888,6 @@ class WebglAquariumRenderer implements AquariumRenderer {
this.drawThoughtSource(ctx, activeAgent);
this.drawOptionsSource(ctx, activeAgent);
}
this.drawDeckSource(ctx);
this.drawOperatorSource(ctx, time);
this.drawParameterImpulseSource(ctx, time);
ctx.restore();
}
@ -1189,16 +1187,18 @@ class WebglAquariumRenderer implements AquariumRenderer {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.crispCanvas.width, this.crispCanvas.height);
ctx.scale(scaleX, scaleY);
for (const agent of projected) {
const hot = agent.id === activeAgent?.id || agent.id === this.frame.selectedAgentId;
this.drawCrispAgent(ctx, agent, hot);
if (this.frame.variant !== "fullscreen") {
for (const agent of projected) {
const hot = agent.id === activeAgent?.id || agent.id === this.frame.selectedAgentId;
this.drawCrispAgent(ctx, agent, hot);
}
if (activeAgent) {
this.drawCrispThought(ctx, activeAgent);
this.drawCrispOptions(ctx, activeAgent);
}
this.drawCrispDeck(ctx);
this.drawOperatorUi(ctx, time);
}
if (activeAgent) {
this.drawCrispThought(ctx, activeAgent);
this.drawCrispOptions(ctx, activeAgent);
}
this.drawCrispDeck(ctx);
this.drawOperatorUi(ctx, time);
this.drawFluidPanel(ctx, time);
ctx.restore();
}
@ -1489,8 +1489,11 @@ class WebglAquariumRenderer implements AquariumRenderer {
private drawFluidPanel(ctx: CanvasRenderingContext2D, time: number) {
this.fluidParamZones = [];
const iconSize = 32;
const inspectorGuard = this.frame.variant === "fullscreen" && this.simWidth >= 720
? Math.min(230, this.simHeight * 0.25)
: 0;
const iconX = this.simWidth - iconSize - 16;
const iconY = this.simHeight - iconSize - 16;
const iconY = this.simHeight - iconSize - 16 - inspectorGuard;
const iconZone: FluidParamZone = { key: "toggle", x: iconX, y: iconY, width: iconSize, height: iconSize };
this.fluidParamZones.push(iconZone);
const nearIcon = this.pointer.active && pointInInflatedRect(this.pointer.x, this.pointer.y, iconZone, 54);

View file

@ -472,22 +472,6 @@ body:has(.immersiveShell) {
mask-image: radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0.8), transparent 72%);
}
.immersiveShell .hudGrid,
.immersiveShell .immersiveTopbar,
.immersiveShell .deckRail,
.immersiveShell .diegeticPanel,
.immersiveShell .hudToastStack {
position: absolute !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0 0 0 0) !important;
clip-path: inset(50%) !important;
opacity: 0 !important;
pointer-events: none !important;
}
.immersiveTopbar,
.deckRail,
.diegeticPanel,