diff --git a/frontend/LAYOUT_IMPLEMENTATION.md b/frontend/LAYOUT_IMPLEMENTATION.md index 2e6d8884..81e80b6c 100644 --- a/frontend/LAYOUT_IMPLEMENTATION.md +++ b/frontend/LAYOUT_IMPLEMENTATION.md @@ -1,241 +1,112 @@ -# Frontend Issue #19 - Responsive Layout System Implementation +# Application shell -## ✅ **COMPLETED** - All Requirements Fulfilled +The single layout every in-app page renders inside. Introduced for the +responsive layout system (frontend issue #19) and unified for +[#352](https://github.com/Epta-Node/ai-net/issues/352), which consolidated the +per-route variations into one shell. -This implementation successfully addresses all requirements from Frontend Issue #19: "Responsive Layout System with Navigation, Sidebar, and Mobile Drawer". +## Files -## 📁 **Files Created** - -### Core Layout Components -- `src/components/layout/AppShell.tsx` - Main shell component wrapping authenticated routes -- `src/components/layout/TopNav.tsx` - Top navigation with logo, title, and wallet connection -- `src/components/layout/Sidebar.tsx` - Collapsible sidebar navigation for desktop -- `src/components/layout/MobileDrawer.tsx` - Bottom sheet drawer for mobile navigation -- `src/components/layout/Breadcrumb.tsx` - Navigation breadcrumb component -- `src/components/layout/index.ts` - Export index for layout components +### Components +- `src/components/layout/AppShell.tsx` — the shell; owns sidebar/drawer state +- `src/components/layout/TopNav.tsx` — header: page title, notifications, theme, language, wallet +- `src/components/layout/Sidebar.tsx` — collapsible grouped sidebar (desktop) +- `src/components/layout/MobileDrawer.tsx` — slide-over navigation (below 1024px) +- `src/components/layout/Breadcrumb.tsx` — breadcrumb trail +- `src/components/layout/navigation.ts` — **single source of truth for nav items and groups** +- `src/components/layout/index.ts` — barrel export ### Styling -- `src/components/layout/AppShell.css` - Main layout styling with responsive grid -- `src/components/layout/TopNav.css` - Header navigation styling -- `src/components/layout/Sidebar.css` - Desktop sidebar with collapsed states -- `src/components/layout/MobileDrawer.css` - Mobile drawer with animations -- `src/components/layout/Breadcrumb.css` - Breadcrumb navigation styling - -### Integration -- Updated `src/App.tsx` to use new AppShell layout system -- Created `src/pages/WalletPage.tsx` for wallet navigation route -- Enhanced `src/styles/global.css` with responsive design variables - -## ✅ **Acceptance Criteria Verified** - -### 1. **AppShell Component** -- ✅ Wraps all authenticated routes -- ✅ Renders sidebar + top nav consistently -- ✅ Responsive behavior for desktop and mobile - -### 2. **Sidebar State Persistence** -- ✅ Collapsed state persists across page refreshes -- ✅ Uses `localStorage` key: `sidebar_collapsed` -- ✅ Toggle functionality maintains state - -### 3. **Mobile Drawer Implementation** -- ✅ Opens on hamburger click (< 768px breakpoint) -- ✅ Closes on Escape key press -- ✅ Closes on backdrop click -- ✅ Smooth framer-motion animations - -### 4. **ARIA Compliance** -- ✅ `aria-current="page"` applied to active nav links -- ✅ `role="navigation"` on sidebar and mobile drawer -- ✅ `role="banner"` on top navigation -- ✅ `aria-expanded` on sidebar toggle button -- ✅ `aria-label` attributes for screen readers - -### 5. **TopNav Features** -- ✅ Truncates public key to `GABC...XYZ` format -- ✅ Handles keys of any length correctly -- ✅ Shows connection status with visual indicators - -### 6. **Responsive Design** -- ✅ No horizontal scroll from 320px to 1920px+ viewports -- ✅ Mobile-first responsive breakpoints -- ✅ Proper viewport handling and layout adaptation - -### 7. **Keyboard Navigation** -- ✅ Tab navigation through all nav items -- ✅ Enter/Space key activation for nav buttons -- ✅ Escape key closes mobile drawer -- ✅ Focus management for accessibility - -## 🛠 **Technical Implementation** - -### Dependencies Added -```json -{ - "framer-motion": "^10.x.x" // For smooth mobile drawer animations -} -``` - -### Key Features Implemented +Each component has a sibling `.css` file. `AppShell.css` defines the layout +custom properties (`--sidebar-width`, `--sidebar-width-collapsed`, +`--topnav-height`) that the others consume. -#### **Responsive Breakpoint System** -- Desktop: `≥ 768px` - Shows sidebar navigation -- Mobile: `< 768px` - Shows hamburger menu with bottom drawer +## Structure -#### **LocalStorage Integration** -```typescript -// Sidebar state persistence -const [sidebarCollapsed, setSidebarCollapsed] = useState( - localStorage.getItem('sidebar_collapsed') === 'true' -) - -useEffect(() => { - localStorage.setItem('sidebar_collapsed', sidebarCollapsed.toString()) -}, [sidebarCollapsed]) ``` - -#### **ARIA Accessibility Implementation** -```tsx -// Navigation roles and states - +App +└── / ......................... LandingPage (public, renders bare) +└── /* ........................ AppShell + ├── TopNav (fixed header) + ├── Sidebar (≥1024px) + ├── MobileDrawer (<1024px, when open) + └── main + ├── Breadcrumb + └── page content ``` -#### **Mobile Drawer with Framer Motion** -```tsx - -``` +`/` is the public marketing page and is deliberately outside the shell. Every +other route — including the 404 — renders inside `AppShell`, so the navigation +is assembled once rather than per route. The command palette is mounted once +beside the route tree so Ctrl/Cmd+K works everywhere without remounting on +navigation. -## 🎨 **CSS Architecture** - -### CSS Custom Properties System -```css -:root { - /* Layout Colors */ - --bg-primary: #ffffff; - --bg-secondary: #f1f5f9; - --border-color: #e2e8f0; - - /* Responsive breakpoints */ - --mobile-breakpoint: 767px; - - /* Z-index layers */ - --z-topnav: 1000; - --z-drawer: 1200; -} -``` +## Navigation config -### Responsive Grid Layout -```css -.main-content { - margin-left: 280px; /* Desktop sidebar width */ - transition: margin-left 0.3s ease; -} - -.main-content.sidebar-collapsed { - margin-left: 80px; /* Collapsed sidebar width */ -} - -@media (max-width: 767px) { - .main-content { - margin-left: 0; /* Mobile: no sidebar */ - } -} -``` +`navigation.ts` is the only place nav items are declared. The sidebar, the +mobile drawer, the breadcrumb labels, and the command palette's page results all +read from it. -## 🧪 **Testing & Validation** - -### Automated Validation Script -Created `validate-layout.cjs` which verifies: -- ✅ All required component files exist -- ✅ framer-motion dependency installed -- ✅ localStorage implementation present -- ✅ ARIA attributes in components -- ✅ Responsive CSS breakpoints defined - -**Validation Results: 10/10 (100%)** ✅ - -### Manual Testing Checklist -- ✅ Sidebar collapses/expands and state persists -- ✅ Mobile drawer opens/closes smoothly -- ✅ Navigation works at all viewport sizes -- ✅ Keyboard navigation functional -- ✅ Screen reader accessibility -- ✅ Public key truncation works correctly -- ✅ No layout overflow or horizontal scroll - -## 🚀 **Usage** - -### Integration in App.tsx -```tsx -import AppShell from './components/layout/AppShell' - -const App = () => ( - - - - - } /> - } /> - } /> - } /> - - - - -) +```ts +NAV_GROUPS // grouped, in sidebar order: Overview / Work / Account +NAV_ITEMS // flat list of every item +isNavItemActive(currentPath, itemPath) ``` -### Component Structure -``` -AppShell -├── TopNav (fixed header) -├── Sidebar (desktop navigation) -├── MobileDrawer (mobile navigation) -├── Breadcrumb (page hierarchy) -└── main[children] (page content) -``` +Before #352 each surface carried its own copy, which is how the drawer ended up +with hardcoded English labels while the sidebar was translated, and how the +sidebar's "Dashboard" ended up pointing at `/` (the public landing page) rather +than `/dashboard`. -## 📱 **Responsive Behavior** +**Active state** is an exact match or a descendant of it, so `/tasks/new/step-2` +highlights "New Task" while `/tasks/abc-123` — a detail page with no nav entry — +correctly highlights nothing. -| Viewport | Layout | Navigation | Sidebar | -|----------|---------|------------|---------| -| ≥ 768px | Desktop | Top nav + Sidebar | Collapsible | -| < 768px | Mobile | Top nav + Hamburger | Bottom drawer | +## Responsive behaviour -## ♿ **Accessibility Features** +| Viewport | Navigation | Content | +|---|---|---| +| ≥ 1024px | Top nav + collapsible sidebar | Offset by the sidebar rail | +| < 1024px | Top nav + hamburger → slide-over drawer | Full width | -- **Screen Reader Support**: Full ARIA labeling and roles -- **Keyboard Navigation**: Tab order and focus management -- **Visual Indicators**: Clear active states and hover effects -- **Responsive Touch Targets**: Minimum 44px touch areas on mobile -- **Color Contrast**: WCAG AA compliant color schemes +The breakpoint lives in two places that must agree: `MOBILE_BREAKPOINT_QUERY` in +`AppShell.tsx` decides which navigation renders, and the `@media (max-width: +1023px)` blocks decide the layout. Change one, change the other. ---- +The drawer enters from the **left**, the same side the sidebar occupies on +desktop, so navigation appears in one place at every width. Drag it left or +flick to dismiss. -## 🎯 **Issue #19 Status: COMPLETED** +## Sidebar state persistence -All acceptance criteria have been successfully implemented and validated. The responsive layout system is production-ready with: +Collapsed state is stored per user: -- ✅ Complete component architecture -- ✅ Full responsive design (320px - 1920px+) -- ✅ ARIA accessibility compliance -- ✅ Persistent sidebar state -- ✅ Smooth mobile drawer animations -- ✅ Keyboard navigation support -- ✅ Zero horizontal scroll issues -- ✅ Public key truncation -- ✅ Comprehensive testing +``` +sidebar_collapsed: // connected wallet +sidebar_collapsed // signed out +``` -The layout system provides a solid foundation for the ai-net frontend application with modern UX patterns and full accessibility support. +The unscoped key is also the key the app used before scoping existed, so no +existing preference is dropped. Reads and writes are wrapped in `try/catch`: +private-mode browsers throw on storage access, and the shell falls back to an +expanded sidebar rather than failing to render. + +## Accessibility + +- `role="banner"` on the top nav, `role="navigation"` on the sidebar and drawer +- `aria-current="page"` on the active nav item +- `aria-expanded` on the sidebar toggle and the hamburger +- Skip-to-content link, visible on focus +- Collapsing the sidebar hides labels and group headings **visually only** — + they stay in the accessibility tree via `.visually-hidden` +- Drawer: focus trap, restores focus on close, Escape and backdrop-click dismiss +- `prefers-reduced-motion` disables the sidebar and nav transitions + +## Tests + +- `AppShell.test.tsx` — shell structure, ARIA, grouping, per-wallet persistence, + active-state matching, drawer Escape +- `MobileDrawer.test.tsx` — rendering, close paths, focus trap, nav config +- `Breadcrumb.test.tsx` — trail construction, labelling, non-navigable segments +- `TopNav.test.tsx` / `TopNav.i18n.test.tsx` — title derivation, key truncation, + language switching diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6bed989f..cdc47d16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,86 +17,99 @@ import RendererDemoPage from './pages/RendererDemoPage' import WalletPage from './pages/WalletPage' import DashboardPage from './pages/dashboard' import ErrorBoundary from './components/common/ErrorBoundary' -import { ProtectedRoute } from './components/common/ProtectedRoute' +import { ProtectedRoute } from './components/auth/ProtectedRoute' import { CommandPalette } from './components/common/CommandPalette' import { useCommandPalette } from './hooks/useCommandPalette' -import { ProtectedRoute } from './components/auth/ProtectedRoute' import './components/common/Toast.css' /** - * Everything that needs router context lives here, so `` (mounted by - * `App` below) is already in place before `useCommandPalette` calls - * `useNavigate`. + * Everything below the router. + * + * `/` is the public landing page and renders bare. **Every other route** — + * including 404 — renders inside a single ``, so the top nav, + * sidebar, drawer, and breadcrumb are assembled once rather than per route. + * + * The command palette is mounted here, once, outside the route tree: it is + * reachable with Ctrl/Cmd+K from any page and must not remount on navigation. + * `useCommandPalette` calls `useNavigate`, so this component has to sit inside + * `` rather than beside it. */ -const AppContent: React.FC = () => { - return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ) -} - -// Lives INSIDE : useCommandPalette() calls useNavigate(), which -// throws the "may be used only in the context of a " invariant when -// rendered above it. const RoutedContent: React.FC = () => { - const { isOpen, closePalette, search, recentSearches } = useCommandPalette() + const { isOpen, closePalette, search, recentSearches, runRecentSearch } = useCommandPalette() return ( <> } /> - - - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - {import.meta.env.DEV && ( - } /> - )} - } /> - - - } /> - } /> + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {import.meta.env.DEV && ( + } /> + )} + } /> + + + } + /> + { - // Trigger search with the recent query - search(query) - }} + onRecentSearchClick={runRecentSearch} /> ) @@ -109,9 +122,11 @@ const App: React.FC = () => { - - - + + + + + diff --git a/frontend/src/components/agents/AgentDetailModal.tsx b/frontend/src/components/agents/AgentDetailModal.tsx index 952679c6..b95a795c 100644 --- a/frontend/src/components/agents/AgentDetailModal.tsx +++ b/frontend/src/components/agents/AgentDetailModal.tsx @@ -17,6 +17,9 @@ interface AgentDetailModalProps { export function AgentDetailModal({ agent, onClose }: AgentDetailModalProps) { const { t } = useTranslation() + // Called unconditionally (hook rules) — it no-ops on an empty id, which is + // what the closed-modal case passes. + const { data: reputationData, loading: reputationLoading } = useAgentReputation(agent?.id ?? '') useEffect(() => { if (!agent) return diff --git a/frontend/src/components/agents/AgentReputationRadar.test.tsx b/frontend/src/components/agents/AgentReputationRadar.test.tsx index f2696827..972eee36 100644 --- a/frontend/src/components/agents/AgentReputationRadar.test.tsx +++ b/frontend/src/components/agents/AgentReputationRadar.test.tsx @@ -1,14 +1,21 @@ +import React from 'react'; import { render } from '@testing-library/react'; import { AgentReputationRadar } from './AgentReputationRadar'; import { vi, describe, it, expect } from 'vitest'; -// Mock recharts because ResponsiveContainer doesn't work well in JSDOM +// ResponsiveContainer measures its parent, and jsdom reports 0x0 for +// everything — so the real one renders a chart of zero size, which recharts +// skips entirely. The stand-in hands the chart concrete pixel dimensions; +// passing them down is the part that actually makes the chart render, since a +// RadarChart with no width/height draws nothing. vi.mock('recharts', async () => { const OriginalRecharts = await vi.importActual('recharts'); return { ...OriginalRecharts, ResponsiveContainer: ({ children }: any) => ( -
{children}
+
+ {React.cloneElement(React.Children.only(children), { width: 400, height: 250 })} +
), }; }); diff --git a/frontend/src/components/agents/TaskSubmissionForm.tsx b/frontend/src/components/agents/TaskSubmissionForm.tsx index bb9b52d5..6ef5d7ce 100644 --- a/frontend/src/components/agents/TaskSubmissionForm.tsx +++ b/frontend/src/components/agents/TaskSubmissionForm.tsx @@ -3,13 +3,12 @@ import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { AlertCircle } from 'lucide-react'; import type { TFunction } from 'i18next'; import { DAGPreview } from './DAGPreview'; import { useTaskSubmit } from '../../hooks/useTaskSubmit'; -import { useToast } from '../../hooks/useToast'; import { useToast } from '../../context/ToastContext'; -import { FormField } from '../common/FormField'; -import { taskSchema, type TaskFormValues } from '../../schemas/task'; import type { AgentPreference, TaskSubmitResponse } from '../../services/taskService'; // Only the label is translated: `value` is the wire format the API and the zod @@ -43,10 +42,7 @@ export function TaskSubmissionForm() { const navigate = useNavigate(); const { showToast } = useToast(); const [preview, setPreview] = useState(null); - const { submitTask, status, data } = useTaskSubmit(); - const [preview, setPreview] = useState(null); const { submitTask, status, error, data } = useTaskSubmit(); - const { showToast } = useToast(); const agentPreferences = useMemo( () => @@ -222,14 +218,14 @@ export function TaskSubmissionForm() { + - {isSearching &&
Loading...
} - {!isSearching && query.trim() && allItems.length === 0 && ( -
-
No results found
-
Try adjusting your search
+ + {isSearching && ( +
+ {t('palette.loading')}
)} + + {!isSearching && query.trim() && !hasItems && ( +
+
{t('palette.noResults')}
+
{t('palette.noResultsHint')}
+
+ )} + {!isSearching && hasItems && ( -
- {categories.map((cat) => { - const items = getItemsForCategory(cat); +
+ {CATEGORY_ORDER.map((category) => { + const items = allItems.filter((item) => item.category === category); if (items.length === 0) return null; return ( -
-
{categoryLabels[cat]}
+
+
{t(CATEGORY_LABEL_KEYS[category])}
{items.map((item) => { const globalIndex = allItems.indexOf(item); + const isSelected = globalIndex === selectedIndex; return ( - + + + + + {item.subtitle && ( + {item.subtitle} + )} + + {item.metadata && ( + {item.metadata} + )} + {item.shortcut && {item.shortcut}} +
); })}
@@ -195,6 +341,19 @@ export const CommandPalette: React.FC = ({ })}
)} + +
+ + + {t('palette.hint.navigate')} + + + {t('palette.hint.select')} + + + esc {t('palette.hint.close')} + +
); diff --git a/frontend/src/components/common/FormField.test.tsx b/frontend/src/components/common/FormField.test.tsx index 6e7cd185..6e4abd16 100644 --- a/frontend/src/components/common/FormField.test.tsx +++ b/frontend/src/components/common/FormField.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { FormField } from './FormField'; diff --git a/frontend/src/components/common/Toast.css b/frontend/src/components/common/Toast.css index e45876cc..3a597d05 100644 --- a/frontend/src/components/common/Toast.css +++ b/frontend/src/components/common/Toast.css @@ -1,18 +1,14 @@ .toast-container { position: fixed; - right: 24px; - bottom: 24px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 2000; top: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; - gap: 8px; + gap: 12px; max-width: 400px; + /* The stack itself must not swallow clicks on the page behind it; each + toast re-enables pointer events for its own box. */ pointer-events: none; } @@ -29,6 +25,8 @@ border: 1px solid transparent; background: rgba(15, 23, 42, 0.96); color: #f8fafc; + font-size: 14px; + line-height: 1.5; pointer-events: auto; animation: toast-slide-in 0.2s ease-out; } @@ -59,98 +57,31 @@ } .toast__dismiss { + flex-shrink: 0; appearance: none; border: none; background: transparent; color: inherit; font-size: 1.3rem; line-height: 1; - cursor: pointer; - opacity: 0.8; - align-items: flex-start; - gap: 12px; - padding: 14px 18px; - border-radius: 10px; - box-shadow: 0 8px 32px rgba(15, 23, 42, 0.18); - font-size: 14px; - line-height: 1.5; - pointer-events: auto; - animation: toast-slide-in 0.3s ease-out; - word-break: break-word; -} - -.toast button { - flex-shrink: 0; - background: none; - border: none; - font-size: 18px; - cursor: pointer; padding: 0 2px; - line-height: 1; + cursor: pointer; opacity: 0.7; transition: opacity 0.15s; } -.toast button:hover { +.toast__dismiss:hover { opacity: 1; } -.toast-success { - background: #f0fdf4; - border: 1px solid #86efac; - color: #166534; -} - -.toast-success button { - color: #166534; -} - -.toast-error { - background: #fef2f2; - border: 1px solid #fca5a5; - color: #991b1b; -} - -.toast-error button { - color: #991b1b; -} - -.toast-warning { - background: #fffbeb; - border: 1px solid #fcd34d; - color: #92400e; -} - -.toast-warning button { - color: #92400e; -} - -.toast-info { - background: #eff6ff; - border: 1px solid #93c5fd; - color: #1e40af; -} - -.toast-info button { - color: #1e40af; -} - @keyframes toast-slide-in { from { opacity: 0; - transform: translateY(12px) scale(0.98); + transform: translateX(12px) scale(0.98); } to { opacity: 1; - transform: translateY(0) scale(1); - } -} - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; + transform: translateX(0) scale(1); } } @@ -160,4 +91,9 @@ right: 12px; max-width: none; } + + .toast { + min-width: 0; + max-width: none; + } } diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx index 3b484d74..ecf19f17 100644 --- a/frontend/src/components/common/Toast.tsx +++ b/frontend/src/components/common/Toast.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import './Toast.css'; import type { Toast } from '../../context/ToastContext'; @@ -7,12 +8,14 @@ interface ToastContainerProps { } export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) { + const { t } = useTranslation(); + return (
{toasts.map((toast) => (
{toast.message} -
diff --git a/frontend/src/components/landing/Hero.tsx b/frontend/src/components/landing/Hero.tsx index a1b9e2e3..e29a1b15 100644 --- a/frontend/src/components/landing/Hero.tsx +++ b/frontend/src/components/landing/Hero.tsx @@ -13,11 +13,8 @@ const Hero: React.FC = () => { const { canvasRef, prefersReducedMotion } = useParticles() return ( - {!prefersReducedMotion ? ( @@ -48,7 +45,7 @@ const Hero: React.FC = () => { >
{t('landing.hero.badge')} - +
{/* Headline */}

{ , ]} /> - +

{/* Subtext */}

{ style={{ animationDelay: '400ms' }} > {t('landing.hero.subtitle')} - +

{/* CTAs */}
) => + Object.fromEntries(Object.entries(props).filter(([key]) => !MOTION_ONLY_PROPS.includes(key))) + vi.mock('framer-motion', async () => { const actual = await vi.importActual('framer-motion') return { @@ -15,7 +26,14 @@ vi.mock('framer-motion', async () => { AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, motion: { div: React.forwardRef>( - ({ children, ...props }, ref) =>
{children}
+ ({ children, ...props }, ref) => ( +
)}>{children}
+ ) + ), + span: React.forwardRef>( + ({ children, ...props }, ref) => ( + )}>{children} + ) ), }, } @@ -79,7 +97,7 @@ describe('Sidebar aria-current', () => { test('active link changes when currentPath changes', () => { const { rerender } = render( - + ) expect(screen.getByRole('button', { name: /dashboard/i })).toHaveAttribute('aria-current', 'page') @@ -156,6 +174,117 @@ describe('Sidebar collapsed state persistence', () => { }) }) +// ─── Grouped sidebar (#352) ─────────────────────────────────────────────── + +describe('Sidebar grouping', () => { + const navigate = vi.fn() + + test('renders every nav item under a labelled group', () => { + render( + + + + ) + + NAV_GROUPS.forEach((group) => { + const heading = screen.getByRole('heading', { name: i18n.t(group.labelKey) }) + expect(heading).toBeInTheDocument() + + group.items.forEach((item) => { + expect(screen.getByRole('button', { name: i18n.t(item.labelKey) })).toBeInTheDocument() + }) + }) + }) + + test('keeps item labels in the accessibility tree while collapsed', () => { + render( + + + + ) + // Collapsing is a visual affordance; it must not remove names from AT. + NAV_ITEMS.forEach((item) => { + expect(screen.getByRole('button', { name: i18n.t(item.labelKey) })).toBeInTheDocument() + }) + }) + + test('highlights a nav item for a descendant route', () => { + render( + + + + ) + expect(screen.getByRole('button', { name: 'New Task' })).toHaveAttribute('aria-current', 'page') + }) + + test('highlights nothing for a task detail route, which has no nav entry', () => { + render( + + + + ) + expect(screen.getByRole('button', { name: 'New Task' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('button', { name: 'Task History' })).not.toHaveAttribute('aria-current') + }) + + test('navigates to the dashboard route, not the public landing page', () => { + const onNavigate = vi.fn() + render( + + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) + expect(onNavigate).toHaveBeenCalledWith('/dashboard') + }) +}) + +// ─── Per-user sidebar persistence (#352) ────────────────────────────────── + +describe('Sidebar state is scoped to the connected wallet', () => { + const WALLET = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901234567890123456789' + + beforeEach(() => localStorage.clear()) + + test('a connected wallet writes to its own key, leaving the anonymous one alone', async () => { + localStorage.setItem('wallet_pubkey', WALLET) + renderInShell() + + const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i }) + await act(async () => { fireEvent.click(toggleBtn) }) + + expect(localStorage.getItem(`sidebar_collapsed:${WALLET}`)).toBe('true') + expect(localStorage.getItem('sidebar_collapsed')).toBeNull() + }) + + test('a wallet reads back its own preference, not another wallet\'s', () => { + localStorage.setItem('sidebar_collapsed', 'false') + localStorage.setItem(`sidebar_collapsed:${WALLET}`, 'true') + localStorage.setItem('wallet_pubkey', WALLET) + + renderInShell() + expect(document.querySelector('.sidebar')).toHaveClass('collapsed') + }) + + test('survives a localStorage that throws for the sidebar key', () => { + // Private-mode browsers throw on storage access. Only the sidebar key is + // made to throw here, so the assertion is about the shell's own guard and + // not about how any other provider handles storage. + const getItem = Storage.prototype.getItem + Storage.prototype.getItem = function (key: string) { + if (key.startsWith('sidebar_collapsed')) throw new Error('storage disabled') + return getItem.call(this, key) + } + try { + expect(() => renderInShell()).not.toThrow() + // Falls back to the expanded sidebar rather than rendering nothing. + expect(document.querySelector('.sidebar')).not.toHaveClass('collapsed') + } finally { + Storage.prototype.getItem = getItem + } + }) +}) + // ─── Mobile drawer Escape key ───────────────────────────────────────────── describe('Mobile drawer keyboard', () => { diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 4e14c694..b3173686 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { useLocation, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { AnimatePresence } from 'framer-motion' import { useMediaQuery } from '../../hooks/useMediaQuery' +import { useWallet } from '../../context/WalletContext' import Sidebar from './Sidebar' import TopNav from './TopNav' import MobileDrawer from './MobileDrawer' @@ -12,31 +14,80 @@ interface AppShellProps { children: React.ReactNode } +/** + * Below this width the sidebar gives way to the slide-over drawer. 1024px is + * where the sidebar rail plus a readable content column stops fitting. + */ +export const MOBILE_BREAKPOINT_QUERY = '(max-width: 1023px)' + +const SIDEBAR_STATE_KEY = 'sidebar_collapsed' + +/** + * Storage key for the sidebar's collapsed state, scoped to the connected + * wallet. + * + * Two people sharing a browser profile each keep their own preference, and a + * signed-out visitor gets the unscoped key — which is also the key the app used + * before scoping existed, so nobody's existing preference is silently dropped. + */ +function sidebarStateKey(publicKey: string | null): string { + return publicKey ? `${SIDEBAR_STATE_KEY}:${publicKey}` : SIDEBAR_STATE_KEY +} + +function readSidebarState(publicKey: string | null): boolean { + try { + return localStorage.getItem(sidebarStateKey(publicKey)) === 'true' + } catch { + // Private-mode browsers can throw on access; an expanded sidebar is the + // safe default. + return false + } +} + +/** + * The single application shell. + * + * Every in-app route renders inside this component, so the top nav, sidebar, + * drawer, and breadcrumb behave identically on every page rather than being + * assembled differently per route. + */ const AppShell: React.FC = ({ children }) => { - const isMobile = useMediaQuery('(max-width: 767px)') + const { t } = useTranslation() + const isMobile = useMediaQuery(MOBILE_BREAKPOINT_QUERY) + const { publicKey } = useWallet() const [isDrawerOpen, setIsDrawerOpen] = useState(false) - const [sidebarCollapsed, setSidebarCollapsed] = useState( - localStorage.getItem('sidebar_collapsed') === 'true' - ) + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readSidebarState(publicKey)) const location = useLocation() const navigate = useNavigate() const drawerRef = useRef(null) + // Re-read when the identity changes, so connecting a wallet adopts that + // wallet's saved preference instead of carrying over the anonymous one. + useEffect(() => { + setSidebarCollapsed(readSidebarState(publicKey)) + }, [publicKey]) + useEffect(() => { - if (isMobile === false) { + if (!isMobile) { setIsDrawerOpen(false) } }, [isMobile]) useEffect(() => { - localStorage.setItem('sidebar_collapsed', sidebarCollapsed.toString()) - }, [sidebarCollapsed]) + try { + localStorage.setItem(sidebarStateKey(publicKey), String(sidebarCollapsed)) + } catch { + // Persistence is a convenience; losing it must not break the shell. + } + }, [sidebarCollapsed, publicKey]) useEffect(() => { setIsDrawerOpen(false) }, [location.pathname]) useEffect(() => { + if (!isDrawerOpen) return + const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { setIsDrawerOpen(false) @@ -49,10 +100,8 @@ const AppShell: React.FC = ({ children }) => { } } - if (isDrawerOpen) { - document.addEventListener('keydown', handleEscape) - document.addEventListener('mousedown', handleClickOutside) - } + document.addEventListener('keydown', handleEscape) + document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('keydown', handleEscape) @@ -60,33 +109,41 @@ const AppShell: React.FC = ({ children }) => { } }, [isDrawerOpen]) - const toggleSidebar = () => { - setSidebarCollapsed(!sidebarCollapsed) - } + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => !prev) + }, []) - const toggleDrawer = () => { - setIsDrawerOpen(!isDrawerOpen) - } + const toggleDrawer = useCallback(() => { + setIsDrawerOpen((prev) => !prev) + }, []) + + const handleNavigate = useCallback( + (path: string) => { + navigate(path) + setIsDrawerOpen(false) + }, + [navigate], + ) return ( -
+
- Skip to content + {t('a11y.skipToContent')} - - + {!isMobile && ( - )} @@ -96,10 +153,7 @@ const AppShell: React.FC = ({ children }) => { ref={drawerRef} onClose={() => setIsDrawerOpen(false)} currentPath={location.pathname} - onNavigate={(path) => { - navigate(path) - setIsDrawerOpen(false) - }} + onNavigate={handleNavigate} /> )} diff --git a/frontend/src/components/layout/Breadcrumb.css b/frontend/src/components/layout/Breadcrumb.css index e3aa185c..85d03925 100644 --- a/frontend/src/components/layout/Breadcrumb.css +++ b/frontend/src/components/layout/Breadcrumb.css @@ -5,6 +5,10 @@ .breadcrumb ol { display: flex; align-items: center; + /* Wrap instead of overflowing: a deep trail must never make the page scroll + sideways on a narrow viewport. */ + flex-wrap: wrap; + row-gap: 4px; list-style: none; margin: 0; padding: 0; diff --git a/frontend/src/components/layout/Breadcrumb.test.tsx b/frontend/src/components/layout/Breadcrumb.test.tsx new file mode 100644 index 00000000..a1a2ad9d --- /dev/null +++ b/frontend/src/components/layout/Breadcrumb.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, test, expect } from 'vitest' +import Breadcrumb from './Breadcrumb' + +const renderAt = (path: string) => + render( + + + , + ) + +describe('Breadcrumb', () => { + test('renders nothing on the landing page', () => { + const { container } = renderAt('/') + expect(container).toBeEmptyDOMElement() + }) + + test('renders nothing at the in-app root — the trail would be one link to itself', () => { + const { container } = renderAt('/dashboard') + expect(container).toBeEmptyDOMElement() + }) + + test('is exposed as a labelled navigation landmark', () => { + renderAt('/agents') + expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument() + }) + + test('links back to the dashboard from a nested page', () => { + renderAt('/agents') + expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/dashboard') + }) + + test('marks the current page with aria-current and does not link it', () => { + renderAt('/agents') + const current = screen.getByText('Agents') + expect(current).toHaveAttribute('aria-current', 'page') + expect(screen.queryByRole('link', { name: 'Agents' })).not.toBeInTheDocument() + }) + + test('labels a segment from the shared nav config', () => { + renderAt('/tasks/new') + expect(screen.getByText('New Task')).toBeInTheDocument() + }) + + test('renders the "/tasks" segment as plain text — there is no page at that path', () => { + renderAt('/tasks/history') + const tasks = screen.getByText('Tasks') + expect(tasks).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Tasks' })).not.toBeInTheDocument() + }) + + test('names a task detail page by its id', () => { + renderAt('/tasks/abc-123') + expect(screen.getByText('Task abc-123')).toBeInTheDocument() + }) + + test('falls back to a capitalised segment for an unknown route', () => { + renderAt('/settings') + expect(screen.getByText('Settings')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx index 148624d8..a5e0d543 100644 --- a/frontend/src/components/layout/Breadcrumb.tsx +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -1,52 +1,68 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useLocation, Link } from 'react-router-dom' +import { NAV_ITEMS } from './navigation' import './Breadcrumb.css' +/** Root of the in-app hierarchy. `/` is the public landing page, not this. */ +const HOME_PATH = '/dashboard' + +/** + * Segments that exist only as URL structure and have no page of their own. + * They render as plain text rather than a link that would 404. + */ +const NON_NAVIGABLE_PREFIXES = new Set(['/tasks']) + +/** + * Breadcrumb trail derived from the current pathname. + * + * Labels come from the shared nav config where a segment corresponds to a real + * nav destination, so a rename in one place moves the sidebar and the trail + * together. + */ const Breadcrumb: React.FC = () => { const { t } = useTranslation() const location = useLocation() - const pathnames = location.pathname.split('/').filter(Boolean) - - const getBreadcrumbLabel = (path: string, index: number) => { - const fullPath = '/' + pathnames.slice(0, index + 1).join('/') - - switch (fullPath) { - case '/': return t('nav.dashboard') - case '/tasks': return t('nav.tasks') - case '/tasks/new': return t('nav.newTask') - case '/agents': return t('nav.agents') - case '/wallet': return t('nav.wallet') - default: - if (fullPath.startsWith('/tasks/') && pathnames.length > 1) { - return t('nav.taskWithId', { id: pathnames[1] }) - } - return path.charAt(0).toUpperCase() + path.slice(1) + const segments = location.pathname.split('/').filter(Boolean) + + const labelFor = (fullPath: string, segment: string, index: number): string => { + const navItem = NAV_ITEMS.find((item) => item.path === fullPath) + if (navItem) return t(navItem.labelKey) + + if (fullPath === '/tasks') return t('nav.tasks') + // `/tasks/` — a detail page, named by the id it is showing. + if (index === 1 && segments[0] === 'tasks') { + return t('nav.taskWithId', { id: segment }) } + return segment.charAt(0).toUpperCase() + segment.slice(1) } - if (pathnames.length === 0) { - return null - } + // Nothing above the root to show a trail for. + if (segments.length === 0) return null + const isAtHome = location.pathname === HOME_PATH + if (isAtHome) return null return (