From 5b0c24a92c9ae16dc045d0cb92f0cabdc91fb1c8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 25 Mar 2026 02:07:39 -0400 Subject: [PATCH] feat(web): make web UI mobile responsive (#2354) * feat(web): make web UI mobile responsive Fixes #2274 Add mobile-first responsive design to the GSD web UI: - Viewport meta tag via Next.js Viewport export - Collapsible sidebar as slide-out drawer on mobile with hamburger menu - Milestone explorer as right-side drawer on mobile with bottom bar toggle - Responsive header: hide project label, scope badge, beta badge on small screens - Dashboard: responsive grid (1col mobile -> 2col sm -> 4col xl), responsive padding - Status bar: hide secondary info on small screens, responsive text sizing - Touch-friendly 44px minimum tap targets on mobile nav items - Mobile CSS utilities in globals.css (overlay, drawer transitions) - 19 structural tests verifying responsive classes exist in key components Co-Authored-By: Claude Opus 4.6 (1M context) * ci: retrigger after stale check --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/tests/web-responsive.test.ts | 144 ++++++++++++++++++++++++++++++ web/app/globals.css | 33 +++++++ web/app/layout.tsx | 9 +- web/components/gsd/app-shell.tsx | 123 ++++++++++++++++++++----- web/components/gsd/dashboard.tsx | 16 ++-- web/components/gsd/sidebar.tsx | 91 ++++++++++++++++++- web/components/gsd/status-bar.tsx | 16 ++-- 7 files changed, 394 insertions(+), 38 deletions(-) create mode 100644 src/tests/web-responsive.test.ts diff --git a/src/tests/web-responsive.test.ts b/src/tests/web-responsive.test.ts new file mode 100644 index 000000000..847a7a5e2 --- /dev/null +++ b/src/tests/web-responsive.test.ts @@ -0,0 +1,144 @@ +/** + * Structural tests verifying mobile-responsive CSS classes exist in key web UI components. + * + * These tests read the source files and assert that responsive Tailwind classes + * (md:, sm:, lg:, xl:) and mobile-specific markup are present where expected. + */ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const WEB_ROOT = resolve(import.meta.dirname, '../../web') + +function readComponent(relativePath: string): string { + return readFileSync(resolve(WEB_ROOT, relativePath), 'utf-8') +} + +// ── layout.tsx ────────────────────────────────────────────────────────────── + +test('layout.tsx exports a Viewport with device-width', () => { + const src = readComponent('app/layout.tsx') + assert.ok(src.includes("Viewport"), 'should import Viewport type from next') + assert.ok(src.includes("device-width"), 'should set width to device-width') + assert.ok(src.includes("maximumScale"), 'should set maximumScale for mobile') +}) + +// ── app-shell.tsx ─────────────────────────────────────────────────────────── + +test('app-shell.tsx has a mobile hamburger menu toggle', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-nav-toggle'), 'should have mobile-nav-toggle test id') + assert.ok(src.includes('Menu'), 'should import Menu icon for hamburger') +}) + +test('app-shell.tsx hides desktop sidebar on mobile with md:flex', () => { + const src = readComponent('components/gsd/app-shell.tsx') + // The desktop sidebar wrapper should use hidden + md:flex + assert.ok(src.includes('hidden md:flex'), 'desktop sidebar should be hidden on mobile') +}) + +test('app-shell.tsx has a mobile nav drawer', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-nav-drawer'), 'should have mobile-nav-drawer test id') + assert.ok(src.includes('mobile-nav-overlay'), 'should have mobile-nav-overlay test id') +}) + +test('app-shell.tsx has a mobile milestone drawer', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-milestone-drawer'), 'should have mobile-milestone-drawer test id') + assert.ok(src.includes('mobile-milestone-toggle'), 'should have mobile-milestone-toggle test id') +}) + +test('app-shell.tsx has a mobile bottom bar', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('mobile-bottom-bar'), 'should have mobile-bottom-bar test id') +}) + +test('app-shell.tsx header uses responsive padding', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('md:px-4'), 'header should have responsive horizontal padding') +}) + +test('app-shell.tsx hides project label on small screens', () => { + const src = readComponent('components/gsd/app-shell.tsx') + assert.ok(src.includes('hidden sm:inline'), 'project label should be hidden on mobile') +}) + +test('app-shell.tsx hides desktop milestone sidebar on mobile', () => { + const src = readComponent('components/gsd/app-shell.tsx') + // The milestone sidebar resize handle should be hidden on mobile + assert.ok( + src.includes('hidden md:flex') || src.includes('hidden md:block'), + 'milestone sidebar should be hidden on mobile', + ) +}) + +// ── sidebar.tsx ────────────────────────────────────────────────────────────── + +test('sidebar.tsx supports a mobile prop', () => { + const src = readComponent('components/gsd/sidebar.tsx') + assert.ok(src.includes('mobile?:'), 'Sidebar should accept a mobile prop') + assert.ok(src.includes('mobile?: boolean'), 'mobile prop should be boolean') +}) + +test('sidebar.tsx has a MobileNavPanel with touch-friendly targets', () => { + const src = readComponent('components/gsd/sidebar.tsx') + assert.ok(src.includes('mobile-nav-panel'), 'should have mobile-nav-panel test id') + assert.ok(src.includes('min-h-[44px]'), 'nav items should have 44px minimum touch target height') +}) + +// ── dashboard.tsx ─────────────────────────────────────────────────────────── + +test('dashboard.tsx has responsive grid for metric cards', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('sm:grid-cols-2'), 'metric grid should stack to 2 cols on sm') + assert.ok(src.includes('xl:grid-cols-4'), 'metric grid should expand to 4 cols on xl') +}) + +test('dashboard.tsx has responsive padding on content area', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('md:p-6'), 'content area should have responsive padding') +}) + +test('dashboard.tsx has responsive header padding', () => { + const src = readComponent('components/gsd/dashboard.tsx') + assert.ok(src.includes('md:px-6'), 'dashboard header should have responsive horizontal padding') +}) + +// ── status-bar.tsx ────────────────────────────────────────────────────────── + +test('status-bar.tsx hides branch info on small screens', () => { + const src = readComponent('components/gsd/status-bar.tsx') + // Branch info should be hidden on mobile + assert.ok( + src.includes('hidden sm:flex'), + 'branch info should use hidden sm:flex for responsive display', + ) +}) + +test('status-bar.tsx has responsive text sizing', () => { + const src = readComponent('components/gsd/status-bar.tsx') + assert.ok(src.includes('md:text-xs'), 'status bar should have responsive text size') +}) + +test('status-bar.tsx has responsive gap spacing', () => { + const src = readComponent('components/gsd/status-bar.tsx') + assert.ok(src.includes('md:gap-4'), 'status bar should have responsive gap') +}) + +// ── globals.css ───────────────────────────────────────────────────────────── + +test('globals.css has mobile touch target styles', () => { + const src = readComponent('../web/app/globals.css') + assert.ok(src.includes('max-width: 767px'), 'should have a mobile media query') + assert.ok(src.includes('mobile-touch-target'), 'should define mobile-touch-target class') + assert.ok(src.includes('min-height: 44px'), 'touch targets should be at least 44px') +}) + +test('globals.css has mobile sidebar drawer styles', () => { + const src = readComponent('../web/app/globals.css') + assert.ok(src.includes('mobile-sidebar-drawer'), 'should define mobile-sidebar-drawer class') + assert.ok(src.includes('mobile-sidebar-overlay'), 'should define mobile-sidebar-overlay class') +}) diff --git a/web/app/globals.css b/web/app/globals.css index c87d2c15d..085e0fa3e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -146,6 +146,39 @@ } } +/* ── Mobile responsive: touch targets & safe areas ── */ +@media (max-width: 767px) { + /* Ensure touch targets meet 44px minimum */ + .mobile-touch-target { + min-height: 44px; + min-width: 44px; + } + + /* Mobile overlay for sidebar drawer */ + .mobile-sidebar-overlay { + position: fixed; + inset: 0; + z-index: 40; + background: oklch(0 0 0 / 0.5); + } + + /* Mobile sidebar drawer */ + .mobile-sidebar-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 50; + width: 260px; + transform: translateX(-100%); + transition: transform 200ms ease-out; + } + + .mobile-sidebar-drawer.open { + transform: translateX(0); + } +} + /* ── File viewer: Shiki code blocks ── */ .file-viewer-code pre { margin: 0; diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 8a3202a2b..f5afdf9d0 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next' +import type { Metadata, Viewport } from 'next' import { Geist, Geist_Mono } from 'next/font/google' import { Toaster } from '@/components/ui/sonner' import { ThemeProvider } from '@/components/theme-provider' @@ -36,6 +36,13 @@ export const metadata: Metadata = { }, } +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +} + export default function RootLayout({ children, }: Readonly<{ diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx index 8f3454922..cfe8440d9 100644 --- a/web/components/gsd/app-shell.tsx +++ b/web/components/gsd/app-shell.tsx @@ -2,6 +2,7 @@ import Image from "next/image" import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react" +import { Menu, X } from "lucide-react" import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/gsd/sidebar" import { ShellTerminal } from "@/components/gsd/shell-terminal" import { Dashboard } from "@/components/gsd/dashboard" @@ -57,6 +58,8 @@ function WorkspaceChrome() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [viewRestored, setViewRestored] = useState(false) const [projectsPanelOpen, setProjectsPanelOpen] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false) const workspace = useGSDWorkspaceState() const { refreshBoot } = useGSDWorkspaceActions() @@ -122,8 +125,10 @@ function WorkspaceChrome() { document.title = titleOverride ? `${titleOverride} · ${base}` : base }, [titleOverride, projectLabel]) + // Close mobile nav on view change const handleViewChange = useCallback((view: string) => { setActiveView(view) + setMobileNavOpen(false) }, []) // Listen for cross-component file navigation events (e.g. sidebar task clicks) @@ -232,8 +237,17 @@ function WorkspaceChrome() { return (
-
-
+
+
+ {/* Mobile hamburger menu */} +
- + beta
- / - + / + {isConnecting ? ( ) : ( @@ -274,11 +288,11 @@ function WorkspaceChrome() {
-
+
{/* Hidden status marker for test instrumentation */} {status.label} {isConnecting ? : } @@ -307,8 +321,53 @@ function WorkspaceChrome() {
)} + {/* Mobile navigation drawer */} + {mobileNavOpen && ( +
setMobileNavOpen(false)} + data-testid="mobile-nav-overlay" + /> + )} +
+ {} : handleViewChange} isConnecting={isConnecting} mobile /> +
+ + {/* Mobile milestone drawer */} + {mobileMilestoneOpen && ( +
setMobileMilestoneOpen(false)} + data-testid="mobile-milestone-overlay" + /> + )} + {!isWelcomeState && ( +
+ setMobileMilestoneOpen(false)} + /> +
+ )} +
- {} : handleViewChange} isConnecting={isConnecting} /> + {/* Desktop sidebar — hidden on mobile */} +
+ {} : handleViewChange} isConnecting={isConnecting} /> +
- {/* Resizable milestone sidebar — hidden during project welcome */} + {/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */} {!isWelcomeState && !sidebarCollapsed && (
{/* Thin visible border */} @@ -399,18 +458,42 @@ function WorkspaceChrome() { />
)} - {!isWelcomeState && (sidebarCollapsed ? ( - setSidebarCollapsed(false)} /> - ) : ( - setSidebarCollapsed(true)} - /> - ))} +
+ {!isWelcomeState && (sidebarCollapsed ? ( + setSidebarCollapsed(false)} /> + ) : ( + setSidebarCollapsed(true)} + /> + ))} +
- + {/* Desktop status bar — hidden on mobile */} +
+ +
+ + {/* Mobile bottom bar — quick access to milestones + status */} + {!isWelcomeState && ( +
+
+ {status.label} + + {scopeLabel} +
+ +
+ )} + diff --git a/web/components/gsd/dashboard.tsx b/web/components/gsd/dashboard.tsx index 495ce4bc5..b1480fda2 100644 --- a/web/components/gsd/dashboard.tsx +++ b/web/components/gsd/dashboard.tsx @@ -181,18 +181,18 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = { return (
-
-
-

Dashboard

+
+
+

Dashboard

{!isConnecting && scopeLabel && ( <> - / - + / + )} {isConnecting && }
-
+
{isConnecting ? ( <> @@ -220,8 +220,8 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
-
-
+
+
diff --git a/web/components/gsd/sidebar.tsx b/web/components/gsd/sidebar.tsx index 07ed98802..521cdfea9 100644 --- a/web/components/gsd/sidebar.tsx +++ b/web/components/gsd/sidebar.tsx @@ -698,12 +698,101 @@ interface SidebarProps { activeView: string onViewChange: (view: string) => void isConnecting?: boolean + mobile?: boolean } -export function Sidebar({ activeView, onViewChange, isConnecting = false }: SidebarProps) { +export function Sidebar({ activeView, onViewChange, isConnecting = false, mobile = false }: SidebarProps) { + if (mobile) { + return + } return (
) } + +/* ─── Mobile Nav Panel (full-width labels for touch) ─── */ + +function MobileNavPanel({ activeView, onViewChange, isConnecting = false }: NavRailProps) { + const { openCommandSurface } = useGSDWorkspaceActions() + const { theme, setTheme } = useTheme() + + const cycleTheme = () => { + if (theme === "system") setTheme("light") + else if (theme === "light") setTheme("dark") + else setTheme("system") + } + + const themeLabel = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System" + const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor + + const navItems = [ + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, + { id: "power", label: "Power Mode", icon: Columns2 }, + { id: "chat", label: "Chat", icon: MessagesSquare }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, + { id: "files", label: "Files", icon: Folder }, + { id: "activity", label: "Activity", icon: Activity }, + { id: "visualize", label: "Visualize", icon: BarChart3 }, + ] + + return ( +
+
+ {navItems.map((item) => ( + + ))} +
+
+ + + + +
+
+ ) +} diff --git a/web/components/gsd/status-bar.tsx b/web/components/gsd/status-bar.tsx index 4a239a56d..04786e887 100644 --- a/web/components/gsd/status-bar.tsx +++ b/web/components/gsd/status-bar.tsx @@ -83,13 +83,13 @@ export function StatusBar() { }, [fetchProjectTotals]) return ( -
-
+
+
{status.label}
-
+
{isConnecting ? ( @@ -97,7 +97,7 @@ export function StatusBar() { {branch} )}
-
+
{isConnecting ? ( @@ -141,12 +141,12 @@ export function StatusBar() {
)}
-
-
+
+
{isConnecting ? : {formatProjectDuration(projectTotals?.duration ?? auto?.elapsed ?? 0)}}
-
+
{isConnecting ? : {formatTokenCount(projectTotals?.tokens.total ?? auto?.totalTokens ?? 0)}}
@@ -154,7 +154,7 @@ export function StatusBar() { {isConnecting ? : {formatProjectCost(projectTotals?.cost ?? auto?.totalCost ?? 0)}}
- + {isConnecting ? : }