mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
1fb50d15d4
commit
483c23f3e2
5 files changed
+298
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user