shell-redesign V1 attempt-3 Phase 5 P5-9: mobile responsive polish

Per universal-shell.md L119-L144 mobile / narrow-viewport degradation.
V1 mobile bottom nav = For You / Project / Topology only (NOT Talk;
Talk slots are V2 deferred per L144 — when web terminal ships).

NEW packages/ui/src/components/AppShell.tsx :: MobileBottomNav:
- Fixed bottom-0 left-0 right-0; lg:hidden so desktop never sees it.
- 3 slots: For You (Sparkles icon), Project (Folder), Topology (Network).
- Active state: data-active="true" on the slot whose route prefix
  matches pathname; tactical font-mono uppercase 9px label.
- Vellum aesthetic (`.vellum` background + 1px outline-variant top
  border) consistent with the rail.

ADDED main content pb-14 lg:pb-0 so mobile bottom nav doesn't cover
scroll content (clearance for the ~52px bottom nav at lg breakpoint
0..1024px).

NEW packages/ui/src/hooks/useShellViewport.ts (per code-map AFTER tree):
- Standalone hook so non-AppShell surfaces can degrade gracefully on
  mobile without prop-drilling. Same WIDE_LAYOUT_BREAKPOINT (1024px)
  as AppShell — kept in lockstep.
- Returns { isWideLayout, innerWidth }; reacts to resize events.

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- HostScopePage + RigScopePage: at <lg viewport, graph view-mode
  effectively renders the table view per universal-shell.md L143.
  effectiveActive = !isWideLayout && active === "graph" ? "table" : active.
- Tab nav still shows graph as the user's selected mode; resize-to-wide
  reactivates the graph automatically (effectiveActive recomputes).
- Small italic hint "Graph view degrades to table on narrow viewports."
  (testid: topology-mobile-graph-degraded) prepends the table.
- Pod scope unchanged (graph was placeholder in Phase 3 anyway).

UPDATED packages/ui/test/app-shell.test.tsx (SC-1 strict count):
- Filter `nav, aside` query by lg:hidden so MobileBottomNav (mobile-only)
  doesn't count toward desktop chrome total. SC-1's "exactly 2 left
  chromes on desktop" is about VISIBLE chromes; raw element count was
  too broad.

NEW packages/ui/test/p5-mobile-polish.test.tsx (9 tests):
- useShellViewport: isWideLayout=true at >=1024 / false at <1024 /
  reacts to resize events.
- MobileBottomNav: 3 slots present; advisor/operator/talk explicitly
  ABSENT (negative-assertion ritual #8 + source-assertion ritual #9
  on the MobileBottomNav slot definitions); active route highlighted;
  lg:hidden class present.
- Topology graph degradation: at <lg /topology renders table + degraded
  hint; at >=lg /topology renders graph placeholder (no degradation).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 676/678 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 17:56:25 -07:00
1 parent 1fb50d15d4
commit 483c23f3e2
5 files changed
+298 -8

No files matched your search

+52 -1
View File
@@ -605,7 +605,7 @@ function AppShellInner({ children }: AppShellProps) {
above; child surfaces should treat their own offset as 0. */}
<div
key={pathname}
className="relative z-10 route-enter flex-1 flex flex-col"
className="relative z-10 route-enter flex-1 flex flex-col pb-14 lg:pb-0"
style={{
"--workspace-left-offset": "0px",
"--workspace-right-offset": "0px",
@@ -630,9 +630,60 @@ function AppShellInner({ children }: AppShellProps) {
Self-hides when no pins; positioned right edge under the
drawer when drawer mounted. */}
<PreviewStack />
{/* V1 attempt-3 Phase 5 P5-9 — Mobile bottom nav per
universal-shell.md L135 + L144: For You / Project /
Topology only (NOT Talk; Talk slots are V2 when web
terminal ships). lg:hidden so desktop never sees it. */}
<MobileBottomNav pathname={pathname} />
</div>
</div>
</DiscoveryPlacementContext.Provider>
</DrawerSelectionContext.Provider>
);
}
/** V1 mobile bottom nav per universal-shell.md L135 + L144 — 3 slots
* (For You / Project / Topology). Talk slots are V2 deferred (when web
* terminal ships). Rendered at lg:hidden so desktop never shows it. */
function MobileBottomNav({ pathname }: { pathname: string }) {
const slots: Array<{
id: "for-you" | "project" | "topology";
label: string;
to: string;
activeWhen: (p: string) => boolean;
icon: ComponentType<{ className?: string; strokeWidth?: number | string }>;
}> = [
{ id: "for-you", label: "For You", to: "/for-you", activeWhen: (p) => p.startsWith("/for-you"), icon: Sparkles },
{ id: "project", label: "Project", to: "/project", activeWhen: (p) => p.startsWith("/project"), icon: Folder },
{ id: "topology", label: "Topology", to: "/topology", activeWhen: (p) => p.startsWith("/topology") || p.startsWith("/rigs/"), icon: Network },
];
return (
<nav
data-testid="mobile-bottom-nav"
aria-label="Mobile bottom navigation"
className="fixed bottom-0 left-0 right-0 z-40 lg:hidden vellum border-t border-outline-variant flex"
>
{slots.map((slot) => {
const active = slot.activeWhen(pathname);
return (
<Link
key={slot.id}
to={slot.to}
data-testid={`mobile-nav-${slot.id}`}
data-active={active}
className={cn(
"flex-1 flex flex-col items-center justify-center gap-0.5 py-2 font-mono text-[9px] uppercase tracking-wide",
active
? "text-stone-900"
: "text-on-surface-variant hover:text-stone-900",
)}
>
<slot.icon className="h-5 w-5" strokeWidth={1.25} aria-hidden="true" />
<span>{slot.label}</span>
</Link>
);
})}
</nav>
);
}
@@ -24,6 +24,11 @@ import { RigGraph } from "../RigGraph.js";
import { useRigSummary } from "../../hooks/useRigSummary.js";
import { LiveNodeDetails } from "../LiveNodeDetails.js";
import { useTopologyOverlay } from "./topology-overlay-context.js";
// V1 attempt-3 Phase 5 P5-9: graph view-mode degrades to table on
// narrow viewports per universal-shell.md L143 ("Topology graph view
// degrades to table view by default on mobile (graph is too dense for
// phone screens)").
import { useShellViewport } from "../../hooks/useShellViewport.js";
/** Set the AppShell's Explorer overlay mode based on the scope page's
* active view-mode. Graph view-mode → overlay (vellum-translucent
@@ -86,15 +91,21 @@ function ScopeShell({
export function HostScopePage() {
const [active, setActive] = useState<TopologyHostScopeTab>("graph");
const { data: rigs } = useRigSummary();
const { isWideLayout } = useShellViewport();
useOverlayForActiveTab(active);
// P5-9 mobile graph degradation: at <lg viewport, treat graph view-mode
// as table per universal-shell.md L143. The tab nav still shows graph
// selected (operator may resize to wide and the graph reactivates).
const effectiveActive = !isWideLayout && active === "graph" ? "table" : active;
return (
<ScopeShell
eyebrow="Topology · Host"
title="localhost"
tabsNav={<TopologyViewModeTabs tabs={HOST_SCOPE_TABS} active={active} onSelect={setActive} testIdPrefix="topology-host" />}
>
{active === "graph" ? (
{effectiveActive === "graph" ? (
<div className="p-6">
<EmptyState
label="HOST GRAPH"
@@ -109,12 +120,20 @@ export function HostScopePage() {
/>
</div>
) : null}
{active === "table" ? (
{effectiveActive === "table" ? (
<div className="px-6 pb-6">
{!isWideLayout && active === "graph" ? (
<p
data-testid="topology-mobile-graph-degraded"
className="font-mono text-[9px] text-on-surface-variant italic mb-2"
>
Graph view degrades to table on narrow viewports.
</p>
) : null}
<TopologyTableView />
</div>
) : null}
{active === "terminal" ? <TopologyTerminalView scope="host" /> : null}
{effectiveActive === "terminal" ? <TopologyTerminalView scope="host" /> : null}
</ScopeShell>
);
}
@@ -124,25 +143,36 @@ export function RigScopePage() {
const { data: rigs } = useRigSummary();
const rig = rigs?.find((r) => r.id === rigId);
const [active, setActive] = useState<TopologyRigPodScopeTab>("graph");
const { isWideLayout } = useShellViewport();
useOverlayForActiveTab(active);
const effectiveActive = !isWideLayout && active === "graph" ? "table" : active;
return (
<ScopeShell
eyebrow="Topology · Rig"
title={rig?.name ?? rigId}
tabsNav={<TopologyViewModeTabs tabs={RIG_POD_SCOPE_TABS} active={active} onSelect={setActive} testIdPrefix="topology-rig" />}
>
{active === "graph" ? (
{effectiveActive === "graph" ? (
<div className="flex-1 min-h-0 relative">
<RigGraph rigId={rigId} rigName={rig?.name ?? null} showDiscovered={false} />
</div>
) : null}
{active === "table" ? (
{effectiveActive === "table" ? (
<div className="px-6 pb-6">
{!isWideLayout && active === "graph" ? (
<p
data-testid="topology-mobile-graph-degraded"
className="font-mono text-[9px] text-on-surface-variant italic mb-2"
>
Graph view degrades to table on narrow viewports.
</p>
) : null}
<TopologyTableView rigIdScope={rigId} />
</div>
) : null}
{active === "terminal" ? <TopologyTerminalView scope="rig" rigId={rigId} /> : null}
{effectiveActive === "terminal" ? <TopologyTerminalView scope="rig" rigId={rigId} /> : null}
{active === "overview" ? (
<div className="p-6">
<EmptyState
+45
View File
@@ -0,0 +1,45 @@
// V1 attempt-3 Phase 2 — viewport-detection hook (per code-map AFTER tree).
//
// V1 attempt-3 Phase 5 P5-9: extracted as a standalone hook so non-AppShell
// surfaces (TopologyTerminalView, topology ScopePages) can degrade
// gracefully on mobile without prop-drilling isWideLayout from AppShell.
// Same WIDE_LAYOUT_BREAKPOINT (1024px) as AppShell — keeps the breakpoint
// in lockstep across consumers.
import { useEffect, useState } from "react";
const WIDE_LAYOUT_BREAKPOINT = 1024;
export interface ShellViewport {
/** True when window.innerWidth >= 1024px (Tailwind lg breakpoint). */
isWideLayout: boolean;
/** Live innerWidth in px; useful for mid-band decisions (e.g., 768
* iPad-portrait breakpoint between mobile and desktop). */
innerWidth: number;
}
export function useShellViewport(): ShellViewport {
const [state, setState] = useState<ShellViewport>(() => {
if (typeof window === "undefined") {
return { isWideLayout: true, innerWidth: WIDE_LAYOUT_BREAKPOINT };
}
return {
isWideLayout: window.innerWidth >= WIDE_LAYOUT_BREAKPOINT,
innerWidth: window.innerWidth,
};
});
useEffect(() => {
const handleResize = () => {
setState({
isWideLayout: window.innerWidth >= WIDE_LAYOUT_BREAKPOINT,
innerWidth: window.innerWidth,
});
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return state;
}
+7 -1
View File
@@ -72,7 +72,13 @@ describe("AppShell — Phase 2 chrome", () => {
describe("SC-1: exactly 2 left chromes on desktop (rail + explore)", () => {
it("renders exactly 2 left chromes at /topology desktop (rail + explore) — SC-1 strict count", async () => {
const { container } = await renderAt("/topology");
const chromeCount = container.querySelectorAll("nav, aside").length;
// SC-1: count desktop-visible nav/aside elements only. The Phase 5 P5-9
// MobileBottomNav uses <nav lg:hidden> — it's in the DOM but display:none
// at desktop (lg breakpoint). SC-1's "exactly 2 left chromes on desktop"
// is about VISIBLE chromes, not raw element count — filter by lg:hidden.
const chromeCount = Array.from(
container.querySelectorAll("nav, aside"),
).filter((el) => !(el as HTMLElement).className.includes("lg:hidden")).length;
expect(chromeCount).toBe(2);
// No legacy Sidebar.tsx anywhere — file is deleted.
expect(container.querySelector("[data-testid='sidebar']")).toBeNull();
+158
View File
@@ -0,0 +1,158 @@
// V1 attempt-3 Phase 5 P5-9 — Mobile responsive polish.
//
// Coverage:
// - MobileBottomNav renders 3 slots at <lg viewport (For You / Project /
// Topology). Talk slots are V2-deferred per universal-shell.md L144;
// negative-assertion that "talk" / advisor / operator do NOT appear
// in the mobile bottom nav.
// - Topology graph view-mode degrades to table at <lg viewport per
// universal-shell.md L143; tab nav still shows graph as the user's
// selected mode (so resize-to-wide reactivates the graph).
// - useShellViewport hook reflects window.innerWidth changes.
// - Bottom nav is hidden at >= lg viewport (lg:hidden class).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
RouterProvider,
createRouter,
} from "@tanstack/react-router";
import { readFileSync } from "node:fs";
import path from "node:path";
import { useShellViewport } from "../src/hooks/useShellViewport.js";
import { renderHook, act } from "@testing-library/react";
import { createMockEventSourceClass } from "./helpers/mock-event-source.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
let OriginalEventSource: typeof EventSource | undefined;
beforeEach(async () => {
mockFetch.mockReset();
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/rigs/summary")) return new Response(JSON.stringify([]));
if (url.includes("/api/rigs/ps")) return new Response(JSON.stringify([]));
if (url.includes("/api/inventory")) return new Response(JSON.stringify([]));
if (url.includes("/api/config")) return new Response("not implemented", { status: 404 });
return new Response("[]");
});
OriginalEventSource = globalThis.EventSource;
globalThis.EventSource = createMockEventSourceClass() as unknown as typeof EventSource;
const { queryClient } = await import("../src/lib/query-client.js");
queryClient.clear();
});
afterEach(() => {
if (OriginalEventSource) globalThis.EventSource = OriginalEventSource;
window.localStorage.clear();
cleanup();
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
writable: true,
});
window.dispatchEvent(new Event("resize"));
});
async function renderAt(initialPath: string, viewportWidth: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: viewportWidth,
writable: true,
});
window.dispatchEvent(new Event("resize"));
const { router } = await import("../src/routes.js");
const memoryHistory = createMemoryHistory({ initialEntries: [initialPath] });
const memoryRouter = createRouter({ routeTree: router.routeTree, history: memoryHistory });
const result = render(<RouterProvider router={memoryRouter} />);
await waitFor(() => {
expect(result.container.querySelector("[data-testid='app-rail']")).toBeTruthy();
}, { timeout: 5000 });
return result;
}
describe("useShellViewport (P5-9 hook)", () => {
it("reports isWideLayout=true when innerWidth >= 1024", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440, writable: true });
const { result } = renderHook(() => useShellViewport());
expect(result.current.isWideLayout).toBe(true);
expect(result.current.innerWidth).toBeGreaterThanOrEqual(1024);
});
it("reports isWideLayout=false when innerWidth < 1024", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375, writable: true });
const { result } = renderHook(() => useShellViewport());
expect(result.current.isWideLayout).toBe(false);
});
it("reacts to window resize events", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1440, writable: true });
const { result } = renderHook(() => useShellViewport());
expect(result.current.isWideLayout).toBe(true);
act(() => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375, writable: true });
window.dispatchEvent(new Event("resize"));
});
expect(result.current.isWideLayout).toBe(false);
});
});
describe("MobileBottomNav P5-9 — universal-shell.md L135 + L144", () => {
it("renders 3 slots (For You / Project / Topology) at mobile viewport", async () => {
const { container } = await renderAt("/", 375);
const nav = container.querySelector("[data-testid='mobile-bottom-nav']");
expect(nav).toBeTruthy();
expect(container.querySelector("[data-testid='mobile-nav-for-you']")).toBeTruthy();
expect(container.querySelector("[data-testid='mobile-nav-project']")).toBeTruthy();
expect(container.querySelector("[data-testid='mobile-nav-topology']")).toBeTruthy();
});
it("does NOT render Talk / advisor / operator slots (V2 deferred per L144)", async () => {
const { container } = await renderAt("/", 375);
expect(container.querySelector("[data-testid='mobile-nav-advisor']")).toBeNull();
expect(container.querySelector("[data-testid='mobile-nav-operator']")).toBeNull();
expect(container.querySelector("[data-testid='mobile-nav-talk']")).toBeNull();
// Source-assertion: AppShell.tsx mobile-bottom-nav block must not
// mention "advisor" or "operator" or "talk" in slot ids.
const src = readFileSync(
path.resolve(__dirname, "../src/components/AppShell.tsx"),
"utf8",
);
const navBlock = src.match(/MobileBottomNav[\s\S]*?^}/m)?.[0] ?? "";
expect(navBlock).not.toMatch(/id:\s*"(advisor|operator|talk)"/);
});
it("active route highlights the matching mobile nav slot", async () => {
const { container } = await renderAt("/topology", 375);
const topology = container.querySelector("[data-testid='mobile-nav-topology']");
expect(topology?.getAttribute("data-active")).toBe("true");
const project = container.querySelector("[data-testid='mobile-nav-project']");
expect(project?.getAttribute("data-active")).toBe("false");
});
it("nav element carries lg:hidden so desktop never shows it (CSS-source contract)", async () => {
const { container } = await renderAt("/", 375);
const nav = container.querySelector("[data-testid='mobile-bottom-nav']");
expect(nav?.className).toMatch(/lg:hidden/);
});
});
describe("Topology graph degradation P5-9 (universal-shell.md L143)", () => {
it("at <lg viewport, /topology graph view-mode renders the table view + degraded hint", async () => {
const { container, findByTestId } = await renderAt("/topology", 375);
// The table view-mode is what actually mounts (graph degraded).
expect(await findByTestId("topology-mobile-graph-degraded")).toBeTruthy();
// graph placeholder is NOT rendered at mobile.
expect(container.querySelector("[data-testid='topology-host-graph-placeholder']")).toBeNull();
});
it("at >= lg viewport, /topology graph view-mode renders the graph (no degradation hint)", async () => {
const { findByTestId, container } = await renderAt("/topology", 1440);
// Default tab is graph; placeholder visible.
expect(await findByTestId("topology-host-graph-placeholder")).toBeTruthy();
expect(container.querySelector("[data-testid='topology-mobile-graph-degraded']")).toBeNull();
});
});