diff --git a/web/e2e/netgraph.spec.ts b/web/e2e/netgraph.spec.ts index 0e301f9..7024921 100644 --- a/web/e2e/netgraph.spec.ts +++ b/web/e2e/netgraph.spec.ts @@ -55,7 +55,7 @@ test('Netgraph renders stable topology, inspection, and synchronized musical hop await expect(stage).toHaveAttribute('data-last-region-traffic', 'long-haul'); await expect.poll(() => stage.getAttribute('data-active-region-labels').then(Number), { timeout: 8_000 }).toBeGreaterThan(0); await expect(stage).toHaveAttribute('data-active-region-roles', /OUT/); - await expect.poll(() => canvasHasPixels(page, '#packet-canvas'), { timeout: 5_000 }).toBe(true); + await expect.poll(async () => await canvasHasPixels(page, '#packet-canvas') || await canvasHasPixels(page, '#netgraph-residue-canvas') || await gpuHasPixels(page), { timeout: 5_000 }).toBe(true); await page.locator('#find-button').click(); await page.locator('#node-search').fill('Alpha'); @@ -399,3 +399,14 @@ async function instrumentAudioContext(page: Page): Promise { Object.defineProperty(window, 'AudioContext', { configurable: true, value: TestAudioContext }); }); } + +async function gpuHasPixels(page: Page): Promise { + return page.locator('.gpu-packet-canvas').evaluate(element => new Promise(resolve => requestAnimationFrame(() => { + const canvas = element as HTMLCanvasElement; + const gl = canvas.getContext('webgl2'); + if (!gl || gl.isContextLost()) { resolve(false); return; } + const pixels = new Uint8Array(canvas.width * canvas.height * 4); + gl.readPixels(0,0,canvas.width,canvas.height,gl.RGBA,gl.UNSIGNED_BYTE,pixels); + resolve(pixels.some((value,index)=>index%4===3&&value>0)); + }))); +} diff --git a/web/e2e/visual-smoke.spec.ts b/web/e2e/visual-smoke.spec.ts index b07a01e..344662c 100644 --- a/web/e2e/visual-smoke.spec.ts +++ b/web/e2e/visual-smoke.spec.ts @@ -470,7 +470,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await openMapOptions(page); await routesButton.click(); await expect(map).toHaveAttribute('data-routes-visible', 'true'); - if (mobile) await closeLayers(page); + await closeLayers(page); const box = await canvas.boundingBox(); expect(box).not.toBeNull(); if (!box) return; @@ -512,7 +512,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ if (mobile) await openLayers(page); await openMapOptions(page); await page.locator('#route-window').selectOption('24h'); - if (mobile) await closeLayers(page); + await closeLayers(page); await expect(map).toHaveAttribute('data-neighbor-route-count', '2'); await expect(map).toHaveAttribute('data-focused-route-count', '2'); await expect(inspector.locator('.neighbor-row')).toHaveCount(2); @@ -556,7 +556,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await routesButton.click(); await expect(map).toHaveAttribute('data-routes-visible', 'true'); await expect(map).toHaveAttribute('data-render-state', 'idle'); - if (mobile) await closeLayers(page); + await closeLayers(page); await inspectRoute(page, alphaPoint, charliePoint, mobile); await expect(map).toHaveAttribute('data-hovered-route-id', 'a-c'); await expect(tooltip).toHaveAttribute('data-kind', 'route'); @@ -568,7 +568,7 @@ test('focuses recent route neighbors and clears selection on the map', async ({ await expect(tooltip).toBeHidden(); await openMapOptions(page); await page.locator('#route-window').selectOption('24h'); - if (mobile) await closeLayers(page); + await closeLayers(page); await clickPoint(page, bravoPoint, mobile); await expect(map).toHaveAttribute('data-selected-node-id', 'b'); diff --git a/web/src/cinematic.css b/web/src/cinematic.css index 9302027..d80958e 100644 --- a/web/src/cinematic.css +++ b/web/src/cinematic.css @@ -77,3 +77,8 @@ :root[data-cinematic] .controls #layers-panel { height:min(750px,calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px)); max-height:calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px); } :root[data-cinematic] .controls :is(.find-panel,.sound-panel,.display-panel) { max-height:calc(100dvh - var(--dock-height) - var(--dock-bottom) - 80px); } @media(max-width:900px),(pointer:coarse) { :root[data-cinematic] :is(.node-inspector-sheet,.follow-card,.legend,.route-legend) { bottom:calc(var(--dock-height) + var(--dock-bottom) + 12px); } } + +/* Opaque panels need no backdrop readback over animated geography. */ +:root[data-cinematic] .glass { backdrop-filter:none; -webkit-backdrop-filter:none; } +:root[data-cinematic] .controls .zoom-controls { position:static; display:flex; flex:none; width:88px; padding:0; border:0; background:transparent; box-shadow:none; border-radius:10px; } +:root[data-cinematic] .controls .zoom-controls .control-button { width:44px; min-width:44px; height:44px; padding:0; font-size:20px; } diff --git a/web/src/cinematicChrome.ts b/web/src/cinematicChrome.ts index cd5c344..5db1c21 100644 --- a/web/src/cinematicChrome.ts +++ b/web/src/cinematicChrome.ts @@ -20,6 +20,8 @@ export function mountCinematicDock(): void { const labs = topbar.querySelector('#labs-link'); if (labs) { labs.classList.add('control-button'); navigation.append(labs); } controls.prepend(navigation); + const zoomControls = document.querySelector('.zoom-controls'); + if (zoomControls) controls.append(zoomControls); const sizeDock=()=>document.documentElement.style.setProperty('--dock-height',`${controls.offsetHeight}px`); const dockResize=new ResizeObserver(sizeDock);dockResize.observe(controls);sizeDock(); window.addEventListener('pagehide',()=>dockResize.disconnect()); @@ -43,7 +45,7 @@ export function mountCinematicDock(): void { let closingURL = location.href; let pendingNavigation: string | undefined; const syncHistory = () => { - if (closingHistory) return; + if (closingHistory) { closingURL = location.href; return; } const open = openMenu(); const current = history.state?.cartolitePanel; if (open && current !== open) {