fix(ui): suppress Topology Explorer mount at narrow viewports (slice 26 HG-8 OPT-D3 repair)

OPT-C3 z-index carve-out (1d935da) was invalidated by velocity-qa
recheck: pinning Topology mobile Explorer to z-20 hides it visually
but does NOT prevent mount; the pre-existing TopologyTableView +
TopologyTreeView renderer-spin pegs the browser when the Explorer
component mounts on hamburger click, independent of whether it is
visible.

OPT-D3 reverts the surface-conditional z-index back to uniform
opaque-mode z-40 (matching all 4 other Explorer-bearing destinations)
and instead gates the Explorer MOUNT on viewport for the Topology
surface. The rule lives in a new pure helper
`shouldSuppressExplorerMount(surface, isWideLayout)` exported from
Explorer.tsx; AppShell composes it with the existing explorerVisible
gate. At narrow viewports (< lg breakpoint) the Topology Explorer is
not rendered at all — the peg trigger is never armed.

Cross-destination preservation: settings / project / specs / for-you
mount normally regardless of viewport; the carve-out is Topology-
specific. The 0.3.2 Topology mobile render-path fix will remove this
suppression.

Tests: pure-predicate tests for shouldSuppressExplorerMount cover 5
surfaces × 2 viewports + the "none" surface; replaces the 2 OPT-C3
class-contract tests that asserted z-20 for Topology opaque-mode.
Other 13 tests (cross-destination z-40 contract for 4 surfaces +
overlay-mode z-30 preservation) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 04:38:26 -07:00
1 parent 1d935dae5e
commit 94e7155f92
3 files changed
+85 -101

No files matched your search

+14 -4
View File
@@ -41,7 +41,7 @@ import {
Sparkles,
Wrench,
} from "lucide-react";
import { Explorer, type ExplorerSurface } from "./Explorer.js";
import { Explorer, shouldSuppressExplorerMount, type ExplorerSurface } from "./Explorer.js";
import { SharedDetailDrawer, type DrawerSelection } from "./SharedDetailDrawer.js";
import { PreviewStack } from "./preview/PreviewStack.js";
import type { DiscoveryPlacementTarget } from "./DiscoveryPanel.js";
@@ -436,6 +436,14 @@ function AppShellInner({ children }: AppShellProps) {
useGlobalEvents();
const explorerVisible = surface !== "none";
// Slice 26.D OPT-D3 Topology mobile mount-suppression: rule lives
// in shouldSuppressExplorerMount() (Explorer.tsx). Pre-existing
// renderer-spin in Topology mobile render path pegs the browser
// when Explorer mounts at 375px; only mount-suppression sidesteps
// the peg trigger. Other 4 destinations mount normally regardless
// of viewport. 0.3.2 fixes Topology mobile render path; this
// suppression reverts at that time.
const explorerMounted = explorerVisible && !shouldSuppressExplorerMount(surface, isWideLayout);
const drawerOpen = Boolean(selectionState);
// V1 attempt-3 Phase 3 bounce-fix — Class B fixed-anchor + selective overlay.
@@ -572,9 +580,11 @@ function AppShellInner({ children }: AppShellProps) {
{/* Explorer — desktop column or mobile slide-over.
In overlay mode (topology graph): vellum-translucent + z-30
so it floats over the canvas. In opaque mode: default
Phase 2 behavior (z-20, opaque background). */}
{explorerVisible && (
so it floats over the canvas. In opaque mode: z-40
per slice 26.B OPT-B (above rail-tray on mobile).
Slice 26.D OPT-D3: Topology Explorer doesn't mount
on mobile (suppressTopologyMobileExplorer above). */}
{explorerMounted && (
<Explorer
open={explorerOpen}
onClose={() => setExplorerOpen(false)}
+36 -21
View File
@@ -37,6 +37,26 @@ export type ExplorerSurface =
| "settings"
| "none";
/**
* Slice 26.D OPT-D3 Topology mobile Explorer mount-suppression rule.
*
* Returns true when the Explorer for a given surface should NOT MOUNT
* at the current viewport. The single carve-out: Topology surface at
* narrow viewports (isWideLayout=false). Pre-existing renderer-spin
* in the Topology mobile render path (TopologyTableView +
* TopologyTreeView combined) pegs the browser when Explorer mounts
* at 375px; mount-suppression sidesteps the peg trigger.
*
* 0.3.2 fixes the Topology mobile render path; this carve-out
* returns false for all surfaces at that time.
*/
export function shouldSuppressExplorerMount(
surface: ExplorerSurface,
isWideLayout: boolean,
): boolean {
return surface === "topology" && !isWideLayout;
}
interface ExplorerProps {
open: boolean;
onClose: () => void;
@@ -316,30 +336,25 @@ export function Explorer({
// click hits register on Explorer items. Pre-repair value
// was z-20 (below rail-tray) — pre-existing bug exposed by
// slice 26 because Settings was the 5th Explorer-bearing
// destination on mobile. Bumped to z-40 for opaque-mode
// destinations. Overlay-mode stays z-30 (Topology graph
// behavior preserved; rail-tray and overlay-mode Explorer
// paint at same z, DOM order resolves Explorer above since
// it renders later in AppShell). Rail-tray remains reachable
// via backdrop dismissal on mobile.
// destination on mobile. Opaque-mode bumped to z-40 for all
// surfaces; overlay-mode stays z-30 (Topology graph behavior
// preserved; rail-tray and overlay-mode Explorer paint at
// same z, DOM order resolves Explorer above since it renders
// later in AppShell). Rail-tray remains reachable via backdrop
// dismissal on mobile.
//
// Slice 26.C OPT-C3 Topology mobile carve-out (per orch
// routing on qa renderer-peg report at qitem-2026-05-12...):
// Topology surface is intentionally pinned to z-20 on opaque
// mode — restoring the pre-OPT-B "covered by rail-tray"
// baseline. Pre-existing renderer-spin in TopologyTableView
// pegs the headless browser when Explorer mounts/becomes
// visible at 375px; keeping Topology Explorer hidden behind
// rail-tray on mobile prevents the click path from reaching
// the peg trigger. Other 4 Explorer destinations (settings /
// project / specs / for-you) keep the OPT-B z-40 fix. 0.3.2
// will fix the Topology mobile render path; this carve-out
// reverts at that time.
// Slice 26.D OPT-D3 carve-out (separate gate in AppShell.tsx,
// NOT here): Topology mobile Explorer doesn't MOUNT on
// viewport < lg (avoids pre-existing TopologyTableView
// renderer-spin that pegs the browser when Explorer mounts
// at 375px). The mount-gate is in AppShell.tsx ~line 577;
// this z-index block is reached only when Explorer is
// actually mounted, so no conditional is needed here. 0.3.2
// will fix the Topology mobile render path; the AppShell
// mount-gate reverts at that time.
isOverlay
? "vellum z-30 shadow-[6px_0_14px_rgba(46,52,46,0.06)]"
: surface === "topology"
? "z-20 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]"
: "z-40 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]",
: "z-40 bg-[rgba(250,249,245,0.035)] supports-[backdrop-filter]:bg-[rgba(250,249,245,0.018)] backdrop-blur-[14px] backdrop-saturate-75 shadow-[6px_0_14px_rgba(46,52,46,0.04)]",
// Mobile: slide-over from left below the top-bar header (h-14).
"fixed top-14 bottom-0 left-0 transition-transform duration-200 ease-tactical w-72 max-w-[80vw]",
open ? "translate-x-0" : "-translate-x-full",
@@ -21,7 +21,7 @@
import { describe, it, expect, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { Explorer } from "../src/components/Explorer.js";
import { Explorer, shouldSuppressExplorerMount } from "../src/components/Explorer.js";
import type { ExplorerSurface } from "../src/components/Explorer.js";
import { createAppTestRouter } from "./helpers/test-router.js";
@@ -138,82 +138,41 @@ describe("Explorer mobile drawer z-index layering (slice 26.B HG-8 OPT-B repair)
});
});
// Slice 26.C OPT-C3 Topology mobile carve-out: opaque-mode Topology
// Explorer is intentionally pinned to z-20 (pre-OPT-B baseline) so
// mobile-rail-tray covers it — sidesteps the pre-existing
// TopologyTableView renderer-spin that pegs the browser when the
// mobile drawer becomes visible at 375px. The 0.3.2 Topology mobile
// refactor will fix the renderer-spin; this carve-out reverts then.
describe("surface=topology (opaque-mode OPT-C3 carve-out)", () => {
it("opaque-mode Topology Explorer pins to z-20 (BELOW mobile-rail-tray z-30)", async () => {
render(
createAppTestRouter({
initialPath: "/topology",
routes: [
{
path: "/topology",
component: () => (
<Explorer
open={true}
onClose={() => {}}
selection={null}
onSelect={() => {}}
desktopMode="full"
surface="topology"
overlayMode="opaque"
/>
),
},
],
}),
);
await waitFor(() => {
expect(screen.getByTestId("explorer")).toBeTruthy();
});
const explorer = screen.getByTestId("explorer");
// POSITIVE: z-20 present (the carve-out value; restores
// pre-OPT-B baseline for Topology mobile)
expect(explorer.className).toMatch(/\bz-20\b/);
// NEGATIVE: z-40 absent (the OPT-B fix that applies to other
// destinations is intentionally NOT applied to Topology — the
// carve-out discriminator)
expect(explorer.className).not.toMatch(/\bz-40\b/);
});
});
it("NUMERIC LAYERING INVARIANT: Topology opaque-mode z-index is LESS THAN mobile-rail-tray z-30", async () => {
render(
createAppTestRouter({
initialPath: "/topology",
routes: [
{
path: "/topology",
component: () => (
<Explorer
open={true}
onClose={() => {}}
selection={null}
onSelect={() => {}}
desktopMode="full"
surface="topology"
overlayMode="opaque"
/>
),
},
],
}),
);
await waitFor(() => {
expect(screen.getByTestId("explorer")).toBeTruthy();
});
const explorer = screen.getByTestId("explorer");
const zMatch = explorer.className.match(/\bz-(\d+)\b/);
expect(zMatch).toBeTruthy();
const explorerZ = Number(zMatch![1]);
// Topology mobile drawer must be BELOW rail-tray (z-30) to
// remain covered + prevent the renderer-peg path. Inverts the
// other-destinations invariant where Explorer must be above.
expect(explorerZ).toBeLessThan(MOBILE_RAIL_TRAY_Z);
expect(explorerZ).toBe(20);
// Slice 26.D OPT-D3 Topology mobile Explorer mount-suppression.
//
// OPT-C3 z-index carve-out was invalidated by velocity-qa recheck:
// z-index suppresses visibility but NOT React MOUNT. The peg trigger
// is the Explorer drawer MOUNTING on hamburger click, which causes
// adjacent Topology re-render cascade — independent of whether the
// drawer is visible. OPT-D3 suppresses the mount itself for Topology
// at narrow viewports. Pure predicate; trivially testable.
describe("shouldSuppressExplorerMount (slice 26.D OPT-D3 mount-suppression predicate)", () => {
it("Topology + narrow viewport (isWideLayout=false) → SUPPRESS mount (true)", () => {
expect(shouldSuppressExplorerMount("topology", false)).toBe(true);
});
it("Topology + wide viewport (isWideLayout=true) → mount normally (false)", () => {
expect(shouldSuppressExplorerMount("topology", true)).toBe(false);
});
// Cross-destination preservation: all 4 other Explorer-bearing
// surfaces mount regardless of viewport. The carve-out is
// Topology-specific.
const otherSurfaces: ExplorerSurface[] = ["settings", "project", "specs", "for-you"];
for (const surface of otherSurfaces) {
it(`${surface} + narrow viewport → mount normally (false)`, () => {
expect(shouldSuppressExplorerMount(surface, false)).toBe(false);
});
it(`${surface} + wide viewport → mount normally (false)`, () => {
expect(shouldSuppressExplorerMount(surface, true)).toBe(false);
});
}
it("'none' surface → never suppressed (consistent with Explorer not mounting anyway via explorerVisible gate)", () => {
expect(shouldSuppressExplorerMount("none", false)).toBe(false);
expect(shouldSuppressExplorerMount("none", true)).toBe(false);
});
});