Gate aquarium UI behind agents

This commit is contained in:
Meta Vens 2026-05-05 12:21:44 +01:00
commit 4c2943ddff
3 changed files with 190 additions and 56 deletions

View file

@ -63,8 +63,8 @@ async function launchSmokeBrowser() {
async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPanel) {
const page = await browser.newPage({ viewport });
await page.goto(url, { waitUntil: "networkidle" });
await page.locator("h1").filter({ hasText: "Operator Console" }).waitFor({ state: "attached" });
await page.locator(".immersiveShell").waitFor();
await page.locator('[data-agent-node="coordinator"]').waitFor({ state: "attached" });
await page.locator(".agentSmokeCanvas").waitFor();
await page.locator(".agentCrispCanvas").waitFor();
await page.waitForTimeout(1000);
@ -75,25 +75,9 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
throw new Error(`agent smoke canvas did not render: ${smokeProbe.reason}`);
}
const operatorSurface = await page.evaluate(() => {
return [".immersiveTopbar", ".deckRail", ".diegeticPanel"].every((selector) => {
const element = document.querySelector(`.agentStage ${selector}`);
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.opacity !== "0" &&
style.visibility !== "hidden" &&
rect.width > 20 &&
rect.height > 20
);
});
});
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}`);
const quietSurface = await page.evaluate(() => !document.querySelector(".agentFocusSurface"));
if (!quietSurface) {
throw new Error("operator surface rendered before an aquarium object was selected");
}
const projectionProbe = await page.evaluate(() => {
@ -176,6 +160,24 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
throw new Error("research DOM node has no clickable bounds");
}
await page.mouse.click(researchBox.x + researchBox.width / 2, researchBox.y + researchBox.height / 2);
const operatorSurface = await page.evaluate(() => {
const focus = document.querySelector('.agentStage [data-agent-focus="research"]');
if (!(focus instanceof HTMLElement)) return { ok: false, reason: "research focus surface missing" };
const style = window.getComputedStyle(focus);
const surfaces = [".immersiveTopbar", ".deckRail", ".diegeticPanel"].every((selector) => {
const element = focus.querySelector(selector);
if (!(element instanceof HTMLElement)) return false;
const rect = element.getBoundingClientRect();
return rect.width > 20 && rect.height > 20;
});
return {
ok: style.opacity !== "0" && style.visibility !== "hidden" && surfaces,
reason: `opacity=${style.opacity} visibility=${style.visibility} surfaces=${surfaces}`,
};
});
if (!operatorSurface.ok) {
throw new Error(`agent-owned operator surface did not open: ${operatorSurface.reason}`);
}
try {
await page.waitForFunction(() => {
const audio = window.__epiphanyAquariumAudio;
@ -195,7 +197,7 @@ async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPan
throw new Error(`aquarium audio did not wake correctly: ${JSON.stringify(audio)}`, { cause: error });
}
audioProbe = await page.evaluate(() => window.__epiphanyAquariumAudio ?? null);
await page.locator(".deckRail button").nth(1).click();
await page.locator('[data-agent-focus="research"] .deckRail button').nth(1).dispatchEvent("pointerdown", { bubbles: true });
await page.waitForFunction(() => {
const audio = window.__epiphanyAquariumAudio;
return audio?.state === "running" && audio.interfaceHitCount >= 1;

View file

@ -933,7 +933,6 @@ export function App() {
const operatorSurface = (
<>
<div className="hudGrid" aria-hidden="true" />
<header className="immersiveTopbar">
<div className="operatorIdentity">
<p className="eyebrow">Epiphany MVP</p>
@ -1471,7 +1470,8 @@ function AgentConstellation({
const agentNodeRefs = useRef(new globalThis.Map<string, HTMLButtonElement>());
const thoughtNodeRefs = useRef(new globalThis.Map<string, HTMLDivElement>());
const optionHaloNodeRefs = useRef(new globalThis.Map<string, HTMLDivElement>());
const [selectedAgentId, setSelectedAgentId] = useState("coordinator");
const focusSurfaceRef = useRef<HTMLDivElement | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [hoveredAgentId, setHoveredAgentId] = useState<string | null>(null);
const agents = useMemo<ProjectedAgent[]>(() => {
return constellationSpecs.map((spec) => {
@ -1614,6 +1614,7 @@ function AgentConstellation({
const agentNode = agentNodeRefs.current.get(projection.id);
const thoughtNode = thoughtNodeRefs.current.get(projection.id);
const optionHaloNode = optionHaloNodeRefs.current.get(projection.id);
const focusSurfaceNode = (selectedAgentId ?? hoveredAgentId) === projection.id ? focusSurfaceRef.current : null;
const properties: Array<[string, string]> = [
["--agent-x", `${projection.xPercent}%`],
["--agent-y", `${projection.yPercent}%`],
@ -1631,17 +1632,19 @@ function AgentConstellation({
agentNode?.style.setProperty(name, value);
thoughtNode?.style.setProperty(name, value);
optionHaloNode?.style.setProperty(name, value);
focusSurfaceNode?.style.setProperty(name, value);
}
thoughtNode?.toggleAttribute("data-agent-hot", projection.hover > 0.35);
optionHaloNode?.toggleAttribute("data-agent-hot", projection.hover > 0.2);
}
}, []);
}, [hoveredAgentId, selectedAgentId]);
useEffect(() => {
rendererRef.current?.setFrame({
activeLabel: activeDeck ? `${deckLabels[activeDeck]} / ${activeSubdeck ?? ""}` : undefined,
agents: aquariumAgents,
onProjectionFrame: applyProjectionFrame,
selectedAgentId,
selectedAgentId: selectedAgentId ?? "",
ui,
variant,
});
@ -1737,8 +1740,12 @@ function AgentConstellation({
const agentId = rendererRef.current?.pickAgent();
if (agentId) {
setSelectedAgentId(agentId);
} else {
setSelectedAgentId(null);
}
}
const focusedAgentId = selectedAgentId;
const focusedAgent = focusedAgentId ? aquariumAgents.find((agent) => agent.id === focusedAgentId) : null;
return (
<section
@ -1760,9 +1767,6 @@ function AgentConstellation({
</div>
)}
<div className="agentStage">
<div className="aquariumOperatorLayer" onPointerDownCapture={handleInterfacePointerDown}>
{operatorSurface}
</div>
<canvas
ref={canvasRef}
className="agentSmokeCanvas"
@ -1779,6 +1783,26 @@ function AgentConstellation({
aria-hidden="true"
/>
<div className="agentStageVignette" aria-hidden="true" />
{operatorSurface && focusedAgent && (
<div
className={`agentFocusSurface ${selectedAgentId === focusedAgent.id ? "locked" : "preview"} ${
focusedAgent.baseX > 62 ? "anchorLeft" : focusedAgent.baseX < 38 ? "anchorRight" : "anchorCenter"
} ${focusedAgent.baseY > 58 ? "anchorUp" : "anchorDown"}`}
ref={focusSurfaceRef}
data-agent-focus={focusedAgent.id}
onPointerDownCapture={handleInterfacePointerDown}
style={
{
"--agent-x": `${focusedAgent.baseX}%`,
"--agent-y": `${focusedAgent.baseY}%`,
"--agent-color": focusedAgent.color,
"--agent-glow": focusedAgent.glow,
} as React.CSSProperties
}
>
{operatorSurface}
</div>
)}
{agents.map((agent) => (
<button
className={`agentCharacter ${agent.shape} ${agent.tone} ${selectedAgentId === agent.id ? "selected" : ""}`}
@ -1883,19 +1907,21 @@ function AgentConstellation({
</div>
);
})}
<div className="constellationInspector">
<div>
<span>{selectedAgent.title}</span>
<strong>{selectedAgent.name}</strong>
<p>{selectedAgent.thought}</p>
{variant !== "fullscreen" && (
<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>
<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

@ -472,21 +472,6 @@ body:has(.immersiveShell) {
mask-image: radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0.8), transparent 72%);
}
.aquariumOperatorLayer {
position: absolute;
inset: 0;
z-index: 7;
pointer-events: none;
}
.aquariumOperatorLayer > * {
pointer-events: auto;
}
.aquariumOperatorLayer .hudGrid {
pointer-events: none;
}
.immersiveTopbar,
.deckRail,
.diegeticPanel,
@ -495,6 +480,115 @@ body:has(.immersiveShell) {
z-index: 8;
}
.agentFocusSurface {
position: absolute;
left: var(--agent-x);
top: var(--agent-y);
z-index: 7;
display: grid;
grid-template-areas:
"top top"
"rail panel"
"toast panel";
grid-template-columns: 86px minmax(360px, min(680px, 44vw));
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
max-width: calc(100vw - 32px);
max-height: min(78vh, 820px);
opacity: 0;
pointer-events: auto;
transform: translate(var(--focus-x, -12%), var(--focus-y, -8%)) scale(0.94);
transform-origin: 18% 12%;
transition:
opacity 170ms ease,
filter 170ms ease,
transform 170ms ease;
filter: drop-shadow(0 0 22px color-mix(in srgb, var(--agent-glow) 32%, transparent));
}
.agentFocusSurface.preview,
.agentFocusSurface.locked {
opacity: 1;
transform: translate(var(--focus-x, -12%), var(--focus-y, -8%)) scale(1);
}
.agentFocusSurface.anchorLeft {
--focus-x: -92%;
}
.agentFocusSurface.anchorRight {
--focus-x: 10%;
}
.agentFocusSurface.anchorCenter {
--focus-x: -48%;
}
.agentFocusSurface.anchorUp {
--focus-y: -86%;
}
.agentFocusSurface.anchorDown {
--focus-y: -10%;
}
.agentFocusSurface .immersiveTopbar,
.agentFocusSurface .deckRail,
.agentFocusSurface .diegeticPanel,
.agentFocusSurface .hudToastStack {
position: relative;
top: auto;
right: auto;
bottom: auto;
left: auto;
z-index: auto;
}
.agentFocusSurface .immersiveTopbar {
grid-area: top;
align-items: center;
min-width: 0;
padding: 9px 10px;
border: 1px solid rgba(219, 238, 216, 0.28);
border-radius: 10px;
background: rgba(8, 17, 14, 0.74);
backdrop-filter: blur(16px);
}
.agentFocusSurface .deckRail {
grid-area: rail;
width: auto;
align-content: start;
}
.agentFocusSurface .diegeticPanel {
grid-area: panel;
width: min(680px, 44vw);
min-width: 360px;
max-height: min(66vh, 700px);
}
.agentFocusSurface .hudToastStack {
grid-area: toast;
width: auto;
}
.agentFocusSurface .operatorIdentity {
max-width: min(420px, 34vw);
}
.agentFocusSurface .operatorTopControls {
max-width: min(360px, 32vw);
}
.agentFocusSurface .deckRail button {
min-height: 60px;
}
.agentFocusSurface .deckBody {
max-height: calc(min(66vh, 700px) - 68px);
}
.immersiveTopbar {
top: 18px;
right: 24px;
@ -1087,17 +1181,29 @@ body:has(.immersiveShell) {
color: #172018;
background: rgba(249, 252, 246, 0.88);
box-shadow: 0 14px 30px rgba(6, 11, 9, 0.26);
opacity: var(--agent-bubble-opacity, 0.62);
opacity: 0;
pointer-events: none;
transform:
translate(-10%, -142%)
rotate(var(--agent-bubble-tilt, 0deg))
scale(var(--agent-scale, 1));
scale(calc(var(--agent-scale, 1) * 0.96));
animation: thoughtDrift 7600ms ease-in-out infinite;
transition:
opacity 160ms ease,
transform 160ms ease;
}
.thoughtBubble.selected,
.thoughtBubble[data-agent-hot] {
z-index: 4;
opacity: var(--agent-bubble-opacity, 0.62);
transform:
translate(-10%, -142%)
rotate(var(--agent-bubble-tilt, 0deg))
scale(var(--agent-scale, 1));
}
.thoughtBubble.selected {
z-index: 4;
opacity: 1;
}